mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 06:17:25 +00:00
## 用户要的两件事 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,不回显具体状态 —— 那是该接口的 特性,不是设置失败。
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.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)
|
||
}
|
||
}
|