feat(qq): msg_id→get_history 7天兜底 + list_chats/mark_read 会话列表 v1.2.0

## 问题

1. NapCat get_msg 的 message_id 是 QQ 服务端临时短号,约 3 天后失效。
   实测 1306 条真实 webhook 消息,141 条(10.8%)现在查回报「消息不存在」,
   全是 3 天前的旧消息;1 小时内的消息可正常查回。NapCat 侧无保留时长配置项
   (napcat.json / onebot11_*.json / webui.json 均无),是 QQ 协议硬限制。
   模型收到 not_found 后曾编造正文(虚构 message_id + 虚构需求),
   已在核心 prompt 加事实性约束,此处从插件层根治取不到正文的问题。

2. 插件与真人客户端差距大:没有会话列表、消息不按到达先后排序、无未读提醒,
   模型只能靠单条中断消息被动响应,导致消息处理不及时。

## 改动

### msg_id → get_history 兜底(不缓存正文)
- 新增 msgRef{peerID,isGroup,time}:只记 msg_id → (peer, 时间) 映射,7 天 TTL
  (qqMsgTTL),超 2000 条时惰性清理过期项。不缓存消息正文。
- handleGetMessage 改为包装层:命中映射且 <7 天 → getMsgFromHistoryByTime()
  按 peer 拉 get_history(count=50),取时间最接近的一条,
  经 msgToGetMsgResult() 包装为与 get_msg 同构的结果(附 resolved_via:history);
  未命中或超 7 天 → 回退原 NapCat get_msg(重命名为 getMsgFromNapcat)。
- 超 7 天的消息由 get_history 自行处理,插件不做长期缓存。
- not_found 文案改为明确引导改用 qq_get_history / qq_list_chats。

### 会话列表(对齐真人客户端)
- 新增 chatMeta:会话名、未读数、最新一条 ≤60 字摘要(qqLastSumLen)、最新时间。
  只维护最新一条摘要,不存历史。
- 新增 qq_list_chats:按最新消息时间降序返回会话列表,每项含
  peer_id/type/name/unread/last_text/last_nick/last_time。
- 新增 qq_mark_read:按 group_id/user_id 清零未读;get_history 拉取某会话后
  自动标已读(看过=已读,与真人客户端一致)。
- webhook 记录时机前移:策略允许的消息(群/私聊、是否 @bot 均记)都进入映射与
  会话状态,@bot 只决定是否发中断——与真人客户端一致能看到全部会话。

### 中断模板
- 补 fallback 路径与私聊 user_id(原模板只给 message_id,取不到正文时
  模型没有 peer 信息可用于 get_history):
  「先用 get_message 取正文;若取不到(已过期),改用 get_history(...) 按会话拉取,
   或用 list_chats 查看未读会话。用 output_send__qq 回复」

## 验证

用重建的 plugindev build --target linux/amd64 产出 dist/qq_linux_amd64.hmap,
经内核 plugin_install(overwrite=true) 安装(action=reinstalled, config_kept=true),
重启 homed 后内核注册 20 个 qq_* 工具(原 18 + list_chats + mark_read)。
实测 qq_list_chats(count=8) 返回按时间排序的会话,unread=6 正确累积。

注意:cgo c-shared 插件带完整 Go runtime,dlclose 后引用计数不归零,
同路径 dlopen 复用旧映像,plgreload 无法热替换 .so,换 .so 必须重启 homed。
This commit is contained in:
JianFeeeee
2026-08-30 17:11:57 +08:00
parent 59c6e1844c
commit 61f307be1a
2 changed files with 353 additions and 5 deletions

View File

