From bea410f8916be5d6eb73f399dda004c6fef2a930 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 19 Oct 2026 20:27:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(qq):=20=E7=A7=81=E8=81=8A=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=8B=89=E4=B8=8D=E5=88=B0=20=E2=80=94=E2=80=94=20group=5Fid?= =?UTF-8?q?=3D0=20=E8=A2=AB=E5=BD=93=E6=88=90"=E6=9F=A5=E7=BE=A4=200"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场(2026-10-19 内核日志,13 次调用全灭): 用户反复说"gethistory 拉不到私聊消息",纠正多次仍不改。 查日志:qq_get_history 失败 13 次,错误全部是 napcat get_group_msg_history: retcode=200: 消息undefined不存在 而 get_friend_msg_history 一次都没被调用过。 ## 根因 handleGetHistory 用 `convInt64(args["group_id"])` 的 **err==nil** 判"调用方给了群号", 而 convInt64 对 0 也成功返回。于是: 模型 qq_get_message 取私聊正文 → 返回体无条件带 "group_id": 0(QQ 协议里私聊群号就是 0) → 模型把字段原样抄进 qq_get_history → 代码认为"要查群 0" → get_group_msg_history(group_id=0) → NapCat 回「消息undefined不存在」,看起来像历史拉不到 实机验证:直接 POST NapCat get_group_msg_history {"group_id":0} → retcode=200 消息undefined不存在 get_friend_msg_history {"user_id":…} → retcode=0 正常 ## 改动 1. 新增 argPeerID:把 0 判为"未提供"(群号/QQ号都是正整数,0 从不是合法会话)。 权限门用的 argInt64 早有 parsed!=0 检查 —— 本仓此前是「权限门严、取数松」。 2. handleGetHistory 改用它,并区分三种情况:只给 user_id 走私聊、只给 group_id 走群聊、两个都给直接报错(旧实现静默选群分支,把私聊打成群查询)。 3. 同一零值缺陷一并修:handleSendFile(group_id=0 会发往不存在的群)、 handleMarkRead(markChatRead(0) 静默清空不存在的会话)、 handleResolveNickname(group_id=0 打群成员端点)。 4. **msgToGetMsgResult 私聊不再输出 group_id** —— 这是根因的上游。只改下游判据 只能止血,模型仍会一次次把 0 抄回来。 5. schema 写死参数语义:「私聊必须省略 group_id、只传 user_id;不要传 0」。 模型需要知道自己该传什么,否则会反复试错。 6. skill 补「拉私聊历史」整节 + 常见报错对照表;顺带修正一句错述 (原文称"本插件声明 auto_start: false",实际三方都未声明,按契约默认自启)。 7. 新增 history_endpoint_test.go:5 条判据钉住端点选择与返回体契约, 用 httptest 打桩 NapCat,断言真实命中的 action。 ## 版本 1.4.3 → 1.4.4。已构建 hmap 并经 plugin_install 装到本机生产实例, 增量重载后 qq 子进程换新、工具全部重注册、skill 移交 skillmgr。 --- example/qq/history_endpoint_test.go | 181 ++++++++++++++++++++++++++++ example/qq/plg.json | 2 +- example/qq/plugin.go | 91 ++++++++++---- example/qq/skill/qq-tools/SKILL.md | 43 ++++++- 4 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 example/qq/history_endpoint_test.go diff --git a/example/qq/history_endpoint_test.go b/example/qq/history_endpoint_test.go new file mode 100644 index 0000000..ddc2765 --- /dev/null +++ b/example/qq/history_endpoint_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// ★ 回归:qq_get_history 的端点选择不能把 group_id=0 当成"查群 0"。 +// +// 现场故障(2026-10-19,内核日志 13 次调用全灭): +// +// 模型先 qq_get_message 拿到私聊正文,返回体当时无条件带 "group_id": 0 +// (QQ 协议里私聊的群号就是 0)。它把字段原样抄进 qq_get_history, +// 于是后端走 get_group_msg_history(group_id=0),NapCat 回 +// 「retcode=200 消息undefined不存在」;真正的 get_friend_msg_history +// 一次都没被调用过。用户看到的现象是"私聊历史拉不到"。 +// +// 本测试钉住两条:① 0 视为未提供,私聊走私聊端点;② 私聊返回体不再吐 group_id。 + +// fakeNapCat 记录被命中的 action,并对历史端点返回一条固定消息。 +type fakeNapCat struct { + mu sync.Mutex + actions []string + srv *httptest.Server +} + +func newFakeNapCat(t *testing.T) *fakeNapCat { + t.Helper() + f := &fakeNapCat{} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + action := strings.TrimPrefix(r.URL.Path, "/") + f.mu.Lock() + f.actions = append(f.actions, action) + f.mu.Unlock() + + // 一条私聊消息:group_id 缺省/为 0,与真实 NapCat 的返回一致。 + body := map[string]interface{}{ + "status": "ok", + "retcode": 0, + "data": map[string]interface{}{ + "messages": []interface{}{ + map[string]interface{}{ + "message_id": 123456, + "user_id": 2198972886, + "group_id": 0, + "message_type": "private", + "time": 1792407859, + "raw_message": "hello", + "sender": map[string]interface{}{"nickname": "—/"}, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(body) + })) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeNapCat) hits() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.actions...) +} + +func newHistoryPlugin(t *testing.T) (*Plugin, *fakeNapCat) { + t.Helper() + f := newFakeNapCat(t) + instance, err := NewPluginFactory("qq", nil) + if err != nil { + t.Fatal(err) + } + p := instance.(*Plugin) + // httpClient 在 Start() 里才建;不经 Start 的单测得自己给,否则 napcat() 空指针。 + p.httpClient = &http.Client{Timeout: 5 * time.Second} + p.napcatURL = f.srv.URL + return p, f +} + +// group_id=0(私聊消息体里抄来的)必须走私聊端点,而不是群端点。 +func TestGetHistoryTreatsZeroGroupIDAsAbsent(t *testing.T) { + p, f := newHistoryPlugin(t) + + // 模型最容易犯的错:把 get_message 返回体里的 group_id:0 连同 user_id 一起传。 + if _, err := p.handleGetHistory(map[string]interface{}{ + "group_id": float64(0), + "user_id": float64(2198972886), + }); err != nil { + t.Fatalf("私聊历史不该报错: %v", err) + } + + hits := f.hits() + if len(hits) != 1 { + t.Fatalf("应只调用 1 次 NapCat,实际 %d 次: %v", len(hits), hits) + } + if hits[0] != "get_friend_msg_history" { + t.Fatalf("私聊必须走 get_friend_msg_history,实际走了 %s", hits[0]) + } +} + +// 只传 group_id=0(没有任何有效会话)必须明确报错,而不是去查"群 0"。 +func TestGetHistoryRejectsOnlyZeroGroupID(t *testing.T) { + p, f := newHistoryPlugin(t) + + _, err := p.handleGetHistory(map[string]interface{}{"group_id": float64(0)}) + if err == nil { + t.Fatal("只传 group_id=0 应报错(0 不是合法会话)") + } + if !strings.Contains(err.Error(), "user_id") { + t.Fatalf("错误信息应引导用 user_id,实际: %v", err) + } + if hits := f.hits(); len(hits) != 0 { + t.Fatalf("参数非法时不该请求 NapCat,实际: %v", hits) + } +} + +// 群聊传 group_id 仍走群端点(别把正常路径改坏)。 +func TestGetHistoryGroupStillUsesGroupEndpoint(t *testing.T) { + p, f := newHistoryPlugin(t) + + if _, err := p.handleGetHistory(map[string]interface{}{"group_id": float64(123456)}); err != nil { + t.Fatalf("群聊历史不该报错: %v", err) + } + hits := f.hits() + if len(hits) != 1 || hits[0] != "get_group_msg_history" { + t.Fatalf("群聊应走 get_group_msg_history,实际: %v", hits) + } +} + +// 两个都给 ⇒ 语义不明,必须报错而不是静默选一个分支。 +func TestGetHistoryRejectsBothPeers(t *testing.T) { + p, f := newHistoryPlugin(t) + + _, err := p.handleGetHistory(map[string]interface{}{ + "group_id": float64(123456), + "user_id": float64(2198972886), + }) + if err == nil { + t.Fatal("同时传 group_id 与 user_id 应报错") + } + if !strings.Contains(err.Error(), "二选一") { + t.Fatalf("错误信息应说明二选一,实际: %v", err) + } + if hits := f.hits(); len(hits) != 0 { + t.Fatalf("参数非法时不该请求 NapCat,实际: %v", hits) + } +} + +// msgToGetMsgResult:私聊不吐 group_id,群聊才吐。防止模型再从返回体里抄 0。 +func TestMsgToGetMsgResultOmitsGroupIDForPrivate(t *testing.T) { + privateMsg := map[string]interface{}{ + "message_id": float64(1), + "user_id": float64(2198972886), + "group_id": float64(0), + "message_type": "private", + "time": float64(1792407859), + "raw_message": "hi", + } + got := msgToGetMsgResult(privateMsg) + if _, exists := got["group_id"]; exists { + t.Fatalf("私聊返回体不该包含 group_id(会诱导模型回灌 0),实际: %v", got) + } + + groupMsg := map[string]interface{}{ + "message_id": float64(2), + "user_id": float64(2198972886), + "group_id": float64(123456), + "message_type": "group", + "time": float64(1792407859), + "raw_message": "hi all", + } + gotGroup := msgToGetMsgResult(groupMsg) + if gid, ok := gotGroup["group_id"]; !ok || gid != int64(123456) { + t.Fatalf("群聊返回体应包含真实 group_id,实际: %v", gotGroup) + } +} diff --git a/example/qq/plg.json b/example/qq/plg.json index cce6077..213b5d9 100644 --- a/example/qq/plg.json +++ b/example/qq/plg.json @@ -2,7 +2,7 @@ "name": "qq", "name_zh": "QQ消息", "name_en": "qq", - "version": "1.4.3", + "version": "1.4.4", "description": "QQ 消息收发插件,通过 NapCat 协议桥接", "author": "HomeAgent", "entry": "plugin.so", diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 940ff1a..83ef1e5 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -599,8 +599,16 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 RecallPolicy: "auto", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史(与group_id二选一)"}, + // ★ 参数语义必须写死「私聊不要传 group_id」。 + // + // 现场故障(2026-10-19):模型先调 qq_get_message 取私聊正文, + // 而私聊消息的返回体带 "group_id": 0(QQ 协议里私聊群号为 0)。 + // 它随后把整个返回体的字段原样喂给本工具,于是传入 group_id=0, + // 后端走了 get_group_msg_history(群0) → NapCat 回「消息undefined不存在」, + // 私聊历史永远拉不到。光在代码里把 0 判成缺失还不够 —— 模型仍需 + // 知道自己该传什么,否则会反复试错。 + "group_id": map[string]interface{}{"type": "integer", "description": "群号(仅群聊)。私聊必须省略此字段、只传 user_id;不要传 group_id=0 —— 0 不是合法群号,私聊的 group_id 在消息体里就是 0,回灌进来会查不到任何东西。"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(仅私聊历史)。私聊请只传这个,不要同时传 group_id。"}, "count": map[string]interface{}{"type": "integer", "description": "拉取条数,默认10"}, }, "required": []string{}, }, @@ -1212,6 +1220,24 @@ func argInt64(args map[string]interface{}, key string) (int64, bool) { return parsed, err == nil && parsed != 0 } +// argPeerID 解析「会话标识」参数(group_id / user_id),把 0 视为**未提供**。 +// +// ## 为什么不能直接用 convInt64 的 err==nil 当「存在」(2026-10-19 实测故障) +// +// convInt64 对 0 是成功返回的,于是 `if gerr == nil` 会把「没传」和「传了 0」 +// 混为一谈。真实后果:模型从 qq_get_message 的返回体里读到私聊消息的 +// `group_id: 0`,原样回灌给 qq_get_history,代码便认为「调用方要查群 0」, +// 打 get_group_msg_history(group_id=0),NapCat 回 +// `retcode=200 消息undefined不存在`;而真正的私聊端点 +// get_friend_msg_history **一次都没被调用**(内核日志 13 次失败全部如此)。 +// +// 群号/QQ 号都是正整数,0 从来不是合法会话,所以这里必须把 0 判成缺失。 +// 权限门用的 argInt64 早有 `parsed != 0` 检查 —— 本函数把同一个判据 +// 补到业务分支上,避免同一份代码里「权限门严、取数松」的错配。 +func argPeerID(args map[string]interface{}, key string) (int64, bool) { + return argInt64(args, key) +} + func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) { if !auth.Active || auth.Owner { return true, "" @@ -2221,16 +2247,28 @@ func msgToGetMsgResult(msg map[string]interface{}) map[string]interface{} { if mtType == "group" || gid > 0 { loc = "群聊" } - return map[string]interface{}{ + // ★ 私聊不要输出 group_id。 + // + // 现场故障(2026-10-19):这里无条件写 "group_id": 0,模型取回私聊正文后 + // 看到 group_id 字段就以为"这个会话的群号是 0",把它原样回灌给 + // qq_get_history,于是后者打 get_group_msg_history(group_id=0) 拿到 + // 「消息undefined不存在」,13 次调用全灭(get_friend_msg_history 0 次)。 + // + // 光在下游把 0 判成缺失只能止血,模型仍会一次次误用。**不在返回体里给它 + // 这个字段**,才是从源头断掉这条错误链:群聊才带 group_id,私聊只带 user_id。 + result := map[string]interface{}{ "content": content, "message_id": int64(mid), "user_id": int64(uid), - "group_id": int64(gid), "nickname": nickname, "message_type": mtType, "type": loc, "time": time.Unix(int64(mt), 0).Format("2006-01-02 15:04:05"), } + if gid > 0 { + result["group_id"] = int64(gid) + } + return result } func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { @@ -2611,10 +2649,15 @@ func (p *Plugin) ttsToFile(text string) (string, error) { } func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - if gerr != nil && uerr != nil { - return nil, fmt.Errorf("need group_id or user_id") + // 用 argPeerID(0 视为未提供):旧的 convInt64 会把 group_id=0 当成 + // "发往群 0",于是文件发不出去还报得含糊 —— 同一个零值缺陷,见 argPeerID 注释。 + gid, hasGroup := argPeerID(args, "group_id") + uid, hasUser := argPeerID(args, "user_id") + if !hasGroup && !hasUser { + return nil, fmt.Errorf("需要 group_id(群)或 user_id(私聊):私聊请只传 user_id,不要传 group_id=0") + } + if hasGroup && hasUser { + return nil, fmt.Errorf("group_id 与 user_id 只能二选一") } filePath, _ := args["file"].(string) if filePath == "" { @@ -2646,7 +2689,7 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error } params := map[string]interface{}{"message": cqMsg} - if gerr == nil { + if hasGroup { params["group_id"] = gid return p.napcat("send_group_msg", params) } @@ -2668,11 +2711,12 @@ func (p *Plugin) handleListChats(args map[string]interface{}) (interface{}, erro } func (p *Plugin) handleMarkRead(args map[string]interface{}) (interface{}, error) { - if gid, err := convInt64(args["group_id"]); err == nil { + // 0 不是合法会话,必须当"未提供"(否则 markChatRead(0) 静默清不存在的会话) + if gid, ok := argPeerID(args, "group_id"); ok { p.markChatRead(gid) return map[string]interface{}{"status": "ok", "group_id": gid, "unread": 0}, nil } - if uid, err := convInt64(args["user_id"]); err == nil { + if uid, ok := argPeerID(args, "user_id"); ok { p.markChatRead(uid) return map[string]interface{}{"status": "ok", "user_id": uid, "unread": 0}, nil } @@ -2680,8 +2724,8 @@ func (p *Plugin) handleMarkRead(args map[string]interface{}) (interface{}, error } func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) + gid, hasGroup := argPeerID(args, "group_id") + uid, hasUser := argPeerID(args, "user_id") count := 10 if c, err := convInt64(args["count"]); err == nil && c > 0 { count = int(c) @@ -2689,14 +2733,19 @@ func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, err var endpoint string var params map[string]interface{} - if gerr == nil { + switch { + case hasGroup && hasUser: + // 两个都给 ⇒ 语义不明。旧实现静默走群聊分支,把私聊请求打成群号查询, + // 用户看到的是 NapCat 的「消息undefined不存在」,根本看不出是参数用错。 + return nil, fmt.Errorf("group_id 与 user_id 只能二选一:私聊请只传 user_id,群聊请只传 group_id") + case hasGroup: endpoint = "get_group_msg_history" params = map[string]interface{}{"group_id": gid, "count": count} - } else if uerr == nil { + case hasUser: endpoint = "get_friend_msg_history" params = map[string]interface{}{"user_id": uid, "count": count} - } else { - return nil, fmt.Errorf("need group_id or user_id") + default: + return nil, fmt.Errorf("需要 group_id(群聊)或 user_id(私聊):私聊请只传 user_id,不要传 group_id=0") } rawResp, err := p.napcat(endpoint, params) @@ -2818,9 +2867,9 @@ func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, err result["files"] = files } // 拉取过某会话历史即视为已读(与真人客户端一致:看过=已读) - if gerr == nil { + if hasGroup { p.markChatRead(gid) - } else if uerr == nil { + } else if hasUser { p.markChatRead(uid) } return result, nil @@ -2859,8 +2908,8 @@ func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{} } keyword = strings.ToLower(keyword) - gid, groupErr := convInt64(args["group_id"]) - if groupErr == nil { + gid, hasGroupID := argPeerID(args, "group_id") + if hasGroupID { v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) if err != nil { return nil, err diff --git a/example/qq/skill/qq-tools/SKILL.md b/example/qq/skill/qq-tools/SKILL.md index 7cdb2ab..852674b 100644 --- a/example/qq/skill/qq-tools/SKILL.md +++ b/example/qq/skill/qq-tools/SKILL.md @@ -22,7 +22,43 @@ QQ 插件在 **HTTP 端口上监听 NapCat 推送**。用户发来的 QQ 消息* | 工具 | 用途 | |---|---| -| `qq_send`(及消息类工具) | 主动发消息 | +| `qq_get_message` | 按 message_id 取单条正文(消息刚到时先用它) | +| `qq_get_history` | 按会话拉最近历史(**群聊传 group_id,私聊传 user_id**) | +| `qq_list_chats` | 会话列表,按最新消息排序,带未读数与摘要 | +| `qq_mark_read` | 清零某会话未读数 | +| `output_send__qq` | 主动发消息(群/私聊由 meta 决定) | + +## ★★ 拉私聊历史:只传 user_id,**不要**传 group_id + +这是本插件最容易踩的坑,已经真实咬过人(2026-10-19 连续 13 次调用全失败): + +``` +qq_get_history(user_id=2198972886) ✅ 拉私聊 +qq_get_history(group_id=123456) ✅ 拉群聊 +qq_get_history(group_id=0, user_id=2198972886) ❌ 报「消息undefined不存在」 +``` + +**为什么容易错**:`qq_get_message` 取回一条**私聊**消息时,QQ 协议里群号 +就是 `0`。如果返回体带了这个字段,把它原样抄进 `qq_get_history` 就会去查 +"群 0" —— 而群 0 不存在,NapCat 只会回一句含糊的 +`retcode=200 消息undefined不存在`,看起来像"历史拉不到",其实是参数用错。 + +现在的行为(已修): + +- 私聊消息的返回体**不再包含 `group_id`** —— 没这个字段就别去抄它 +- 后端把 `group_id=0` 判为"未提供",走私聊端点 +- 两个都传会直接报错并说明"只能二选一",而不是静默选错分支 + +**判断该传哪个**:看消息来源。私聊注入会带 `user_id=...`;群聊注入带 +`group_id=...`。拿不准就先 `qq_list_chats`,它会标明每个会话是群还是私聊。 + +## 常见报错怎么读 + +| 现象 | 含义 | 怎么办 | +|---|---|---| +| `消息undefined不存在` | 通常是把私聊当群聊查了(group_id=0 或群号错) | 私聊改传 `user_id`,别传 `group_id` | +| `message_id 可能已过期` | NapCat 侧短号有时效(约 7 天) | 改用 `qq_get_history` 按会话拉 | +| `只能访问当前 QQ 会话` | 权限门:非 owner 只能读本轮会话 | 不是 bug;需要跨会话请让 owner 操作 | ## 注意 @@ -44,5 +80,6 @@ QQ 插件在 **HTTP 端口上监听 NapCat 推送**。用户发来的 QQ 消息* **释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启" 永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。 -(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable` -仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。) +(本插件**没有**声明 `auto_start: false`,即按契约默认**自启** —— 这是对的, +它必须常驻。所以这里更要小心:`plugin_disable` 会写进持久禁用清单, +让重启后的自启也失效,只能再用 `plugin_enable` 解。)