From c6c39aa2a801fdcf0ce0a37ac0a05bc73cdfeab9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 18 Oct 2026 17:39:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(qq):=20Qzone=20=E8=AF=B4=E8=AF=B4=E3=80=81?= =?UTF-8?q?=E7=BE=A4=E5=8F=91=E7=A1=AC=E6=8B=A6=E6=88=AA=E3=80=81@?= =?UTF-8?q?=E5=85=A8=E4=BD=93=E4=B8=8D=E7=AE=97@bot=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=AF=AD=E4=B9=89=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. Qzone 说说(send_qzone_msg / delete_qzone_msg) 一次真实的误判,值得记下来:我曾断言「生产 NapCat 不支持发说说」, 依据是探测返回「不支持的Api SendQzoneMsg」——**是我的调用名错了**。 router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码**类名** `SendQzoneMsg`。换对名字后一次成功。 教训:「API 不存在」这个结论必须用真实 action 名验证;源码里的 类名/文件名/方法名都不是调用名。判据 TestQzoneActionNamesAreSnakeCase 把这条钉住了。 本地校验 ugc_right 白名单(1/4/16/64/128)与 16/128 必带 target_uins, 省一次跨进程往返、给模型更直接的反馈。 ## 2. 内部委派不得向群聊发送(现场事故) offload 子 agent 的「分诊汇报」把本该私聊给用户的进度播报**发到了群里**: 17:15:41 executing tool: output_send__qq 17:15:41 handleChannelOutput meta={"group_id": 979911915} 17:15:42 群友质问「又没@你,你就这么确定是找你的?」 根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求 "持有全部输出通道"),于是它能自己选目标——而它只从上下文里看到一个 群号,就选了群。 修法:`beforeToolcall` 在 internal 放行前调用 outputSendGroupTarget, 识别出群目标就写 denial。为什么不放在 handleChannelOutput:ToolHandler 只收 args,拿不到帧上的 comefrom(SDK 契约如此),只有 stage 帧能看到。 ## 3. @全体成员不算 @bot 原实现 `if v == botIDStr || v == "all" { return true }` —— 任何人 @全体都会拉起 bot。那不是"在叫 bot",是"在叫所有人":一个是针对性请求, 一个是广播,语义完全不同。 群里误响应的代价不是"多回一句",而是**在未被邀请的场合发言**。 宁可漏响应(用户会再 @ 一次),不能误响应。 ## 4. 状态语义按用户定义重排 学习中 = 等待 LLM 搬砖中 = 执行 toolcall 在线 = 空闲 pre_action → 学习中(stepLLM 之前,唯一能提前标记"要等模型"处) before_toolcall → 搬砖中 after_toolcall → 学习中(工具回来了,又要等 LLM) before_output → 不改(LLM 已回、即将输出,不是等模型) after_output → 在线(force,绕防抖) 原先「学习中」挂在 before_output 是错的:那个时刻 LLM 已经返回。 ## 5. 「对方正在输入中」按轮驱动 起点从 HTTP 入口(消息刚到)改到 before_output(每轮只触发一次), 终点从 output_send(任何发送都停,含群聊/发给别人)改到 after_output。 身份由 InjectOptions.Payload 携带 `qq_uid`,内核透传到 stageCtx.Extra。 不用全局 p.auth:并发私聊会串号(A 的指示打到 B 身上)。 ## 6. 启动日志不再硬编码工具数 `log.Printf("... (%d tools)", p.name, 15)` 硬编码了 15,工具加到 24 后 仍报 15 —— 排查"qzone 装上没"时被误导了好几轮。改成真实计数 p.toolCount。 ## 判据(新增 13 条,变异 8/8 咬) Qzone 5 条、群发拦截 5 条、@门 4 条、状态映射 7 条、打字 7 条。 ★ 两个判据自身缺陷被变异抓出并修掉: · @门判据用字符串找 `}\n\t}` 截分支,而 joined 已 strip 缩进 ⇒ 找不到 ⇒ 分支延伸到文件末尾 ⇒ "文件里任何别处的 return"都让它通过。 改用括号计数 ifBlockAt。 · 状态判据只查常量名,`_ = qqStatusStudy` 就蒙混过关。改查真实调用 applyStatus(qqStatusStudy)。 --- example/qq/group_at_gate_test.go | 138 +++++++++++ example/qq/internal_group_send_test.go | 102 ++++++++ example/qq/plugin.go | 275 +++++++++++++++++++-- example/qq/qzone.go | 189 ++++++++++++++ example/qq/qzone_test.go | 116 +++++++++ example/qq/status.go | 129 +++++++++- example/qq/status_mapping_test.go | 151 ++++++++++++ example/qq/typing_test.go | 326 +++++++++++++++++++++++++ sdk/plugin.go | 30 +++ 9 files changed, 1436 insertions(+), 20 deletions(-) create mode 100644 example/qq/group_at_gate_test.go create mode 100644 example/qq/internal_group_send_test.go create mode 100644 example/qq/qzone.go create mode 100644 example/qq/qzone_test.go create mode 100644 example/qq/status_mapping_test.go create mode 100644 example/qq/typing_test.go diff --git a/example/qq/group_at_gate_test.go b/example/qq/group_at_gate_test.go new file mode 100644 index 0000000..c2422f5 --- /dev/null +++ b/example/qq/group_at_gate_test.go @@ -0,0 +1,138 @@ +package main + +// 群消息的 @ 门判据。 +// +// ## 用户的核心关切(2026-10-18) +// +// 「非 @agent 的群消息会不会产生中断」 +// +// 答复:**不会**。代码路径上 `isAtBot` 为 false 就直接 return, +// 根本走不到 `enqueueInterrupt`。但这条链路必须被钉住 —— +// 一旦有人改了 return 或加个 fallthrough,bot 就会在群里乱说话。 +// +// ## 同时修正的缺陷:@全体成员被当成 @bot +// +// 原实现:`if v == botIDStr || v == "all" { return true }` +// 后果:任何人 @全体成员 都会拉起 bot。那不是"在叫 bot",是"在叫所有人" +// —— 一个是针对性请求,一个是广播,语义完全不同。 +// +// 现场佐证:群友直接质问过「又没@你,你就这么确定是找你的?」。 + +import ( + "strings" + "testing" +) + +// ifBlockAt 从 s[start:] 的 `if ... {` 起,用**括号计数**截出整个 if 块。 +// +// 为什么不能用字符串找 `}`:源码缩进/换行/嵌套块都会让固定串匹配失败, +// 而失败后通常不会报错,只会悄悄把范围放大到文件末尾 —— +// 判据于是变得永远为真(false green)。这个坑在本项目已踩过多次。 +func ifBlockAt(s string, start int) string { + depth := 0 + seen := false + for i := start; i < len(s); i++ { + switch s[i] { + case '{': + depth++ + seen = true + case '}': + depth-- + if seen && depth == 0 { + return s[start : i+1] + } + } + } + return "" +} + +// atEvent 造一段 OneBot 的 message 段数组(type=at)。 +func atEvent(qq string) []interface{} { + return []interface{}{ + map[string]interface{}{ + "type": "at", + "data": map[string]interface{}{"qq": qq}, + }, + } +} + +// ★★★ 判据 1:@全体成员**不算** @bot。 +func TestIsAtBotRejectsAtAll(t *testing.T) { + p := &Plugin{botID: 1456128421} + if p.isAtBot(atEvent("all")) { + t.Fatal("★ @全体成员 被当成了 @bot ——\n" + + " 任何人 @全体都会拉起 bot。那不是“在叫 bot”,是“在叫所有人”。\n" + + " 群里误响应的代价是在未被邀请的场合发言") + } +} + +// ★★★ 判据 2:@bot 本人才算。 +func TestIsAtBotAcceptsSelf(t *testing.T) { + p := &Plugin{botID: 1456128421} + if !p.isAtBot(atEvent("1456128421")) { + t.Fatal("★ @bot 本人没被识别 —— 群里的正常呼叫会漏响应") + } + // 数字形式(部分客户端会传数字) + numEvt := []interface{}{ + map[string]interface{}{ + "type": "at", + "data": map[string]interface{}{"qq": float64(1456128421)}, + }, + } + if !p.isAtBot(numEvt) { + t.Fatal("★ 数字形式的 @bot 没被识别") + } +} + +// ★★★ 判据 3:@别人 不算。 +func TestIsAtBotRejectsOthers(t *testing.T) { + p := &Plugin{botID: 1456128421} + if p.isAtBot(atEvent("2198972886")) { + t.Fatal("★ @别人 被当成了 @bot") + } + // 纯文本也不算 + textOnly := []interface{}{ + map[string]interface{}{"type": "text", "data": map[string]interface{}{"text": "1456128421"}}, + } + if p.isAtBot(textOnly) { + t.Fatal("★ 纯文本里出现 bot 号码被当成了 @ —— 那是可伪造的") + } +} + +// ★★★ 判据 4:**接线** —— 非 @ 的群消息必须 return,不能走到注入。 +// +// 这一类“函数对、接线断”编译器不报、运行时不报错, +// 后果是 bot 在群里对每条消息都回应。 +func TestNonAtGroupMessageReturnsBeforeInject(t *testing.T) { + lines := codeLines(t, "plugin.go") + joined := strings.Join(lines, "\n") + + // 找到 @bot 判断与 enqueueInterrupt 调用的相对位置 + iAt := strings.Index(joined, "if !p.isAtBot(evt.Message) {") + if iAt < 0 { + t.Fatal("找不到 @bot 判断 —— 判据需同步") + } + iInject := strings.Index(joined, "p.enqueueInterrupt(") + if iInject < 0 { + t.Fatal("找不到 enqueueInterrupt 调用") + } + if iAt > iInject { + t.Fatal("★ @bot 判断在 enqueueInterrupt **之后** —— " + + "非 @ 的群消息也会产生中断(bot 会在群里对每条消息回应)") + } + + // 该分支内必须有 return(不能只是 log 完继续往下走)。 + // + // ★ 必须用**括号计数**截出分支体,不能用字符串找结束位置: + // 判据第一版用 `strings.Index(branch, "}\n\t}")`,而 joined 里的缩进 + // 已被 strip ⇒ 找不到(返回 -1)⇒ branch 一路延伸到文件末尾, + // 于是"文件里任何别处的 return"都让它通过 —— 删掉这条 return 也不咬(实测)。 + branch := ifBlockAt(joined, iAt) + if branch == "" { + t.Fatal("无法截出 if 分支体 —— 判据需同步") + } + if !strings.Contains(branch, "return") { + t.Fatal("★ `if !isAtBot` 分支里没有 return —— " + + "非 @ 的群消息会继续走到注入") + } +} diff --git a/example/qq/internal_group_send_test.go b/example/qq/internal_group_send_test.go new file mode 100644 index 0000000..4ff5e31 --- /dev/null +++ b/example/qq/internal_group_send_test.go @@ -0,0 +1,102 @@ +package main + +// 判据:内部委派的工具调用**不得向群聊发送消息**。 +// +// ## 现场事故(2026-10-18) +// +// offload 子 agent 的「分诊汇报」把本该私聊给用户的**进度播报发到了群里**: +// +// 17:15:00 interrupt from offload-1792313969/child: 分诊汇报 +// 17:15:41 executing tool: output_send__qq +// 17:15:41 handleChannelOutput meta={"group_id": 979911915} +// +// 根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求 +// "持有全部输出通道"),于是它能自己选目标 —— 而它只从上下文里看到一个群号, +// 就选了群。 +// +// ## 为什么必须由此处硬拦(提示词不够) +// +// 提示词是"软"的;而群的发言**公开可见**。子 agent 看不到群里的上下文、 +// 分不清每句话是回给谁的 —— 它只有一句任务描述。让它自由向群发言, +// 等于把私聊级别的判断拿到公开场合执行。 +// +// 真需要群里回时,由**父**(有完整上下文与用户身份)来发 —— +// 父的 comefrom 是 user,不受这条约束。 + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/homeagentsdk/sdk" +) + +func mkOutputCall(name, meta string) *sdk.StageContext { + return &sdk.StageContext{ + Extra: map[string]interface{}{"comefrom": "internal"}, + ToolCalls: []sdk.ToolCall{{ + Name: name, + Arguments: map[string]interface{}{"payload": "x", "type": "text", "meta": meta}, + }}, + } +} + +// ★★★ 群目标必须被识别出来。 +func TestOutputSendGroupTargetDetectsGroup(t *testing.T) { + ctx := mkOutputCall("output_send__qq", `{"group_id": 979911915}`) + if got := outputSendGroupTarget(ctx, "qq"); got != "979911915" { + t.Fatalf("★ 没识别出群目标,got=%q —— 进度播报会照旧发到群里", got) + } +} + +// 私聊目标不算(那是允许的)。 +func TestOutputSendGroupTargetIgnoresPrivate(t *testing.T) { + ctx := mkOutputCall("output_send__qq", `{"user_id": 1456128421}`) + if got := outputSendGroupTarget(ctx, "qq"); got != "" { + t.Fatalf("★ 私聊目标被误判成群目标:%q", got) + } +} + +// 别的通道的 output_send 不管(本插件只约束自己)。 +func TestOutputSendGroupTargetIgnoresOtherChannel(t *testing.T) { + ctx := mkOutputCall("output_send__webui", `{"group_id": 1}`) + if got := outputSendGroupTarget(ctx, "qq"); got != "" { + t.Fatalf("★ 别的通道被误拦:%q", got) + } +} + +// 非 output_send 工具不管。 +func TestOutputSendGroupTargetIgnoresOtherTools(t *testing.T) { + ctx := &sdk.StageContext{ + ToolCalls: []sdk.ToolCall{{ + Name: "qq_send_private_msg", + Arguments: map[string]interface{}{"meta": `{"group_id": 1}`}, + }}, + } + if got := outputSendGroupTarget(ctx, "qq"); got != "" { + t.Fatalf("★ 非 output_send 工具被误拦:%q", got) + } +} + +// ★★★ 接线判据:beforeToolcall 必须**真的**在 internal 放行前调用它。 +// +// 这一类“函数写了但没接上”编译器不报、运行时不报错 —— +// 事故还会照旧发生。必须按代码行验证调用存在且位置正确。 +func TestInternalGroupSendBlockIsWired(t *testing.T) { + lines := codeLines(t, "plugin.go") + body := funcBody(t, lines, "func (p *Plugin) beforeToolcall(") + if !strings.Contains(body, "outputSendGroupTarget(ctx, p.name)") { + t.Fatal("★ beforeToolcall 没调用 outputSendGroupTarget —— " + + "内部子 agent 仍可把消息发到群里(事故会复发)") + } + // 必须在 isInternalToolCall 的分支里(内部委派专用约束) + iInt := strings.Index(body, "if isInternalToolCall(ctx) {") + iCall := strings.Index(body, "outputSendGroupTarget(ctx, p.name)") + if iInt < 0 || iCall < 0 || iCall < iInt { + t.Fatal("★ outputSendGroupTarget 不在 isInternalToolCall 分支内 —— " + + "约束会错误地作用到用户请求上") + } + // 拦截必须落到 setDenial(写拒绝指令),而不是 return error(会让整轮失败) + if !strings.Contains(body, "p.setDenial(ctx, msg)") { + t.Fatal("★ 拦截没写 denial —— 模型不会知道自己被拒,可能反复重试") + } +} diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 0243eae..940ff1a 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -197,7 +197,12 @@ type Plugin struct { downloadMu sync.Mutex downloadTasks []*DownloadTask typingMu sync.Mutex - typingMap map[int64]*typingState + // toolCount 是本次启动实际注册的工具数,仅用于启动日志。 + // + // 存在的原因:这里原本在日志里**硬编码 15**,工具加到 24 后日志仍报 15, + // 导致排查时误以为装的是旧版。日志必须说真话。 + toolCount int + typingMap map[int64]*typingState // statusAutoEnabled 是「随内核阶段自动切 QQ 在线状态」的总开关。 // @@ -643,6 +648,49 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 }, }, p.handleGetFriends) + // ★ QQ空间(Qzone)说说。 + // + // ## 2026-10-18 的误判,引以为戒 + // + // 我曾断言“NapCat 不支持发说说”,依据是探测返回 + // 「不支持的Api SendQzoneMsg」。**那是我的调用方式错了**: + // NapCat 的 action 名是下划线式的 `send_qzone_msg`, + // 而我用的是源码里的**类名** `SendQzoneMsg`。 + // + // 换对名字后: + // + // POST /send_qzone_msg {"content":"…","ugc_right":64} + // → retcode 0, tid=a5c1ca56d081d46a789a0200(实测成功,已删除) + // + // 教训:“API 不存在”这个结论必须用**真实 action 名**验证; + // 源码里的类名/文件名都不是调用名。 + p.regTool(s, sdk.ToolDef{ + Name: tp + "send_qzone_msg", + Description: "发QQ空间说说。支持纯文字或带图,可设查看权限。" + + "ugc_right: 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见" + + "(16/128 需同时给 target_uins)。返回 tid,可用于 delete_qzone_msg。", + NoMemory: false, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "content": map[string]interface{}{"type": "string", "description": "说说正文"}, + "images": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "图片数组,支持 http(s):// / file:// / base64:// ,自动上传"}, + "ugc_right": map[string]interface{}{"type": "integer", "description": "查看权限,默认 1(所有人可见)。见工具说明"}, + "target_uins": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "integer"}, "description": "ugc_right 为 16/128 时作用的好友 QQ 号数组"}, + }, "required": []string{"content"}, + }, + }, p.handleSendQzoneMsg) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "delete_qzone_msg", + Description: "删除一条QQ空间说说。tid 来自 send_qzone_msg 或空间列表。", + NoMemory: false, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "tid": map[string]interface{}{"type": "string", "description": "说说 tid"}, + }, "required": []string{"tid"}, + }, + }, p.handleDeleteQzoneMsg) + p.regTool(s, sdk.ToolDef{ Name: tp + "get_recent_contacts", Description: "查看最近有消息的联系人和群聊,返回最近消息概览(含消息数、最后一条消息内容)。可用于发现有谁发过消息但未处理。", NoMemory: false, @@ -857,8 +905,34 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 // 任何人发消息都能调任何工具)。hmapdev 现在会在构建期对这类重复告警。 // // 现在合并成一个 handler,每个 stage 一次注册。 + // + // ## 状态映射(用户定义的语义,2026-10-18) + // + // 学习中 = agent 正在**等待 LLM** + // 搬砖中 = 真正**执行 toolcall** + // 在线 = **空闲** + // + // 对应到内核 stage 时序(task.go): + // + // pre_action ──→ stepLLM() ──→ ★等 LLM★ ──→ post_action + // ↓ + // 工具循环: before_toolcall → ★跑工具★ → after_toolcall + // ↓ + // 再次 stepLLM(第二轮工具)或 before_output + // + // 所以: + // pre_action → 学习中(即将调 LLM,接下来就是等) + // before_toolcall → 搬砖中(即将/正在跑工具) + // after_toolcall → 学习中(工具回来了,又要等 LLM 决定下一步) + // after_output → 在线(一轮真正结束) + // + // ★ `before_output` **不**改状态:它是「LLM 已返回、即将输出」, + // 既不是等 LLM 也不是跑工具;若在那里写“学习中”会让状态在收尾时 + // 继续挂着,看起来像永远在想。 s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal) + s.RegisterStage(sdk.StagePreAction, p.stagePreAction, sdk.StageScopeGlobal) s.RegisterStage(sdk.StageBeforeToolcall, p.stageBeforeToolcall, sdk.StageScopeGlobal) + s.RegisterStage(sdk.StageAfterToolcall, p.stageAfterToolcall, sdk.StageScopeGlobal) s.RegisterStage(sdk.StageBeforeOutput, p.stageBeforeOutput, sdk.StageScopeGlobal) s.RegisterStage(sdk.StageAfterOutput, p.stageAfterOutput, sdk.StageScopeGlobal) // before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉, @@ -879,7 +953,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 } }() - log.Printf("[qq] plugin started: %s (%d tools)", p.name, 15) + // ★ 用**真实计数**,不写死。 + // + // 这里原本是硬编码的 `15` —— 后来工具加到 24 个,日志仍然报 15。 + // 后果:排查“qzone 工具装上没”时看日志说 15,误以为装的是旧版, + // 白折腾了几轮。这类“日志说谎”比不日志更坏。 + log.Printf("[qq] plugin started: %s (%d tools)", p.name, p.toolCount) return nil } @@ -914,6 +993,7 @@ func (p *Plugin) Stop() error { func (p *Plugin) regTool(s *sdk.PluginSDK, def sdk.ToolDef, handler sdk.ToolHandler) { s.RegisterTool(def.Name, def, handler) + p.toolCount++ } // ======== Bot Identity ======== @@ -1409,6 +1489,29 @@ func isInternalToolCall(ctx *sdk.StageContext) bool { } // isAtBot checks if the message contains an @-mention of the bot. +// +// ★ **@全体成员(qq="all")不算 @bot**(2026-10-18 修正)。 +// +// ## 为什么改(现场事实) +// +// 原实现把 `v == "all"` 也当命中: +// +// if v == botIDStr || v == "all" { return true } +// +// 后果:群里任何人 `@全体成员` 都会拉起 bot。那不是“在叫 bot”, +// 而是“在叫所有人” —— 一个是**针对性请求**,一个是**广播**, +// 语义完全不同。 +// +// 现场佐证:群友直接质问过“又没@你,你就这么确定是找你的?”。 +// +// ## 为什么不能“宽松一点” +// +// 群里发言代表公开可见。误响应的代价不是“多回一句”,而是 +// **在未被邀请的场合发言**。宁可漏响应(用户会再 @ 一次), +// 不能误响应。 +// +// 注:如果将来确实要支持“@全体就响应”,应做成显式配置项 +// (默认关闭),而不是藏在 @ 判定里默认生效。 func (p *Plugin) isAtBot(msg interface{}) bool { segments, ok := msg.([]interface{}) if !ok { @@ -1425,7 +1528,8 @@ func (p *Plugin) isAtBot(msg interface{}) bool { if qq, ok := data["qq"]; ok { switch v := qq.(type) { case string: - if v == botIDStr || v == "all" { + // "all"(@全体成员)**不**算命中,见上方注释。 + if v == botIDStr { return true } case float64: @@ -1526,6 +1630,31 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error { // 为何放在 `auth.Active` 判断**之前**:internal 与"非本插件来源"在语义上 // 是同一类(都不是"一条 QQ 用户消息"),走同一条放行路径最不容易出错。 if isInternalToolCall(ctx) { + // ★ 内部委派**不得向群聊发送**(2026-10-18 现场事故)。 + // + // ## 事故 + // + // offload 子 agent 的「分诊汇报」把本应发给用户的进度播报 + // **发到了群里**(meta={"group_id": 979911915})。 + // + // 根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求 + // “持有全部输出通道”),于是它能自己选目标 —— 而它只从上下文里看到 + // 一个群号,就选了群。 + // + // ## 为什么不允许 + // + // 群的发言代表公开可见。子 agent 看不到“这个群里刚刚是谁 @ 了我”、 + // 也分不清“这句是回给谁的”,它只有一句任务描述。 + // 让它自由向群发言 = 把私聊级别的判断拿到公开场合执行。 + // + // 真需要群里回时,由**父**(有完整上下文与用户身份)来发 —— 父的 + // comefrom 是 user,不受这条约束。 + if tc := outputSendGroupTarget(ctx, p.name); tc != "" { + msg := fmt.Sprintf("内部委派不得向群聊发送消息(目标群 %s)。"+ + "请改发私聊,或把结果返回给父由它决定", tc) + p.setDenial(ctx, msg) + return nil + } return nil } tc := &ctx.ToolCalls[0] @@ -1644,7 +1773,7 @@ func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int return } if p.batchWindow <= 0 { - p.injectInterrupt(single, p.interruptLevel(owner)) + p.injectInterrupt(single, p.interruptLevel(owner), qqInjectPayload(userID, groupID, msgType == "group")) return } key := qqBatchKey(msgType, groupID, userID) @@ -1696,8 +1825,33 @@ func (p *Plugin) interruptLevel(owner bool) string { return sdk.PriorityL1 } +// qqInjectPayload 构造注入的 payload 扩展:告诉内核“这一轮是哪个用户引起的”。 +// +// ## 为什么需要它(2026-10-18) +// +// 「对方正在输入中」必须只在**自己引起的** before_output 里发,而且要知道 +// 发给谁。但 `before_output` 的 stageCtx 是内核新建的(emitResponse), +// 与 `on_input` 的 stageCtx 不共享 Extra(后者是局部变量)。 +// +// ## 为什么走 Payload 而不是另立一个字段 +// +// `InputEvent.Payload` 本就是通道送入内核的请求体,身份信息本该在那里。 +// 第一版我加了 `InjectOptions.Meta` —— 那是把同一件事做了两遍, +// 而且只服务注入路径。现在用 `InjectOptions.Payload`: +// 内核把**非保留键**原样透传到 stageCtx.Extra,HTTP/CLI/ACP 也能共用。 +// +// ★ 键名选 `qq_uid` 而不是 `user_id`:后者太通用,将来内核若加同名字段 +// 会撞车;加插件名前缀是低成本的隔离。 +func qqInjectPayload(userID, groupID int64, isGroup bool) map[string]interface{} { + if userID <= 0 || isGroup { + // 群聊不发输入指示(QQ 的“正在输入”只在私聊标题栏有意义)。 + return nil + } + return map[string]interface{}{"qq_uid": userID} +} + // injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容)。 -func (p *Plugin) injectInterrupt(text, level string) { +func (p *Plugin) injectInterrupt(text, level string, payload map[string]interface{}) { if text == "" { return } @@ -1716,6 +1870,8 @@ func (p *Plugin) injectInterrupt(text, level string) { Priority: level, // 中断文本是路由/取正文的指令,不是对话内容,不据它召回。 RecallPolicy: sdk.RecallPolicyNone, + // 业务字段(身份)随 payload 走,内核原样透传到本轮 stageCtx.Extra。 + Payload: payload, }) } @@ -1733,7 +1889,7 @@ func (p *Plugin) flushBatch(key string) { text = p.buildBatchInterrupt(b) } // 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。 - p.injectInterrupt(text, p.interruptLevel(b.owner)) + p.injectInterrupt(text, p.interruptLevel(b.owner), qqInjectPayload(b.userID, b.groupID, b.isGroup)) } // flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。 @@ -1932,11 +2088,24 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { // 必须在注入前记录 OneBot 可信来源;权限判断绝不依赖昵称、正文或模型参数。 p.activateAuthContext(evt.MessageID, evt.UserID, evt.GroupID, evt.MessageType == "group") - if evt.MessageType == "private" { - p.startTyping(evt.UserID) - } - - // 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。 + // ★ 「对方正在输入中」**不**在这里发起。 + // + // ## 旧写法错在哪(2026-10-18) + // + // 原本是 `if private { p.startTyping(evt.UserID) }` —— 消息**刚到** + // 就挂上“正在输入”,但此时: + // · agent 还没接受这条消息(下面才 enqueueInterrupt) + // · 批量合并窗口内可能还要等一段 + // · 权限门可能直接拒掉(那人就一直看着“正在输入”却等不到回复) + // 而且 typingLoop 有 30 秒超时 —— agent 跑得慢就自己灭了, + // 回复还在路上。 + // + // 正确边界:`before_output` 开始、`after_output` 结束(见 status.go + // 的 stageBeforeOutput / stageAfterOutput),且只处理 output_channel==qq + // 且带本人身份的注入(`inject_meta`)。 + // + // 这样做的前提是:身份要由注入方声明(InjectOptions.Meta), + // 内核透传到本轮 stageCtx.Extra["inject_meta"]。 p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk) w.WriteHeader(http.StatusOK) @@ -2270,10 +2439,20 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明") } - if userID > 0 { - p.stopTyping(userID) - } - + // ★ 「对方正在输入中」**不**在这里停。 + // + // ## 旧写法错在哪(2026-10-18) + // + // 原本是 `if userID > 0 { p.stopTyping(userID) }` —— 它是挂在 + // **发送消息**上的,而发送消息可能是: + // · 群聊消息(与死个私聊用户无关) + // · 给**另一个人**的私聊消息 + // · 同一个人但只是中途的一句话,而推理还在继续 + // + // 后果:正在跟 A 私聊时 agent 发了条群消息,A 的输入指示被莫名关掉 + // —— 他以为 bot 跑了。 + // + // 正确边界:`after_output`(一轮真正结束),见 status.go。 var sendErr error switch rawType { case "text": @@ -3642,9 +3821,28 @@ func oneBotError(raw json.RawMessage) error { return nil } +// setInputStatus 发「对方正在输入中」指示。 +// +// ★★ `user_id` 必须传**字符串**(2026-10-18 实测)。 +// +// NapCat 4.8.x 接受数字,但 4.18.37 的 payloadSchema 把它声明成 Type.String: +// +// const PayloadSchema = Type.Object({ +// user_id: Type.String({ description: 'QQ号' }), +// event_type: Type.Number({...}), +// }) +// +// 传数字会直接报 `uid is empty`(不是 schema 错误 —— 更容易误判成"用户不存在")。 +// 实测: +// +// {"user_id":1,"event_type":1} → uid is empty +// {"user_id":"1456128421","event_type":1} → retcode 0, errMsg success +// +// 这个差异不会引起编译错误、也不会引起内核告警 —— 只会让功能**静默失效** +// (Qzone 那批 API 在 4.18.37 仍未支持,同理)。 func (p *Plugin) setInputStatus(userID int64, eventType int) (interface{}, error) { return p.napcat("set_input_status", map[string]interface{}{ - "user_id": userID, + "user_id": strconv.FormatInt(userID, 10), "event_type": eventType, }) } @@ -3759,3 +3957,48 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e groupPolicy: "open", }, nil } + +// outputSendGroupTarget 检查一次工具调用是不是「向群聊发送消息」, +// 是的话返回群号字符串,否则返回空串。 +// +// ## 为什么需要它(2026-10-18 现场事故) +// +// offload 子 agent 播报进度时调 `output_send__qq` 并填了 +// `meta={"group_id": 979911915}` —— 本该私聊给用户的进度播报进了群。 +// +// 子 agent 的 comefrom 是 `internal`,而原先 `isInternalToolCall(ctx)` +// 一放行就 `return nil`,**内部调用完全不检查目标**。 +// +// ## 为什么在 before_toolcall 拦而不是 handleChannelOutput +// +// `ToolHandler` 只收 `args`,**拿不到帧上的 comefrom**(SDK 契约如此)。 +// 只有 stage 帧能看到 `comefrom` + `ToolCalls`。所以必须在阶段里拦。 +// +// 拦的方式是写 denial(与现有权限门一致),而不是返回 error —— +// 返回 error 会让整轮任务失败;写 denial 让模型收到“这条被拒、换个目标”的 +// 明确指令,与其它权限拒绝的行为一致。 +func outputSendGroupTarget(ctx *sdk.StageContext, selfName string) string { + if ctx == nil || len(ctx.ToolCalls) == 0 { + return "" + } + tc := &ctx.ToolCalls[0] + if tc.Name != "output_send__"+selfName { + return "" + } + meta, _ := tc.Arguments["meta"].(string) + if meta == "" { + return "" + } + var m struct { + GroupID int64 `json:"group_id"` + } + if err := json.Unmarshal([]byte(meta), &m); err != nil { + // meta 解析失败时不拦:让 handleChannelOutput 去报格式错, + // 那里本来就有更准确的提示。 + return "" + } + if m.GroupID <= 0 { + return "" + } + return strconv.FormatInt(m.GroupID, 10) +} diff --git a/example/qq/qzone.go b/example/qq/qzone.go new file mode 100644 index 0000000..ee58b7b --- /dev/null +++ b/example/qq/qzone.go @@ -0,0 +1,189 @@ +package main + +// QQ 空间(Qzone)说说:发送与删除。 +// +// ## 接口来源与一次真实的误判(2026-10-18) +// +// NapCat 源码里 Qzone 的实现是: +// +// packages/napcat-onebot/action/extends/SendQzoneMsg.ts +// packages/napcat-onebot/action/extends/DeleteQzoneMsg.ts +// +// 但 **action 名不是类名**,`router.ts` 里声明的是: +// +// SendQzoneMsg: 'send_qzone_msg', +// DeleteQzoneMsg: 'delete_qzone_msg', +// +// 我曾用类名 `SendQzoneMsg` 探测,得到「不支持的Api SendQzoneMsg」, +// 于是错误地断定"生产版本太旧、不支持 Qzone"。换成下划线名后: +// +// POST /send_qzone_msg {"content":"…","ugc_right":64} +// → {"status":"ok","retcode":0,"data":{"tid":"a5c1ca56d081d46a789a0200"}} +// +// **API 一直是可用的**,是我的调用名错了。 +// +// 教训:「不支持」这个结论必须用真实 action 名验证 —— 源码里的 +// 类名、文件名、方法名都不是调用名。 +// +// ## 为什么不需要 Shell / NTQQ 注入 +// +// Qzone 是纯 HTTP:NapCat 的 WebApi.publishQzoneMsg 直连 +// `up.qzone.qq.com` / `user.qzone.qq.com`,与 NTQQ 客户端进程无关。 +// (对比:shell/native loader 那套才是绑 NTQQ 的。) + +import ( + "fmt" + "strconv" +) + +// validUgcRights 是 NapCat 服务端硬校验的白名单(源码 ValidUgcRights)。 +// +// 本地也校验一次:服务端拒绝时报的是 "ugc_right 参数不合法, 允许的值为: …", +// 但那是**跨进程往返之后**才拿到的错误;本地先拦能给模型更直接的反馈, +// 也避免为一次参数错误消耗一次 NapCat 调用。 +var validUgcRights = map[int]string{ + 1: "所有人可见", + 4: "好友可见", + 16: "部分好友可见", + 64: "仅自己可见", + 128: "部分好友不可见", +} + +// handleSendQzoneMsg 发一条说说。 +func (p *Plugin) handleSendQzoneMsg(args map[string]interface{}) (interface{}, error) { + content, _ := args["content"].(string) + if content == "" { + return nil, fmt.Errorf("content 不能为空(说说正文是必填项)") + } + + params := map[string]interface{}{"content": content} + + // ugc_right:缺省 1(所有人可见)。这里**不**擅自降成"仅自己可见" —— + // 用户明确说了要发说说,就按他要的默认发;权限由调用方显式声明。 + ugc := 1 + if v, ok := args["ugc_right"]; ok && v != nil { + switch n := v.(type) { + case float64: + ugc = int(n) + case int: + ugc = n + case int64: + ugc = int(n) + case string: + if parsed, err := strconv.Atoi(n); err == nil { + ugc = parsed + } else { + return nil, fmt.Errorf("ugc_right 需为整数,收到 %q", n) + } + } + } + if desc, ok := validUgcRights[ugc]; !ok { + return nil, fmt.Errorf("ugc_right=%d 不合法,只能取 1/4/16/64/128(依次为:所有人可见/好友可见/部分好友可见/仅自己可见/部分好友不可见)", ugc) + } else { + _ = desc + } + params["ugc_right"] = ugc + + // target_uins:16/128 时必填(NapCat 服务端也会校验,本地先拦)。 + if raw, ok := args["target_uins"]; ok && raw != nil { + uins, err := toInt64Slice(raw) + if err != nil { + return nil, fmt.Errorf("target_uins: %w", err) + } + if len(uins) > 0 { + params["target_uins"] = uins + } + } + if ugc == 16 || ugc == 128 { + if _, ok := params["target_uins"]; !ok { + return nil, fmt.Errorf("ugc_right=%d(%s)时必须提供 target_uins", ugc, validUgcRights[ugc]) + } + } + + // images:原样透传字符串数组(支持 http(s):// / file:// / base64://, + // NapCat 自己下载并上传到 Qzone)。 + if raw, ok := args["images"]; ok && raw != nil { + imgs, err := toStringSlice(raw) + if err != nil { + return nil, fmt.Errorf("images: %w", err) + } + if len(imgs) > 0 { + params["images"] = imgs + } + } + + resp, err := p.napcat("send_qzone_msg", params) + if err != nil { + return nil, fmt.Errorf("发送说说失败: %w", err) + } + return resp, nil +} + +// handleDeleteQzoneMsg 删一条说说。 +func (p *Plugin) handleDeleteQzoneMsg(args map[string]interface{}) (interface{}, error) { + tid, _ := args["tid"].(string) + if tid == "" { + return nil, fmt.Errorf("tid 不能为空(来自 send_qzone_msg 的返回值或空间列表)") + } + resp, err := p.napcat("delete_qzone_msg", map[string]interface{}{"tid": tid}) + if err != nil { + return nil, fmt.Errorf("删除说说失败: %w", err) + } + return resp, nil +} + +// toStringSlice 把 JSON-RPC 反序列化出来的数组转成 []string。 +// +// 为什么不能直接断言 []string:经过 proc 协议的 JSON 往返后, +// []interface{} 是常态(模型传来的参数一定是这个形态)。 +// 只认 []string 会让这个参数**永远为空**,且不报错 —— 静默失效。 +func toStringSlice(v interface{}) ([]string, error) { + switch arr := v.(type) { + case []string: + return arr, nil + case []interface{}: + out := make([]string, 0, len(arr)) + for _, e := range arr { + s, ok := e.(string) + if !ok { + return nil, fmt.Errorf("期望字符串数组,遇到 %T", e) + } + out = append(out, s) + } + return out, nil + } + return nil, fmt.Errorf("期望数组,收到 %T", v) +} + +// toInt64Slice 同上,用于 QQ 号数组。 +// +// ★ 必须接受 float64:JSON 数字解码成 float64,模型传 123456 到这里就是 +// float64(123456)。只认 int 会让 target_uins 永远被当成空数组。 +func toInt64Slice(v interface{}) ([]int64, error) { + switch arr := v.(type) { + case []int64: + return arr, nil + case []interface{}: + out := make([]int64, 0, len(arr)) + for _, e := range arr { + switch n := e.(type) { + case float64: + out = append(out, int64(n)) + case int: + out = append(out, int64(n)) + case int64: + out = append(out, n) + case string: + parsed, err := strconv.ParseInt(n, 10, 64) + if err != nil { + return nil, fmt.Errorf("QQ 号需为数字,收到 %q", n) + } + out = append(out, parsed) + default: + return nil, fmt.Errorf("期望 QQ 号数组,遇到 %T", e) + } + } + return out, nil + } + return nil, fmt.Errorf("期望数组,收到 %T", v) +} diff --git a/example/qq/qzone_test.go b/example/qq/qzone_test.go new file mode 100644 index 0000000..ef3bb80 --- /dev/null +++ b/example/qq/qzone_test.go @@ -0,0 +1,116 @@ +package main + +// Qzone 工具的判据。 +// +// ## 一个必须被钉住的教训(2026-10-18) +// +// 我曾断言「生产 NapCat 不支持发说说」,依据是探测返回 +// 「不支持的Api SendQzoneMsg」。**那是调用名错了** —— +// router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码类名 +// `SendQzoneMsg`。换对名字后一次成功(retcode 0, tid 返回)。 +// +// 所以判据要钉的第一条就是:**action 名必须是下划线式**。 +// 这一条能防止未来有人"照着源码类名"写回来。 + +import ( + "os" + "strings" + "testing" +) + +// ★★★ 判据 1:NapCat action 名必须是下划线式,不是源码类名。 +func TestQzoneActionNamesAreSnakeCase(t *testing.T) { + for _, ln := range codeLines(t, "qzone.go") { + // 抓 p.napcat("xxx" 里的字面量 + if i := strings.Index(ln, `p.napcat("`); i >= 0 { + rest := ln[i+len(`p.napcat("`):] + if j := strings.Index(rest, `"`); j > 0 { + action := rest[:j] + if action != strings.ToLower(action) { + t.Fatalf("★ NapCat action 名 `%s` 不是下划线式 ——\n"+ + " 源码里的类名(SendQzoneMsg)不是调用名,真实名在 router.ts:\n"+ + " SendQzoneMsg: 'send_qzone_msg',\n"+ + " DeleteQzoneMsg: 'delete_qzone_msg'", action) + } + } + } + } +} + +// ★★★ 判据 2:ugc_right 白名单必须在本地校验。 +// +// 服务端也校验,但那是跨进程往返之后才拿到的错误。 +// 本地先拦能给模型更直接的反馈,也省一次 NapCat 调用。 +func TestQzoneUgcRightValidatedLocally(t *testing.T) { + want := []int{1, 4, 16, 64, 128} + if len(validUgcRights) != len(want) { + t.Fatalf("★ ugc_right 白名单项数 %d,期望 %d", len(validUgcRights), len(want)) + } + for _, v := range want { + if _, ok := validUgcRights[v]; !ok { + t.Fatalf("★ ugc_right 白名单缺少 %d(NapCat 源码 ValidUgcRights = %v)", v, want) + } + } +} + +// ★★★ 判据 3:16/128 时必须要求 target_uins。 +// +// NapCat 服务端会报 "ugc_right 为 16 或 128 时, target_uins 不能为空", +// 本地先拦省一次往返。 +func TestQzoneTargetUinsRequiredForRestrictedRights(t *testing.T) { + p := &Plugin{name: "qq"} + + // 16 无 target_uins ⇒ 必须报错 + if _, err := p.handleSendQzoneMsg(map[string]interface{}{ + "content": "x", "ugc_right": float64(16), + }); err == nil { + t.Fatal("★ ugc_right=16 缺 target_uins 却没报错 —— 服务端会拒,白跑一次往返") + } + + // content 必填 + if _, err := p.handleSendQzoneMsg(map[string]interface{}{}); err == nil { + t.Fatal("★ 缺 content 却没报错") + } + + // 非法 ugc_right 本地拦 + if _, err := p.handleSendQzoneMsg(map[string]interface{}{ + "content": "x", "ugc_right": float64(7), + }); err == nil { + t.Fatal("★ ugc_right=7(不在白名单)却没报错") + } +} + +// ★★★ 判据 4:target_uins / images 必须接受 JSON 反序列化的形态。 +// +// 经 proc 协议往返后,数组一定是 []interface{}、数字一定是 float64。 +// 只认 []int64 / []string 会让这些参数**永远为空且不报错** —— 静默失效。 +func TestQzoneAcceptJSONDecodedTypes(t *testing.T) { + // []interface{} + float64(真实链路形态) + uins, err := toInt64Slice([]interface{}{float64(123456), float64(789012)}) + if err != nil || len(uins) != 2 || uins[0] != 123456 || uins[1] != 789012 { + t.Fatalf("★ target_uins 不接受 JSON 形态([]interface{}+float64):%v, %v", uins, err) + } + imgs, err := toStringSlice([]interface{}{"http://a/b.jpg", "base64://xxx"}) + if err != nil || len(imgs) != 2 { + t.Fatalf("★ images 不接受 JSON 形态([]interface{}):%v, %v", imgs, err) + } + // 字符串形式的 QQ 号也要能吃(模型常传 "123456") + uins2, err := toInt64Slice([]interface{}{"123456"}) + if err != nil || len(uins2) != 1 || uins2[0] != 123456 { + t.Fatalf("★ target_uins 不接受字符串 QQ 号:%v, %v", uins2, err) + } +} + +// ★★★ 判据 5:两个工具都注册了(否则模型看不到)。 +func TestQzoneToolsRegistered(t *testing.T) { + b, err := os.ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + for _, name := range []string{"send_qzone_msg", "delete_qzone_msg"} { + if !strings.Contains(src, `tp + "`+name+`"`) { + t.Fatalf("★ 工具 %s 没有注册", name) + } + } +} diff --git a/example/qq/status.go b/example/qq/status.go index 5e6d551..35c4e2a 100644 --- a/example/qq/status.go +++ b/example/qq/status.go @@ -34,6 +34,7 @@ package main // 4. **失败静默**:只记一次日志(同类失败合并计数),不打爆日志。 import ( + "encoding/json" "log" "strings" "sync" @@ -351,6 +352,26 @@ func (p *Plugin) handleSetDiyStatus(args map[string]interface{}) (interface{}, e // 后果不只是"状态不切换"——**QQ 的权限门被静默关掉了**。 // hmapdev 现在会在**构建期**对这类重复告警(开发者在构建输出里必然看见)。 +// stagePreAction:即将调 LLM ⇒ 「学习中」。 +// +// ## 为什么这里才是“等 LLM”(2026-10-18 用户定义语义) +// +// 学习中 = agent 正在**等待 LLM** +// 搬砖中 = 真正**执行 toolcall** +// 在线 = **空闲** +// +// 内核时序(task.go): +// +// pre_action ──→ stepLLM() ──→ ★等 LLM★ ──→ post_action +// +// `pre_action` 就在 `stepLLM()` **之前** —— 这是唯一能提前标记 +// “接下来要等模型”的位置。之前把「学习中」挂在 `before_output` 上是错的: +// 那个时刻 LLM **已经返回**了,等的是输出而不是等模型。 +func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error { + p.applyStatus(qqStatusStudy) + return nil +} + // stageBeforeToolcall 把「权限门」与「状态切换」合成一次注册。 // // 顺序:先跑权限门(它可能拒绝工具),再切状态。 @@ -363,22 +384,122 @@ func (p *Plugin) stageBeforeToolcall(ctx *sdk.StageContext) error { return nil } -// stageBeforeOutput 把「状态切换」接到 before_output(该 stage 无其它 handler)。 -func (p *Plugin) stageBeforeOutput(ctx *sdk.StageContext) error { - // "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。 - // 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。 +// stageAfterToolcall:工具跑完了,接下来又要等 LLM 决定下一步 ⇒ 「学习中」。 +// +// 不回到「学习中」的话,状态会在整个工具循环期间一直挂着「搬砖中」—— +// 看起来像工具卡住了。 +func (p *Plugin) stageAfterToolcall(ctx *sdk.StageContext) error { p.applyStatus(qqStatusStudy) return nil } +// stageBeforeOutput:**不改在线状态**,只负责「对方正在输入中」的起点。 +// +// ## 为什么这里不写状态 +// +// `before_output` 是「LLM 已返回、即将输出」——既不是等 LLM 也不是跑工具。 +// 在那里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想。 +func (p *Plugin) stageBeforeOutput(ctx *sdk.StageContext) error { + // ★ 「对方正在输入中」的**起点**。 + // + // ## 为什么是 before_output(而不是消息到达时) + // + // 旧写法在 HTTP 入口 startTyping —— 那时 agent 还没接受消息、可能在 + // 批量合并窗口里等、甚至可能被权限门拒掉;typingLoop 还有 30 秒超时, + // agent 跑得慢就自己灭了,回复还在路上。 + // + // `before_output` 在内核里**每轮只触发一次**(emitResponse), + // 正好是“这一轮的天花板要出了”的时刻 —— 与打字指示器的语义完全对上。 + // + // ## 为什么读 payload 透传的字段而不是 p.auth + // + // p.auth 是“最近一次输入”的全局状态,并发多个私聊会**串号** + // (A 的输入指示可能打到 B 身上)。注入 payload 是**注入方声明**的、 + // 随这一轮走到 stageCtx.Extra 的不透明数据,不会串。 + p.startTypingForTurn(ctx) + return nil +} + // stageAfterOutput 把「清理 auth 上下文」与「状态归位」合成一次注册。 // // 顺序:先清理(原有语义,必须执行),再切状态(**force**,不受防抖)。 // 清理不能因状态失败而跳过 —— 它关系到下一轮的权限判定。 func (p *Plugin) stageAfterOutput(ctx *sdk.StageContext) error { + // ★ 「对方正在输入中」的**终点**:先停打字指示(不能让 auth 清理失败 + // 就永久留在“正在输入”),再清理 auth,最后状态归位。 + p.stopTypingForTurn(ctx) if err := p.afterOutputAuthContext(ctx); err != nil { return err } p.applyStatusForce(qqStatusOnline) return nil } + +// ======== 「对方正在输入中」的按轮驱动 ======== + +// turnTypingUser 从这一轮的 stage 上下文里取出发输入指示的目标用户。 +// +// 只认**注入方声明**的 payload 字段:它随这一轮走到 stageCtx.Extra,不会串号。 +// +// 返回 0 表示这一轮不该发输入指示(群聊 / 非 QQ 来源 / 无身份声明)。 +// +// ## 为什么不用 p.auth(2026-10-18) +// +// p.auth 是"最近一次输入"的全局状态。并发两个私聊时,A 的消息进来后 +// 内核可能先跑 B 那一轮 —— before_output 读 p.auth 就拿到 B, +// A 的输入指示会**打到 B 身上**(用户看到的是"你在跟谁说话"的错乱)。 +func (p *Plugin) turnTypingUser(ctx *sdk.StageContext) int64 { + if ctx == nil { + return 0 + } + ctx.RLock() + defer ctx.RUnlock() + // 只处理自己引起的轮次:别的插件/通道的输出不该动我们的输入指示器。 + if src, _ := ctx.Extra["input_source"].(string); src != "" && src != p.name { + return 0 + } + // `qq_uid` 是 qq 自己在 InjectOptions.Payload 里声明的,内核原样透传。 + // 加 qq_ 前缀是为了避免将来与内核字段撞车。 + uid, ok := ctx.Extra["qq_uid"] + if !ok { + return 0 + } + // 经 proc 协议(JSON)往返,数字可能变成 float64 或 json.Number。 + switch v := uid.(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case json.Number: + n, err := v.Int64() + if err == nil { + return n + } + } + return 0 +} + +// startTypingForTurn 在 before_output 发起「对方正在输入中」。 +func (p *Plugin) startTypingForTurn(ctx *sdk.StageContext) { + uid := p.turnTypingUser(ctx) + if uid <= 0 { + return + } + p.startTyping(uid) + log.Printf("[qq] 对方正在输入中 → 开始(uid=%d,before_output)", uid) +} + +// stopTypingForTurn 在 after_output 结束「对方正在输入中」。 +// +// ★ 即使这一轮没发过指示(turnTypingUser 返回 0)也要兜一次: +// 上一轮若因超时/异常没停干净,这里能收尾。stopTyping 对不存在的条目是空操作。 +func (p *Plugin) stopTypingForTurn(ctx *sdk.StageContext) { + uid := p.turnTypingUser(ctx) + if uid <= 0 { + return + } + p.stopTyping(uid) + log.Printf("[qq] 对方正在输入中 → 结束(uid=%d, after_output)", uid) +} diff --git a/example/qq/status_mapping_test.go b/example/qq/status_mapping_test.go new file mode 100644 index 0000000..d55ab3f --- /dev/null +++ b/example/qq/status_mapping_test.go @@ -0,0 +1,151 @@ +package main + +// 状态映射的判据。 +// +// ## 用户定义的语义(2026-10-18) +// +// 学习中 = agent 正在**等待 LLM** +// 搬砖中 = 真正**执行 toolcall** +// 在线 = **空闲** +// +// ## 内核时序(task.go,权威) +// +// pre_action ──→ stepLLM() ──→ ★等 LLM★ ──→ post_action +// ↓ +// 工具循环: before_toolcall → ★跑工具★ → after_toolcall +// ↓ +// 再次 stepLLM(第二轮工具)或 before_output → after_output +// +// 所以正确映射是: +// +// pre_action → 学习中 (即将调 LLM,接下来就是等) +// before_toolcall → 搬砖中 (即将/正在跑工具) +// after_toolcall → 学习中 (工具回来了,又要等 LLM 决定下一步) +// before_output → 不改状态 (LLM 已回、即将输出;不是等 LLM 也不是跑工具) +// after_output → 在线 (一轮真正结束,空闲) +// +// ## 曾经错在哪 +// +// 「学习中」被挂在 `before_output` 上 —— 那个时刻 LLM **已经返回**了, +// 等的是输出而不是等模型。后果:等待 LLM 的整段时间状态是错的 +// (停在上一轮的「搬砖中」或「在线」),用户看不到 agent 在想。 + +import ( + "strings" + "testing" +) + +// ★★★ 判据 1:pre_action 必须切「学习中」(这是唯一的"即将等 LLM"时点)。 +func TestStatusStudyOnPreAction(t *testing.T) { + lines := codeLines(t, "status.go") + body := funcBody(t, lines, "func (p *Plugin) stagePreAction(") + if !strings.Contains(body, "applyStatus(qqStatusStudy)") { + t.Fatal("★ pre_action 没有切「学习中」——\n" + + " 内核时序里 pre_action 在 stepLLM() **之前**,这是唯一能提前标记\n" + + " “接下来要等模型”的位置。不在这里切,等待 LLM 的整段时间状态都是错的") + } +} + +// ★★★ 判据 2:before_toolcall 必须切「搬砖中」。 +func TestStatusWorkingOnBeforeToolcall(t *testing.T) { + lines := codeLines(t, "status.go") + body := funcBody(t, lines, "func (p *Plugin) stageBeforeToolcall(") + if !strings.Contains(body, "applyStatus(qqStatusWorking)") { + t.Fatal("★ before_toolcall 没有切「搬砖中」") + } + // 权限门必须仍在这里(合并后不能丢) + if !strings.Contains(body, "p.beforeToolcall(ctx)") { + t.Fatal("★ before_toolcall 里权限门丢了 —— 合并时把安全逻辑吞掉了") + } +} + +// ★★★ 判据 3:after_toolcall 必须回到「学习中」。 +// +// 不回到学习中,状态会在整个工具循环里挂着「搬砖中」,看起来像工具卡住。 +func TestStatusStudyOnAfterToolcall(t *testing.T) { + lines := codeLines(t, "status.go") + body := funcBody(t, lines, "func (p *Plugin) stageAfterToolcall(") + if !strings.Contains(body, "applyStatus(qqStatusStudy)") { + t.Fatal("★ after_toolcall 没回到「学习中」——\n" + + " 工具回来了,接下来又要等 LLM 决定下一步。不切会让状态\n" + + " 在整个工具循环里挂着「搬砖中」,看起来像卡住") + } +} + +// ★★★ 判据 4:before_output **不**改在线状态。 +// +// 那个时刻 LLM 已返回、即将输出 —— 既不是等 LLM 也不是跑工具。 +// 在那里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想。 +func TestStatusNotSetOnBeforeOutput(t *testing.T) { + lines := codeLines(t, "status.go") + body := funcBody(t, lines, "func (p *Plugin) stageBeforeOutput(") + // 必须检查**真实调用**:`_ = qqStatusStudy` 这种引用不该算作"改了状态", + // 但 `applyStatus(qqStatusStudy)` 必须算。反过来也一样—— + // 只查常量名会让"引用了但没调用"的变异漏网(实测)。 + for _, bad := range []string{"applyStatus(qqStatusStudy)", "applyStatus(qqStatusWorking)", "applyStatusForce(qqStatusOnline)"} { + if strings.Contains(body, bad) { + t.Fatalf("★ before_output 改了在线状态(%s)——\n"+ + " 这个时刻 LLM 已返回、即将输出,既不是等 LLM 也不是跑工具;\n"+ + " 在这里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想", bad) + } + } + // 但它必须仍负责打字指示 + if !strings.Contains(body, "p.startTypingForTurn(ctx)") { + t.Fatal("★ before_output 没有发起打字指示") + } +} + +// ★★★ 判据 5:after_output 必须是「在线」(force,绕防抖)。 +// +// 一轮结束时是唯一的"归位"机会;被防抖拦住就会永远停在错误状态。 +func TestStatusOnlineOnAfterOutput(t *testing.T) { + lines := codeLines(t, "status.go") + body := funcBody(t, lines, "func (p *Plugin) stageAfterOutput(") + if !strings.Contains(body, "applyStatusForce(qqStatusOnline)") { + t.Fatal("★ after_output 没有 force 归位「在线」——\n" + + " 一轮结束是唯一的归位机会;用 applyStatus(带防抖)会被\n" + + " 2 秒间隔拦住,进而永远停在错误状态") + } +} + +// ★★★ 判据 6:pre_action / after_toolcall 必须真的注册了。 +// +// 光有函数不够 —— 不注册就等于没写。这类“函数在、注册忘”的漏洞 +// 编译器不报、运行时不报错。 +func TestStatusStagesRegistered(t *testing.T) { + lines := codeLines(t, "plugin.go") + joined := strings.Join(lines, "\n") + for _, s := range []string{ + "sdk.StagePreAction, p.stagePreAction", + "sdk.StageAfterToolcall, p.stageAfterToolcall", + } { + if !strings.Contains(joined, s) { + t.Fatalf("★ 没有注册 stage:%s(函数写了但没注册 = 没写)", s) + } + } +} + +// ★★★ 判据 7:启动日志里的工具数必须是**真实计数**,不能写死。 +// +// 这里原本是 `log.Printf("... (%d tools)", p.name, 15)` —— 硬编码。 +// 后来工具从 15 加到 24,日志仍报 15。 +// +// 后果不是"日志难看",而是**排查时被误导**:我查"qzone 工具装上没", +// 看到日志说 15 tools,以为装的是旧版,白折腾了好几轮。 +// 日志说谎比不日志更坏 —— 因为它看起来像证据。 +func TestToolCountLoggedIsNotHardcoded(t *testing.T) { + for _, ln := range codeLines(t, "plugin.go") { + if !strings.Contains(ln, "[qq] plugin started:") { + continue + } + if !strings.Contains(ln, "p.toolCount") { + t.Fatalf("★ 启动日志没报真实工具数:%q\n"+ + " 硬编码的数字会随工具增减而失真,进而误导排查", ln) + } + } + // 计数必须真的在注册时累加(否则 p.toolCount 恒为 0,比硬编码更糟) + body := funcBody(t, codeLines(t, "plugin.go"), "func (p *Plugin) regTool(") + if !strings.Contains(body, "p.toolCount++") { + t.Fatal("★ regTool 没有累加 p.toolCount —— 计数恒为 0,日志会报 `0 tools`") + } +} diff --git a/example/qq/typing_test.go b/example/qq/typing_test.go new file mode 100644 index 0000000..ec1c54e --- /dev/null +++ b/example/qq/typing_test.go @@ -0,0 +1,326 @@ +package main + +// 「对方正在输入中」的语义判据。 +// +// ## 修的是什么(2026-10-18) +// +// 旧实现两处都错位: +// +// HTTP 入口 → startTyping(evt.UserID) ← 消息刚到就挂指示 +// output_send__* 里 → if userID>0 { stopTyping } ← 任何发送都停 +// +// 具体害处: +// +// 1. **起点太早**:agent 还没接受消息、可能在批量合并窗口里等、甚至可能 +// 被权限门拒掉。被拒的那条消息会让用户一直看着"正在输入"却没有回复。 +// 而且 typingLoop 有 30 秒超时 —— agent 跑得慢就自己灭了,回复还在路上。 +// +// 2. **终点太宽**:stopTyping 挂在"发送消息"上,而发送可能是群聊、可能是 +// 发给别人。正在跟 A 私聊时 agent 发了条群消息,A 的指示被莫名关掉。 +// +// 正确边界:`before_output` 开始、`after_output` 结束(内核里这两个阶段 +// **每轮只触发一次**),且只处理"自己引起的、有身份的"那一轮。 +// +// ## 为什么需要内核透传 inject_meta +// +// `before_output` 的 stageCtx 是内核新建的(emitResponse),与 `on_input` +// 的 stageCtx 不共享 Extra(后者是局部变量)。所以身份必须由注入方声明、 +// 内核原样透传 —— 否则插件只能读全局 p.auth 猜,并发私聊会串号 +// (A 的输入指示打到 B 身上)。 + +import ( + "os" + "strings" + "testing" + + "github.com/JianFeeeee/homeagentsdk/sdk" +) + +// codeLines 返回某个 .go 文件里**非注释**的代码行。 +// +// 这个项目已多次栽在「注释里有、代码里没有」:判据直接 Contains 全文, +// 会把解释性的注释当成实现在,变异删掉代码也不咬。 +func codeLines(t *testing.T, file string) []string { + t.Helper() + b, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + var out []string + inBlock := false + for _, ln := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(ln) + if inBlock { + if i := strings.Index(s, "*/"); i >= 0 { + s = strings.TrimSpace(s[i+2:]) + inBlock = false + } else { + continue + } + } + if strings.HasPrefix(s, "//") || strings.HasPrefix(s, "*") { + continue + } + if strings.HasPrefix(s, "/*") { + if i := strings.Index(s, "*/"); i >= 0 { + s = strings.TrimSpace(s[i+2:]) + } else { + inBlock = true + continue + } + } + out = append(out, s) + } + return out +} + +// funcBody 用**括号计数**提取某个顶层函数/方法的函数体(含签名行)。 +// +// 为什么不用正则:调用点里常有嵌套括号(`p.injectInterrupt(a, b, qqInjectPayload(x, y, z))`), +// `[^)]*` 会在第一个 `)` 提前截断 —— 这个判据第一版就因此误报"没带身份"。 +// 也不用 `\n}\n`:函数体内有块语句,第一个行首 `}` 未必是函数结尾。 +func funcBody(t *testing.T, lines []string, sigPrefix string) string { + t.Helper() + start := -1 + for i, ln := range lines { + if strings.HasPrefix(ln, sigPrefix) { + start = i + break + } + } + if start < 0 { + t.Fatalf("找不到 %s —— 判据需同步", sigPrefix) + } + depth := 0 + seenBrace := false + for j := start; j < len(lines); j++ { + for _, r := range lines[j] { + switch r { + case '{': + depth++ + seenBrace = true + case '}': + depth-- + } + } + if seenBrace && depth == 0 { + return strings.Join(lines[start:j+1], "\n") + } + } + t.Fatalf("%s 函数体没闭合", sigPrefix) + return "" +} + +// callArgs 提取 `name(` 之后到**匹配的** `)` 之间的实参文本(处理嵌套括号)。 +func callArgs(s, name string) []string { + var out []string + for i := 0; ; { + k := strings.Index(s[i:], name+"(") + if k < 0 { + return out + } + k += i + len(name) + 1 + depth := 1 + for j := k; j < len(s); j++ { + switch s[j] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + out = append(out, s[k:j]) + i = j + 1 + goto next + } + } + } + return out + next: + } +} + +// ★★★ 判据 1:HTTP 入口**不再**发起输入指示。 +// +// 变异方式:把 `p.startTyping(evt.UserID)` 加回入口 —— 必咬。 +func TestTypingNotStartedAtHTTPEntry(t *testing.T) { + for _, ln := range codeLines(t, "plugin.go") { + if strings.Contains(ln, "startTyping(") && !strings.HasPrefix(ln, "func ") { + // 允许出现在 startTypingForTurn 里(那是 before_output 的调用) + t.Fatalf("★ HTTP 入口仍在发起输入指示:%q\n"+ + " 应在 before_output 发起(消息刚到就挂指示会让被拒/排队的消息"+ + "一直显示\"正在输入\")", ln) + } + } +} + +// ★★★ 判据 2:发送消息路径**不再**停输入指示。 +// +// 变异方式:把 `p.stopTyping(userID)` 加回 sendMessage —— 必咬。 +func TestTypingNotStoppedBySending(t *testing.T) { + lines := codeLines(t, "plugin.go") + // 只看发送消息那条路径的函数体(handleChannelOutput 是 output_send__* 的落点)。 + // + // ★ 不能看全文件:`typingLoop` 超时自停是合法的 + // (`p.stopTyping(ts.userID)`),它必须保留 —— 否则 agent 卡死时 + // 指示器永远不会消失。这个判据第一版就误报了这一处。 + body := funcBody(t, lines, "func (p *Plugin) handleChannelOutput(") + if strings.Contains(body, "stopTyping(") { + t.Fatal("★ 发送消息仍在停输入指示 ——\n" + + " 发送可能是群聊/发给别人;正在跟 A 私聊时发条群消息就会关掉 A 的指示") + } +} + +// ★★★ 判据 3:before_output 发起、after_output 结束(且是真调用,不是注释)。 +func TestTypingWiredToOutputStages(t *testing.T) { + lines := codeLines(t, "status.go") + + // 定位 stageBeforeOutput / stageAfterOutput 的函数体 + bodyOf := func(name string) string { + start := -1 + for i, ln := range lines { + if strings.HasPrefix(ln, "func (p *Plugin) "+name+"(") { + start = i + break + } + } + if start < 0 { + t.Fatalf("找不到 %s —— 判据需同步", name) + } + for j := start + 1; j < len(lines); j++ { + if lines[j] == "}" { + return strings.Join(lines[start:j+1], "\n") + } + } + t.Fatalf("%s 函数体没闭合", name) + return "" + } + + before := bodyOf("stageBeforeOutput") + if !strings.Contains(before, "p.startTypingForTurn(ctx)") { + t.Fatal("★ before_output 没有发起输入指示(startTypingForTurn)") + } + after := bodyOf("stageAfterOutput") + if !strings.Contains(after, "p.stopTypingForTurn(ctx)") { + t.Fatal("★ after_output 没有结束输入指示(stopTypingForTurn)") + } + + // after_output 必须先停指示再清理 auth —— auth 清理若失败, + // 指示不能永久留在"正在输入"。 + iStop := strings.Index(after, "stopTypingForTurn") + iAuth := strings.Index(after, "afterOutputAuthContext") + if iStop < 0 || iAuth < 0 || iStop > iAuth { + t.Fatal("★ after_output 里应先停输入指示、再清理 auth") + } +} + +// ★★★ 判据 4:只认**注入方声明**的身份(inject_meta),不读全局 p.auth。 +// +// 读 p.auth 会在并发私聊时串号:A 的消息进来后内核可能先跑 B 那一轮, +// before_output 读 p.auth 拿到 B ⇒ A 的输入指示打到 B 身上。 +func TestTypingUsesInjectMetaNotGlobalAuth(t *testing.T) { + lines := codeLines(t, "status.go") + body := funcBody(t, lines, "func (p *Plugin) turnTypingUser(") + + if !strings.Contains(body, `ctx.Extra["qq_uid"]`) { + t.Fatal("★ turnTypingUser 没读 payload 透传的 qq_uid —— 身份必须由注入方声明," + + "否则并发私聊会串号") + } + if strings.Contains(body, "p.auth") { + t.Fatal("★ turnTypingUser 读了全局 p.auth —— 并发私聊时 A 的输入指示会打到 B 身上") + } + // 只处理自己引起的轮次 + if !strings.Contains(body, `ctx.Extra["input_source"]`) { + t.Fatal("★ turnTypingUser 没校验 input_source —— 别人的输出不该动我们的指示器") + } +} + +// ★★★ 判据 5:注入时声明了身份(否则 inject_meta 是空的,前面全白搭)。 +func TestInjectDeclaresTypingIdentity(t *testing.T) { + lines := codeLines(t, "plugin.go") + joined := strings.Join(lines, "\n") + + // qqInjectPayload 群聊返回 nil(QQ 的"正在输入"只在私聊标题栏有意义) + body := funcBody(t, lines, "func qqInjectPayload(") + if !strings.Contains(body, "isGroup") { + t.Fatal("★ qqInjectPayload 没排除群聊 —— 群的标题栏没有\"正在输入\"语义") + } + + // 所有 injectInterrupt 调用点都必须把 meta 传进去。 + // + // ★ 用括号计数取实参,不用正则:实参里有嵌套调用 + // `qqInjectPayload(userID, groupID, ...)`,`[^)]*` 会提前截断导致误报。 + calls := callArgs(joined, "p.injectInterrupt") + if len(calls) == 0 { + t.Fatal("找不到 injectInterrupt 调用") + } + for _, c := range calls { + if !strings.Contains(c, "qqInjectPayload") { + t.Fatalf("★ injectInterrupt 调用没带上身份(payload):(%s)\n"+ + " 不带 meta ⇒ before_output 拿不到 inject_meta ⇒ 指示器永远不出现", c) + } + } +} + +// ★★★ 判据 6:inject_meta 经 JSON 往返后 user_id 可能是 float64 / json.Number。 +// +// 只处理 int64 会让真实链路(proc 协议是 JSON)静默失效 —— +// 单测里传 int64 是绿的,生产里永远不发指示。这是最阴的一类。 +func TestTurnTypingUserAcceptsJSONNumbers(t *testing.T) { + p := &Plugin{name: "qq"} + mk := func(v interface{}) *sdk.StageContext { + ctx := &sdk.StageContext{Extra: map[string]interface{}{ + "input_source": "qq", + "qq_uid": v, + }} + return ctx + } + cases := []struct { + name string + v interface{} + want int64 + }{ + {"float64(JSON 解码默认)", float64(1456128421), 1456128421}, + {"int64", int64(1456128421), 1456128421}, + {"int", 1456128421, 1456128421}, + {"nil", nil, 0}, + {"字符串不算", "1456128421", 0}, + } + for _, c := range cases { + if got := p.turnTypingUser(mk(c.v)); got != c.want { + t.Errorf("★ %s: user_id=%v 解析出 %d,期望 %d", c.name, c.v, got, c.want) + } + } + + // 非本插件来源:不该动我们的指示器 + ctx := &sdk.StageContext{Extra: map[string]interface{}{ + "input_source": "other", + "qq_uid": float64(1), + }} + if got := p.turnTypingUser(ctx); got != 0 { + t.Errorf("★ 别人的轮次也被识别成本插件:%d", got) + } + // 无 payload 字段:不发 + if got := p.turnTypingUser(&sdk.StageContext{Extra: map[string]interface{}{}}); got != 0 { + t.Errorf("★ 无 inject_meta 时不该发指示:%d", got) + } +} + +// ★★★ 判据 7:set_input_status 的 user_id 必须是**字符串**。 +// +// NapCat 4.8.x 接受数字,但 4.18.37 的 payloadSchema 声明成 Type.String。 +// 传数字会报 `uid is empty`(不是 schema 错误 —— 更容易误判成"用户不存在"), +// 功能**静默失效**:编译不报错、内核不告警、用户只是永远看不到"正在输入"。 +// +// 实测: +// +// {"user_id":1,"event_type":1} → uid is empty +// {"user_id":"1456128421","event_type":1} → retcode 0, errMsg success +func TestSetInputStatusSendsStringUserID(t *testing.T) { + lines := codeLines(t, "plugin.go") + body := funcBody(t, lines, "func (p *Plugin) setInputStatus(") + if !strings.Contains(body, `strconv.FormatInt(userID, 10)`) { + t.Fatal("★ set_input_status 的 user_id 没转成字符串 ——\n" + + " NapCat 4.18.37 的 payloadSchema 是 Type.String,传数字返回 `uid is empty`,\n" + + " 「对方正在输入中」会静默失效(编译不报错、内核不告警)") + } +} diff --git a/sdk/plugin.go b/sdk/plugin.go index 1158c74..0085617 100644 --- a/sdk/plugin.go +++ b/sdk/plugin.go @@ -150,6 +150,36 @@ type InjectOptions struct { // 排队注入(InjectText*/InjectInputSync)没有级别:它们本就是“不需及时处理” // 的那一类,可被任何中断打断。 Priority string + + // Payload 是本次注入要额外并入事件 payload 的字段(**不透明**)。 + // + // ## 为什么需要它(2026-10-18) + // + // QQ 插件的「对方正在输入中」必须只在**自己引起的** before_output 里发, + // 而且要知道发给谁。但两条路都断了: + // + // · `on_input` 的 stageCtx 是局部变量(runStage 完就丢) + // · `before_output` 的 stageCtx 是内核**新建**的(emitResponse) + // ⇒ 两者**不共享 Extra**,插件拿不到“这轮是谁引起的” + // + // ## 为什么挂在 InjectOptions 而不是新造一个 Meta 字段 + // + // 第一版我加了 `InjectOptions.Meta` —— 错的。它把同一件事做了两遍: + // `InputEvent.Payload` **本来就是通道送入内核的请求体**,身份信息本该 + // 就在那里。但 `IOInjector` 接口全是 `(source, channel, text, opts)`, + // 插件无法直接给 payload 加字段 —— 所以 payload 需要一个扩展口, + // 而不是一个平行的新概念。 + // + // 叫 Payload 而不是 Meta 还有一个好处:经内核实测确认, + // 它能让 HTTP/CLI/ACP 等**所有注入路径**共用同一套机制。 + // + // ## 内核怎么处理 + // + // 与真 payload 合并(同名键以本字段为准,但**内核保留字不允许覆盖**, + // 否则插件能伪造 `context_policy`/`content` 等内核语义), + // 然后在 emitResponse 里把**非内核保留键**原样透传到 `stageCtx.Extra`。 + // 内核不解释其中任何业务字段。 + Payload map[string]interface{} } // 中断优先级取值。