mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +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)。
139 lines
4.6 KiB
Go
139 lines
4.6 KiB
Go
package main
|
||
|
||
// 群消息的 @ 门判据。
|
||
//
|
||
// ## 用户的核心关切(2026-10-18)
|
||
//
|
||
// 「非 @agent 的群消息会不会产生中断」
|
||
//
|
||
// 答复:**不会**。代码路径上 `isAtBot` 为 false 就直接 return,
|
||
// 根本走不到 `enqueueInterrupt`。但这条链路必须被钉住 ——
|
||
// 一旦有人改了 return 或加个 fallthrough,bot 就会在群里乱说话。
|
||
//
|
||
// ## 同时修正的缺陷:@全体成员被当成 @bot
|
||
//
|
||
// 原实现:`if v == botIDStr || v == "all" { return true }`
|
||
// 后果:任何人 @全体成员 都会拉起 bot。那不是"在叫 bot",是"在叫所有人"
|
||
// —— 一个是针对性请求,一个是广播,语义完全不同。
|
||
//
|
||
// 现场佐证:群友直接质问过「又没@你,你就这么确定是找你的?」。
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ifBlockAt 从 s[start:] 的 `if ... {` 起,用**括号计数**截出整个 if 块。
|
||
//
|
||
// 为什么不能用字符串找 `}`:源码缩进/换行/嵌套块都会让固定串匹配失败,
|
||
// 而失败后通常不会报错,只会悄悄把范围放大到文件末尾 ——
|
||
// 判据于是变得永远为真(false green)。这个坑在本项目已踩过多次。
|
||
func ifBlockAt(s string, start int) string {
|
||
depth := 0
|
||
seen := false
|
||
for i := start; i < len(s); i++ {
|
||
switch s[i] {
|
||
case '{':
|
||
depth++
|
||
seen = true
|
||
case '}':
|
||
depth--
|
||
if seen && depth == 0 {
|
||
return s[start : i+1]
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// atEvent 造一段 OneBot 的 message 段数组(type=at)。
|
||
func atEvent(qq string) []interface{} {
|
||
return []interface{}{
|
||
map[string]interface{}{
|
||
"type": "at",
|
||
"data": map[string]interface{}{"qq": qq},
|
||
},
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 1:@全体成员**不算** @bot。
|
||
func TestIsAtBotRejectsAtAll(t *testing.T) {
|
||
p := &Plugin{botID: 1456128421}
|
||
if p.isAtBot(atEvent("all")) {
|
||
t.Fatal("★ @全体成员 被当成了 @bot ——\n" +
|
||
" 任何人 @全体都会拉起 bot。那不是“在叫 bot”,是“在叫所有人”。\n" +
|
||
" 群里误响应的代价是在未被邀请的场合发言")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 2:@bot 本人才算。
|
||
func TestIsAtBotAcceptsSelf(t *testing.T) {
|
||
p := &Plugin{botID: 1456128421}
|
||
if !p.isAtBot(atEvent("1456128421")) {
|
||
t.Fatal("★ @bot 本人没被识别 —— 群里的正常呼叫会漏响应")
|
||
}
|
||
// 数字形式(部分客户端会传数字)
|
||
numEvt := []interface{}{
|
||
map[string]interface{}{
|
||
"type": "at",
|
||
"data": map[string]interface{}{"qq": float64(1456128421)},
|
||
},
|
||
}
|
||
if !p.isAtBot(numEvt) {
|
||
t.Fatal("★ 数字形式的 @bot 没被识别")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 3:@别人 不算。
|
||
func TestIsAtBotRejectsOthers(t *testing.T) {
|
||
p := &Plugin{botID: 1456128421}
|
||
if p.isAtBot(atEvent("2198972886")) {
|
||
t.Fatal("★ @别人 被当成了 @bot")
|
||
}
|
||
// 纯文本也不算
|
||
textOnly := []interface{}{
|
||
map[string]interface{}{"type": "text", "data": map[string]interface{}{"text": "1456128421"}},
|
||
}
|
||
if p.isAtBot(textOnly) {
|
||
t.Fatal("★ 纯文本里出现 bot 号码被当成了 @ —— 那是可伪造的")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 4:**接线** —— 非 @ 的群消息必须 return,不能走到注入。
|
||
//
|
||
// 这一类“函数对、接线断”编译器不报、运行时不报错,
|
||
// 后果是 bot 在群里对每条消息都回应。
|
||
func TestNonAtGroupMessageReturnsBeforeInject(t *testing.T) {
|
||
lines := codeLines(t, "plugin.go")
|
||
joined := strings.Join(lines, "\n")
|
||
|
||
// 找到 @bot 判断与 enqueueInterrupt 调用的相对位置
|
||
iAt := strings.Index(joined, "if !p.isAtBot(evt.Message) {")
|
||
if iAt < 0 {
|
||
t.Fatal("找不到 @bot 判断 —— 判据需同步")
|
||
}
|
||
iInject := strings.Index(joined, "p.enqueueInterrupt(")
|
||
if iInject < 0 {
|
||
t.Fatal("找不到 enqueueInterrupt 调用")
|
||
}
|
||
if iAt > iInject {
|
||
t.Fatal("★ @bot 判断在 enqueueInterrupt **之后** —— " +
|
||
"非 @ 的群消息也会产生中断(bot 会在群里对每条消息回应)")
|
||
}
|
||
|
||
// 该分支内必须有 return(不能只是 log 完继续往下走)。
|
||
//
|
||
// ★ 必须用**括号计数**截出分支体,不能用字符串找结束位置:
|
||
// 判据第一版用 `strings.Index(branch, "}\n\t}")`,而 joined 里的缩进
|
||
// 已被 strip ⇒ 找不到(返回 -1)⇒ branch 一路延伸到文件末尾,
|
||
// 于是"文件里任何别处的 return"都让它通过 —— 删掉这条 return 也不咬(实测)。
|
||
branch := ifBlockAt(joined, iAt)
|
||
if branch == "" {
|
||
t.Fatal("无法截出 if 分支体 —— 判据需同步")
|
||
}
|
||
if !strings.Contains(branch, "return") {
|
||
t.Fatal("★ `if !isAtBot` 分支里没有 return —— " +
|
||
"非 @ 的群消息会继续走到注入")
|
||
}
|
||
}
|