Chips

把 QQ 机器人接进 Agent:OneBot 反向 WebSocket 的工程细节

· 约 8 分钟 · 2807 字

用 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 连接由谁建立,而这对部署形态影响很大:

  1. 协议端容器不需要任何入站端口。 它只做出站 TCP,容器网络可以完全封闭。
  2. 出站连接可以穿过 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 名称能区分实际生效的是哪一套。改错文件的表现是"改了没反应",且服务一切正常。

补丁的维护风险:

检测脚本(无输出即正常):

#!/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 数组
转义 &#91;&#93;&amp; 与字面量混淆 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 场景的具体结论:

--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(以及你明确知道用途的条目)。多出来的每一行都应能解释清楚是谁在监听、为何必须监听在非回环上。

可操作要点

  1. 反向 WS 一律绑 127.0.0.1,用 ss -tlnp | grep <端口> 验证 Local Address 列;只要不是回环就是错的,与是否配置 token 无关。
  2. 绑定地址写死在代码里时,先查配置项与环境变量的优先级;必须打补丁时,让补丁支持配置覆盖,而不是再写死一次。
  3. 补丁若落在未跟踪的源码文件上,必被升级覆盖且不报错——必须配分钟级的绑定范围回归检测与告警。
  4. messagePostFormat 全链路统一为 array;ID 类字段按字符串比较。
  5. 协议端的配置与账号数据目录必须挂载出来;网络模式决定防火墙规则落在 INPUT 还是 FORWARD,选之前先确认。
  6. WebUI、HTTP API、反向 WS 三者都不应公网可达;远程管理走隧道加边缘认证,回源地址保持私网。

← 全部文章