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
This commit is contained in:
JianFeeeee
2026-10-18 15:38:52 +08:00
parent 678b9cf90c
commit 4a9705ca41
8 changed files with 386 additions and 183 deletions

View File

@ -842,20 +842,28 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
NoMemory: true,
}, p.handleSendLike)
// 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal)
// 在线状态随阶段切换(默认关闭,见 statusAutoEnabled 的注释)。
// ★★ 每个 stage **只能注册一个 handler**(2026-10-18 踩到的坑)。
//
// ★ 三个钩子都只有一件事:调 p.applyStatus;**永远 return nil**。
// 状态是非致命错误 —— 不能因为它阻塞工具调用或改写回复。
s.RegisterStage(sdk.StageBeforeToolcall, p.stageStatusWorking, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeOutput, p.stageStatusThinking, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.stageStatusOnline, sdk.StageScopeGlobal)
// 插件侧(工具链生成的 proc_main)把 handler 存在
// `stageHandlers map[string]StageHandler`(stage 名为键)⇒
// **同一 stage 注册两次,后者静默覆盖前者**。而之前写成:
//
// s.RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global)
// s.RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了上面
// s.RegisterStage(StageAfterOutput, p.stageStatusOnline, Global)
// s.RegisterStage(StageAfterOutput, p.afterOutputAuthContext, Global) ← 覆盖了上面
//
// 后果不只是状态不切换 —— **QQ 的权限门被静默关掉了**(那几版部署里
// 任何人发消息都能调任何工具)。hmapdev 现在会在构建期对这类重复告警。
//
// 现在合并成一个 handler,每个 stage 一次注册。
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.stageBeforeToolcall, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeOutput, p.stageBeforeOutput, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.stageAfterOutput, sdk.StageScopeGlobal)
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
// 否则内核会把它误当作“结束整个推理”的最终响应。
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal)
// ---- HTTP server for NapCat webhook ----
mux := http.NewServeMux()

View File

