feat(bridge): add multi-platform bridge — QQ, Telegram, Discord, Webhook
- platform-bridge: unified message model + Adapter interface - qq/adapter: OBv11 WebSocket protocol with @mention detection - telegram/discord: stub adapters with send capability - webhook: HTTP POST receiver for generic webhook integration - Router: message routing from platform → AI-Core → back - Group chat rules: 500 char limit, 3s gap, silence keywords - main.go: conditional adapter loading via env vars
This commit is contained in:
1 parent
7e3ec8da93
commit
17ef73bcbe
7 files changed
+460
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
@@ -38,6 +39,11 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
|
||||
bridge "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
||||
bridgeqq "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/qq"
|
||||
bridgetg "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/telegram"
|
||||
bridgedc "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/discord"
|
||||
bridgewh "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/webhook"
|
||||
voicesvc "git.yeij.top/AskaEth/Cyrene_v2/services/voice"
|
||||
)
|
||||
|
||||
@@ -175,6 +181,51 @@ func main() {
|
||||
orch := orchestrator.NewPipeline(llmSelector, semanticCache, ctxStore, subMgr, personaInjector)
|
||||
log.Printf("[orchestrator] pipeline ready")
|
||||
|
||||
// === 11.5 Platform Bridge ===
|
||||
bridgeRouter := bridge.NewRouter()
|
||||
// QQ adapter — enabled when OBv11 WebSocket URL is configured
|
||||
if qqURL := os.Getenv("QQ_OBV11_WS_URL"); qqURL != "" {
|
||||
qqAdapter := bridgeqq.New(bridgeqq.Config{
|
||||
Name: "qq-main", WSURL: qqURL,
|
||||
AccessToken: os.Getenv("QQ_ACCESS_TOKEN"),
|
||||
BotQQ: os.Getenv("QQ_BOT_QQ"),
|
||||
})
|
||||
bridgeRouter.Register(qqAdapter)
|
||||
}
|
||||
// Telegram
|
||||
if tgToken := os.Getenv("TG_BOT_TOKEN"); tgToken != "" {
|
||||
bridgeRouter.Register(bridgetg.New(bridgetg.Config{Name: "tg-main", Token: tgToken}))
|
||||
}
|
||||
// Discord
|
||||
if dcToken := os.Getenv("DC_BOT_TOKEN"); dcToken != "" {
|
||||
bridgeRouter.Register(bridgedc.New(bridgedc.Config{Name: "dc-main", Token: dcToken}))
|
||||
}
|
||||
// Webhook
|
||||
if whAddr := os.Getenv("WEBHOOK_LISTEN_ADDR"); whAddr != "" {
|
||||
bridgeRouter.Register(bridgewh.New(bridgewh.Config{Name: "webhook-main", ListenAddr: whAddr}))
|
||||
}
|
||||
|
||||
// Route incoming messages to orchestrator
|
||||
bridgeRouter.SetMessageHandler(func(msg *bridge.UnifiedMessage) {
|
||||
log.Printf("[bridge] incoming %s from %s: %s", msg.Platform, msg.SenderName, msg.Content)
|
||||
tokenCh, _, err := orch.ProcessInput(context.Background(), "bridge-"+msg.Source, msg.Content)
|
||||
if err != nil {
|
||||
log.Printf("[bridge] orchestrator error: %v", err)
|
||||
return
|
||||
}
|
||||
var fullContent string
|
||||
for token := range tokenCh {
|
||||
fullContent += token.Content
|
||||
}
|
||||
if fullContent != "" {
|
||||
bridgeRouter.SendTo(msg.Platform, msg.Source, fullContent, bridge.SendOptions{
|
||||
MaxLength: 500, MinGap: 3 * time.Second,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
bridgeRouter.StartAll(); log.Printf("[bridge] platform bridge ready")
|
||||
|
||||
// === 12. Gateway ===
|
||||
userStore := api.NewMemUserStore(cfg.AdminPassword)
|
||||
authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package bridge provides multi-platform message bridging for Cyrene.
|
||||
// It defines unified message types and adapter interfaces for QQ, Telegram, Discord, and Webhook.
|
||||
package bridge
|
||||
|
||||
import "time"
|
||||
|
||||
// UnifiedMessage is the internal representation of a message from any platform.
|
||||
type UnifiedMessage struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"` // "qq" | "telegram" | "discord" | "webhook"
|
||||
Source string `json:"source"` // Group ID or user ID
|
||||
SenderID string `json:"sender_id"` // Platform-specific sender ID
|
||||
SenderName string `json:"sender_name"` // Display name
|
||||
Content string `json:"content"`
|
||||
IsAtBot bool `json:"is_at_bot"` // Whether @bot was used
|
||||
IsDM bool `json:"is_dm"` // Private message
|
||||
IsAdmin bool `json:"is_admin"` // Sender is admin
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
RawData any `json:"raw_data,omitempty"` // Platform-specific raw payload
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Attachment represents a file, image, or voice attachment.
|
||||
type Attachment struct {
|
||||
Type string `json:"type"` // "image" | "file" | "voice" | "video"
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// SendOptions controls how a message is sent to a platform.
|
||||
type SendOptions struct {
|
||||
ReplyTo string // Message ID to reply to
|
||||
MaxLength int // Max content length (default 500 for group chats)
|
||||
MinGap time.Duration // Min interval between messages (default 3s for QQ groups)
|
||||
}
|
||||
|
||||
// Adapter is the interface for all platform adapters.
|
||||
type Adapter interface {
|
||||
// Name returns the unique adapter identifier (e.g. "qq-main", "telegram-bot")
|
||||
Name() string
|
||||
|
||||
// Platform returns the platform type: "qq", "telegram", "discord", "webhook"
|
||||
Platform() string
|
||||
|
||||
// Start begins listening for messages (WebSocket connect / long polling / HTTP server)
|
||||
Start() error
|
||||
|
||||
// Stop shuts down the adapter
|
||||
Stop() error
|
||||
|
||||
// Send delivers a message to the platform
|
||||
Send(target string, content string, opts SendOptions) error
|
||||
|
||||
// Incoming returns the channel of incoming messages from this adapter
|
||||
Incoming() <-chan *UnifiedMessage
|
||||
|
||||
// IsConnected returns whether the adapter is currently connected
|
||||
IsConnected() bool
|
||||
}
|
||||
|
||||
// Router matches incoming messages to the AI pipeline and routes responses back.
|
||||
type Router struct {
|
||||
adapters map[string]Adapter
|
||||
onMessage func(msg *UnifiedMessage) // Called when a message should be sent to AI-Core
|
||||
}
|
||||
|
||||
// NewRouter creates a new bridge router.
|
||||
func NewRouter() *Router {
|
||||
return &Router{adapters: make(map[string]Adapter)}
|
||||
}
|
||||
|
||||
// Register adds an adapter to the router.
|
||||
func (r *Router) Register(a Adapter) {
|
||||
r.adapters[a.Name()] = a
|
||||
}
|
||||
|
||||
// SetMessageHandler sets the callback for incoming messages.
|
||||
func (r *Router) SetMessageHandler(fn func(*UnifiedMessage)) {
|
||||
r.onMessage = fn
|
||||
}
|
||||
|
||||
// StartAll starts all registered adapters.
|
||||
func (r *Router) StartAll() {
|
||||
for name, a := range r.adapters {
|
||||
go func(name string, a Adapter) {
|
||||
if err := a.Start(); err != nil {
|
||||
println("[bridge]", name, "start error:", err.Error())
|
||||
return
|
||||
}
|
||||
for msg := range a.Incoming() {
|
||||
if r.onMessage != nil {
|
||||
r.onMessage(msg)
|
||||
}
|
||||
}
|
||||
}(name, a)
|
||||
}
|
||||
}
|
||||
|
||||
// StopAll stops all adapters.
|
||||
func (r *Router) StopAll() {
|
||||
for _, a := range r.adapters {
|
||||
a.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// SendTo routes a response message to the appropriate adapter.
|
||||
func (r *Router) SendTo(platform, target, content string, opts SendOptions) error {
|
||||
for _, a := range r.adapters {
|
||||
if a.Platform() == platform {
|
||||
return a.Send(target, content, opts)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupChatRules defines the behavior constraints for group chat interactions.
|
||||
type GroupChatRules struct {
|
||||
MaxLength int // Max characters per message
|
||||
MinGap time.Duration // Min interval between messages
|
||||
MaxPerRound int // Max messages per conversation round
|
||||
SilenceKeywords []string // Keywords that trigger silence mode ("闭嘴", "别说话")
|
||||
AmbientMode bool // Whether to use ambient/审查 mode (reply only when mentioned or LLM decides)
|
||||
}
|
||||
|
||||
// DefaultGroupRules returns sensible defaults for QQ group chats.
|
||||
func DefaultGroupRules() GroupChatRules {
|
||||
return GroupChatRules{
|
||||
MaxLength: 500,
|
||||
MinGap: 3 * time.Second,
|
||||
MaxPerRound: 2,
|
||||
SilenceKeywords: []string{"闭嘴", "别说话", "别说了", "安静", "shut up", "stop"},
|
||||
AmbientMode: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUnifiedMessage(t *testing.T) {
|
||||
msg := &UnifiedMessage{
|
||||
ID: "123", Platform: "qq", Source: "group-456",
|
||||
SenderID: "789", SenderName: "测试用户",
|
||||
Content: "你好", IsAtBot: true, Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if msg.Platform != "qq" { t.Error("expected qq") }
|
||||
if !msg.IsAtBot { t.Error("expected is_at_bot") }
|
||||
}
|
||||
|
||||
func TestGroupChatRules(t *testing.T) {
|
||||
r := DefaultGroupRules()
|
||||
if r.MaxLength != 500 { t.Errorf("MaxLength=%d", r.MaxLength) }
|
||||
if r.MinGap != 3*time.Second { t.Errorf("MinGap=%v", r.MinGap) }
|
||||
if len(r.SilenceKeywords) == 0 { t.Error("expected silence keywords") }
|
||||
}
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
r := NewRouter()
|
||||
var received *UnifiedMessage
|
||||
r.SetMessageHandler(func(msg *UnifiedMessage) { received = msg })
|
||||
|
||||
// Verify handler is set
|
||||
_ = received
|
||||
_ = r
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package discord
|
||||
import (
|
||||
"fmt"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
||||
)
|
||||
type Config struct{ Name, Token string }
|
||||
type Adapter struct {
|
||||
cfg Config; incoming chan *bridge.UnifiedMessage; done chan struct{}
|
||||
}
|
||||
func New(cfg Config) *Adapter {
|
||||
return &Adapter{cfg:cfg, incoming:make(chan *bridge.UnifiedMessage,64), done:make(chan struct{})}
|
||||
}
|
||||
func (a *Adapter) Name() string { return a.cfg.Name }
|
||||
func (a *Adapter) Platform() string { return "discord" }
|
||||
func (a *Adapter) Start() error { return nil }
|
||||
func (a *Adapter) Stop() error { close(a.done); return nil }
|
||||
func (a *Adapter) Incoming() <-chan *bridge.UnifiedMessage { return a.incoming }
|
||||
func (a *Adapter) IsConnected() bool { return true }
|
||||
func (a *Adapter) Send(target, content string, opts bridge.SendOptions) error {
|
||||
fmt.Printf("[dc:%s] -> %s: %s\n", a.cfg.Name, target, content); return nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package qq provides an adapter for QQ (OBv11 protocol) integration.
|
||||
package qq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
||||
)
|
||||
|
||||
// Config holds QQ adapter configuration.
|
||||
type Config struct {
|
||||
Name string // Adapter name (e.g. "qq-main")
|
||||
WSURL string // OBv11 WebSocket URL ("ws://localhost:3001")
|
||||
AccessToken string // OBv11 access token
|
||||
BotQQ string // Bot's own QQ number
|
||||
}
|
||||
|
||||
// Adapter implements bridge.Adapter for QQ OBv11.
|
||||
type Adapter struct {
|
||||
cfg Config
|
||||
conn *websocket.Conn
|
||||
incoming chan *bridge.UnifiedMessage
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// New creates a QQ adapter.
|
||||
func New(cfg Config) *Adapter {
|
||||
return &Adapter{
|
||||
cfg: cfg,
|
||||
incoming: make(chan *bridge.UnifiedMessage, 64),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) Name() string { return a.cfg.Name }
|
||||
func (a *Adapter) Platform() string { return "qq" }
|
||||
|
||||
func (a *Adapter) Start() error {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(a.cfg.WSURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq ws connect: %w", err)
|
||||
}
|
||||
a.conn = conn
|
||||
|
||||
// Send auth
|
||||
authMsg := map[string]any{
|
||||
"type": "meta::connect",
|
||||
"access_token": a.cfg.AccessToken,
|
||||
}
|
||||
conn.WriteJSON(authMsg)
|
||||
|
||||
// Read loop
|
||||
go func() {
|
||||
defer close(a.incoming)
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := a.parseMessage(event)
|
||||
if msg != nil {
|
||||
a.incoming <- msg
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Stop() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.conn != nil {
|
||||
a.conn.Close()
|
||||
}
|
||||
close(a.done)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Incoming() <-chan *bridge.UnifiedMessage { return a.incoming }
|
||||
|
||||
func (a *Adapter) IsConnected() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.conn != nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Send(target string, content string, opts bridge.SendOptions) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Apply length limit
|
||||
if opts.MaxLength > 0 && len([]rune(content)) > opts.MaxLength {
|
||||
runes := []rune(content)
|
||||
content = string(runes[:opts.MaxLength]) + "..."
|
||||
}
|
||||
|
||||
msg := map[string]any{
|
||||
"action": "send_msg",
|
||||
"params": map[string]any{
|
||||
"user_id": target,
|
||||
"message": content,
|
||||
"auto_escape": false,
|
||||
},
|
||||
}
|
||||
return a.conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
func (a *Adapter) parseMessage(event map[string]any) *bridge.UnifiedMessage {
|
||||
postType, _ := event["post_type"].(string)
|
||||
if postType != "message" {
|
||||
return nil
|
||||
}
|
||||
|
||||
msgType, _ := event["message_type"].(string)
|
||||
sender := event["sender"].(map[string]any)
|
||||
rawMsg, _ := event["raw_message"].(string)
|
||||
senderID := fmt.Sprintf("%v", sender["user_id"])
|
||||
senderName, _ := sender["nickname"].(string)
|
||||
|
||||
isDM := msgType == "private"
|
||||
groupID := ""
|
||||
if !isDM {
|
||||
groupID = fmt.Sprintf("%v", event["group_id"])
|
||||
}
|
||||
|
||||
// Check if bot was @mentioned
|
||||
atBot := false
|
||||
if atList, ok := event["at_list"].([]any); ok {
|
||||
for _, at := range atList {
|
||||
if fmt.Sprintf("%v", at) == a.cfg.BotQQ {
|
||||
atBot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &bridge.UnifiedMessage{
|
||||
ID: fmt.Sprintf("%v", event["message_id"]),
|
||||
Platform: "qq",
|
||||
Source: groupID,
|
||||
SenderID: senderID,
|
||||
SenderName: senderName,
|
||||
Content: rawMsg,
|
||||
IsAtBot: atBot,
|
||||
IsDM: isDM,
|
||||
RawData: event,
|
||||
Timestamp: time.Unix(int64(event["time"].(float64)), 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package telegram
|
||||
import (
|
||||
"fmt"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
||||
)
|
||||
type Config struct{ Name, Token string }
|
||||
type Adapter struct {
|
||||
cfg Config; incoming chan *bridge.UnifiedMessage; done chan struct{}
|
||||
}
|
||||
func New(cfg Config) *Adapter {
|
||||
return &Adapter{cfg:cfg, incoming:make(chan *bridge.UnifiedMessage,64), done:make(chan struct{})}
|
||||
}
|
||||
func (a *Adapter) Name() string { return a.cfg.Name }
|
||||
func (a *Adapter) Platform() string { return "telegram" }
|
||||
func (a *Adapter) Start() error { return nil }
|
||||
func (a *Adapter) Stop() error { close(a.done); return nil }
|
||||
func (a *Adapter) Incoming() <-chan *bridge.UnifiedMessage { return a.incoming }
|
||||
func (a *Adapter) IsConnected() bool { return true }
|
||||
func (a *Adapter) Send(target, content string, opts bridge.SendOptions) error {
|
||||
fmt.Printf("[tg:%s] -> %s: %s\n", a.cfg.Name, target, content); return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package webhook
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
||||
)
|
||||
type Config struct{ Name, ListenAddr, APIKey string }
|
||||
type Adapter struct {
|
||||
cfg Config; incoming chan *bridge.UnifiedMessage; server *http.Server; done chan struct{}
|
||||
}
|
||||
func New(cfg Config) *Adapter {
|
||||
return &Adapter{cfg:cfg, incoming:make(chan *bridge.UnifiedMessage,64), done:make(chan struct{})}
|
||||
}
|
||||
func (a *Adapter) Name() string { return a.cfg.Name }
|
||||
func (a *Adapter) Platform() string { return "webhook" }
|
||||
func (a *Adapter) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.cfg.APIKey != "" && r.Header.Get("X-API-Key") != a.cfg.APIKey { w.WriteHeader(401); return }
|
||||
var body map[string]any; json.NewDecoder(r.Body).Decode(&body)
|
||||
c,_:=body["content"].(string); s,_:=body["source"].(string)
|
||||
a.incoming <- &bridge.UnifiedMessage{Platform:"webhook", Source:s, Content:c, SenderName:"webhook", Timestamp:time.Now()}
|
||||
w.WriteHeader(200)
|
||||
})
|
||||
a.server = &http.Server{Addr: a.cfg.ListenAddr, Handler: mux}
|
||||
go a.server.ListenAndServe(); return nil
|
||||
}
|
||||
func (a *Adapter) Stop() error { if a.server!=nil { a.server.Close() }; close(a.done); return nil }
|
||||
func (a *Adapter) Incoming() <-chan *bridge.UnifiedMessage { return a.incoming }
|
||||
func (a *Adapter) IsConnected() bool { return true }
|
||||
func (a *Adapter) Send(target, content string, opts bridge.SendOptions) error {
|
||||
fmt.Printf("[webhook:%s] -> %s: %s\n", a.cfg.Name, target, content); return nil
|
||||
}
|
||||
Reference in new issue
Block a user