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

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.stageStatusWorking},
{"before_output", p.stageStatusThinking},
{"after_output", p.stageStatusOnline},
} {
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.stageStatusWorking(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)
}
}