Files
homeagent-sdk/example/qq/status_test.go
JianFeeeee 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

129 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// QQ 在线状态切换的判据。
//
// ## 核心纪律(用户明确要求)
//
// 「失败不阻塞不重试,只写日志,因为是完全非致命错误」
//
// 这条比"状态设对了"更重要:NapCat 掉线是常态(重启、网络抖动、升级),
// 如果状态设置失败能让工具调用失败或让一轮回复中断,那才是真故障 ——
// 而它本来只是个装饰性功能。
//
// 所以判据的重点是**失败路径**,不是成功路径。
import (
"net/http"
"testing"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// ★★★ 判据:NapCat 不可达时,三个 stage 钩子**仍返回 nil**(不阻塞)。
//
// 用 127.0.0.1:1(必拒绝)当 NapCat 地址,确保请求必然失败。
func TestStatusStageNeverBlocksOnFailure(t *testing.T) {
p := &Plugin{
name: "qq",
napcatURL: "http://127.0.0.1:1",
// ★ 必须给 httpClient:产品里它由 Start() 赋值(plugin.go:470)。
// 不给的话 napcat() 会在 nil client 上 panic —— 而真机不会,
// 所以那只是测试构造漏项,不是产品缺陷。
httpClient: &http.Client{Timeout: 2 * time.Second},
}
p.statusAutoEnabled.Store(true) // 强制开启,确保真的会尝试发请求
ctx := &sdk.StageContext{}
for _, c := range []struct {
name string
fn func(*sdk.StageContext) error
}{
{"before_toolcall", p.stageBeforeToolcall},
{"before_output", p.stageBeforeOutput},
{"after_output", p.stageAfterOutput},
} {
if err := c.fn(ctx); err != nil {
t.Fatalf("★ %s 返回了 error(%v)—— 状态设置是非致命错误,"+
"不能让它阻塞工具调用或中断回复", c.name, err)
}
}
}
// ★★★ 判据:开关关闭时**完全不发请求**(默认关闭是常态)。
func TestStatusStageNoopWhenDisabled(t *testing.T) {
p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"}
// statusAutoEnabled 默认零值 false
ctx := &sdk.StageContext{}
if err := p.stageBeforeToolcall(ctx); err != nil {
t.Fatalf("开关关闭时不应出错: %v", err)
}
p.statusState.mu.Lock()
applied := p.statusState.applied
p.statusState.mu.Unlock()
if applied.Name != "" {
t.Fatalf("开关关闭却应用了状态 %q —— 不该发任何请求", applied.Name)
}
}
// ★★★ 判据:状态名归一化(工具参数)。
func TestNormalizeStatusName(t *testing.T) {
ok := map[string]qqStatus{
"online": qqStatusOnline, "busy": qqStatusBusy,
"working": qqStatusWorking, "study": qqStatusStudy,
"音乐": qqStatusMusic, "在线": qqStatusOnline, "忙碌": qqStatusBusy,
}
for in, want := range ok {
got, found := normalizeStatusName(in)
if !found {
t.Fatalf("应识别 %q", in)
}
if got.Name != want.Name {
t.Errorf("%q → %q,应为 %q", in, got.Name, want.Name)
}
}
if _, found := normalizeStatusName("乱写的状态"); found {
t.Error("未知状态名不该被识别(否则会发一个无效值给 NapCat)")
}
}
// ★★★ 判据:阶段映射用**源码里的权威取值**,不是猜的。
//
// NapCat 的 SetOnlineStatus.ts 里 status=50 是「忙碌」、10+2023 是「搬砖中」、
// 10+1018 是「学习中」。我最初拿 status=11 试探,NapCat 也返回 retcode=0
// (它不校验),但那是**无效状态** —— 必须以取值表为准。
func TestStatusPhaseMappingUsesAuthoritativeValues(t *testing.T) {
// 工具调用中 ⇒ 搬砖中(status=10, ext=2023)
if st, ok := statusForPhase(string(sdk.StageBeforeToolcall)); !ok ||
st.Status != 10 || st.ExtStatus != 2023 {
t.Errorf("before_toolcall 应映射到 status=10/ext=2023(搬砖中),实际 %+v", st)
}
// 生成回复中 ⇒ 学习中(status=10, ext=1018)
if st, ok := statusForPhase(string(sdk.StageBeforeOutput)); !ok ||
st.Status != 10 || st.ExtStatus != 1018 {
t.Errorf("before_output 应映射到 status=10/ext=1018(学习中),实际 %+v", st)
}
// 一轮结束 ⇒ 在线(status=10, ext=0)
if st, ok := statusForPhase(string(sdk.StageAfterOutput)); !ok ||
st.Status != 10 || st.ExtStatus != 0 {
t.Errorf("after_output 应映射到 status=10/ext=0(在线),实际 %+v", st)
}
// 忙碌必须是 status=50(不是 11 或别的)
if qqStatusBusy.Status != 50 || qqStatusBusy.ExtStatus != 0 {
t.Errorf("忙碌应为 status=50/ext=0,实际 %+v", qqStatusBusy)
}
}
// ★★★ 判据:手动工具遇到未知状态名时**不发请求**,只回错误。
func TestHandleSetStatusRejectsUnknown(t *testing.T) {
p := &Plugin{name: "qq", napcatURL: "http://127.0.0.1:1"}
out, err := p.handleSetStatus(map[string]interface{}{"status": "不存在的状态"})
if err != nil {
t.Fatalf("不该返回 error(应为结果里的 error 字段): %v", err)
}
m, ok := out.(map[string]interface{})
if !ok || m["error"] == nil {
t.Fatalf("未知状态应给 error 字段,实际 %#v", out)
}
}