diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 69408a8..0c05094 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -21,6 +21,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/JianFeeeee/homeagentsdk/sdk" @@ -198,6 +199,13 @@ type Plugin struct { typingMu sync.Mutex typingMap map[int64]*typingState + // statusAutoEnabled 是「随内核阶段自动切 QQ 在线状态」的总开关。 + // + // 默认**关闭**:自动改 QQ 状态是内核替用户做决定(与 offload 同理, + // 属于显式特权)。且它对外可见 —— 好友会看到你的状态在跳。 + statusAutoEnabled atomic.Bool + statusState statusState + authMu sync.RWMutex auth qqAuthContext authGeneration uint64 @@ -397,6 +405,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) + // 在线状态随阶段切换(默认关)。 + // + // 默认关的理由:它对外可见(好友会看到状态在跳),属于 + // "内核替用户做决定"——与 offload 同一类,必须显式打开。 + s.Settings().RegisterDef(sdk.ConfigDef{Key: "status_auto", Default: "false", Type: "bool", DisplayName: "随阶段切在线状态", Description: "开启后:调工具时切「搬砖中」、生成回复时切「学习中」、一轮结束切回「在线」。失败不影响工具调用", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "owner", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ", Description: "Bot 所有者 QQ 号列表,逗号分隔。所有者无论私聊或群聊均拥有完整工具权限", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ(旧配置)", Description: "兼容旧版 admin 配置;owner 为空时作为 Bot 所有者列表", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_tool_allowlists", Default: defaultGroupToolAllowlists, Type: "string", DisplayName: "群聊工具白名单", Description: "JSON 对象:群号到允许工具名/前缀*的数组;* 为未单独配置群的默认白名单。Bot 所有者不受限制", Category: "qq"}) @@ -421,6 +434,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") p.webhookToken = getSetting[string](settings, "webhook_token", "") p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") + // 自动状态切换的总开关(默认 false,见 RegisterDef 处的理由)。 + // + // ★ 用 getSetting[string] 而不是 bool:该泛型约束是 + // string | int64 | float64(不含 bool),SettingsAPI.Get 返回 + // interface{} ⇒ 布尔值在存储层是 "true"/"false" 字符串。 + if strings.EqualFold(getSetting[string](settings, "status_auto", "false"), "true") { + p.statusAutoEnabled.Store(true) + log.Printf("[qq] 自动在线状态切换已开启(随内核阶段)") + } ownerRaw := getSetting[string](settings, "owner", "") if strings.TrimSpace(ownerRaw) == "" { ownerRaw = getSetting[string](settings, "admin", "") @@ -779,6 +801,36 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 }, p.handleVideoDownload) // ---- 附加 ---- + p.regTool(s, sdk.ToolDef{ + Name: tp + "set_status", + Description: "设 QQ 在线状态(对外可见)。" + + "可选:online/busy/leave/dnd/hidden/working/study/music(也收中文名)。" + + "失败只记日志、不影响其他工具。", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "status": map[string]interface{}{ + "type": "string", + "description": "状态名:online|busy|leave|dnd|hidden|working|study|music,或中文(在线/忙碌/离开/请勿打扰/隐身/搬砖中/学习中/听歌中)", + }, + }, "required": []string{"status"}, + }, + NoMemory: true, + }, p.handleSetStatus) + + p.regTool(s, sdk.ToolDef{ + Name: tp + "set_diy_status", + Description: "设 QQ **自定义**在线状态(图标 + 文字)。" + + "face_id 是 QQ 状态图标 ID(需自行提供,无内置图标表);" + + "wording 是显示文字(如「思考中…」)。失败只记日志。", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "face_id": map[string]interface{}{"type": "string", "description": "状态图标 ID(QQ 的 QSid)"}, + "wording": map[string]interface{}{"type": "string", "description": "状态文字,如「思考中…」"}, + }, "required": []string{"face_id", "wording"}, + }, + NoMemory: true, + }, p.handleSetDiyStatus) + p.regTool(s, sdk.ToolDef{ Name: tp + "send_like", Description: "给QQ好友点赞/戳一戳", Parameters: map[string]interface{}{ @@ -793,6 +845,13 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 // 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。 s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal) s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal) + // 在线状态随阶段切换(默认关闭,见 statusAutoEnabled 的注释)。 + // + // ★ 三个钩子都只有一件事:调 p.applyStatus;**永远 return nil**。 + // 状态是非致命错误 —— 不能因为它阻塞工具调用或改写回复。 + s.RegisterStage(sdk.StageBeforeToolcall, p.stageStatusWorking, sdk.StageScopeGlobal) + s.RegisterStage(sdk.StageBeforeOutput, p.stageStatusThinking, sdk.StageScopeGlobal) + s.RegisterStage(sdk.StageAfterOutput, p.stageStatusOnline, sdk.StageScopeGlobal) // before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉, // 否则内核会把它误当作“结束整个推理”的最终响应。 s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal) @@ -817,6 +876,15 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 } func (p *Plugin) Stop() error { + // ★ 停机前把在线状态恢复为「在线」。 + // + // 不恢复的话,若停机时正处于“搬砖中”,QQ 会一直挂着那个状态, + // 而下一次自动切换只在开关打开时才生效 —— 用户会看到一个 + // **永久错位**的状态,且不知道是什么改的。 + // + // 放在最前:后面的 flushAllBatches 可能耗时,先把对外观感收尾。 + p.restoreStatus() + p.typingMu.Lock() for _, st := range p.typingMap { select { diff --git a/example/qq/status.go b/example/qq/status.go new file mode 100644 index 0000000..ec26ab8 --- /dev/null +++ b/example/qq/status.go @@ -0,0 +1,321 @@ +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, +} + +// 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) { + if !p.statusAutoEnabled.Load() { + return + } + p.statusState.mu.Lock() + if p.statusState.applied.Name == st.Name { + p.statusState.mu.Unlock() + return + } + if time.Since(p.statusState.lastAt) < 2*time.Second { + 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)的处置一致:那套也从不因失败中断对话。 + +func (p *Plugin) stageStatusWorking(ctx *sdk.StageContext) error { + p.applyStatus(qqStatusWorking) + return nil +} + +func (p *Plugin) stageStatusThinking(ctx *sdk.StageContext) error { + // "生成回复中"用「学习中」的预置状态:QQ 没有"思考中"这个预置项。 + // 想要精确文字可调 qq_set_diy_status 工具(自定义 face_id + wording)。 + p.applyStatus(qqStatusStudy) + return nil +} + +func (p *Plugin) stageStatusOnline(ctx *sdk.StageContext) error { + p.applyStatus(qqStatusOnline) + return nil +} + +// ======== 工具 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 +} diff --git a/example/qq/status_test.go b/example/qq/status_test.go new file mode 100644 index 0000000..386d63b --- /dev/null +++ b/example/qq/status_test.go @@ -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) + } +}