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

117 lines
4.2 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
// Qzone 工具的判据。
//
// ## 一个必须被钉住的教训(2026-10-18)
//
// 我曾断言「生产 NapCat 不支持发说说」,依据是探测返回
// 「不支持的Api SendQzoneMsg」。**那是调用名错了** ——
// router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码类名
// `SendQzoneMsg`。换对名字后一次成功(retcode 0, tid 返回)。
//
// 所以判据要钉的第一条就是:**action 名必须是下划线式**。
// 这一条能防止未来有人"照着源码类名"写回来。
import (
"os"
"strings"
"testing"
)
// ★★★ 判据 1:NapCat action 名必须是下划线式,不是源码类名。
func TestQzoneActionNamesAreSnakeCase(t *testing.T) {
for _, ln := range codeLines(t, "qzone.go") {
// 抓 p.napcat("xxx" 里的字面量
if i := strings.Index(ln, `p.napcat("`); i >= 0 {
rest := ln[i+len(`p.napcat("`):]
if j := strings.Index(rest, `"`); j > 0 {
action := rest[:j]
if action != strings.ToLower(action) {
t.Fatalf("★ NapCat action 名 `%s` 不是下划线式 ——\n"+
" 源码里的类名(SendQzoneMsg)不是调用名,真实名在 router.ts:\n"+
" SendQzoneMsg: 'send_qzone_msg',\n"+
" DeleteQzoneMsg: 'delete_qzone_msg'", action)
}
}
}
}
}
// ★★★ 判据 2:ugc_right 白名单必须在本地校验。
//
// 服务端也校验,但那是跨进程往返之后才拿到的错误。
// 本地先拦能给模型更直接的反馈,也省一次 NapCat 调用。
func TestQzoneUgcRightValidatedLocally(t *testing.T) {
want := []int{1, 4, 16, 64, 128}
if len(validUgcRights) != len(want) {
t.Fatalf("★ ugc_right 白名单项数 %d,期望 %d", len(validUgcRights), len(want))
}
for _, v := range want {
if _, ok := validUgcRights[v]; !ok {
t.Fatalf("★ ugc_right 白名单缺少 %d(NapCat 源码 ValidUgcRights = %v)", v, want)
}
}
}
// ★★★ 判据 3:16/128 时必须要求 target_uins。
//
// NapCat 服务端会报 "ugc_right 为 16 或 128 时, target_uins 不能为空",
// 本地先拦省一次往返。
func TestQzoneTargetUinsRequiredForRestrictedRights(t *testing.T) {
p := &Plugin{name: "qq"}
// 16 无 target_uins ⇒ 必须报错
if _, err := p.handleSendQzoneMsg(map[string]interface{}{
"content": "x", "ugc_right": float64(16),
}); err == nil {
t.Fatal("★ ugc_right=16 缺 target_uins 却没报错 —— 服务端会拒,白跑一次往返")
}
// content 必填
if _, err := p.handleSendQzoneMsg(map[string]interface{}{}); err == nil {
t.Fatal("★ 缺 content 却没报错")
}
// 非法 ugc_right 本地拦
if _, err := p.handleSendQzoneMsg(map[string]interface{}{
"content": "x", "ugc_right": float64(7),
}); err == nil {
t.Fatal("★ ugc_right=7(不在白名单)却没报错")
}
}
// ★★★ 判据 4:target_uins / images 必须接受 JSON 反序列化的形态。
//
// 经 proc 协议往返后,数组一定是 []interface{}、数字一定是 float64。
// 只认 []int64 / []string 会让这些参数**永远为空且不报错** —— 静默失效。
func TestQzoneAcceptJSONDecodedTypes(t *testing.T) {
// []interface{} + float64(真实链路形态)
uins, err := toInt64Slice([]interface{}{float64(123456), float64(789012)})
if err != nil || len(uins) != 2 || uins[0] != 123456 || uins[1] != 789012 {
t.Fatalf("★ target_uins 不接受 JSON 形态([]interface{}+float64):%v, %v", uins, err)
}
imgs, err := toStringSlice([]interface{}{"http://a/b.jpg", "base64://xxx"})
if err != nil || len(imgs) != 2 {
t.Fatalf("★ images 不接受 JSON 形态([]interface{}):%v, %v", imgs, err)
}
// 字符串形式的 QQ 号也要能吃(模型常传 "123456")
uins2, err := toInt64Slice([]interface{}{"123456"})
if err != nil || len(uins2) != 1 || uins2[0] != 123456 {
t.Fatalf("★ target_uins 不接受字符串 QQ 号:%v, %v", uins2, err)
}
}
// ★★★ 判据 5:两个工具都注册了(否则模型看不到)。
func TestQzoneToolsRegistered(t *testing.T) {
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
for _, name := range []string{"send_qzone_msg", "delete_qzone_msg"} {
if !strings.Contains(src, `tp + "`+name+`"`) {
t.Fatalf("★ 工具 %s 没有注册", name)
}
}
}