mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 22:37:25 +00:00
## 用户指出的关键:告警要给**开发者**看,所以必须在构建期
我第一版把告警放进了工具链生成的 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
129 lines
4.8 KiB
Go
129 lines
4.8 KiB
Go
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)
|
||
}
|
||
}
|