feat(qq): add group_reply_all config to match Feishu/Discord/Telegram (#1475)

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":"<id>"}}
  - CQ code: raw_message contains "[CQ:at,qq=<id>]" or "[CQ:at,qq=<id>,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.
This commit is contained in:
dev-claudecode
2026-07-01 19:35:06 +00:00
parent 7915635f10
commit e4bd21d50d
4 changed files with 306 additions and 2 deletions

View File

@@ -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=<self_id>]` or the JSON `{"type":"at","data":{"qq":"<id>"}}` 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 `<at>` 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_dir>/<name>/SKILL.md` is registered; nested SKILL.md files (e.g. inside `<name>/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).

View File

@@ -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 / 取消注释以启用)

View File

@@ -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":"<id>"}}, ...]
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=<id>]" or
// "[CQ:at,qq=<id>,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

View File

@@ -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)")
}
}