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

326
example/qq/typing_test.go Normal file
View File

@ -0,0 +1,326 @@
package main
// 「对方正在输入中」的语义判据。
//
// ## 修的是什么(2026-10-18)
//
// 旧实现两处都错位:
//
// HTTP 入口 → startTyping(evt.UserID) ← 消息刚到就挂指示
// output_send__* 里 → if userID>0 { stopTyping } ← 任何发送都停
//
// 具体害处:
//
// 1. **起点太早**:agent 还没接受消息、可能在批量合并窗口里等、甚至可能
// 被权限门拒掉。被拒的那条消息会让用户一直看着"正在输入"却没有回复。
// 而且 typingLoop 有 30 秒超时 —— agent 跑得慢就自己灭了,回复还在路上。
//
// 2. **终点太宽**:stopTyping 挂在"发送消息"上,而发送可能是群聊、可能是
// 发给别人。正在跟 A 私聊时 agent 发了条群消息,A 的指示被莫名关掉。
//
// 正确边界:`before_output` 开始、`after_output` 结束(内核里这两个阶段
// **每轮只触发一次**),且只处理"自己引起的、有身份的"那一轮。
//
// ## 为什么需要内核透传 inject_meta
//
// `before_output` 的 stageCtx 是内核新建的(emitResponse),与 `on_input`
// 的 stageCtx 不共享 Extra(后者是局部变量)。所以身份必须由注入方声明、
// 内核原样透传 —— 否则插件只能读全局 p.auth 猜,并发私聊会串号
// (A 的输入指示打到 B 身上)。
import (
"os"
"strings"
"testing"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// codeLines 返回某个 .go 文件里**非注释**的代码行。
//
// 这个项目已多次栽在「注释里有、代码里没有」:判据直接 Contains 全文,
// 会把解释性的注释当成实现在,变异删掉代码也不咬。
func codeLines(t *testing.T, file string) []string {
t.Helper()
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
var out []string
inBlock := false
for _, ln := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(ln)
if inBlock {
if i := strings.Index(s, "*/"); i >= 0 {
s = strings.TrimSpace(s[i+2:])
inBlock = false
} else {
continue
}
}
if strings.HasPrefix(s, "//") || strings.HasPrefix(s, "*") {
continue
}
if strings.HasPrefix(s, "/*") {
if i := strings.Index(s, "*/"); i >= 0 {
s = strings.TrimSpace(s[i+2:])
} else {
inBlock = true
continue
}
}
out = append(out, s)
}
return out
}
// funcBody 用**括号计数**提取某个顶层函数/方法的函数体(含签名行)。
//
// 为什么不用正则:调用点里常有嵌套括号(`p.injectInterrupt(a, b, qqInjectPayload(x, y, z))`),
// `[^)]*` 会在第一个 `)` 提前截断 —— 这个判据第一版就因此误报"没带身份"。
// 也不用 `\n}\n`:函数体内有块语句,第一个行首 `}` 未必是函数结尾。
func funcBody(t *testing.T, lines []string, sigPrefix string) string {
t.Helper()
start := -1
for i, ln := range lines {
if strings.HasPrefix(ln, sigPrefix) {
start = i
break
}
}
if start < 0 {
t.Fatalf("找不到 %s —— 判据需同步", sigPrefix)
}
depth := 0
seenBrace := false
for j := start; j < len(lines); j++ {
for _, r := range lines[j] {
switch r {
case '{':
depth++
seenBrace = true
case '}':
depth--
}
}
if seenBrace && depth == 0 {
return strings.Join(lines[start:j+1], "\n")
}
}
t.Fatalf("%s 函数体没闭合", sigPrefix)
return ""
}
// callArgs 提取 `name(` 之后到**匹配的** `)` 之间的实参文本(处理嵌套括号)。
func callArgs(s, name string) []string {
var out []string
for i := 0; ; {
k := strings.Index(s[i:], name+"(")
if k < 0 {
return out
}
k += i + len(name) + 1
depth := 1
for j := k; j < len(s); j++ {
switch s[j] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
out = append(out, s[k:j])
i = j + 1
goto next
}
}
}
return out
next:
}
}
// ★★★ 判据 1:HTTP 入口**不再**发起输入指示。
//
// 变异方式:把 `p.startTyping(evt.UserID)` 加回入口 —— 必咬。
func TestTypingNotStartedAtHTTPEntry(t *testing.T) {
for _, ln := range codeLines(t, "plugin.go") {
if strings.Contains(ln, "startTyping(") && !strings.HasPrefix(ln, "func ") {
// 允许出现在 startTypingForTurn 里(那是 before_output 的调用)
t.Fatalf("★ HTTP 入口仍在发起输入指示:%q\n"+
" 应在 before_output 发起(消息刚到就挂指示会让被拒/排队的消息"+
"一直显示\"正在输入\")", ln)
}
}
}
// ★★★ 判据 2:发送消息路径**不再**停输入指示。
//
// 变异方式:把 `p.stopTyping(userID)` 加回 sendMessage —— 必咬。
func TestTypingNotStoppedBySending(t *testing.T) {
lines := codeLines(t, "plugin.go")
// 只看发送消息那条路径的函数体(handleChannelOutput 是 output_send__* 的落点)。
//
// ★ 不能看全文件:`typingLoop` 超时自停是合法的
// (`p.stopTyping(ts.userID)`),它必须保留 —— 否则 agent 卡死时
// 指示器永远不会消失。这个判据第一版就误报了这一处。
body := funcBody(t, lines, "func (p *Plugin) handleChannelOutput(")
if strings.Contains(body, "stopTyping(") {
t.Fatal("★ 发送消息仍在停输入指示 ——\n" +
" 发送可能是群聊/发给别人;正在跟 A 私聊时发条群消息就会关掉 A 的指示")
}
}
// ★★★ 判据 3:before_output 发起、after_output 结束(且是真调用,不是注释)。
func TestTypingWiredToOutputStages(t *testing.T) {
lines := codeLines(t, "status.go")
// 定位 stageBeforeOutput / stageAfterOutput 的函数体
bodyOf := func(name string) string {
start := -1
for i, ln := range lines {
if strings.HasPrefix(ln, "func (p *Plugin) "+name+"(") {
start = i
break
}
}
if start < 0 {
t.Fatalf("找不到 %s —— 判据需同步", name)
}
for j := start + 1; j < len(lines); j++ {
if lines[j] == "}" {
return strings.Join(lines[start:j+1], "\n")
}
}
t.Fatalf("%s 函数体没闭合", name)
return ""
}
before := bodyOf("stageBeforeOutput")
if !strings.Contains(before, "p.startTypingForTurn(ctx)") {
t.Fatal("★ before_output 没有发起输入指示(startTypingForTurn)")
}
after := bodyOf("stageAfterOutput")
if !strings.Contains(after, "p.stopTypingForTurn(ctx)") {
t.Fatal("★ after_output 没有结束输入指示(stopTypingForTurn)")
}
// after_output 必须先停指示再清理 auth —— auth 清理若失败,
// 指示不能永久留在"正在输入"。
iStop := strings.Index(after, "stopTypingForTurn")
iAuth := strings.Index(after, "afterOutputAuthContext")
if iStop < 0 || iAuth < 0 || iStop > iAuth {
t.Fatal("★ after_output 里应先停输入指示、再清理 auth")
}
}
// ★★★ 判据 4:只认**注入方声明**的身份(inject_meta),不读全局 p.auth。
//
// 读 p.auth 会在并发私聊时串号:A 的消息进来后内核可能先跑 B 那一轮,
// before_output 读 p.auth 拿到 B ⇒ A 的输入指示打到 B 身上。
func TestTypingUsesInjectMetaNotGlobalAuth(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) turnTypingUser(")
if !strings.Contains(body, `ctx.Extra["qq_uid"]`) {
t.Fatal("★ turnTypingUser 没读 payload 透传的 qq_uid —— 身份必须由注入方声明," +
"否则并发私聊会串号")
}
if strings.Contains(body, "p.auth") {
t.Fatal("★ turnTypingUser 读了全局 p.auth —— 并发私聊时 A 的输入指示会打到 B 身上")
}
// 只处理自己引起的轮次
if !strings.Contains(body, `ctx.Extra["input_source"]`) {
t.Fatal("★ turnTypingUser 没校验 input_source —— 别人的输出不该动我们的指示器")
}
}
// ★★★ 判据 5:注入时声明了身份(否则 inject_meta 是空的,前面全白搭)。
func TestInjectDeclaresTypingIdentity(t *testing.T) {
lines := codeLines(t, "plugin.go")
joined := strings.Join(lines, "\n")
// qqInjectPayload 群聊返回 nil(QQ 的"正在输入"只在私聊标题栏有意义)
body := funcBody(t, lines, "func qqInjectPayload(")
if !strings.Contains(body, "isGroup") {
t.Fatal("★ qqInjectPayload 没排除群聊 —— 群的标题栏没有\"正在输入\"语义")
}
// 所有 injectInterrupt 调用点都必须把 meta 传进去。
//
// ★ 用括号计数取实参,不用正则:实参里有嵌套调用
// `qqInjectPayload(userID, groupID, ...)`,`[^)]*` 会提前截断导致误报。
calls := callArgs(joined, "p.injectInterrupt")
if len(calls) == 0 {
t.Fatal("找不到 injectInterrupt 调用")
}
for _, c := range calls {
if !strings.Contains(c, "qqInjectPayload") {
t.Fatalf("★ injectInterrupt 调用没带上身份(payload):(%s)\n"+
" 不带 meta ⇒ before_output 拿不到 inject_meta ⇒ 指示器永远不出现", c)
}
}
}
// ★★★ 判据 6:inject_meta 经 JSON 往返后 user_id 可能是 float64 / json.Number。
//
// 只处理 int64 会让真实链路(proc 协议是 JSON)静默失效 ——
// 单测里传 int64 是绿的,生产里永远不发指示。这是最阴的一类。
func TestTurnTypingUserAcceptsJSONNumbers(t *testing.T) {
p := &Plugin{name: "qq"}
mk := func(v interface{}) *sdk.StageContext {
ctx := &sdk.StageContext{Extra: map[string]interface{}{
"input_source": "qq",
"qq_uid": v,
}}
return ctx
}
cases := []struct {
name string
v interface{}
want int64
}{
{"float64(JSON 解码默认)", float64(1456128421), 1456128421},
{"int64", int64(1456128421), 1456128421},
{"int", 1456128421, 1456128421},
{"nil", nil, 0},
{"字符串不算", "1456128421", 0},
}
for _, c := range cases {
if got := p.turnTypingUser(mk(c.v)); got != c.want {
t.Errorf("★ %s: user_id=%v 解析出 %d,期望 %d", c.name, c.v, got, c.want)
}
}
// 非本插件来源:不该动我们的指示器
ctx := &sdk.StageContext{Extra: map[string]interface{}{
"input_source": "other",
"qq_uid": float64(1),
}}
if got := p.turnTypingUser(ctx); got != 0 {
t.Errorf("★ 别人的轮次也被识别成本插件:%d", got)
}
// 无 payload 字段:不发
if got := p.turnTypingUser(&sdk.StageContext{Extra: map[string]interface{}{}}); got != 0 {
t.Errorf("★ 无 inject_meta 时不该发指示:%d", got)
}
}
// ★★★ 判据 7:set_input_status 的 user_id 必须是**字符串**。
//
// NapCat 4.8.x 接受数字,但 4.18.37 的 payloadSchema 声明成 Type.String。
// 传数字会报 `uid is empty`(不是 schema 错误 —— 更容易误判成"用户不存在"),
// 功能**静默失效**:编译不报错、内核不告警、用户只是永远看不到"正在输入"。
//
// 实测:
//
// {"user_id":1,"event_type":1} → uid is empty
// {"user_id":"1456128421","event_type":1} → retcode 0, errMsg success
func TestSetInputStatusSendsStringUserID(t *testing.T) {
lines := codeLines(t, "plugin.go")
body := funcBody(t, lines, "func (p *Plugin) setInputStatus(")
if !strings.Contains(body, `strconv.FormatInt(userID, 10)`) {
t.Fatal("★ set_input_status 的 user_id 没转成字符串 ——\n" +
" NapCat 4.18.37 的 payloadSchema 是 Type.String,传数字返回 `uid is empty`,\n" +
" 「对方正在输入中」会静默失效(编译不报错、内核不告警)")
}
}