package qq import ( "context" "encoding/json" "fmt" "net/http" "sync" "time" "github.com/gorilla/websocket" "github.com/yourname/cyrene-ai/platform-bridge/internal/bridge" ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } // Adapter implements PlatformAdapter for QQ via OBv11 WebSocket. type Adapter struct { port string conn *websocket.Conn connMu sync.Mutex connected bool // Pending API call responses. pendingResponses map[string]chan *OBv11APIResponse respMu sync.Mutex } func NewAdapter(port string) *Adapter { return &Adapter{ port: port, pendingResponses: make(map[string]chan *OBv11APIResponse), } } func (a *Adapter) PlatformName() string { return "qq" } func (a *Adapter) Capabilities() bridge.PlatformCapabilities { return bridge.PlatformCapabilities{ MaxMessageLength: 200, SupportsMarkdown: true, // QQ supports basic markdown SupportsImage: true, SupportsVoice: false, SupportsEmoji: true, SupportsReaction: false, SupportsTypingHint: false, RecommendBurstMax: 4, } } func (a *Adapter) Connect(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("/ws/qq", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { fmt.Printf("[qq] upgrade error: %v\n", err) return } a.connMu.Lock() a.conn = conn a.connected = true a.connMu.Unlock() fmt.Println("[qq] bot connected") }) mux.HandleFunc("/ws/qq/event", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { fmt.Printf("[qq] event upgrade error: %v\n", err) return } a.connMu.Lock() a.conn = conn a.connected = true a.connMu.Unlock() fmt.Println("[qq] event WebSocket connected") }) addr := ":" + a.port srv := &http.Server{Addr: addr, Handler: mux} go func() { fmt.Printf("[qq] listening on %s (waiting for bot WebSocket connection)\n", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { fmt.Printf("[qq] server error: %v\n", err) } }() return nil } func (a *Adapter) Disconnect(ctx context.Context) error { a.connMu.Lock() defer a.connMu.Unlock() if a.conn != nil { a.conn.Close() a.conn = nil } a.connected = false return nil } func (a *Adapter) IsConnected() bool { a.connMu.Lock() defer a.connMu.Unlock() return a.connected } func (a *Adapter) HealthCheck() error { if !a.IsConnected() { return fmt.Errorf("QQ bot not connected") } return nil } // ToUnified converts an OBv11 message to UnifiedMessage. func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) { msg, ok := rawMessage.(*OBv11Message) if !ok { return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage) } // Extract text content. content := extractText(msg) // Determine sender. senderID := "" senderName := "unknown" channelType := "direct" channelID := "" switch msg.MessageType { case "private": senderID = fmt.Sprintf("%d", msg.UserID) channelType = "direct" channelID = fmt.Sprintf("private_%d", msg.UserID) if msg.Sender != nil { senderName = msg.Sender.Nickname } case "group": senderID = fmt.Sprintf("%d", msg.UserID) channelType = "group" channelID = fmt.Sprintf("%d", msg.GroupID) if msg.Sender != nil { if msg.Sender.Card != "" { senderName = msg.Sender.Card } else { senderName = msg.Sender.Nickname } } } // Extract mentions. var mentions []string if segments, ok := msg.Message.([]interface{}); ok { for _, s := range segments { if seg, ok := s.(map[string]interface{}); ok { if seg["type"] == "at" { if data, ok := seg["data"].(map[string]interface{}); ok { if qq, ok := data["qq"].(string); ok { mentions = append(mentions, qq) } } } } } } return &bridge.UnifiedMessage{ SenderID: senderID, SenderName: senderName, Platform: "qq", ChannelID: channelID, ChannelType: channelType, Content: content, ContentType: "text", MessageID: fmt.Sprintf("%d", msg.MessageID), Mentions: mentions, RawData: rawMessage, Timestamp: time.Unix(msg.Time, 0), }, nil } // FromUnified converts a unified response to QQ platform messages. func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) { var msgs []bridge.PlatformMessage for _, rm := range response.Messages { content := rm.Content if rm.FormatMode == "markdown" { content = convertMarkdownToQQ(rm.Content) } // QQ prefers shorter messages — split if needed. runes := []rune(content) if len(runes) > 200 { content = string(runes[:200]) } msgs = append(msgs, bridge.PlatformMessage{ Content: content, ReplyTo: response.ReplyTo, FormatMode: "text", }) } return msgs, nil } // SendMessage sends a message through the QQ WebSocket connection. func (a *Adapter) SendMessage(msgType string, userID, groupID int64, content string) error { a.connMu.Lock() conn := a.conn a.connMu.Unlock() if conn == nil { return fmt.Errorf("QQ bot not connected") } req := OBv11SendMsg{ Action: "send_msg", Params: OBv11Params{ MessageType: msgType, UserID: userID, GroupID: groupID, Message: content, }, Echo: fmt.Sprintf("echo_%d", time.Now().UnixNano()), } data, _ := json.Marshal(req) return conn.WriteMessage(websocket.TextMessage, data) } // ReadMessages reads OBv11 messages from the WebSocket connection. func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message) { for { select { case <-ctx.Done(): return default: } a.connMu.Lock() conn := a.conn a.connMu.Unlock() if conn == nil { time.Sleep(time.Second) continue } _, raw, err := conn.ReadMessage() if err != nil { fmt.Printf("[qq] read error: %v\n", err) a.connMu.Lock() a.conn = nil a.connected = false a.connMu.Unlock() time.Sleep(time.Second) continue } // Try to parse as OBv11 message (event from QQ). var msg OBv11Message if err := json.Unmarshal(raw, &msg); err != nil { // Might be an API response. var resp OBv11APIResponse if err := json.Unmarshal(raw, &resp); err != nil { fmt.Printf("[qq] unknown message: %s\n", string(raw)) continue } if resp.Echo != "" { a.respMu.Lock() if ch, ok := a.pendingResponses[resp.Echo]; ok { ch <- &resp delete(a.pendingResponses, resp.Echo) } a.respMu.Unlock() } continue } // Only handle message events. if msg.PostType == "message" { select { case msgCh <- &msg: case <-ctx.Done(): return } } } } // extractText retrieves plain text from an OBv11 message. func extractText(msg *OBv11Message) string { if msg.RawMessage != "" { return msg.RawMessage } switch m := msg.Message.(type) { case string: return m case []interface{}: var text string for _, seg := range m { if s, ok := seg.(map[string]interface{}); ok { if s["type"] == "text" { if data, ok := s["data"].(map[string]interface{}); ok { if t, ok := data["text"].(string); ok { text += t } } } } } return text } return "" } // convertMarkdownToQQ converts common markdown to QQ-supported format. func convertMarkdownToQQ(md string) string { // QQ supports basic markdown: **bold**, *italic*, ~~strikethrough~~ // Remove unsupported elements (headings, code blocks, links). md = removeHeadings(md) md = removeCodeBlocks(md) // Preserve bold, italic, strikethrough which QQ supports. return md } func removeHeadings(s string) string { for _, line := range splitLines(s) { if len(line) > 0 && line[0] == '#' { s = replaceLine(s, line, stripPrefix(line, "# ")) } } return s } func removeCodeBlocks(s string) string { // Simple: remove ``` markers. result := "" inCode := false for _, line := range splitLines(s) { if hasPrefix(line, "```") { inCode = !inCode continue } if inCode { result += line + "\n" } else { result += line + "\n" } } return result } func splitLines(s string) []string { var lines []string start := 0 for i, c := range s { if c == '\n' { lines = append(lines, s[start:i]) start = i + 1 } } if start < len(s) { lines = append(lines, s[start:]) } return lines } func hasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix } func stripPrefix(s, prefix string) string { if len(s) >= len(prefix) && s[:len(prefix)] == prefix { return s[len(prefix):] } return s } func replaceLine(s, old, new string) string { // Simple: find old line and replace with new. idx := indexOf(s, old) if idx < 0 { return s } return s[:idx] + new + s[idx+len(old):] } func indexOf(s, sub string) int { for i := 0; i <= len(s)-len(sub); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }