Commit Graph

4 Commits

Author SHA1 Message Date
cca6fdaa6b fix(qq): 打字指示器挂到正确路径 + 状态限流改为补发而非丢弃
用户反馈两处仍然不对:
「qq插件的通过在线状态展示agent工作状态还是不正确」
「正在输入中显示也不正确」

## 缺陷 1:打字指示器一次都没触发过

诊断日志给出了决定性证据(加临时探针跑真实私聊):

	[qq-dbg] before_output: input_source="" qq_uid=<nil>(has=false) extra_keys=0

`extra_keys=0` —— 说明触发的是**另一条路径的 before_output**。

`before_output` 被两个语义共用:

	emitResponse(eventloop.go)   = 一轮的回复,turn 边界
	output.go                      = 单次 output_send__* 的工具输出

**真正把消息发出去的是后者**,而它构造 stageCtx 时**连 Extra 都没初始化**
(nil)。我把打字指示器挂在它上面,读了永远不存在的 qq_uid。

修法:内核 output.go 的 stageCtx.Extra 补三个键

	output_channel  目标通道
	output_meta     本次发送的 meta 原文(user_id / group_id)
	output_type     text / image / file

插件据此在**消息发出前** startTyping,发出后 stopTyping。
群聊不发(QQ 的「正在输入」只在私聊标题栏有意义)。

## 缺陷 2:output.go 路径没有 after_output

它才是"消息真的送达"的时刻,却只发了 before_output。没有配对的收尾,
指示器只能等 30 秒超时自己灭。补上 after_output。

★ 但 after_output 现在被两条路径共用,必须区分:

	单次发送 → stopTypingAfterOutput(配对的收尾)
	turn 边界 → stopTypingForTurn + auth 清理 + **状态归位「在线」**

状态归位**只能由 turn 边界触发**:一轮可能发多条消息,若每条发完都归位
「在线」,状态会在干活期间反复跳回空闲 —— 那正是"用状态展示工作进度"
失效的原因之一。

## 缺陷 3:限流把期望状态丢掉了

`applyStatusMaybe` 命中 2 秒限流时直接 `return`,**期望状态被丢弃**。
而 stage 之间常常不到 2 秒:

	pre_action(学习中) --0.5s--> before_toolcall(搬砖中)
	⇒ "搬砖中" 被丢

状态是「当前工作阶段」的**投影**,不是事件流 —— 投影必须最终收敛到最新值。
改为:限流只限制发送频率,被推迟的记进 `pending`,窗口结束后补发**最新**值
(不是补发被拒的那个:窗口内可能连来多个,中间值没有独立意义)。

`restoreStatus` 停机时先取消待补发,否则定时器会在连接拆除后醒来。

## 实测(真实私聊 webhook)

	17:58:15 对方正在输入中 → 开始(uid=2198972886,消息发出前)
	17:58:15 对方正在输入中 → 结束(uid=2198972886,消息已发出)

状态序列体现出工作阶段:

	17:58:05 学习中(限流后补发)
	17:58:07 搬砖中
	17:58:09 学习中(限流后补发)
	17:58:15 搬砖中
	17:58:19 在线(turn 边界,任务结束)

## 判据

新增 TestOnlineResetOnlyOnTurnBoundary(变异咬:单次发送也归位 → 报错)。

★ 修了判据自身的假绿:typing_test 的 bodyOf 用"找行首 }"截函数体,
stageAfterOutput 新增 if 分支后,第一个行首 } 是**内层块**的结尾
⇒ 判据只看到前半段 ⇒ 后半段的调用"看不见"、删掉也不咬。
改用括号计数的 funcBody。
2026-10-18 18:01:00 +08:00
c6c39aa2a8 feat(qq): Qzone 说说、群发硬拦截、@全体不算@bot、状态语义修正
## 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)。
2026-10-18 17:39:40 +08:00
4a9705ca41 feat(hmapdev): 构建期告警「同一 stage 被重复注册」+ qq 合并写法
## 用户指出的关键:告警要给**开发者**看,所以必须在构建期

我第一版把告警放进了工具链生成的 proc_main 模板(插件**运行期**打日志)。
用户直接指出:「本身工具链这个编译器警告就是给开发者看的」——运行期日志
是给运维/用户看的,开发者看不到。已撤销,改为构建期静态检查。

## 缺陷(真实事故,2026-10-18)

插件侧把 stage handler 存在 `map[string]sdk.StageHandler`(stage 名为键)⇒
**同一 stage 注册两次,后者静默覆盖前者**。

QQ 插件当时写成:

	RegisterStage(BeforeToolcall, 权限门)   ← 被下一条覆盖
	RegisterStage(BeforeToolcall, 切状态)   ← 实际生效

