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:
Aska committed 2026-08-12 12:30:55 +08:00
1 parent 7e3ec8da93
commit 17ef73bcbe
7 files changed
+460

No files matched your search

+51
View File
@@ -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)
+135
View File
@@ -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
}