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

190 lines
5.9 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
// QQ 空间(Qzone)说说:发送与删除。
//
// ## 接口来源与一次真实的误判(2026-10-18)
//
// NapCat 源码里 Qzone 的实现是:
//
// packages/napcat-onebot/action/extends/SendQzoneMsg.ts
// packages/napcat-onebot/action/extends/DeleteQzoneMsg.ts
//
// 但 **action 名不是类名**,`router.ts` 里声明的是:
//
// SendQzoneMsg: 'send_qzone_msg',
// DeleteQzoneMsg: 'delete_qzone_msg',
//
// 我曾用类名 `SendQzoneMsg` 探测,得到「不支持的Api SendQzoneMsg」,
// 于是错误地断定"生产版本太旧、不支持 Qzone"。换成下划线名后:
//
// POST /send_qzone_msg {"content":"…","ugc_right":64}
// → {"status":"ok","retcode":0,"data":{"tid":"a5c1ca56d081d46a789a0200"}}
//
// **API 一直是可用的**,是我的调用名错了。
//
// 教训:「不支持」这个结论必须用真实 action 名验证 —— 源码里的
// 类名、文件名、方法名都不是调用名。
//
// ## 为什么不需要 Shell / NTQQ 注入
//
// Qzone 是纯 HTTP:NapCat 的 WebApi.publishQzoneMsg 直连
// `up.qzone.qq.com` / `user.qzone.qq.com`,与 NTQQ 客户端进程无关。
// (对比:shell/native loader 那套才是绑 NTQQ 的。)
import (
"fmt"
"strconv"
)
// validUgcRights 是 NapCat 服务端硬校验的白名单(源码 ValidUgcRights)。
//
// 本地也校验一次:服务端拒绝时报的是 "ugc_right 参数不合法, 允许的值为: …",
// 但那是**跨进程往返之后**才拿到的错误;本地先拦能给模型更直接的反馈,
// 也避免为一次参数错误消耗一次 NapCat 调用。
var validUgcRights = map[int]string{
1: "所有人可见",
4: "好友可见",
16: "部分好友可见",
64: "仅自己可见",
128: "部分好友不可见",
}
// handleSendQzoneMsg 发一条说说。
func (p *Plugin) handleSendQzoneMsg(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return nil, fmt.Errorf("content 不能为空(说说正文是必填项)")
}
params := map[string]interface{}{"content": content}
// ugc_right:缺省 1(所有人可见)。这里**不**擅自降成"仅自己可见" ——
// 用户明确说了要发说说,就按他要的默认发;权限由调用方显式声明。
ugc := 1
if v, ok := args["ugc_right"]; ok && v != nil {
switch n := v.(type) {
case float64:
ugc = int(n)
case int:
ugc = n
case int64:
ugc = int(n)
case string:
if parsed, err := strconv.Atoi(n); err == nil {
ugc = parsed
} else {
return nil, fmt.Errorf("ugc_right 需为整数,收到 %q", n)
}
}
}
if desc, ok := validUgcRights[ugc]; !ok {
return nil, fmt.Errorf("ugc_right=%d 不合法,只能取 1/4/16/64/128(依次为:所有人可见/好友可见/部分好友可见/仅自己可见/部分好友不可见)", ugc)
} else {
_ = desc
}
params["ugc_right"] = ugc
// target_uins:16/128 时必填(NapCat 服务端也会校验,本地先拦)。
if raw, ok := args["target_uins"]; ok && raw != nil {
uins, err := toInt64Slice(raw)
if err != nil {
return nil, fmt.Errorf("target_uins: %w", err)
}
if len(uins) > 0 {
params["target_uins"] = uins
}
}
if ugc == 16 || ugc == 128 {
if _, ok := params["target_uins"]; !ok {
return nil, fmt.Errorf("ugc_right=%d(%s)时必须提供 target_uins", ugc, validUgcRights[ugc])
}
}
// images:原样透传字符串数组(支持 http(s):// / file:// / base64://,
// NapCat 自己下载并上传到 Qzone)。
if raw, ok := args["images"]; ok && raw != nil {
imgs, err := toStringSlice(raw)
if err != nil {
return nil, fmt.Errorf("images: %w", err)
}
if len(imgs) > 0 {
params["images"] = imgs
}
}
resp, err := p.napcat("send_qzone_msg", params)
if err != nil {
return nil, fmt.Errorf("发送说说失败: %w", err)
}
return resp, nil
}
// handleDeleteQzoneMsg 删一条说说。
func (p *Plugin) handleDeleteQzoneMsg(args map[string]interface{}) (interface{}, error) {
tid, _ := args["tid"].(string)
if tid == "" {
return nil, fmt.Errorf("tid 不能为空(来自 send_qzone_msg 的返回值或空间列表)")
}
resp, err := p.napcat("delete_qzone_msg", map[string]interface{}{"tid": tid})
if err != nil {
return nil, fmt.Errorf("删除说说失败: %w", err)
}
return resp, nil
}
// toStringSlice 把 JSON-RPC 反序列化出来的数组转成 []string。
//
// 为什么不能直接断言 []string:经过 proc 协议的 JSON 往返后,
// []interface{} 是常态(模型传来的参数一定是这个形态)。
// 只认 []string 会让这个参数**永远为空**,且不报错 —— 静默失效。
func toStringSlice(v interface{}) ([]string, error) {
switch arr := v.(type) {
case []string:
return arr, nil
case []interface{}:
out := make([]string, 0, len(arr))
for _, e := range arr {
s, ok := e.(string)
if !ok {
return nil, fmt.Errorf("期望字符串数组,遇到 %T", e)
}
out = append(out, s)
}
return out, nil
}
return nil, fmt.Errorf("期望数组,收到 %T", v)
}
// toInt64Slice 同上,用于 QQ 号数组。
//
// ★ 必须接受 float64:JSON 数字解码成 float64,模型传 123456 到这里就是
// float64(123456)。只认 int 会让 target_uins 永远被当成空数组。
func toInt64Slice(v interface{}) ([]int64, error) {
switch arr := v.(type) {
case []int64:
return arr, nil
case []interface{}:
out := make([]int64, 0, len(arr))
for _, e := range arr {
switch n := e.(type) {
case float64:
out = append(out, int64(n))
case int:
out = append(out, int64(n))
case int64:
out = append(out, n)
case string:
parsed, err := strconv.ParseInt(n, 10, 64)
if err != nil {
return nil, fmt.Errorf("QQ 号需为数字,收到 %q", n)
}
out = append(out, parsed)
default:
return nil, fmt.Errorf("期望 QQ 号数组,遇到 %T", e)
}
}
return out, nil
}
return nil, fmt.Errorf("期望数组,收到 %T", v)
}