Files
Cyrene_v2/backend/services/platform-bridge/qq/adapter.go
T
Aska 17ef73bcbe 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
2026-08-12 12:30:55 +08:00

164 lines
3.5 KiB
Go

// 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),
}
}