后果不只是状态不切换 —— **权限门被静默关掉了**(那几版部署里任何人
发消息都能调任何工具)。编译器不报、运行时不报错、内核日志还显示
`2 handlers [qq qq]`(看着完全正常)。只有静态检查能发现。

## 实现

 · `stage_dup_check.go`:扫构建目录下的 .go(排除 _test.go 与 z_proc*),
   统计每个 stage 常量被 RegisterStage 引用几次,>1 就告警
 · 接入 `buildTarget` 开头 ⇒ 所有 target(含 lua)都检查
 · **只告警不中断**:存量插件里重复注册可能是本意,直接 fail 会让它们
   升 SDK 后无法构建
 · `example/qq`:三个重复 stage 合并成单 handler(权限门 + 状态切换)

## 判据(4 条)

 · 同 stage 两次 ⇒ 报出来
 · 不同 stage 各一次 ⇒ 不误报
 · **注释里的示例注册不计入**
 · 测试文件 / 工具链生成文件不计入

★ 「注释」这条是这个检查器**第一版自己栽的坑**:直接对全文跑正则,
把插件里「当初写错了、留在注释里当教训」的那几行也统计进去,于是
修好之后照样报「注册了 3 次」(实测)。现在逐行跳 `//` 与跨行 `/* */`。
本项目已多次绊倒在「注释里有、代码里没有」上,这次轮到检查器自己。

## 实测

	qq 修好后构建:  packaged qq_linux_amd64.hmap(无告警)
	故意加一条重复:  ⚠️ 警告: stage StageBeforeOutput 注册了 2 次
	                 建议:把多个功能合并进同一个 handler
2026-10-18 15:38:52 +08:00
61a871cf88 feat(qq): 在线状态随内核阶段自动切换 + 手动设置工具
## 用户要的两件事

1. 「在不同内核工作阶段自动切换 QQ 状态,比如 toolcall 阶段切忙碌中、
   生成文字阶段切思考中」
2. 失败不阻塞、不重试、只写日志(完全非致命错误)

## 取值以 NapCat 源码为准(不是我猜的)

我最初拿 `status=11` 试探,NapCat 返回 retcode=0 **但那是无效状态**
(它不校验)。拉了源码 `SetOnlineStatus.ts` 的 actionDescription 才拿到
权威取值表:

	在线      status=10  ext=0        忙碌   status=50  ext=0
	离开      status=30  ext=0        请勿打扰 status=70  ext=0
	隐身      status=40  ext=0        搬砖中  status=10  ext=2023
	学习中    status=10  ext=1018     听歌中  status=10  ext=1028

## 阶段映射

	StageBeforeToolcall → 搬砖中(status=10, ext=2023)
	StageBeforeOutput   → 学习中(status=10, ext=1018)★ QQ 无"思考中"预置项
	StageAfterOutput    → 在线  (status=10, ext=0)

`after_toolcall` 不切:工具刚结束还要继续,频繁切换只会闪。

## 失败处理(用户明确要求的设计)

三个钩子**永远 return nil**,不重试:
 · 不返回 error ⇒ 内核不会因状态失败而中断工具调用或改写回复
 · 不重试 ⇒ NapCat 掉线时不会每轮打 HTTP + 打日志
 · 失败只在 applyStatus 记日志(首次 + 每 10 次),不刷屏

## 其它

 · 默认**关闭**(`status_auto`):改 QQ 状态对外可见,属于"内核替用户
   做决定",与 offload 同类的显式特权。
 · 手动工具两个:`qq_set_status`(预置状态,收中英文名)、
   `qq_set_diy_status`(自定义 face_id + wording)。
 · 防抖:同状态跳过、最短间隔 2s —— stage 每轮都触发,不防抖会打满 NapCat。
 · `Stop()` 里恢复在线:否则停机时停在"搬砖中",用户看到一个永久错位
   且不知道是谁改的状态。★ 恢复判据用**实际应用过什么**(applied),
   不能用 status_auto —— 手动工具设的状态在开关关闭时也要能恢复。

## 判据(5 条,变异 2/2 咬)

 · 指向必拒绝的地址时三个钩子仍返回 nil(不阻塞)
 · 开关关闭时完全不发请求
 · 状态名归一化(含中文别名)
 · 阶段映射用**权威取值**(忙碌必须是 50,不是 11)
 · 未知状态名被拒且不发请求

## 实机验证(生产 NapCat 192.168.2.106:25570)

	qq_set_status busy    → map[ext_status:0    status:忙碌   status_num:50]
	qq_set_status working → map[ext_status:2023 status:搬砖中 status_num:10]
	qq_set_status online  → map[ext_status:0    status:在线   status_num:10]
	qq_set_status 乱写的   → 未知状态名: 乱写的(拒绝,未发请求)

注:`get_status` 只返回 online/good,不回显具体状态 —— 那是该接口的
特性,不是设置失败。
2026-10-18 14:34:17 +08:00