fix(qq): 私聊历史拉不到 —— group_id=0 被当成"查群 0"

现场(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。
This commit is contained in:
JianFeeeee
2026-10-19 20:27:23 +08:00
parent 7618ca492a
commit bea410f891
4 changed files with 292 additions and 25 deletions

View File

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

View File

@ -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",

View File

@ -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

View File

@ -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` 解。)