Files
homeagent-sdk/example/qq/group_at_gate_test.go
JianFeeeee c6c39aa2a8 feat(qq): Qzone 说说、群发硬拦截、@全体不算@bot、状态语义修正
## 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)。
2026-10-18 17:39:40 +08:00

139 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 —— " +
"非 @ 的群消息会继续走到注入")
}
}