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,不回显具体状态 —— 那是该接口的
特性,不是设置失败。
This commit is contained in:
JianFeeeee
2026-10-18 14:34:17 +08:00
parent 74e2c654ed
commit 61a871cf88
3 changed files with 517 additions and 0 deletions

128
example/qq/status_test.go Normal file
View File

@ -0,0 +1,128 @@
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)
}
}