mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 06:17:25 +00:00
## 1. Qzone 说说(send_qzone_msg / delete_qzone_msg)
一次真实的误判,值得记下来:我曾断言「生产 NapCat 不支持发说说」,
依据是探测返回「不支持的Api SendQzoneMsg」——**是我的调用名错了**。
router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码**类名**
`SendQzoneMsg`。换对名字后一次成功。
教训:「API 不存在」这个结论必须用真实 action 名验证;源码里的
类名/文件名/方法名都不是调用名。判据 TestQzoneActionNamesAreSnakeCase
把这条钉住了。
本地校验 ugc_right 白名单(1/4/16/64/128)与 16/128 必带 target_uins,
省一次跨进程往返、给模型更直接的反馈。
## 2. 内部委派不得向群聊发送(现场事故)
offload 子 agent 的「分诊汇报」把本该私聊给用户的进度播报**发到了群里**:
17:15:41 executing tool: output_send__qq
17:15:41 handleChannelOutput meta={"group_id": 979911915}
17:15:42 群友质问「又没@你,你就这么确定是找你的?」
根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求
"持有全部输出通道"),于是它能自己选目标——而它只从上下文里看到一个
群号,就选了群。
修法:`beforeToolcall` 在 internal 放行前调用 outputSendGroupTarget,
识别出群目标就写 denial。为什么不放在 handleChannelOutput:ToolHandler
只收 args,拿不到帧上的 comefrom(SDK 契约如此),只有 stage 帧能看到。
## 3. @全体成员不算 @bot
原实现 `if v == botIDStr || v == "all" { return true }` —— 任何人
@全体都会拉起 bot。那不是"在叫 bot",是"在叫所有人":一个是针对性请求,
一个是广播,语义完全不同。
群里误响应的代价不是"多回一句",而是**在未被邀请的场合发言**。
宁可漏响应(用户会再 @ 一次),不能误响应。
## 4. 状态语义按用户定义重排
学习中 = 等待 LLM 搬砖中 = 执行 toolcall 在线 = 空闲
pre_action → 学习中(stepLLM 之前,唯一能提前标记"要等模型"处)
before_toolcall → 搬砖中
after_toolcall → 学习中(工具回来了,又要等 LLM)
before_output → 不改(LLM 已回、即将输出,不是等模型)
after_output → 在线(force,绕防抖)
原先「学习中」挂在 before_output 是错的:那个时刻 LLM 已经返回。
## 5. 「对方正在输入中」按轮驱动
起点从 HTTP 入口(消息刚到)改到 before_output(每轮只触发一次),
终点从 output_send(任何发送都停,含群聊/发给别人)改到 after_output。
身份由 InjectOptions.Payload 携带 `qq_uid`,内核透传到 stageCtx.Extra。
不用全局 p.auth:并发私聊会串号(A 的指示打到 B 身上)。
## 6. 启动日志不再硬编码工具数
`log.Printf("... (%d tools)", p.name, 15)` 硬编码了 15,工具加到 24 后
仍报 15 —— 排查"qzone 装上没"时被误导了好几轮。改成真实计数 p.toolCount。
## 判据(新增 13 条,变异 8/8 咬)
Qzone 5 条、群发拦截 5 条、@门 4 条、状态映射 7 条、打字 7 条。
★ 两个判据自身缺陷被变异抓出并修掉:
· @门判据用字符串找 `}\n\t}` 截分支,而 joined 已 strip 缩进 ⇒ 找不到
⇒ 分支延伸到文件末尾 ⇒ "文件里任何别处的 return"都让它通过。
改用括号计数 ifBlockAt。
· 状态判据只查常量名,`_ = qqStatusStudy` 就蒙混过关。改查真实调用
applyStatus(qqStatusStudy)。
117 lines
4.2 KiB
Go
117 lines
4.2 KiB
Go
package main
|
||
|
||
// Qzone 工具的判据。
|
||
//
|
||
// ## 一个必须被钉住的教训(2026-10-18)
|
||
//
|
||
// 我曾断言「生产 NapCat 不支持发说说」,依据是探测返回
|
||
// 「不支持的Api SendQzoneMsg」。**那是调用名错了** ——
|
||
// router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码类名
|
||
// `SendQzoneMsg`。换对名字后一次成功(retcode 0, tid 返回)。
|
||
//
|
||
// 所以判据要钉的第一条就是:**action 名必须是下划线式**。
|
||
// 这一条能防止未来有人"照着源码类名"写回来。
|
||
|
||
import (
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ★★★ 判据 1:NapCat action 名必须是下划线式,不是源码类名。
|
||
func TestQzoneActionNamesAreSnakeCase(t *testing.T) {
|
||
for _, ln := range codeLines(t, "qzone.go") {
|
||
// 抓 p.napcat("xxx" 里的字面量
|
||
if i := strings.Index(ln, `p.napcat("`); i >= 0 {
|
||
rest := ln[i+len(`p.napcat("`):]
|
||
if j := strings.Index(rest, `"`); j > 0 {
|
||
action := rest[:j]
|
||
if action != strings.ToLower(action) {
|
||
t.Fatalf("★ NapCat action 名 `%s` 不是下划线式 ——\n"+
|
||
" 源码里的类名(SendQzoneMsg)不是调用名,真实名在 router.ts:\n"+
|
||
" SendQzoneMsg: 'send_qzone_msg',\n"+
|
||
" DeleteQzoneMsg: 'delete_qzone_msg'", action)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 2:ugc_right 白名单必须在本地校验。
|
||
//
|
||
// 服务端也校验,但那是跨进程往返之后才拿到的错误。
|
||
// 本地先拦能给模型更直接的反馈,也省一次 NapCat 调用。
|
||
func TestQzoneUgcRightValidatedLocally(t *testing.T) {
|
||
want := []int{1, 4, 16, 64, 128}
|
||
if len(validUgcRights) != len(want) {
|
||
t.Fatalf("★ ugc_right 白名单项数 %d,期望 %d", len(validUgcRights), len(want))
|
||
}
|
||
for _, v := range want {
|
||
if _, ok := validUgcRights[v]; !ok {
|
||
t.Fatalf("★ ugc_right 白名单缺少 %d(NapCat 源码 ValidUgcRights = %v)", v, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 3:16/128 时必须要求 target_uins。
|
||
//
|
||
// NapCat 服务端会报 "ugc_right 为 16 或 128 时, target_uins 不能为空",
|
||
// 本地先拦省一次往返。
|
||
func TestQzoneTargetUinsRequiredForRestrictedRights(t *testing.T) {
|
||
p := &Plugin{name: "qq"}
|
||
|
||
// 16 无 target_uins ⇒ 必须报错
|
||
if _, err := p.handleSendQzoneMsg(map[string]interface{}{
|
||
"content": "x", "ugc_right": float64(16),
|
||
}); err == nil {
|
||
t.Fatal("★ ugc_right=16 缺 target_uins 却没报错 —— 服务端会拒,白跑一次往返")
|
||
}
|
||
|
||
// content 必填
|
||
if _, err := p.handleSendQzoneMsg(map[string]interface{}{}); err == nil {
|
||
t.Fatal("★ 缺 content 却没报错")
|
||
}
|
||
|
||
// 非法 ugc_right 本地拦
|
||
if _, err := p.handleSendQzoneMsg(map[string]interface{}{
|
||
"content": "x", "ugc_right": float64(7),
|
||
}); err == nil {
|
||
t.Fatal("★ ugc_right=7(不在白名单)却没报错")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 4:target_uins / images 必须接受 JSON 反序列化的形态。
|
||
//
|
||
// 经 proc 协议往返后,数组一定是 []interface{}、数字一定是 float64。
|
||
// 只认 []int64 / []string 会让这些参数**永远为空且不报错** —— 静默失效。
|
||
func TestQzoneAcceptJSONDecodedTypes(t *testing.T) {
|
||
// []interface{} + float64(真实链路形态)
|
||
uins, err := toInt64Slice([]interface{}{float64(123456), float64(789012)})
|
||
if err != nil || len(uins) != 2 || uins[0] != 123456 || uins[1] != 789012 {
|
||
t.Fatalf("★ target_uins 不接受 JSON 形态([]interface{}+float64):%v, %v", uins, err)
|
||
}
|
||
imgs, err := toStringSlice([]interface{}{"http://a/b.jpg", "base64://xxx"})
|
||
if err != nil || len(imgs) != 2 {
|
||
t.Fatalf("★ images 不接受 JSON 形态([]interface{}):%v, %v", imgs, err)
|
||
}
|
||
// 字符串形式的 QQ 号也要能吃(模型常传 "123456")
|
||
uins2, err := toInt64Slice([]interface{}{"123456"})
|
||
if err != nil || len(uins2) != 1 || uins2[0] != 123456 {
|
||
t.Fatalf("★ target_uins 不接受字符串 QQ 号:%v, %v", uins2, err)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 5:两个工具都注册了(否则模型看不到)。
|
||
func TestQzoneToolsRegistered(t *testing.T) {
|
||
b, err := os.ReadFile("plugin.go")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
src := string(b)
|
||
for _, name := range []string{"send_qzone_msg", "delete_qzone_msg"} {
|
||
if !strings.Contains(src, `tp + "`+name+`"`) {
|
||
t.Fatalf("★ 工具 %s 没有注册", name)
|
||
}
|
||
}
|
||
}
|