package qqbot import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "unicode/utf8" "github.com/chenhg5/cc-connect/core" "github.com/gorilla/websocket" ) func init() { core.RegisterPlatform("qqbot", New) } const ( // Default intents: GROUP_AT_MESSAGE_CREATE (1 << 25) | INTERACTION_CREATE (1 << 26) defaultIntents = (1 << 25) | (1 << 26) maxReconnectBackoff = 60 * time.Second maxReconnectAttempts = 30 tokenRefreshMargin = 5 * time.Minute messageMaxLen = 2000 msgSeqTTL = 5 * time.Minute messageCacheTTL = 7 * 24 * time.Hour messageCacheMaxItems = 1000 quotedTextMaxRunes = 800 // Max attachment download size (20MB), aligned with QQ Bot platform limits. maxAttachmentSize = 20 * 1024 * 1024 ) var ( apiBaseProduction = "https://api.sgroup.qq.com" apiBaseSandbox = "https://sandbox.api.sgroup.qq.com" tokenURL = "https://bots.qq.com/app/getAppAccessToken" ) // WebSocket opcodes for the QQ Bot gateway protocol. const ( opDispatch = 0 opHeartbeat = 1 opIdentify = 2 opResume = 6 opReconnect = 7 opInvalidSession = 9 opHello = 10 opHeartbeatACK = 11 ) // Platform implements core.Platform for the official QQ Bot API v2. type Platform struct { appID string appSecret string sandbox bool allowFrom string shareSessionInChannel bool intents int markdownSupport bool // enable markdown messages (msg_type: 2) handler core.MessageHandler ctx context.Context // lifetime context for the platform cancel context.CancelFunc // OAuth2 token management token string tokenExpiry time.Time tokenMu sync.RWMutex // WebSocket state wsConn *websocket.Conn wsMu sync.Mutex sessionID string lastSeq atomic.Int64 heartbeatMs int heartbeatOK atomic.Bool reconnecting atomic.Bool connCancel context.CancelFunc // cancels per-connection goroutines (heartbeatLoop, readLoop) // Message dedup dedup core.MessageDedup // msg_seq counter per event msg_id (for multiple replies to same event) msgSeqMu sync.Mutex msgSeqMap map[string]*msgSeqEntry messageCacheMu sync.Mutex messageCache map[string]cachedMessage messageCachePath string } // msgSeqEntry tracks msg_seq counter with a creation timestamp for TTL eviction. type msgSeqEntry struct { seq atomic.Int32 createdAt time.Time } type cachedMessage struct { Content string `json:"content"` UpdatedAt time.Time `json:"updated_at"` } // replyContext carries the information needed to reply to a QQ Bot message. type replyContext struct { messageType string // "group" or "c2c" groupOpenID string // for group messages userOpenID string // user's openid (member_openid for group, user_openid for c2c) eventMsgID string // msg_id from the incoming event, used for passive reply sessionKey string // pre-computed session key for button_data embedding and session routing } type quotedMessage struct { Content string `json:"content,omitempty"` Title string `json:"title,omitempty"` Attachments []attachment `json:"attachments,omitempty"` } type messageReference struct { MessageID string `json:"message_id"` Content string `json:"content,omitempty"` Title string `json:"title,omitempty"` Message *quotedMessage `json:"message,omitempty"` ReferencedMessage *quotedMessage `json:"referenced_message,omitempty"` SourceMessage *quotedMessage `json:"source_message,omitempty"` } // msgTypeQuote indicates a quote (reply) message in the QQ Bot API. const msgTypeQuote = 103 // msgElement represents a message element in QQ Bot event. // For quote messages (message_type=103), msg_elements[0] contains the quoted content. type msgElement struct { Content string `json:"content"` Attachments []attachment `json:"attachments"` } // New creates a new QQ Bot platform from config options. func New(opts map[string]any) (core.Platform, error) { appID, _ := opts["app_id"].(string) if appID == "" { return nil, fmt.Errorf("qqbot: app_id is required") } appSecret, _ := opts["app_secret"].(string) if appSecret == "" { return nil, fmt.Errorf("qqbot: app_secret is required") } sandbox, _ := opts["sandbox"].(bool) allowFrom, _ := opts["allow_from"].(string) shareSessionInChannel, _ := opts["share_session_in_channel"].(bool) markdownSupport, _ := opts["markdown_support"].(bool) intents := defaultIntents if v, ok := opts["intents"].(int); ok && v > 0 { intents = v } else if v, ok := opts["intents"].(float64); ok && v > 0 { intents = int(v) } core.CheckAllowFrom("qqbot", allowFrom) dataDir, _ := opts["cc_data_dir"].(string) return &Platform{ appID: appID, appSecret: appSecret, sandbox: sandbox, allowFrom: allowFrom, shareSessionInChannel: shareSessionInChannel, intents: intents, markdownSupport: markdownSupport, messageCachePath: qqbotMessageCachePath(dataDir), }, nil } func (p *Platform) Name() string { return "qqbot" } // Start connects to the QQ Bot gateway and begins receiving events. func (p *Platform) Start(handler core.MessageHandler) error { p.handler = handler if err := p.loadMessageCache(); err != nil { slog.Warn("qqbot: load message cache failed", "error", err) } // Get initial access token if err := p.refreshToken(); err != nil { return fmt.Errorf("qqbot: failed to get access token: %w", err) } ctx, cancel := context.WithCancel(context.Background()) p.ctx = ctx p.cancel = cancel if err := p.connectGateway(ctx); err != nil { cancel() return fmt.Errorf("qqbot: failed to connect gateway: %w", err) } slog.Info("qqbot: connected to QQ Bot gateway", "sandbox", p.sandbox) return nil } // Reply sends a message as a reply to an incoming message. func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error { return p.Send(ctx, replyCtx, content) } // Send sends a message to the conversation identified by replyCtx. func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error { rctx, ok := replyCtx.(*replyContext) if !ok { return fmt.Errorf("qqbot: invalid reply context") } chunks := core.SplitMessageCodeFenceAware(content, messageMaxLen) for _, chunk := range chunks { if err := p.sendMessage(rctx, chunk); err != nil { return err } } return nil } // SendImage uploads and sends an image via QQ Bot rich media API. // Implements core.ImageSender. func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error { rctx, ok := replyCtx.(*replyContext) if !ok { return fmt.Errorf("qqbot: SendImage: invalid reply context type %T", replyCtx) } fileInfo, err := p.uploadRichMedia(rctx, 1, img.Data, "") if err != nil { return fmt.Errorf("qqbot: upload image: %w", err) } var url string switch rctx.messageType { case "group": url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID) case "c2c": url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID) default: return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType) } body := map[string]any{ "msg_type": 7, "media": map[string]any{"file_info": fileInfo}, } if rctx.eventMsgID != "" { body["msg_id"] = rctx.eventMsgID body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID) } return p.apiRequest("POST", url, body) } // uploadRichMedia uploads a file to QQ Bot rich media API and returns the file_info. // fileType: 1=image, 2=video, 3=audio, 4=file. func (p *Platform) uploadRichMedia(rctx *replyContext, fileType int, data []byte, fileName string) (string, error) { var url string switch rctx.messageType { case "group": url = fmt.Sprintf("%s/v2/groups/%s/files", p.apiBase(), rctx.groupOpenID) case "c2c": url = fmt.Sprintf("%s/v2/users/%s/files", p.apiBase(), rctx.userOpenID) default: return "", fmt.Errorf("qqbot: unknown message type %q", rctx.messageType) } b64 := base64.StdEncoding.EncodeToString(data) reqBody := map[string]any{ "file_type": fileType, "file_data": b64, "srv_send_msg": false, } if fileType == 4 && fileName != "" { reqBody["file_name"] = fileName } var result struct { FileInfo string `json:"file_info"` } if err := p.apiRequestJSON("POST", url, reqBody, &result); err != nil { return "", err } if result.FileInfo == "" { return "", fmt.Errorf("qqbot: upload rich media: empty file_info") } return result.FileInfo, nil } // apiRequestJSON is like apiRequest but also decodes the response body into result. func (p *Platform) apiRequestJSON(method, url string, body any, result any) error { var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return fmt.Errorf("qqbot: marshal body: %w", err) } bodyReader = bytes.NewReader(data) } token, err := p.getAccessToken() if err != nil { return fmt.Errorf("qqbot: get token: %w", err) } req, err := http.NewRequest(method, url, bodyReader) if err != nil { return err } req.Header.Set("Authorization", "QQBot "+token) req.Header.Set("Content-Type", "application/json") resp, err := core.HTTPClient.Do(req) if err != nil { return fmt.Errorf("qqbot: api request failed: %w", err) } defer resp.Body.Close() // Retry once on 401 if resp.StatusCode == http.StatusUnauthorized { if err := p.refreshToken(); err != nil { return fmt.Errorf("qqbot: token refresh on 401: %w", err) } token, _ = p.getAccessToken() if body != nil { data, _ := json.Marshal(body) bodyReader = bytes.NewReader(data) } req2, err := http.NewRequest(method, url, bodyReader) if err != nil { return fmt.Errorf("qqbot: build retry request: %w", err) } req2.Header.Set("Authorization", "QQBot "+token) req2.Header.Set("Content-Type", "application/json") resp2, err := core.HTTPClient.Do(req2) if err != nil { return fmt.Errorf("qqbot: api retry failed: %w", err) } defer resp2.Body.Close() if resp2.StatusCode >= 300 { raw, _ := io.ReadAll(resp2.Body) return fmt.Errorf("qqbot: api %s %s returned %d (after retry): %s", method, url, resp2.StatusCode, raw) } if result != nil { if err := json.NewDecoder(resp2.Body).Decode(result); err != nil { return fmt.Errorf("qqbot: decode response: %w", err) } } return nil } if resp.StatusCode >= 300 { raw, _ := io.ReadAll(resp.Body) return fmt.Errorf("qqbot: api %s %s returned %d: %s", method, url, resp.StatusCode, raw) } if result != nil { if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return fmt.Errorf("qqbot: decode response: %w", err) } } return nil } var _ core.ImageSender = (*Platform)(nil) // buttonDataPrefix is the prefix for QQ Bot keyboard button_data values. // Format: perm:: const buttonDataPrefix = "perm:" // SendFile uploads and sends a file via QQ Bot rich media API. // Implements core.FileSender. func (p *Platform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error { rctx, ok := replyCtx.(*replyContext) if !ok { return fmt.Errorf("qqbot: SendFile: invalid reply context type %T", replyCtx) } fileInfo, err := p.uploadRichMedia(rctx, 4, file.Data, file.FileName) if err != nil { return fmt.Errorf("qqbot: upload file: %w", err) } var url string switch rctx.messageType { case "group": url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID) case "c2c": url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID) default: return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType) } body := map[string]any{ "msg_type": 7, "media": map[string]any{"file_info": fileInfo}, } if rctx.eventMsgID != "" { body["msg_id"] = rctx.eventMsgID body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID) } return p.apiRequest("POST", url, body) } var _ core.FileSender = (*Platform)(nil) var _ core.InlineButtonSender = (*Platform)(nil) // SendWithButtons sends a message with QQ Bot inline keyboard buttons. // Implements core.InlineButtonSender. func (p *Platform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error { rctx, ok := replyCtx.(*replyContext) if !ok { return fmt.Errorf("qqbot: SendWithButtons: invalid reply context type %T", replyCtx) } // Use session key from replyContext to embed in button_data sessionKey := rctx.sessionKey if sessionKey == "" { return fmt.Errorf("qqbot: empty session key in reply context") } // Build QQ Bot keyboard rows from button options var rows []map[string]any for i, row := range buttons { var btns []map[string]any for j, btn := range row { // Encode decision + session key into button_data so we can route // the INTERACTION_CREATE event back to the right session. // btn.Data is already "perm:allow", "perm:deny", or "perm:allow_all" buttonData := btn.Data + ":" + sessionKey btnID := fmt.Sprintf("b_%d_%d", i, j) visitedLabel := "已操作" style := 1 // blue if strings.Contains(btn.Data, "deny") { visitedLabel = "已拒绝" style = 0 // grey } else if strings.Contains(btn.Data, "allow_all") || strings.Contains(btn.Data, "allow all") { visitedLabel = "已始终允许" } else if strings.Contains(btn.Data, "allow") { visitedLabel = "已允许" } btns = append(btns, map[string]any{ "id": btnID, "render_data": map[string]any{ "label": btn.Text, "visited_label": visitedLabel, "style": style, }, "action": map[string]any{ "type": 1, // callback "data": buttonData, "permission": map[string]int{"type": 2}, "click_limit": 1, }, "group_id": "perm", }) } rows = append(rows, map[string]any{"buttons": btns}) } keyboard := map[string]any{ "content": map[string]any{"rows": rows}, } // Send message with keyboard. // When markdown support is enabled, use msg_type 2 so QQ Bot // properly renders the keyboard alongside markdown content. msgType := 0 sendContent := content if p.markdownSupport { msgType = 2 } body := map[string]any{ "msg_type": msgType, "keyboard": keyboard, } if p.markdownSupport { body["markdown"] = map[string]any{"content": sendContent} } else { body["content"] = sendContent } if rctx.eventMsgID != "" { body["msg_id"] = rctx.eventMsgID body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID) } var url string switch rctx.messageType { case "group": url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID) case "c2c": url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID) default: return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType) } slog.Debug("qqbot: sending message with keyboard", "type", rctx.messageType, "session_key", sessionKey, "num_buttons", len(rows), "content_len", len(content)) return p.apiRequest("POST", url, body) } // Stop shuts down the platform. func (p *Platform) Stop() error { if p.cancel != nil { p.cancel() } p.wsMu.Lock() defer p.wsMu.Unlock() if p.wsConn != nil { return p.wsConn.Close() } return nil } // ReconstructReplyCtx implements core.ReplyContextReconstructor. // Session key format: "qqbot:{group_openid}:{member_openid}", "qqbot:g:{group_openid}" or "qqbot:{user_openid}" func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { parts := strings.SplitN(sessionKey, ":", 3) if len(parts) < 2 || parts[0] != "qqbot" { return nil, fmt.Errorf("qqbot: invalid session key %q", sessionKey) } if len(parts) == 3 { if parts[1] == "g" { return &replyContext{ messageType: "group", groupOpenID: parts[2], sessionKey: sessionKey, }, nil } return &replyContext{ messageType: "group", groupOpenID: parts[1], userOpenID: parts[2], sessionKey: sessionKey, }, nil } return &replyContext{ messageType: "c2c", userOpenID: parts[1], sessionKey: sessionKey, }, nil } // --------------------------------------------------------------------------- // OAuth2 Token Management // --------------------------------------------------------------------------- func (p *Platform) refreshToken() error { body, _ := json.Marshal(map[string]string{ "appId": p.appID, "clientSecret": p.appSecret, }) resp, err := core.HTTPClient.Post(tokenURL, "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("token request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(resp.Body) return fmt.Errorf("token request returned %d: %s", resp.StatusCode, raw) } var result struct { AccessToken string `json:"access_token"` ExpiresIn string `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return fmt.Errorf("token response decode: %w", err) } if result.AccessToken == "" { return fmt.Errorf("empty access_token in response") } var expiresSec int _, _ = fmt.Sscanf(result.ExpiresIn, "%d", &expiresSec) if expiresSec <= 0 { expiresSec = 7200 } p.tokenMu.Lock() p.token = result.AccessToken p.tokenExpiry = time.Now().Add(time.Duration(expiresSec) * time.Second) p.tokenMu.Unlock() slog.Debug("qqbot: access token refreshed", "expires_in", expiresSec) return nil } // getAccessToken returns the current token, refreshing if expired or near-expiry. func (p *Platform) getAccessToken() (string, error) { p.tokenMu.RLock() token := p.token expiry := p.tokenExpiry p.tokenMu.RUnlock() if token != "" && time.Now().Before(expiry.Add(-tokenRefreshMargin)) { return token, nil } if err := p.refreshToken(); err != nil { return "", err } p.tokenMu.RLock() defer p.tokenMu.RUnlock() return p.token, nil } // --------------------------------------------------------------------------- // WebSocket Gateway // --------------------------------------------------------------------------- func (p *Platform) connectGateway(ctx context.Context) error { token, err := p.getAccessToken() if err != nil { return err } // Get gateway URL gatewayURL, err := p.getGatewayURL(token) if err != nil { return err } // Connect WebSocket conn, _, err := websocket.DefaultDialer.Dial(gatewayURL, nil) if err != nil { return fmt.Errorf("ws connect: %w", err) } p.wsMu.Lock() p.wsConn = conn p.wsMu.Unlock() // Wait for Hello (op 10) if err := p.waitForHello(conn); err != nil { conn.Close() return err } // Send Identify (op 2) if err := p.sendIdentify(conn, token); err != nil { conn.Close() return err } // Wait for READY event if err := p.waitForReady(conn); err != nil { conn.Close() return err } // Start heartbeat and read loop with a per-connection context // so we can cancel them cleanly on reconnect. connCtx, connCancel := context.WithCancel(ctx) p.connCancel = connCancel go p.heartbeatLoop(connCtx) go p.readLoop(connCtx) return nil } func (p *Platform) getGatewayURL(token string) (string, error) { req, err := http.NewRequest("GET", p.apiBase()+"/gateway/bot", nil) if err != nil { return "", err } req.Header.Set("Authorization", "QQBot "+token) resp, err := core.HTTPClient.Do(req) if err != nil { return "", fmt.Errorf("gateway request failed: %w", err) } defer resp.Body.Close() var result struct { URL string `json:"url"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("gateway response decode: %w", err) } if result.URL == "" { return "", fmt.Errorf("empty gateway URL in response") } return result.URL, nil } type wsPayload struct { Op int `json:"op"` D json.RawMessage `json:"d,omitempty"` S *int64 `json:"s,omitempty"` T string `json:"t,omitempty"` } func (p *Platform) waitForHello(conn *websocket.Conn) error { _ = conn.SetReadDeadline(time.Now().Add(15 * time.Second)) defer func() { _ = conn.SetReadDeadline(time.Time{}) }() var msg wsPayload if err := conn.ReadJSON(&msg); err != nil { return fmt.Errorf("waiting for hello: %w", err) } if msg.Op != opHello { return fmt.Errorf("expected op 10 (Hello), got op %d", msg.Op) } var hello struct { HeartbeatInterval int `json:"heartbeat_interval"` } if err := json.Unmarshal(msg.D, &hello); err != nil { slog.Warn("qqbot: failed to parse Hello payload", "error", err) } if hello.HeartbeatInterval > 0 { p.heartbeatMs = hello.HeartbeatInterval } else { p.heartbeatMs = 41250 // sane default } p.heartbeatOK.Store(true) slog.Debug("qqbot: received Hello", "heartbeat_interval", p.heartbeatMs) return nil } func (p *Platform) sendIdentify(conn *websocket.Conn, token string) error { identify := map[string]any{ "op": opIdentify, "d": map[string]any{ "token": fmt.Sprintf("QQBot %s", token), "intents": p.intents, "shard": [2]int{0, 1}, }, } p.wsMu.Lock() err := conn.WriteJSON(identify) p.wsMu.Unlock() return err } func (p *Platform) waitForReady(conn *websocket.Conn) error { _ = conn.SetReadDeadline(time.Now().Add(15 * time.Second)) defer func() { _ = conn.SetReadDeadline(time.Time{}) }() var msg wsPayload if err := conn.ReadJSON(&msg); err != nil { return fmt.Errorf("waiting for ready: %w", err) } if msg.Op != opDispatch || msg.T != "READY" { return fmt.Errorf("expected READY event, got op=%d t=%s", msg.Op, msg.T) } var ready struct { SessionID string `json:"session_id"` } if err := json.Unmarshal(msg.D, &ready); err != nil { slog.Warn("qqbot: failed to parse READY payload", "error", err) } p.sessionID = ready.SessionID if msg.S != nil { p.lastSeq.Store(*msg.S) } slog.Info("qqbot: gateway READY", "session_id", p.sessionID) return nil } func (p *Platform) heartbeatLoop(ctx context.Context) { ticker := time.NewTicker(time.Duration(p.heartbeatMs) * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if !p.heartbeatOK.Load() { slog.Warn("qqbot: no heartbeat ACK received, reconnecting") p.triggerReconnect(ctx) return } p.heartbeatOK.Store(false) p.sendHeartbeat() } } } func (p *Platform) sendHeartbeat() { seq := p.lastSeq.Load() var d json.RawMessage if seq > 0 { d, _ = json.Marshal(seq) } else { d = json.RawMessage("null") } p.wsMu.Lock() defer p.wsMu.Unlock() if p.wsConn != nil { if err := p.wsConn.WriteJSON(wsPayload{Op: opHeartbeat, D: d}); err != nil { slog.Debug("qqbot: heartbeat send failed", "error", err) } } } func (p *Platform) readLoop(ctx context.Context) { for { select { case <-ctx.Done(): return default: } p.wsMu.Lock() conn := p.wsConn p.wsMu.Unlock() if conn == nil { return } var msg wsPayload if err := conn.ReadJSON(&msg); err != nil { if ctx.Err() != nil { return } slog.Error("qqbot: ws read error, reconnecting", "error", err) p.triggerReconnect(ctx) return } if msg.S != nil { p.lastSeq.Store(*msg.S) } switch msg.Op { case opDispatch: p.handleDispatch(msg.T, msg.D) case opHeartbeat: // Server requested heartbeat p.sendHeartbeat() case opReconnect: slog.Info("qqbot: server requested reconnect") p.triggerReconnect(ctx) return case opInvalidSession: var resumable bool if err := json.Unmarshal(msg.D, &resumable); err != nil { slog.Warn("qqbot: failed to parse InvalidSession payload", "error", err) } if resumable { slog.Info("qqbot: invalid session (resumable), attempting resume") } else { slog.Info("qqbot: invalid session (not resumable), re-identifying") p.sessionID = "" } p.triggerReconnect(ctx) return case opHeartbeatACK: p.heartbeatOK.Store(true) } } } func (p *Platform) triggerReconnect(_ context.Context) { if p.reconnecting.CompareAndSwap(false, true) { go func() { defer p.reconnecting.Store(false) // Use the platform lifetime context, NOT the per-connection context // that was just canceled. The caller's ctx is a child of connCtx // which connCancel() will cancel inside reconnectLoop. p.reconnectLoop(p.ctx) }() } } func (p *Platform) reconnectLoop(ctx context.Context) { // Cancel old heartbeatLoop/readLoop goroutines before closing the // connection, so they stop promptly instead of racing with the new pair. if p.connCancel != nil { p.connCancel() } // Close existing connection p.wsMu.Lock() if p.wsConn != nil { p.wsConn.Close() p.wsConn = nil } p.wsMu.Unlock() backoff := time.Second for attempt := 1; attempt <= maxReconnectAttempts; attempt++ { select { case <-ctx.Done(): return default: } time.Sleep(backoff) // Refresh token before reconnecting (may have expired) if err := p.refreshToken(); err != nil { slog.Warn("qqbot: token refresh failed during reconnect", "error", err, "attempt", attempt) backoff = min(backoff*2, maxReconnectBackoff) continue } token, _ := p.getAccessToken() gatewayURL, err := p.getGatewayURL(token) if err != nil { slog.Warn("qqbot: get gateway failed during reconnect", "error", err, "attempt", attempt) backoff = min(backoff*2, maxReconnectBackoff) continue } conn, _, err := websocket.DefaultDialer.Dial(gatewayURL, nil) if err != nil { slog.Warn("qqbot: ws reconnect failed", "error", err, "attempt", attempt, "backoff", backoff) backoff = min(backoff*2, maxReconnectBackoff) continue } p.wsMu.Lock() p.wsConn = conn p.wsMu.Unlock() if err := p.waitForHello(conn); err != nil { slog.Warn("qqbot: hello failed during reconnect", "error", err) p.wsMu.Lock() p.wsConn = nil p.wsMu.Unlock() conn.Close() backoff = min(backoff*2, maxReconnectBackoff) continue } // closeAndNil closes the conn and clears p.wsConn so Stop() // and other code paths don't operate on a stale reference. closeAndNil := func() { p.wsMu.Lock() p.wsConn = nil p.wsMu.Unlock() conn.Close() } // Try Resume if we have a session_id, otherwise Identify if p.sessionID != "" { if err := p.sendResume(conn, token); err != nil { slog.Warn("qqbot: resume failed, falling back to identify", "error", err) p.sessionID = "" if err := p.sendIdentify(conn, token); err != nil { closeAndNil() backoff = min(backoff*2, maxReconnectBackoff) continue } if err := p.waitForReady(conn); err != nil { closeAndNil() backoff = min(backoff*2, maxReconnectBackoff) continue } } } else { if err := p.sendIdentify(conn, token); err != nil { closeAndNil() backoff = min(backoff*2, maxReconnectBackoff) continue } if err := p.waitForReady(conn); err != nil { closeAndNil() backoff = min(backoff*2, maxReconnectBackoff) continue } } slog.Info("qqbot: reconnected successfully") connCtx, connCancel := context.WithCancel(ctx) p.connCancel = connCancel go p.heartbeatLoop(connCtx) go p.readLoop(connCtx) return } slog.Error("qqbot: failed to reconnect after max attempts", "attempts", maxReconnectAttempts) } func (p *Platform) sendResume(conn *websocket.Conn, token string) error { resume := map[string]any{ "op": opResume, "d": map[string]any{ "token": fmt.Sprintf("QQBot %s", token), "session_id": p.sessionID, "seq": p.lastSeq.Load(), }, } p.wsMu.Lock() err := conn.WriteJSON(resume) p.wsMu.Unlock() return err } // --------------------------------------------------------------------------- // Event Handling // --------------------------------------------------------------------------- func (p *Platform) handleDispatch(eventType string, data json.RawMessage) { switch eventType { case "GROUP_AT_MESSAGE_CREATE": p.handleGroupMessage(data) case "C2C_MESSAGE_CREATE": p.handleC2CMessage(data) case "INTERACTION_CREATE": p.handleInteractionCreate(data) case "RESUMED": slog.Info("qqbot: session resumed successfully") default: slog.Debug("qqbot: unhandled event", "type", eventType) } } // handleInteractionCreate handles inline keyboard button click events. // When a user clicks a button on a message with keyboard, QQ Bot dispatches // an INTERACTION_CREATE event. This method parses the button_data to extract // the permission decision and session key, then creates a synthetic message // so the engine can process it as a permission response. func (p *Platform) handleInteractionCreate(data json.RawMessage) { var d struct { ID string `json:"id"` GroupOpenID string `json:"group_openid"` GroupMemberOpenID string `json:"group_member_openid"` UserOpenID string `json:"user_openid"` ChatType int `json:"chat_type"` // 1=group, 2=c2c Data struct { Type int `json:"type"` Resolved struct { ButtonData string `json:"button_data"` ButtonID string `json:"button_id"` } `json:"resolved"` } `json:"data"` } if err := json.Unmarshal(data, &d); err != nil { slog.Warn("qqbot: failed to parse interaction create event", "error", err) return } if d.ID == "" { return } slog.Debug("qqbot: INTERACTION_CREATE", "id", d.ID, "button_data", d.Data.Resolved.ButtonData) // ACK the interaction (required by QQ Bot API to prevent "请求超时" on buttons) _ = p.ackInteraction(d.ID) // Parse button_data: perm:: buttonData := d.Data.Resolved.ButtonData if !strings.HasPrefix(buttonData, buttonDataPrefix) { slog.Debug("qqbot: unknown interaction button_data format", "data", buttonData) return } rest := strings.TrimPrefix(buttonData, buttonDataPrefix) // rest = ":" colonIdx := strings.Index(rest, ":") if colonIdx < 0 { slog.Warn("qqbot: invalid interaction button_data", "data", buttonData) return } decision := rest[:colonIdx] sessionKey := rest[colonIdx+1:] if decision == "" || sessionKey == "" { slog.Warn("qqbot: empty decision or session_key in button_data", "data", buttonData) return } // Map decision to response text the engine understands var responseText string switch decision { case "allow": responseText = "allow" case "deny": responseText = "deny" case "allow_all": responseText = "allow all" default: slog.Warn("qqbot: unknown interaction decision", "decision", decision) return } // Build reply context for sending confirmation messages var rctx *replyContext var userID string switch d.ChatType { case 1: // group rctx = &replyContext{ messageType: "group", groupOpenID: d.GroupOpenID, userOpenID: d.GroupMemberOpenID, sessionKey: sessionKey, } userID = d.GroupMemberOpenID case 2: // c2c rctx = &replyContext{ messageType: "c2c", userOpenID: d.UserOpenID, sessionKey: sessionKey, } userID = d.UserOpenID default: slog.Warn("qqbot: unknown interaction chat_type", "chat_type", d.ChatType) return } // Create synthetic message and forward to engine as a permission response msg := &core.Message{ SessionKey: sessionKey, Platform: "qqbot", MessageID: d.ID, UserID: userID, Content: responseText, ReplyCtx: rctx, } slog.Debug("qqbot: forwarding button click as permission response", "decision", decision, "session_key", sessionKey, "chat_type", d.ChatType) p.handler(p, msg) } // ackInteraction acknowledges an INTERACTION_CREATE event. // Uses the same pattern as hermes-agent: PUT /interactions/{id} // with JSON body {"code": 0}. Note: no /v2/ prefix for this endpoint. func (p *Platform) ackInteraction(interactionID string) error { url := fmt.Sprintf("%s/interactions/%s", p.apiBase(), interactionID) body := map[string]int{"code": 0} return p.apiRequestJSON("PUT", url, body, nil) } func (p *Platform) handleGroupMessage(data json.RawMessage) { var d struct { ID string `json:"id"` GroupOpenID string `json:"group_openid"` Content string `json:"content"` Timestamp string `json:"timestamp"` Attachments []attachment `json:"attachments"` MessageReference *messageReference `json:"message_reference"` MessageType *int `json:"message_type"` MsgElements []msgElement `json:"msg_elements"` Author struct { MemberOpenID string `json:"member_openid"` } `json:"author"` } if err := json.Unmarshal(data, &d); err != nil { slog.Warn("qqbot: failed to parse group message", "error", err) return } if d.ID == "" || d.GroupOpenID == "" { return } // Check timestamp for old messages if ts, err := time.Parse(time.RFC3339, d.Timestamp); err == nil { if core.IsOldMessage(ts) { slog.Debug("qqbot: ignoring old group message", "id", d.ID) return } } if p.dedup.IsDuplicate(d.ID) { slog.Debug("qqbot: duplicate group message ignored", "id", d.ID) return } if !core.AllowList(p.allowFrom, d.Author.MemberOpenID) { return } // Strip leading @bot mention (the official API includes it as content prefix) content := stripAtMention(d.Content) quotedText := p.resolveQuotedText(d.MessageReference) if quotedText == "" && d.MessageType != nil && *d.MessageType == msgTypeQuote { quotedText = quotedTextFromElements(d.MsgElements) } content = prependQuotedMessage(quotedText, content) images := downloadAttachmentImages(d.Attachments) files := downloadAttachmentFiles(d.Attachments) p.cacheMessage(d.ID, contentOrAttachmentSummary(content, d.Attachments)) if content == "" && len(images) == 0 && len(files) == 0 { return } var sessionKey string if p.shareSessionInChannel { sessionKey = fmt.Sprintf("qqbot:g:%s", d.GroupOpenID) } else { sessionKey = fmt.Sprintf("qqbot:%s:%s", d.GroupOpenID, d.Author.MemberOpenID) } rctx := &replyContext{ messageType: "group", groupOpenID: d.GroupOpenID, userOpenID: d.Author.MemberOpenID, eventMsgID: d.ID, sessionKey: sessionKey, } msg := &core.Message{ SessionKey: sessionKey, Platform: "qqbot", MessageID: d.ID, UserID: d.Author.MemberOpenID, UserName: d.Author.MemberOpenID, // official API only provides openid, no nickname ChatName: d.GroupOpenID, // group openid as fallback (no group name API) Content: content, Images: images, Files: files, ReplyCtx: rctx, } slog.Debug("qqbot: group message received", "group", d.GroupOpenID, "user", d.Author.MemberOpenID, "len", len(content), "images", len(images), "files", len(files)) p.handler(p, msg) } func (p *Platform) handleC2CMessage(data json.RawMessage) { var d struct { ID string `json:"id"` Content string `json:"content"` Timestamp string `json:"timestamp"` Attachments []attachment `json:"attachments"` MessageReference *messageReference `json:"message_reference"` MessageType *int `json:"message_type"` MsgElements []msgElement `json:"msg_elements"` Author struct { UserOpenID string `json:"user_openid"` } `json:"author"` } if err := json.Unmarshal(data, &d); err != nil { slog.Warn("qqbot: failed to parse c2c message", "error", err) return } if d.ID == "" { return } // Check timestamp for old messages if ts, err := time.Parse(time.RFC3339, d.Timestamp); err == nil { if core.IsOldMessage(ts) { slog.Debug("qqbot: ignoring old c2c message", "id", d.ID) return } } if p.dedup.IsDuplicate(d.ID) { slog.Debug("qqbot: duplicate c2c message ignored", "id", d.ID) return } if !core.AllowList(p.allowFrom, d.Author.UserOpenID) { return } content := strings.TrimSpace(d.Content) quotedText := p.resolveQuotedText(d.MessageReference) if quotedText == "" && d.MessageType != nil && *d.MessageType == msgTypeQuote { quotedText = quotedTextFromElements(d.MsgElements) } content = prependQuotedMessage(quotedText, content) // Download image and file attachments images := downloadAttachmentImages(d.Attachments) files := downloadAttachmentFiles(d.Attachments) p.cacheMessage(d.ID, contentOrAttachmentSummary(content, d.Attachments)) if content == "" && len(images) == 0 && len(files) == 0 { return } sessionKey := fmt.Sprintf("qqbot:%s", d.Author.UserOpenID) rctx := &replyContext{ messageType: "c2c", userOpenID: d.Author.UserOpenID, eventMsgID: d.ID, sessionKey: sessionKey, } msg := &core.Message{ SessionKey: sessionKey, Platform: "qqbot", MessageID: d.ID, UserID: d.Author.UserOpenID, UserName: d.Author.UserOpenID, Content: content, Images: images, Files: files, ReplyCtx: rctx, } slog.Debug("qqbot: c2c message received", "user", d.Author.UserOpenID, "len", len(content), "images", len(images), "files", len(files)) p.handler(p, msg) } // --------------------------------------------------------------------------- // Message Sending // --------------------------------------------------------------------------- func (p *Platform) sendMessage(rctx *replyContext, content string) error { var url string switch rctx.messageType { case "group": url = fmt.Sprintf("%s/v2/groups/%s/messages", p.apiBase(), rctx.groupOpenID) case "c2c": url = fmt.Sprintf("%s/v2/users/%s/messages", p.apiBase(), rctx.userOpenID) default: return fmt.Errorf("qqbot: unknown message type %q", rctx.messageType) } var body map[string]any if p.markdownSupport { // Markdown format (msg_type: 2) body = map[string]any{ "markdown": map[string]any{ "content": content, }, "msg_type": 2, } slog.Debug("qqbot: sending markdown message", "content_len", len(content), "msg_type", 2) } else { // Plain text format (msg_type: 0) body = map[string]any{ "content": content, "msg_type": 0, } slog.Debug("qqbot: sending text message", "content_len", len(content), "msg_type", 0) } // Include msg_id for passive reply if available if rctx.eventMsgID != "" { body["msg_id"] = rctx.eventMsgID body["msg_seq"] = p.nextMsgSeq(rctx.eventMsgID) } var resp struct { ID string `json:"id"` MsgID string `json:"msg_id"` } if err := p.apiRequestJSON("POST", url, body, &resp); err != nil { return err } msgID := strings.TrimSpace(resp.ID) if msgID == "" { msgID = strings.TrimSpace(resp.MsgID) } if msgID != "" { p.cacheMessage(msgID, content) } return nil } func (p *Platform) nextMsgSeq(eventMsgID string) int32 { p.msgSeqMu.Lock() defer p.msgSeqMu.Unlock() if p.msgSeqMap == nil { p.msgSeqMap = make(map[string]*msgSeqEntry) } // Evict expired entries now := time.Now() for k, v := range p.msgSeqMap { if now.Sub(v.createdAt) > msgSeqTTL { delete(p.msgSeqMap, k) } } entry, ok := p.msgSeqMap[eventMsgID] if !ok { entry = &msgSeqEntry{createdAt: now} p.msgSeqMap[eventMsgID] = entry } return entry.seq.Add(1) } // --------------------------------------------------------------------------- // HTTP API Helper // --------------------------------------------------------------------------- func (p *Platform) apiBase() string { if p.sandbox { return apiBaseSandbox } return apiBaseProduction } func (p *Platform) apiRequest(method, url string, body any) error { var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return fmt.Errorf("qqbot: marshal body: %w", err) } bodyReader = bytes.NewReader(data) } token, err := p.getAccessToken() if err != nil { return fmt.Errorf("qqbot: get token: %w", err) } req, err := http.NewRequest(method, url, bodyReader) if err != nil { return err } req.Header.Set("Authorization", "QQBot "+token) req.Header.Set("Content-Type", "application/json") resp, err := core.HTTPClient.Do(req) if err != nil { return fmt.Errorf("qqbot: api request failed: %w", err) } defer resp.Body.Close() // Retry once on 401 (token may have expired) if resp.StatusCode == http.StatusUnauthorized { if err := p.refreshToken(); err != nil { return fmt.Errorf("qqbot: token refresh on 401: %w", err) } token, _ = p.getAccessToken() // Rebuild the request body reader if body != nil { data, _ := json.Marshal(body) bodyReader = bytes.NewReader(data) } req2, err := http.NewRequest(method, url, bodyReader) if err != nil { return fmt.Errorf("qqbot: build retry request: %w", err) } req2.Header.Set("Authorization", "QQBot "+token) req2.Header.Set("Content-Type", "application/json") resp2, err := core.HTTPClient.Do(req2) if err != nil { return fmt.Errorf("qqbot: api retry failed: %w", err) } defer resp2.Body.Close() if resp2.StatusCode >= 300 { raw, _ := io.ReadAll(resp2.Body) return fmt.Errorf("qqbot: api %s %s returned %d (after retry): %s", method, url, resp2.StatusCode, raw) } return nil } if resp.StatusCode >= 300 { raw, _ := io.ReadAll(resp.Body) return fmt.Errorf("qqbot: api %s %s returned %d: %s", method, url, resp.StatusCode, raw) } return nil } // attachment represents an image/file attachment in the QQ Bot API event payload. type attachment struct { ContentType string `json:"content_type"` URL string `json:"url"` Filename string `json:"filename"` } // downloadAttachmentImages downloads all image attachments and returns ImageAttachments. func downloadAttachmentImages(attachments []attachment) []core.ImageAttachment { var images []core.ImageAttachment for _, att := range attachments { if !strings.HasPrefix(att.ContentType, "image/") { continue } url := att.URL if url == "" { continue } // The official API may omit the https:// prefix if !strings.HasPrefix(url, "http") { url = "https://" + url } resp, err := core.HTTPClient.Get(url) if err != nil { slog.Warn("qqbot: download image failed", "url", url, "error", err) continue } if resp.StatusCode != http.StatusOK { slog.Warn("qqbot: download image returned non-200 status", "url", url, "status", resp.StatusCode) resp.Body.Close() continue } data, err := io.ReadAll(io.LimitReader(resp.Body, maxAttachmentSize)) resp.Body.Close() if err != nil { slog.Warn("qqbot: read image body failed", "error", err) continue } mime := att.ContentType if mime == "" { mime = http.DetectContentType(data) } images = append(images, core.ImageAttachment{ MimeType: mime, Data: data, FileName: att.Filename, }) } return images } // downloadAttachmentFiles downloads all non-image file attachments and returns FileAttachments. func downloadAttachmentFiles(attachments []attachment) []core.FileAttachment { var files []core.FileAttachment for _, att := range attachments { if strings.HasPrefix(att.ContentType, "image/") { continue } url := att.URL if url == "" { continue } if !strings.HasPrefix(url, "http") { url = "https://" + url } resp, err := core.HTTPClient.Get(url) if err != nil { slog.Warn("qqbot: download file failed", "url", url, "error", err) continue } if resp.StatusCode != http.StatusOK { slog.Warn("qqbot: download file returned non-200 status", "url", url, "status", resp.StatusCode) resp.Body.Close() continue } data, err := io.ReadAll(io.LimitReader(resp.Body, maxAttachmentSize)) resp.Body.Close() if err != nil { slog.Warn("qqbot: read file body failed", "error", err) continue } mime := att.ContentType if mime == "" { mime = http.DetectContentType(data) } files = append(files, core.FileAttachment{ MimeType: mime, Data: data, FileName: att.Filename, }) } return files } // stripAtMention removes the leading @bot mention from group message content. // The official QQ Bot API prefixes GROUP_AT_MESSAGE_CREATE content with an // @mention tag like "<@!botid> " or sometimes just whitespace after the tag. func stripAtMention(content string) string { content = strings.TrimSpace(content) // The official API formats the @mention as "<@!{bot_id}>" if strings.HasPrefix(content, "<@!") { if idx := strings.Index(content, ">"); idx >= 0 { content = strings.TrimSpace(content[idx+1:]) } } return content } func qqbotMessageCachePath(dataDir string) string { dataDir = strings.TrimSpace(dataDir) if dataDir == "" { return "" } return filepath.Join(dataDir, "run", "qqbot_message_cache.json") } func (p *Platform) loadMessageCache() error { if p.messageCachePath == "" { return nil } data, err := os.ReadFile(p.messageCachePath) if err != nil { if os.IsNotExist(err) { return nil } return err } cache := make(map[string]cachedMessage) if err := json.Unmarshal(data, &cache); err != nil { return err } p.messageCacheMu.Lock() defer p.messageCacheMu.Unlock() p.messageCache = cache p.purgeMessageCacheLocked(time.Now()) return nil } func (p *Platform) cacheMessage(messageID, content string) { messageID = strings.TrimSpace(messageID) content = strings.TrimSpace(content) if messageID == "" || content == "" { return } entry := cachedMessage{ Content: truncateRunes(content, quotedTextMaxRunes), UpdatedAt: time.Now(), } p.messageCacheMu.Lock() if p.messageCache == nil { p.messageCache = make(map[string]cachedMessage) } p.messageCache[messageID] = entry p.purgeMessageCacheLocked(entry.UpdatedAt) err := p.saveMessageCacheLocked() p.messageCacheMu.Unlock() if err != nil { slog.Warn("qqbot: save message cache failed", "error", err) } } func (p *Platform) resolveQuotedText(ref *messageReference) string { if ref == nil { return "" } if text := inlineQuotedText(ref); text != "" { return text } messageID := strings.TrimSpace(ref.MessageID) if messageID == "" { return "" } p.messageCacheMu.Lock() defer p.messageCacheMu.Unlock() if p.messageCache == nil { return "" } p.purgeMessageCacheLocked(time.Now()) entry, ok := p.messageCache[messageID] if !ok { return "" } return strings.TrimSpace(entry.Content) } func (p *Platform) purgeMessageCacheLocked(now time.Time) { for id, entry := range p.messageCache { if now.Sub(entry.UpdatedAt) > messageCacheTTL { delete(p.messageCache, id) } } for len(p.messageCache) > messageCacheMaxItems { var oldestID string var oldestAt time.Time for id, entry := range p.messageCache { if oldestID == "" || entry.UpdatedAt.Before(oldestAt) { oldestID = id oldestAt = entry.UpdatedAt } } if oldestID == "" { break } delete(p.messageCache, oldestID) } } func (p *Platform) saveMessageCacheLocked() error { if p.messageCachePath == "" { return nil } if err := os.MkdirAll(filepath.Dir(p.messageCachePath), 0o755); err != nil { return err } data, err := json.Marshal(p.messageCache) if err != nil { return err } return os.WriteFile(p.messageCachePath, data, 0o644) } func inlineQuotedText(ref *messageReference) string { if ref == nil { return "" } parts := []string{ strings.TrimSpace(ref.Content), quotedMessageText(ref.Message), quotedMessageText(ref.ReferencedMessage), quotedMessageText(ref.SourceMessage), strings.TrimSpace(ref.Title), } var chosen string for _, part := range parts { if part != "" { chosen = part break } } return strings.TrimSpace(chosen) } func quotedMessageText(msg *quotedMessage) string { if msg == nil { return "" } content := strings.TrimSpace(msg.Content) if content != "" { return content } if title := strings.TrimSpace(msg.Title); title != "" { return title } return contentOrAttachmentSummary("", msg.Attachments) } // quotedTextFromElements extracts quoted message text from msg_elements[0]. // QQ Bot sends the referenced message content in msg_elements[0] for quote messages (message_type=103). func quotedTextFromElements(elements []msgElement) string { if len(elements) == 0 { return "" } elem := elements[0] content := strings.TrimSpace(elem.Content) if content != "" { return content } return contentOrAttachmentSummary("", elem.Attachments) } func prependQuotedMessage(quoted, content string) string { quoted = strings.TrimSpace(quoted) content = strings.TrimSpace(content) if quoted == "" { return content } if content == "" { return "[引用消息]\n" + quoted } return "[引用消息]\n" + quoted + "\n\n" + content } func contentOrAttachmentSummary(content string, attachments []attachment) string { content = strings.TrimSpace(content) if content != "" { return content } if len(attachments) == 0 { return "" } var parts []string for _, att := range attachments { switch { case strings.HasPrefix(att.ContentType, "image/"): parts = append(parts, "[图片]") case strings.TrimSpace(att.Filename) != "": parts = append(parts, "[附件: "+strings.TrimSpace(att.Filename)+"]") default: parts = append(parts, "[附件]") } } return strings.Join(parts, " ") } func truncateRunes(s string, max int) string { if max <= 0 || utf8.RuneCountInString(s) <= max { return s } return string([]rune(s)[:max]) }