mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
## 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)。
190 lines
5.9 KiB
Go
190 lines
5.9 KiB
Go
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)
|
||
}
|