@ -88,6 +88,14 @@ var qqStatusByName = map[string]qqStatus{
"离线": qqStatusHidden,
}
// minStatusInterval 是两次自动状态切换的最小间隔。
//
// 取 2 秒:stage 在每次工具调用都会触发,一轮对话可能有十几次;
// 逐次发 HTTP 会把 NapCat 打满,且 QQ 侧看到状态疯狂闪烁。
//
// ★ 收尾(StageAfterOutput)**不受此限**,见 applyStatusForce。
const minStatusInterval = 2 * time.Second
// statusState 是自动状态切换的运行态。
type statusState struct {
mu sync.Mutex
@ -133,6 +141,27 @@ func (p *Plugin) setSelfDiyStatus(faceID, wording string) error {
// ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 stage 补上,
// 没必要维护队列(队列会让状态滞后于阶段,反而更错)。
func (p *Plugin) applyStatus(st qqStatus) {
p.applyStatusMaybe(st, true)
}
// applyStatusForce 跳过防抖(用于"收尾":一轮结束时必须回在线)。
//
// ## 为什么必须有这个分支(2026-10-18 实测 bug)
//
// QQ 界面持续停「学习中」。原因:
//
// BeforeOutput → AfterOutput 常常不足 2 秒
// ⇒ 收尾状态被防抖拦住 ⇒ **永远回不到在线**
//
// 防抖本身是对的(防刷 NapCat),但**收尾不能被防抖** ——
// 它是一轮结束时唯一的"归位"机会,错过就得等下一轮,
// 那期间好友看到的是错的状态。
func (p *Plugin) applyStatusForce(st qqStatus) {
p.applyStatusMaybe(st, false)
}
// applyStatusMaybe 是 applyStatus / applyStatusForce 的共同实现。
func (p *Plugin) applyStatusMaybe(st qqStatus, debounce bool) {
if !p.statusAutoEnabled.Load() {
return
}
@ -141,7 +170,7 @@ func (p *Plugin) applyStatus(st qqStatus) {
p.statusState.mu.Unlock()
return
}
if time.Since(p.statusState.lastAt) < 2*time.Second {
if debounce && time.Since(p.statusState.lastAt) < minStatusInterval {
p.statusState.mu.Unlock()
return
}
@ -249,23 +278,6 @@ func normalizeStatusName(s string) (qqStatus, bool) {
//
// 这与 typing(set_input_status)的处置一致:那套也从不因失败中断对话。
func (p *Plugin) stageStatusWorking(ctx *sdk.StageContext) error {
p.applyStatus(qqStatusWorking)
return nil
}
func (p *Plugin) stageStatusThinking(ctx *sdk.StageContext) error {
// "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。
// 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。
p.applyStatus(qqStatusStudy)
return nil
}
func (p *Plugin) stageStatusOnline(ctx *sdk.StageContext) error {
p.applyStatus(qqStatusOnline)
return nil
}
// ======== 工具 handler ========
// handleSetStatus 处理 qq_set_status:手动设预置在线状态。
@ -319,3 +331,54 @@ func (p *Plugin) handleSetDiyStatus(args map[string]interface{}) (interface{}, e
"wording": wording,
}, nil
}
// ======== 合并后的 stage 入口 ========
//
// ★★ 为什么必须合并(2026-10-18 踩到的真坑)
//
// 工具链生成的 proc_main 把 stage handler 存在
// `stageHandlers map[string]sdk.StageHandler`(**stage 名为键**):
//
// func(stage sdk.Stage, handler sdk.StageHandler) {
// stageHandlers[string(stage)] = handler // ← 后者覆盖前者
// }
//
// 所以一个插件**每个 stage 只能有一个 handler**。原先写成两行注册:
//
// RegisterStage(StageBeforeToolcall, p.beforeToolcall, Global) ← 权限门
// RegisterStage(StageBeforeToolcall, p.stageStatusWorking, Global) ← 覆盖了它
//
// 后果不只是"状态不切换"——**QQ 的权限门被静默关掉了**。
// hmapdev 现在会在**构建期**对这类重复告警(开发者在构建输出里必然看见)。
// stageBeforeToolcall 把「权限门」与「状态切换」合成一次注册。
//
// 顺序:先跑权限门(它可能拒绝工具),再切状态。
// 即使权限门拒绝,状态也该显示"在干活"——拒绝也是 agent 在工作。
func (p *Plugin) stageBeforeToolcall(ctx *sdk.StageContext) error {
if err := p.beforeToolcall(ctx); err != nil {
return err
}
p.applyStatus(qqStatusWorking)
return nil
}
// stageBeforeOutput 把「状态切换」接到 before_output(该 stage 无其它 handler)。
func (p *Plugin) stageBeforeOutput(ctx *sdk.StageContext) error {
// "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。
// 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。
p.applyStatus(qqStatusStudy)
return nil
}
// stageAfterOutput 把「清理 auth 上下文」与「状态归位」合成一次注册。
//
// 顺序:先清理(原有语义,必须执行),再切状态(**force**,不受防抖)。
// 清理不能因状态失败而跳过 —— 它关系到下一轮的权限判定。
func (p *Plugin) stageAfterOutput(ctx *sdk.StageContext) error {
if err := p.afterOutputAuthContext(ctx); err != nil {
return err
}
p.applyStatusForce(qqStatusOnline)
return nil
}

View File

@ -39,9 +39,9 @@ func TestStatusStageNeverBlocksOnFailure(t *testing.T) {
name string
fn func(*sdk.StageContext) error
}{
{"before_toolcall", p.stageStatusWorking},
{"before_output", p.stageStatusThinking},
{"after_output", p.stageStatusOnline},
{"before_toolcall", p.stageBeforeToolcall},
{"before_output", p.stageBeforeOutput},
{"after_output", p.stageAfterOutput},
} {
if err := c.fn(ctx); err != nil {
t.Fatalf("★ %s 返回了 error(%v)—— 状态设置是非致命错误,"+
@ -55,7 +55,7 @@ func TestStatusStageNoopWhenDisabled(t *testing.T) {
p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"}
// statusAutoEnabled 默认零值 false
ctx := &sdk.StageContext{}
if err := p.stageStatusWorking(ctx); err != nil {
if err := p.stageBeforeToolcall(ctx); err != nil {
t.Fatalf("开关关闭时不应出错: %v", err)
}
p.statusState.mu.Lock()