From e4bd21d50db292ba43a2dde65af09ebde8a9baab Mon Sep 17 00:00:00 2001 From: dev-claudecode Date: Wed, 1 Jul 2026 19:35:06 +0000 Subject: [PATCH] feat(qq): add group_reply_all config to match Feishu/Discord/Telegram (#1475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QQ was the only major OneBot-style platform without a group_reply_all-style filter. By default it responded to every group message regardless of @-mention, which produces a hostile experience when sharing a QQ personal account across groups (every group's chatter becomes an agent prompt). New `group_reply_all` boolean (default false): when false, only group messages @-mentioning the bot are forwarded to the agent. Set true to keep the legacy "respond to every group message" behavior. Mention detection handles both forms: - JSON segment: payload["message"][i] == {"type":"at","data":{"qq":""}} - CQ code: raw_message contains "[CQ:at,qq=]" or "[CQ:at,qq=,name=...]" When selfID is unknown (e.g. before get_login_info completes after a reconnect), isMentioned returns true so the bot doesn't silently drop messages during the connect window. ⚠️ BREAKING CHANGE: default flipped from "respond to all" to "@-only", aligning QQ with Feishu / Discord / Telegram / DingTalk which already default to @-only. Existing QQ group deployments that relied on the old behavior must set group_reply_all = true to keep it. CHANGELOG flags the migration in a dedicated "⚠️ Breaking Changes" subsection. Tests: 4 required + 2 extras (private-msg-unaffected, isMentioned graceful fallback when selfID=0). Mock payload pattern reuses TestStart_* infrastructure; no new helpers needed. --- CHANGELOG.md | 4 + config.example.toml | 9 ++ platform/qq/qq.go | 73 +++++++++++++- platform/qq/qq_test.go | 222 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13281213..3927d0912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,12 @@ ## Unreleased ### Added +- **QQ: `group_reply_all` config option** to match Feishu / Discord / Telegram semantics (#1475). New `group_reply_all` boolean (default `false`): when `false`, the bot only responds to group messages that @-mention it (via `[CQ:at,qq=]` or the JSON `{"type":"at","data":{"qq":""}}` segment); when `true`, the bot responds to every group message (legacy behavior). Private messages are unaffected. - **Feishu: outbound bot-to-bot @mention resolution** via new `mention_map` config option. Maps agent-friendly names (e.g. `BOT-A`) to Feishu open_ids so that when an agent writes `@BOT-A` in its reply, cc-connect converts it to a native Feishu `` tag that triggers a real notification. Layered on top of `resolve_mentions` (group-member matching) with higher priority, so explicit config always wins (#1322). +### ⚠️ Breaking Changes +- **QQ group default behavior changed** (#1475): the default value of `group_reply_all` is `false`, so QQ group messages that do not @-mention the bot are no longer delivered to the agent. Existing QQ group deployments that relied on "respond to every message" must set `group_reply_all = true` in the `[projects.platforms.options]` block to keep the legacy behavior. Aligns QQ with Feishu / Discord / Telegram (which already default to `@`-only). + ### Fixed - **Feishu recall fallback probes**: throttle repeated active-message recall checks so long-running turns do not continuously call platform message APIs. - **Skill discovery depth-1 only**: skill scanning no longer recurses into subdirectories. Only `//SKILL.md` is registered; nested SKILL.md files (e.g. inside `/references/...`) are treated as skill assets and ignored, matching the Claude Code CLI convention. Previously, nested SKILL.md files leaked into platform command menus as phantom slash commands (101 leaked commands from `frontend-design` skill alone) (#1304). diff --git a/config.example.toml b/config.example.toml index 51680145c..63e73824e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1572,6 +1572,15 @@ app_secret = "your-feishu-app-secret" # # 可选:OneBot HTTP API 地址,用于文件发送。OneBot 在 WSL/Docker 中时必须配置。 # allow_from = "*" # allowed QQ user IDs, e.g. "12345,67890" or "*" for all # # 允许的 QQ 号,如 "12345,67890","*" 表示所有 +# group_reply_all = false # If true, respond to ALL group messages without @mention +# # ⚠️ BREAKING CHANGE in v1.5.0 (#1475): default changed from true to false +# # to match Feishu / Discord / Telegram semantics. Existing QQ group +# # deployments that relied on "respond to every message" must set +# # this to true to keep the legacy behavior. +# # 设为 true 时,群聊中无需 @机器人 也会响应所有消息(默认 false) +# # ⚠️ v1.5.0 破坏性变更(#1475):默认值由 true 改为 false,与 +# # 飞书/Discord/Telegram 行为一致。依赖"响应所有消息"行为的旧版 +# # QQ 群部署必须显式设为 true 才能保留原行为。 # share_session_in_channel = false # If true, all users in a group share one agent session / 群聊共享会话 # QQ Bot (Official / 官方 QQ 机器人) (uncomment to enable / 取消注释以启用) diff --git a/platform/qq/qq.go b/platform/qq/qq.go index 294c09bc3..5f0cea9e8 100644 --- a/platform/qq/qq.go +++ b/platform/qq/qq.go @@ -31,6 +31,7 @@ type Platform struct { token string // optional access_token allowFrom string // comma-separated user IDs or "*" shareSessionInChannel bool + groupReplyAll bool // if true, respond to all group msgs without @mention handler core.MessageHandler conn *websocket.Conn mu sync.Mutex @@ -40,7 +41,7 @@ type Platform struct { selfID int64 dedup core.MessageDedup groupNameCache sync.Map // groupID -> group name - httpURL string // OneBot HTTP API URL, e.g. "http://127.0.0.1:3000" + httpURL string // OneBot HTTP API URL, e.g. "http://127.0.0.1:3000" } func New(opts map[string]any) (core.Platform, error) { @@ -51,6 +52,7 @@ func New(opts map[string]any) (core.Platform, error) { token, _ := opts["token"].(string) allowFrom, _ := opts["allow_from"].(string) shareSessionInChannel, _ := opts["share_session_in_channel"].(bool) + groupReplyAll, _ := opts["group_reply_all"].(bool) core.CheckAllowFrom("qq", allowFrom) @@ -62,7 +64,8 @@ func New(opts map[string]any) (core.Platform, error) { token: token, allowFrom: allowFrom, shareSessionInChannel: shareSessionInChannel, - httpURL: httpURL, + groupReplyAll: groupReplyAll, + httpURL: httpURL, }, nil } @@ -196,6 +199,17 @@ func (p *Platform) handleMessage(payload map[string]any) { return } + // Group messages: by default only respond when the bot is @-mentioned, + // matching Feishu / Discord / Telegram semantics. Set group_reply_all=true + // in config to keep the legacy "respond to every group message" behavior. + if msgType == "group" && !p.groupReplyAll { + if !p.isMentioned(payload) { + slog.Debug("qq: group message not @-mentioned, skipped", + "group_id", groupID, "user_id", userID) + return + } + } + // Extract sender info var userName string if sender, ok := payload["sender"].(map[string]any); ok { @@ -661,6 +675,61 @@ func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { return &replyContext{messageType: "private", userID: uid}, nil } +// isMentioned reports whether an incoming OneBot v11 group message @-mentions +// the bot. Handles both the JSON segment form (preferred by modern NapCat / +// LLOneBot) and the CQ-code form found in raw_message. Returns true when +// selfID is unknown (0) so the bot doesn't drop messages before +// get_login_info completes or after a reconnect with stale state. +func (p *Platform) isMentioned(payload map[string]any) bool { + if p.selfID == 0 { + return true + } + selfIDStr := strconv.FormatInt(p.selfID, 10) + + // JSON segment form: payload["message"] == [{"type":"at","data":{"qq":""}}, ...] + if msg, ok := payload["message"].([]any); ok { + for _, seg := range msg { + s, ok := seg.(map[string]any) + if !ok { + continue + } + if segType, _ := s["type"].(string); segType != "at" { + continue + } + data, _ := s["data"].(map[string]any) + if data == nil { + continue + } + switch v := data["qq"].(type) { + case string: + if v == selfIDStr { + return true + } + case float64: + if int64(v) == p.selfID { + return true + } + case json.Number: + if n, err := v.Int64(); err == nil && n == p.selfID { + return true + } + } + } + } + + // CQ code form: raw_message contains "[CQ:at,qq=]" or + // "[CQ:at,qq=,name=...]". The trailing ",|" check avoids matching + // "[CQ:at,qq=8888]" against selfID 888888 (different digit length). + if raw, ok := payload["raw_message"].(string); ok { + if strings.Contains(raw, "[CQ:at,qq="+selfIDStr+"]") || + strings.Contains(raw, "[CQ:at,qq="+selfIDStr+",") { + return true + } + } + + return false +} + func (p *Platform) isAllowed(userID int64) bool { if p.allowFrom == "" || p.allowFrom == "*" { return true diff --git a/platform/qq/qq_test.go b/platform/qq/qq_test.go index e1071eb95..8e6be6c98 100644 --- a/platform/qq/qq_test.go +++ b/platform/qq/qq_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" + "sync/atomic" "testing" "time" @@ -149,3 +151,223 @@ func TestStart_FetchesSelfIDWithoutTimeout(t *testing.T) { t.Errorf("selfID = %d, want %d (self-message filter would be disabled)", p.selfID, botUserID) } } + +// --- group_reply_all (#1475) ------------------------------------------------- + +const testBotSelfID = 888888 + +func newTestHandler(t *testing.T) (core.MessageHandler, *atomic.Int32) { + t.Helper() + var called atomic.Int32 + return func(_ core.Platform, _ *core.Message) { + called.Add(1) + }, &called +} + +// buildGroupPayload builds a minimal OneBot v11 group message payload for +// handleMessage() tests. messageSegments / rawMessage let callers exercise +// the JSON segment and CQ-code forms independently. +func buildGroupPayload(userID, groupID, messageID int64, messageSegments []any, rawMessage string) map[string]any { + return map[string]any{ + "post_type": "message", + "message_type": "group", + "user_id": userID, + "group_id": groupID, + "message_id": messageID, + "time": float64(time.Now().Unix()), + "message": messageSegments, + "raw_message": rawMessage, + "sender": map[string]any{"nickname": "alice"}, + } +} + +func TestHandle_GroupMessage_NotMentionedWithGroupReplyAllFalse_Skipped(t *testing.T) { + handler, called := newTestHandler(t) + p := &Platform{ + selfID: testBotSelfID, + groupReplyAll: false, + handler: handler, + } + + payload := buildGroupPayload( + 111111, 222222, 333333, + []any{map[string]any{"type": "text", "data": map[string]any{"text": "hello group"}}}, + "hello group", + ) + p.handleMessage(payload) + + if got := called.Load(); got != 0 { + t.Errorf("handler called %d times, want 0 (un-mentioned group msg must be skipped)", got) + } +} + +func TestHandle_GroupMessage_MentionedResponds(t *testing.T) { + handler, called := newTestHandler(t) + p := &Platform{ + selfID: testBotSelfID, + groupReplyAll: false, + handler: handler, + } + + payload := buildGroupPayload( + 111111, 222222, 444444, + []any{ + map[string]any{"type": "at", "data": map[string]any{"qq": strconv.FormatInt(testBotSelfID, 10)}}, + map[string]any{"type": "text", "data": map[string]any{"text": " hi bot"}}, + }, + "[CQ:at,qq="+strconv.FormatInt(testBotSelfID, 10)+"] hi bot", + ) + p.handleMessage(payload) + + if got := called.Load(); got != 1 { + t.Errorf("handler called %d times, want 1 (@-mentioned group msg must respond)", got) + } +} + +func TestHandle_GroupMessage_WithGroupReplyAllTrue_RespondsAll(t *testing.T) { + handler, called := newTestHandler(t) + p := &Platform{ + selfID: testBotSelfID, + groupReplyAll: true, + handler: handler, + } + + // No @-mention in payload, but group_reply_all=true → respond to all + // (regression guard for legacy behavior). + payload := buildGroupPayload( + 111111, 222222, 555555, + []any{map[string]any{"type": "text", "data": map[string]any{"text": "no mention here"}}}, + "no mention here", + ) + p.handleMessage(payload) + + if got := called.Load(); got != 1 { + t.Errorf("handler called %d times, want 1 (group_reply_all=true must respond to all)", got) + } +} + +func TestHandle_GroupMessage_PrivateMessageUnaffectedByGroupReplyAll(t *testing.T) { + // Private messages (message_type="private") must never be filtered by + // the group_reply_all logic. They have no @-mention concept. + handler, called := newTestHandler(t) + p := &Platform{ + selfID: testBotSelfID, + groupReplyAll: false, + handler: handler, + } + + payload := map[string]any{ + "post_type": "message", + "message_type": "private", + "user_id": 111111, + "message_id": 666666, + "time": float64(time.Now().Unix()), + "message": []any{map[string]any{"type": "text", "data": map[string]any{"text": "hi"}}}, + "raw_message": "hi", + "sender": map[string]any{"nickname": "alice"}, + } + p.handleMessage(payload) + + if got := called.Load(); got != 1 { + t.Errorf("handler called %d times, want 1 (private msg must always pass)", got) + } +} + +func TestNew_GroupReplyAllConfigDefault(t *testing.T) { + p, err := New(map[string]any{}) + if err != nil { + t.Fatalf("New: %v", err) + } + platform := p.(*Platform) + if platform.groupReplyAll { + t.Error("groupReplyAll default = true, want false (must match Feishu/Discord/Telegram default)") + } +} + +func TestNew_GroupReplyAllConfigExplicitTrue(t *testing.T) { + p, err := New(map[string]any{"group_reply_all": true}) + if err != nil { + t.Fatalf("New: %v", err) + } + platform := p.(*Platform) + if !platform.groupReplyAll { + t.Error("groupReplyAll = false, want true (explicit config)") + } +} + +func TestIsMentioned(t *testing.T) { + p := &Platform{selfID: testBotSelfID} + cases := []struct { + name string + payload map[string]any + want bool + }{ + { + "json_segment_at_string", + map[string]any{ + "message": []any{ + map[string]any{"type": "at", "data": map[string]any{"qq": strconv.FormatInt(testBotSelfID, 10)}}, + }, + }, + true, + }, + { + "json_segment_at_other_id", + map[string]any{ + "message": []any{ + map[string]any{"type": "at", "data": map[string]any{"qq": "999999"}}, + }, + }, + false, + }, + { + "cq_code_at_bracketed", + map[string]any{ + "raw_message": "[CQ:at,qq=" + strconv.FormatInt(testBotSelfID, 10) + "] hello", + }, + true, + }, + { + "cq_code_at_with_name", + map[string]any{ + "raw_message": "[CQ:at,qq=" + strconv.FormatInt(testBotSelfID, 10) + ",name=bot] hello", + }, + true, + }, + { + "no_at_plain_text", + map[string]any{ + "message": []any{map[string]any{"type": "text", "data": map[string]any{"text": "hi"}}}, + "raw_message": "hi", + }, + false, + }, + { + "different_id_partial_no_false_positive", + // selfID=888888; a message mentioning 8888 (different digit count) must not match. + map[string]any{ + "raw_message": "[CQ:at,qq=8888] hi", + }, + false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := p.isMentioned(c.payload); got != c.want { + t.Errorf("isMentioned = %v, want %v", got, c.want) + } + }) + } +} + +func TestIsMentioned_SelfIDZeroReturnsTrue(t *testing.T) { + // When selfID isn't known yet (e.g. before get_login_info), don't filter — + // match old behavior to avoid dropping messages during reconnect. + p := &Platform{selfID: 0} + payload := map[string]any{ + "message": []any{map[string]any{"type": "text", "data": map[string]any{"text": "hi"}}}, + } + if !p.isMentioned(payload) { + t.Error("isMentioned should return true when selfID=0 (graceful fallback)") + } +}