Files
homeagent-sdk/example/qq/typing_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

327 lines
11 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)
//
// 旧实现两处都错位:
//
// 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" +
" 「对方正在输入中」会静默失效(编译不报错、内核不告警)")
}
}