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

View File

@ -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 {