把 QQ 机器人接进 Agent:OneBot 反向 WebSocket 的工程细节
用 QQ 协议端把群消息桥接给 Agent 框架的工程笔记:对比正向与反向 WebSocket 的连接方向、反向 WS 为何适合容器、监听地址如何构成安全边界,以及无配置项时的补丁与回归检测、array 与 string 事件格式、登录态持久化与网络模式取舍。
QQ 群消息接入 Agent,最容易出问题的不是模型调用,而是连接方向与监听地址:前者决定协议端与 Agent 谁主动发起连接,后者决定谁能把 Agent 当成一个可被任意驱动的事件接收器。
一、链路由三段组成
| 组件 | 职责 | 对外接口 |
|---|---|---|
| QQ 协议端(NapCat 类) | QQ 登录、私有协议实现、收发群消息 | OneBot v11 |
| OneBot 适配器 | 事件/调用的传输层(HTTP、WebSocket) | 见下节 |
| Agent 侧适配器 | 把 OneBot 事件翻译成会话消息,执行白名单、工具与会话策略 | Agent 内部 API |
协议端只负责保持在线并把消息转成 OneBot 格式,安全策略(谁能触发、可调用哪些工具)全在 Agent 侧。这意味着只要有人能把事件塞进 Agent 的入口,Agent 就会照单执行。
OneBot v11 定义了三种传输方式:
| 方式 | 谁监听 | 谁发起连接 | 事件到达方式 |
|---|---|---|---|
| HTTP API | 协议端 | Agent 发请求 | Agent 主动调用,事件靠轮询或另配 HTTP POST |
| 正向 WebSocket | 协议端 | Agent 连出去 | 协议端在这条连接上推送 |
| 反向 WebSocket | Agent | 协议端连出去 | 协议端在这条连接上推送 |
二、正向与反向的区别只有一件事:谁连谁
正向 WebSocket:协议端起 WS 服务端,Agent 作为客户端连过去。 反向 WebSocket:Agent 起 WS 服务端,协议端作为客户端连出去。
事件推送方向两者相同,差别只在于 TCP 连接由谁建立,而这对部署形态影响很大:
- 协议端容器不需要任何入站端口。 它只做出站 TCP,容器网络可以完全封闭。
- 出站连接可以穿过 NAT 与"只出不进"的策略。 不需要 Agent 能寻址到协议端,也不要求协议端有稳定地址。
代价是:反向 WS 的监听端口成了一个新的、直接的入口。
三、监听地址就是安全边界
反向 WS 端口收到的不是"网络流量",而是用户输入。任何能建立 TCP 连接并发送一段形如 {"post_type":"message","message_type":"group",...} 的 JSON 的人,都能让 Agent 认为群里有人发了消息,进而触发工具调用、文件读写、对外发消息——这条路径既不需要密码,也不需要 QQ 账号。
OneBot 的 access_token 是可选的,很多示例配置里干脆留空;即便配上也只是明文比较。把端口绑在 0.0.0.0 等于把"无认证"换成"弱认证",同时把入口交给全网扫描器。
所以这一层的正确配置是唯一且无歧义的:只绑回环(127.0.0.1)。
验证方式(示例端口 18800):
ss -tlnp | grep 18800
期望输出,注意 Local Address 一列:
LISTEN 0 128 127.0.0.1:18800 0.0.0.0:* users:(("python",pid=12345,fd=7))
危险信号是同一位置的 0.0.0.0:18800、*:18800 或 :::18800,都表示监听在所有接口上。
再从别处尝试连接:
nc -vz 203.0.113.10 18800
# nc: connect to 203.0.113.10 port 18800 (tcp) failed: Connection refused
在同宿主、bridge 网络的容器内,用 docker0 网关地址尝试,同样应被拒绝。判定标准很直接:连接被拒绝是期望结果,能连上是故障。
四、没有配置项时:改配置还是打补丁
并非所有实现都提供监听地址的配置项。一个常见的取值顺序是:
extra["host"] → 环境变量 → 代码内默认值
先查代码确认优先级。若存在配置项或环境变量,直接设置,重启后用 ss 验证(CLI 校验可能对未知键报"不是可识别的配置项",但值确实生效,以实际绑定为准)。
如果地址被写死在源码里,最小补丁的形态大致如下——关键不只是改成回环,而是让配置能覆盖它,避免下次还要动源码:
# __init__ 中新增一个可被 extra 覆盖的字段
self._ws_host: str = str(extra.get("ws_host", "127.0.0.1"))
# 建立监听时使用该字段,而不是字符串字面量
site = web.TCPSite(self._runner, self._ws_host, self._ws_port)
logger.info("reverse WS listening on ws://%s:%d", self._ws_host, self._ws_port)
隐蔽的陷阱:同一功能可能存在两套实现——安装到包目录的一份、源码目录里被运行时加载的另一份,日志里的 logger 名称能区分实际生效的是哪一套。改错文件的表现是"改了没反应",且服务一切正常。
补丁的维护风险:
- 该文件往往是插件运行时写入源码目录的未跟踪文件(
git status显示??),不在版本控制里,升级时不会被 diff 提醒。 - 插件或框架升级、重装可能整体覆盖它,补丁静默消失:服务照常启动,只有绑定范围退回全网卡。
- 因此这类补丁必须配回归检测,把"安全属性悄悄退化"变成一条能被告警的事件。
检测脚本(无输出即正常):
#!/bin/sh
# 列出所有监听套接字,检查目标端口是否只绑回环
for p in 18800 3001 6099; do
ss -tlnH | awk -v p=":$p" '$4 ~ p"$" {print $4}' \
| grep -vqE '^(127\.0\.0\.1|\[::1\]):' && echo "NOT-LOOPBACK: $p"
done
它覆盖两种情况:端口没在监听(无输出,不误报)与端口绑在非回环地址(输出告警)。挂到 cron 或定时器上,周期取分钟级到一刻钟级;升级后还应主动复查一次。
五、消息格式:array 与 string
messagePostFormat 控制事件里 message 字段的形态,同一份事件在两种模式下差异很大:
// string
"[CQ:at,qq=<账号>] 你好"
// array
[{"type":"at","data":{"qq":"<账号>"}},
{"type":"text","data":{"text":" 你好"}}]
| 维度 | string(CQ 码) | array |
|---|---|---|
| 解析成本 | 需要 CQ 码解析器(正则或状态机) | 直接遍历 JSON 数组 |
| 转义 | [、]、& 与字面量混淆 |
JSON 层已处理 |
| 未知消息段 | 解析失败或静默丢内容 | 原样保留,可安全忽略 |
| @ 检测 | 文本匹配,易被引用、代码块、昵称误伤 | 判断是否存在 type == "at" |
| 回发 | 需要拼接并正确转义 | 构造数组即可 |
结论是整条链路统一用 array:协议端出站配置与 Agent 侧解析保持一致。混用会表现为"事件收到了,但 @ 触发、图片、引用全部失效"这类不报错的功能缺失,排查成本远高于统一。
另一个易错点:OneBot 事件里的账号、群号、消息 ID 通常以字符串给出。下游比较时不要顺手转成整数,否则 "123" != 123 会静默失败,表现为白名单永远不命中。
六、容器化:登录态与网络模式
6.1 登录态必须挂出来
协议端把账号数据、配置、日志写在容器内的固定目录。不挂载的话,换镜像、改网络参数、重建容器都要重新扫码登录:
volumes:
- /srv/napcat/config:/app/napcat/config
- /srv/napcat/qq:/app/.config/QQ
- /srv/napcat/logs:/app/napcat/logs
配置文件还有一个坑:协议端常按账号名再写一份 per-account 配置(形如 onebot11_<账号>.json),实际生效的往往是那一份。只改通用文件会出现"配置改了、重启了、行为没变"。直接改文件与通过 WebUI 修改可能互相覆盖,二者选一条路径并固定。
6.2 host 与 bridge 的取舍
| 维度 | bridge(默认) | host |
|---|---|---|
容器内 127.0.0.1 |
容器自身 | 宿主 |
| 容器访问宿主回环上的服务 | 不可达,需经 docker0 网关地址 | 直接可达 |
| 容器监听端口 | 需 -p 发布,经 nat/PREROUTING 做 DNAT |
不经过 DNAT,直接就是宿主端口 |
| 入站过滤的落点 | FORWARD 链(Docker 自身链路) | 宿主 INPUT 链(UFW) |
最后两行是容易搞错的地方。bridge 下发布的端口,目的地址经 DNAT 改写后走 FORWARD 链,完全不经过 INPUT,所以"用 ufw deny 某个已发布的容器端口"无效,需要额外的 Docker 前置链守卫。host 网络则没有映射这一步,协议端监听的端口就是宿主端口,走 INPUT,UFW 规则直接生效。同一条规则在两种模式下作用路径不同,照抄会失效。
对反向 WS 场景的具体结论:
- Agent 与协议端同机、且 Agent 只监听回环时,host 网络最直接:容器内
ws://127.0.0.1:<端口>连的就是宿主回环。但此时协议端自身若监听全网卡,等于把 WebUI/API 摊在宿主所有接口上,必须逐个确认其监听地址已收到回环或私网。 - 用 bridge 则要显式指定宿主可达的地址,并额外处理 DNAT 与转发链的关系,排错时多一层。
--restart unless-stopped 保证进程挂掉自恢复;长时间运行后的内存增长(推测与连接和缓存累积有关)可用定期重启缓解,属经验做法而非必需。
七、三样东西都不该公网可达
| 暴露面 | 默认端口(示例) | 一旦可达的后果 |
|---|---|---|
| WebUI 面板 | 6099 | 改配置、读取登录态 |
| HTTP API | 3001 | 以机器人身份发消息、读取会话 |
| 反向 WS | 18800 | 注入伪造事件,直接驱动 Agent |
三者的正确状态都是只绑回环;WebUI 若必须供隧道侧访问,应绑到私网地址并限制来源。
防火墙是第二层,不是第一层:Docker 的 DNAT 会让已发布的端口绕过 UFW 的 INPUT,只配 ufw 会得到虚假的安全感。
远程管理的做法是隧道 + 边缘认证:内网容器主动向边缘建立长连接(出站,无需入站端口),访问方在边缘完成身份校验后再回源到私网地址。回源目标仍应是回环或私网地址,而不是公网监听,否则边缘认证形同虚设。
自查一行命令:
ss -tlnp | grep -vE '127\.0\.0\.1|\[::1\]'
理想输出只剩 sshd(以及你明确知道用途的条目)。多出来的每一行都应能解释清楚是谁在监听、为何必须监听在非回环上。
可操作要点
- 反向 WS 一律绑
127.0.0.1,用ss -tlnp | grep <端口>验证 Local Address 列;只要不是回环就是错的,与是否配置 token 无关。 - 绑定地址写死在代码里时,先查配置项与环境变量的优先级;必须打补丁时,让补丁支持配置覆盖,而不是再写死一次。
- 补丁若落在未跟踪的源码文件上,必被升级覆盖且不报错——必须配分钟级的绑定范围回归检测与告警。
messagePostFormat全链路统一为array;ID 类字段按字符串比较。- 协议端的配置与账号数据目录必须挂载出来;网络模式决定防火墙规则落在
INPUT还是FORWARD,选之前先确认。 - WebUI、HTTP API、反向 WS 三者都不应公网可达;远程管理走隧道加边缘认证,回源地址保持私网。