Files
homeagent-sdk/example/qq/status.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

385 lines
14 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 源码 SetOnlineStatus.ts 的权威取值表)
//
// set_online_status status + ext_status + battery_status
// set_diy_online_status face_id + face_type + wording
//
// 取值来自 NapCat 的 actionDescription(不是猜的):
//
// 在线 status=10 ext=0
// 忙碌 status=50 ext=0
// 离开 status=30 ext=0
// 请勿打扰 status=70 ext=0
// 隐身 status=40 ext=0
// 听歌中 status=10 ext=1028
// 学习中 status=10 ext=1018
// 搬砖中 status=10 ext=2023
// 睡觉中 status=10 ext=1016
// 摸鱼中 status=10 ext=1300
//
// ★ 注意 status=11 **不是**标准值。我最初用 11 试探,NapCat 也返回 retcode=0
// (它不校验),但那是无效状态 —— 必须以源码里的取值表为准。
//
// ## 设计要点
//
// 1. **只读 stage,不当闸门**:状态切换失败**绝不**影响工具调用。
// NapCat 掉线时若因为设状态失败而阻塞工具,那才是真故障。
// 2. **防抖**:stage 在每次工具调用都会触发,逐次发 HTTP 会把 NapCat 打满。
// 同状态只在变化时发,且最短间隔 2 秒(与 typing 的 5s 心跳同思路)。
// 3. **可关闭**:默认关闭(online_status_enabled=false)。理由与 offload 一致 ——
// 自动改 QQ 状态是"内核替用户做决定",属于显式特权。
// 4. **失败静默**:只记一次日志(同类失败合并计数),不打爆日志。
import (
"log"
"strings"
"sync"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// QQ 在线状态(NapCat 权威取值)。
type qqStatus struct {
Status int // status 字段
ExtStatus int // ext_status 字段
Name string // 人读名(日志与工具回显用)
}
// 常用状态常量。取值出自 NapCat SetOnlineStatus.ts 的 actionDescription。
var (
qqStatusOnline = qqStatus{Status: 10, ExtStatus: 0, Name: "在线"}
qqStatusBusy = qqStatus{Status: 50, ExtStatus: 0, Name: "忙碌"}
qqStatusLeave = qqStatus{Status: 30, ExtStatus: 0, Name: "离开"}
qqStatusDND = qqStatus{Status: 70, ExtStatus: 0, Name: "请勿打扰"}
qqStatusHidden = qqStatus{Status: 40, ExtStatus: 0, Name: "隐身"}
// 工种类状态(ext_status 细分,status 恒为 10)
qqStatusWorking = qqStatus{Status: 10, ExtStatus: 2023, Name: "搬砖中"}
qqStatusStudy = qqStatus{Status: 10, ExtStatus: 1018, Name: "学习中"}
qqStatusMusic = qqStatus{Status: 10, ExtStatus: 1028, Name: "听歌中"}
)
// qqStatusByName 供工具用名字设置状态(避免让模型记数字)。
var qqStatusByName = map[string]qqStatus{
"online": qqStatusOnline,
"busy": qqStatusBusy,
"leave": qqStatusLeave,
"dnd": qqStatusDND,
"hidden": qqStatusHidden,
"working": qqStatusWorking,
"study": qqStatusStudy,
"music": qqStatusMusic,
"在线": qqStatusOnline,
"忙碌": qqStatusBusy,
"离开": qqStatusLeave,
"请勿打扰": qqStatusDND,
"隐身": qqStatusHidden,
"搬砖中": qqStatusWorking,
"学习中": qqStatusStudy,
"听歌中": qqStatusMusic,
// 常用别名(用户/模型可能说“音乐”而不是“听歌中”)
"音乐": qqStatusMusic,
"工作": qqStatusWorking,
"学习": qqStatusStudy,
"勿扰": qqStatusDND,
"离线": qqStatusHidden,
}
// minStatusInterval 是两次自动状态切换的最小间隔。
//
// 取 2 秒:stage 在每次工具调用都会触发,一轮对话可能有十几次;
// 逐次发 HTTP 会把 NapCat 打满,且 QQ 侧看到状态疯狂闪烁。
//
// ★ 收尾(StageAfterOutput)**不受此限**,见 applyStatusForce。
const minStatusInterval = 2 * time.Second
// statusState 是自动状态切换的运行态。
type statusState struct {
mu sync.Mutex
// applied 是**最近一次成功应用**的状态(空 Name = 还没设过)。
applied qqStatus
// lastAt 是最近一次实际发请求的时间(防抖用)。
lastAt time.Time
// failCount 统计连续失败次数(只在首次与每 10 次记日志,避免刷屏)。
failCount int
}
// setSelfStatus 调 NapCat 设置在线状态。
//
// 失败返回 error,但**调用方不应把它当致命错误**(见文件头设计要点 1)。
func (p *Plugin) setSelfStatus(st qqStatus) error {
_, err := p.napcat("set_online_status", map[string]interface{}{
"status": st.Status,
"ext_status": st.ExtStatus,
"battery_status": 100,
})
return err
}
// setSelfDiyStatus 设自定义状态(图标 + 文字)。
//
// face_id 是 QQ 状态图标 ID(face_config.json 的 QSid)。这里不内置图标表:
// 由调用方给,给不出就走 set_online_status 的预置状态。
func (p *Plugin) setSelfDiyStatus(faceID, wording string) error {
_, err := p.napcat("set_diy_online_status", map[string]interface{}{
"face_id": faceID,
"face_type": "1",
"wording": wording,
})
return err
}
// applyStatus 防抖地应用一个状态。
//
// 防抖规则:
// - 与已应用状态相同 ⇒ 直接跳过(stage 每轮都触发,不跳过会把 NapCat 打满)
// - 距上次实际请求 < minInterval ⇒ 跳过(但记下"想切"的意图,下次生效)
//
// ★ 这里刻意**不做排队重试**:状态是"当前观感",错过一次就在下一个 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
}
p.statusState.mu.Lock()
if p.statusState.applied.Name == st.Name {
p.statusState.mu.Unlock()
return
}
if debounce && time.Since(p.statusState.lastAt) < minStatusInterval {
p.statusState.mu.Unlock()
return
}
p.statusState.lastAt = time.Now()
p.statusState.mu.Unlock()
if err := p.setSelfStatus(st); err != nil {
p.statusState.mu.Lock()
p.statusState.failCount++
n := p.statusState.failCount
p.statusState.mu.Unlock()
// 首次失败 + 每 10 次记一条:NapCat 掉线时不能刷屏
if n == 1 || n%10 == 0 {
log.Printf("[qq] 设置在线状态 %s 失败(第 %d 次,已忽略;不影响工具调用): %v",
st.Name, n, err)
}
return
}
p.statusState.mu.Lock()
p.statusState.applied = st
p.statusState.failCount = 0
p.statusState.mu.Unlock()
log.Printf("[qq] 在线状态 → %s(status=%d ext=%d)", st.Name, st.Status, st.ExtStatus)
}
// restoreStatus 把状态恢复到"在线"(停机时用)。
//
// ★ 判据不能用 statusAutoEnabled:手动工具(qq_set_status)也能改状态,
// 而那时自动开关可能是关的。若以开关为准,一次手动设的“搬砖中”
// 会永久挂着。
//
// 正确判据是“**实际应用过什么**”:applied 有名字且不是在线,才需要恢复。
// 注意这里**不走 applyStatus**(它会检查开关、还会防抖)—— 停机时
// 必须无条件发一次,否则状态会错位。
func (p *Plugin) restoreStatus() {
p.statusState.mu.Lock()
cur := p.statusState.applied
p.statusState.mu.Unlock()
if cur.Name == "" || cur.Name == qqStatusOnline.Name {
return // 从未设过,或已经是在线
}
if err := p.setSelfStatus(qqStatusOnline); err != nil {
log.Printf("[qq] 停机恢复在线状态失败(忽略): %v", err)
return
}
p.statusState.mu.Lock()
p.statusState.applied = qqStatusOnline
p.statusState.mu.Unlock()
log.Printf("[qq] 停机:在线状态已从 %s 恢复为在线", cur.Name)
}
// statusForPhase 把内核阶段映射到 QQ 状态。
//
// 映射原则:**让对话者看出你在干什么**,而不是精确反映内部管线。
//
// before_toolcall → 搬砖中(正在调工具干活)
// before_output → 学习中(正在生成文字,QQ 没有"思考中"这个预置状态)
// after_output → 在线(一轮结束)
// after_toolcall → 不切(工具刚结束还要继续,频繁切换只会闪)
//
// ★ "思考中" 用 set_diy_online_status 的自定义文字更贴切,但 face_id 需要
// 图标表。这里先用预置的"学习中",把 diy 留给显式工具调用。
func statusForPhase(phase string) (qqStatus, bool) {
switch phase {
case string(sdk.StageBeforeToolcall):
return qqStatusWorking, true
case string(sdk.StageBeforeOutput):
return qqStatusStudy, true
case string(sdk.StageAfterOutput):
return qqStatusOnline, true
default:
return qqStatus{}, false
}
}
// statusPhaseName 把 phase 常量转成人读名(日志/工具回显用)。
func statusPhaseName(phase string) string {
switch phase {
case string(sdk.StageBeforeToolcall):
return "工具调用中"
case string(sdk.StageBeforeOutput):
return "生成回复中"
case string(sdk.StageAfterOutput):
return "一轮结束"
default:
return phase
}
}
// normalizeStatusName 归一化工具传入的状态名。
func normalizeStatusName(s string) (qqStatus, bool) {
k := strings.ToLower(strings.TrimSpace(s))
st, ok := qqStatusByName[k]
return st, ok
}
// ======== stage 钩子(失败永不阻塞)========
//
// 三个钩子的形状完全一样,只做一件事:防抖地应用一个状态。
//
// ★ 都 **return nil**,且**不重试**:在线状态是完全非致命的错误。
// · 不返回 error ⇒ 内核不会因状态设置失败而中断工具调用或改写回复
// · 不重试 ⇒ NapCat 掉线时不会每轮都打一次 HTTP + 打一条日志
// · 失败只在 applyStatus 里记日志(首次 + 每 10 次),不刷屏
//
// 这与 typing(set_input_status)的处置一致:那套也从不因失败中断对话。
// ======== 工具 handler ========
// handleSetStatus 处理 qq_set_status:手动设预置在线状态。
//
// ★ 与自动切换共用 setSelfStatus,但不走防抖(手动操作应立即生效)。
func (p *Plugin) handleSetStatus(args map[string]interface{}) (interface{}, error) {
name, _ := args["status"].(string)
st, ok := normalizeStatusName(name)
if !ok {
return map[string]interface{}{
"error": "未知状态名: " + name,
"可选": "online / busy / leave / dnd / hidden / working / study / music(或中文名)",
}, nil
}
if err := p.setSelfStatus(st); err != nil {
// 明确报错但不算致命:调用方拿到 error 字段可自行决定
return map[string]interface{}{
"error": "设置失败: " + err.Error(),
"status": st.Name,
"note": "NapCat 可能未运行;这不影响其他 QQ 工具",
}, nil
}
// 手动设置要同步运行态,否则自动切换会以为"还是旧状态"而跳过
p.statusState.mu.Lock()
p.statusState.applied = st
p.statusState.lastAt = time.Now()
p.statusState.mu.Unlock()
return map[string]interface{}{
"status": st.Name,
"status_num": st.Status,
"ext_status": st.ExtStatus,
}, nil
}
// handleSetDiyStatus 处理 qq_set_diy_status:自定义状态(图标 + 文字)。
func (p *Plugin) handleSetDiyStatus(args map[string]interface{}) (interface{}, error) {
faceID, _ := args["face_id"].(string)
wording, _ := args["wording"].(string)
if strings.TrimSpace(faceID) == "" || strings.TrimSpace(wording) == "" {
return map[string]interface{}{"error": "face_id 与 wording 都必须非空"}, nil
}
if err := p.setSelfDiyStatus(faceID, wording); err != nil {
return map[string]interface{}{
"error": "设置自定义状态失败: " + err.Error(),
"note": "NapCat 可能未运行;这不影响其他 QQ 工具",
}, nil
}
return map[string]interface{}{
"ok": true,
"face_id": faceID,
"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
}