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)。
This commit is contained in:
JianFeeeee
2026-10-18 17:39:40 +08:00
parent 4a9705ca41
commit c6c39aa2a8
9 changed files with 1436 additions and 20 deletions

View File

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