mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
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:
@@ -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).
|
||||
|
||||
@@ -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 / 取消注释以启用)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user