@ -2,7 +2,7 @@
"name": "qq",
"name_zh": "QQ消息",
"name_en": "qq",
"version": "1.1.0",
"version": "1.2.0",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -107,13 +107,149 @@ type Plugin struct {
downloadTasks []*DownloadTask
typingMu sync.Mutex
typingMap map[int64]*typingState
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
}
type typingState struct {
userID int64
stopCh chan struct{}
}
// msgRef 一条已见过的消息的引用:只记录 msg_id → (peer, time) 映射,不缓存正文。
// 用途NapCat get_msg 的临时短号 <7 天失效时,据此把 get_msg 兜底为按 peer 拉 get_history。
type msgRef struct {
peerID int64
isGroup bool
time int64 // 秒级时间戳
}
// chatMeta 一个会话(群/私聊)的最新状态,供 list_chats 展示。
// 只维护最新一条的短摘要≤qqLastSumLen 字符)与未读数,不缓存完整历史。
type chatMeta struct {
peerID int64
isGroup bool
name string
unread int
lastTime int64
lastText string
lastNick string
}
const qqMsgTTL = 7 * 86400 // 7 天msg_id → peer 映射的有效期
const qqLastSumLen = 60 // list_chats 里最新一条摘要的最大长度
// snapshotMsg 记录一条策略允许的消息:更新 msg_id→peer 映射与会话未读/最新状态。
// 不缓存消息正文(仅最新一条留 ≤qqLastSumLen 的摘要供列表展示)。
func (p *Plugin) snapshotMsg(msgID, peerID int64, isGroup bool, t int64, nickname, text string) {
if msgID <= 0 {
return
}
p.msgMu.Lock()
defer p.msgMu.Unlock()
// msg_id 映射7 天 TTL惰性清理
p.msgMap[msgID] = msgRef{peerID: peerID, isGroup: isGroup, time: t}
now := time.Now().Unix()
if len(p.msgMap) > 2000 { // 定期清理过期项
for k, v := range p.msgMap {
if now-v.time > qqMsgTTL {
delete(p.msgMap, k)
}
}
}
ch := p.chats[peerID]
if ch == nil {
ch = &chatMeta{peerID: peerID, isGroup: isGroup}
p.chats[peerID] = ch
}
if ch.name == "" {
if isGroup {
ch.name = fmt.Sprintf("群%d", peerID)
} else {
ch.name = nickname
}
}
// 按到达次序维护未读与最新摘要:仅当本条更新时才更新 lastTime/lastText保持按时间排
if t > ch.lastTime {
ch.lastTime = t
ch.lastText = text
ch.lastNick = nickname
}
ch.unread++
}
// lookupMsgRef 查 msg_id 映射,返回 (peer, isGroup, time, ok)。超过 7 天视为无效(交给 get_history
func (p *Plugin) lookupMsgRef(msgID int64) (int64, bool, int64, bool) {
p.msgMu.Lock()
defer p.msgMu.Unlock()
ref, ok := p.msgMap[msgID]
if !ok {
return 0, false, 0, false
}
now := time.Now().Unix()
if now-ref.time > qqMsgTTL {
delete(p.msgMap, msgID)
return 0, false, 0, false
}
return ref.peerID, ref.isGroup, ref.time, true
}
// markChatRead 清零某会话未读数(模型处理完该会话后调用)。
func (p *Plugin) markChatRead(peerID int64) {
p.msgMu.Lock()
defer p.msgMu.Unlock()
if ch := p.chats[peerID]; ch != nil {
ch.unread = 0
}
}
// listChats 返回会话列表:按最新消息时间降序,含未读数与最新一条摘要。
func (p *Plugin) listChats(capN int) []map[string]interface{} {
p.msgMu.Lock()
list := make([]*chatMeta, 0, len(p.chats))
for _, c := range p.chats {
list = append(list, c)
}
p.msgMu.Unlock()
// 降序(最新消息在前)
for i := 1; i < len(list); i++ {
for j := i; j > 0 && list[j].lastTime > list[j-1].lastTime; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
if len(list) > capN {
list = list[:capN]
}
out := make([]map[string]interface{}, 0, len(list))
for _, c := range list {
typ := "private"
if c.isGroup {
typ = "group"
}
item := map[string]interface{}{
"peer_id": c.peerID,
"type": typ,
"name": c.name,
"unread": c.unread,
"last_text": c.lastText,
"last_nick": c.lastNick,
}
if c.lastTime > 0 {
item["last_time"] = time.Unix(c.lastTime, 0).Format("2006-01-02 15:04")
}
out = append(out, item)
}
return out
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
@ -150,6 +286,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.httpClient = &http.Client{Timeout: 30 * time.Second}
// msg_id → peer 映射 + 会话状态(不缓存正文)
p.msgMap = make(map[int64]msgRef)
p.chats = make(map[int64]*chatMeta)
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s
p.fetchBotInfo()
if p.botID == 0 {
@ -248,6 +388,27 @@ type 枚举: text文字/ voice语音转文字后发送/ image
},
}, p.handleGetHistory)
p.regTool(s, sdk.ToolDef{
Name: tp + "list_chats", Description: "获取QQ会话列表与真人客户端一致按最新消息先后排序每条标注会话群/私聊)、会话名、未读消息数、最新一条消息摘要与时间。用于发现有未读消息的会话,再配合 qq_get_history 拉取对应会话内容、output_send__qq 回复。",
NoMemory: false,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"count": map[string]interface{}{"type": "integer", "description": "最多返回会话数默认10"},
}, "required": []string{},
},
}, p.handleListChats)
p.regTool(s, sdk.ToolDef{
Name: tp + "mark_read", Description: "将某个会话的未读计数清零(对象:群聊传 group_id私聊传 user_id。处理完某会话消息后可调用让 list_chats 的未读数回到0与真人客户端标记已读一致。",
NoMemory: false,
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二选一"},
}, "required": []string{},
},
}, p.handleMarkRead)
// ---- 查询 ----
p.regTool(s, sdk.ToolDef{
Name: tp + "get_groups", Description: "获取QQ群列表可按关键词搜索群名",
@ -772,6 +933,28 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}
}
// ---- 记录 msg_id→peer 映射与会话状态(不缓存正文,仅最新一条短摘要)----
// 策略允许的消息(群/私聊、是否 @bot 均记),供 get_msg 兜底与 list_chats 使用;
// @bot 与否只决定是否发中断,不影响记录——与真人客户端一致看到全部会话。
{
peerID, isGroup := evt.UserID, false
if evt.MessageType == "group" {
peerID, isGroup = evt.GroupID, true
}
sum := text
runes := []rune(sum)
if len(runes) > qqLastSumLen {
sum = string(runes[:qqLastSumLen]) + "…"
}
if evt.Time == 0 {
evt.Time = time.Now().Unix()
}
p.snapshotMsg(evt.MessageID, peerID, isGroup, evt.Time, nickname, sum)
}
if evt.MessageType == "group" {
// 群消息必须 @ 机器人才响应
if p.botID == 0 {
log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID)
@ -791,9 +974,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
outputTool := "output_send__" + p.name
var interrupt string
if evt.MessageType == "group" {
interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。如果消息包含引用回复,使用%sget_history(group_id=%d)查看上下文。使用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, outputTool)
interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(group_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, tp, outputTool)
} else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool)
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
}
if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt
@ -841,6 +1024,126 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
// ======== Tool Handlers ========
// getMsgFromHistoryByTime 按 (peer, isGroup, targetTime) 从 NapCat 拉最近历史,返回距 targetTime 最近的完整消息。
func (p *Plugin) getMsgFromHistoryByTime(peerID int64, isGroup bool, targetTime int64) (map[string]interface{}, bool) {
ep := "get_friend_msg_history"
params := map[string]interface{}{"user_id": peerID, "count": 50}
if isGroup {
ep = "get_group_msg_history"
params = map[string]interface{}{"group_id": peerID, "count": 50}
}
raw, err := p.napcat(ep, params)
if err != nil {
return nil, false
}
rawStr, _ := rawString(raw)
if rawStr == "" {
return nil, false
}
var resp struct {
Data *struct {
Messages []interface{} `json:"messages"`
} `json:"data"`
}
if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil {
return nil, false
}
var best map[string]interface{}
bestAbs := int64(-1)
for _, m := range resp.Data.Messages {
mm, ok := m.(map[string]interface{})
if !ok {
continue
}
mt, _ := mm["time"].(float64)
t := int64(mt)
if t == 0 {
continue
}
abs := t - targetTime
if abs < 0 {
abs = -abs
}
if bestAbs < 0 || abs < bestAbs {
bestAbs = abs
best = mm
}
}
if best == nil {
return nil, false
}
return best, true
}
// msgToGetMsgResult 把一条 NapCat 历史消息对象转成与 get_msg 同构的结果(历史包装语义)。
func msgToGetMsgResult(msg map[string]interface{}) map[string]interface{} {
nickname := ""
if s, ok := msg["sender"].(map[string]interface{}); ok {
if n, _ := s["nickname"].(string); n != "" {
nickname = n
}
if c, _ := s["card"].(string); c != "" {
nickname = c
}
}
rawText, _ := msg["raw_message"].(string)
content := rawText
if content == "" {
if segs, ok := msg["message"].([]interface{}); ok {
var parts []string
for _, seg := range segs {
segMap, _ := seg.(map[string]interface{})
if segMap == nil {
continue
}
typ, _ := segMap["type"].(string)
segData, _ := segMap["data"].(map[string]interface{})
if segData == nil {
continue
}
switch typ {
case "text":
if t, _ := segData["text"].(string); t != "" {
parts = append(parts, t)
}
case "image":
parts = append(parts, "[图片]")
case "file":
if n, _ := segData["name"].(string); n != "" {
parts = append(parts, "[文件:"+n+"]")
}
default:
if typ != "" {
parts = append(parts, "["+typ+"]")
}
}
}
if len(parts) > 0 {
content = strings.Join(parts, " ")
}
}
}
mid, _ := msg["message_id"].(float64)
uid, _ := msg["user_id"].(float64)
gid, _ := msg["group_id"].(float64)
mt, _ := msg["time"].(float64)
mtType, _ := msg["message_type"].(string)
loc := "私聊"
if mtType == "group" || gid > 0 {
loc = "群聊"
}
return 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"),
}
}
func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) {
msgID, err := convInt64(args["message_id"])
if err != nil {
@ -850,10 +1153,24 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
}, nil
}
// 本地 msg_id→peer 映射命中且 <7 天 → 用 get_history 语义兜底NapCat 临时短号失效也不怕)
if peerID, isGroup, t, ok := p.lookupMsgRef(msgID); ok {
if m, found := p.getMsgFromHistoryByTime(peerID, isGroup, t); found {
// 找到同会话、时间最接近的消息,包装为 get_msg 同构返回
res := msgToGetMsgResult(m)
res["resolved_via"] = "history" // 标明由历史查询兜底
return res, nil
}
// 历史窗口内没找到(消息可能被裁剪/更早),回退 NapCat 原查询
}
return p.getMsgFromNapcat(msgID)
}
func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) {
raw, err := p.napcat("get_msg", map[string]interface{}{"message_id": msgID})
if err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("查询 NapCat 失败: %s", err),
"content": fmt.Sprintf("查询 NapCat 失败: %s。该 message_id 可能已过期,请改用 qq_get_history 按会话拉取最近消息(或用 qq_list_chats 看未读会话)", err),
"message_id": msgID,
"not_found": true,
}, nil
@ -884,7 +1201,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
}
if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil {
return map[string]interface{}{
"content": "解析 NapCat 响应失败",
"content": "解析 NapCat 响应失败(消息可能已过期)。请改用 qq_get_history 按会话拉取最近消息,或用 qq_list_chats 查看未读会话",
"message_id": msgID,
"not_found": true,
}, nil
@ -1214,6 +1531,31 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
return p.napcat("send_private_msg", params)
}
func (p *Plugin) handleListChats(args map[string]interface{}) (interface{}, error) {
count := 10
if c, err := convInt64(args["count"]); err == nil && c > 0 && c < 100 {
count = int(c)
}
chats := p.listChats(count)
return map[string]interface{}{
"chats": chats,
"total": len(chats),
"hint": "按最新消息先后排序unread 为该会话未读消息数,处理完用 qq_mark_read 清零;用 qq_get_history(group_id/user_id) 拉取会话内容",
}, nil
}
func (p *Plugin) handleMarkRead(args map[string]interface{}) (interface{}, error) {
if gid, err := convInt64(args["group_id"]); err == nil {
p.markChatRead(gid)
return map[string]interface{}{"status": "ok", "group_id": gid, "unread": 0}, nil
}
if uid, err := convInt64(args["user_id"]); err == nil {
p.markChatRead(uid)
return map[string]interface{}{"status": "ok", "user_id": uid, "unread": 0}, nil
}
return nil, fmt.Errorf("need group_id or user_id")
}
func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) {
gid, gerr := convInt64(args["group_id"])
uid, uerr := convInt64(args["user_id"])
@ -1352,6 +1694,12 @@ func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, err
if len(files) > 0 {
result["files"] = files
}
// 拉取过某会话历史即视为已读(与真人客户端一致:看过=已读)
if gerr == nil {
p.markChatRead(gid)
} else if uerr == nil {
p.markChatRead(uid)
}
return result, nil
}