SpringBoot对接NapCatQQ实现QQ机器人:OneBot协议完整实践指南
NapCatQQ这个项目我盯了很久从go-cqhttp停止维护之后QQ机器人这块的选型就一直在摇摆。直到NapCat以NTQQ为底层跑通OneBot协议才算是把稳定性和可扩展性这两件事同时解决了。这篇文章我不打算写成官方文档的复读机而是从我实际搭起整个链路的过程出发讲清楚为什么这样选型、SpringBoot端怎么和NapCat对接、消息收发到底走了哪些环节以及那些不踩一次根本记不住的坑。1. 机器人底层选型为什么是 NapCatQQ OneBot v111.1 go-cqhttp 停更之后QQ 机器人这条路怎么走前两年大家聊QQ机器人绕不开的还是 go-cqhttp。它基于 Mirai 的协议实现在很长一段时间里是个人开发者的首选。但后来项目逐渐停止活跃很多新协议适配没人维护社区里开始频繁出现登录风控、无法发消息、收不到事件上报这类问题。我自己的几个机器人项目也因为这个被迫搁置。这个背景下有三条路可以走换用 Mirai 原生框架 Mirai-http-api自己管理会话生命周期换用全新的社区实现但必须自己评估完整度和维护活跃度直接基于 NTQQ新版 QQ 客户端的内核做协议接入。NapCatQQ 走的就是第三条路。它不是一个从零写协议栈的方案而是以 NTQQ 桌面端的消息能力为基础在外部封装一层 OneBot 兼容协议。这意味着它的消息稳定性基本上等同于 QQ 客户端本身的稳定性不太会出现协议升级就全线崩掉的情况。对我这种更关注业务逻辑而不是逆向协议的人来说这个方向是当前最稳妥的选择。1.2 OneBot 协议是什么为什么需要它OneBot 不是一个具体的软件而是一套通信标准。它定义了机器人框架和上层应用之间的交互格式包括事件上报客户端框架如何把收到的消息、通知、请求推给你的业务服务API 调用业务服务如何让框架去发消息、管理群、获取数据通信方式HTTP、WebSocket、反向 WebSocket 等传输层约定。把框架和业务层拆分出来这件事价值非常大。你可以今天用 NapCat明天换一个同样实现 OneBot 标准的框架而业务代码几乎不动。相反如果你直接在代码里依赖某个框架的内部 API那每次底层升级都是一次重写灾难。在版本上我建议直接用 OneBot v11。虽然 v12 已经出了但社区生态、各框架的适配完整度、你搜到的大多数现成案例都还集中在 v11。我是先基于 v11 跑通全链路再去理解 v12 的差异收益会高很多。1.3 NapCat 相对其他方案的差异NapCat 有四个让我决定选它的点配置路径短。下载、启动、扫码、配置 WebSocket几分钟就能跑通不像有些方案还要自己理解一大堆术语。Docker 部署友好。Linux 服务器上直接拉镜像起容器性能开销和系统依赖都很好解决。对 OneBot v11 支持完整。消息事件、通知事件、请求事件、各类 API 都有覆盖。社区活跃。遇到问题在群里或 GitHub Issues 里基本能搜到解决方案这个对个人开发者太重要了。选型时我的建议是不用一上来就追求协议实现越底层越牛而是看你未来几个月要围绕它写多少业务。你越希望在业务层快速迭代越需要一个稳定、标准化的底层NapCat OneBot 正好满足了这个诉求。2. NapCat 部署与 OneBot 接入配置2.1 安装方式选择NapCat 官方提供 Windows、Linux 和 Docker 三种运行形态。我的主服务器是 Linux所以最常用的是 Docker 方式本地调试时用 Windows 包反而更方便扫码登录直接操作 QQ 客户端界面就完成了。Windows 下你只需要从官方仓库下载对应的 Shell 包解压后运行启动脚本。第一次启动会拉起一个 NTQQ 的实例用 QQ 扫码登录成功后NapCat 的后台服务就会绑定在本地端口上。Linux Docker 方式我用的是这个命令docker run -d \ --name napcat \ --restartalways \ -p 3001:3001 \ -p 3000:3000 \ -e NAPCAT_UID0 \ -e NAPCAT_GID0 \ mlikiowa/napcat-docker:latest这里两个端口要特别说明一下3000 是 NapCat 的 WebUI 管理端口用来方便地配置各种连接方式3001 是 OneBot WebSocket 服务端端口后续 SpringBoot 会去连这个端口。如果你只想让本机访问端口不要暴露到公网或者用防火墙规则限制来源 IP。因为 NapCat 本身是一个具备发消息能力的服务被外部扫描到并调用 API 的话结果会非常糟糕。2.2 启动后的一键配置登录成功之后访问http://服务器IP:3000/webui进入管理界面。这里有一个很容易被忽略的细节WebUI 登录需要密码而密码不是自己设的是 NapCat 启动时打印在日志里的。进入配置界面后找到OneBot 服务端或OneBot 服务器的配置入口主要配置项监听端口默认 3001访问 Token自己设一串随机字符串后续 SpringBoot 连接时会用到启用 WebSocket 服务端勾选开启。Token 的作用是鉴权。你可以在 WebSocket 连接 URL 上通过access_token参数传递也可以放在请求头Authorization: Bearer token。我在 SpringBoot 里选的是请求头方式逻辑更清晰日志里也不容易把 token 暴露出来。2.3 验证连接是否真正打通配置完之后不要急着写代码。先用现成的 WebSocket 客户端工具我用的 WebSocket King连一下ws://127.0.0.1:3001/onebot/v11/ws连上后NapCat 会立刻推过来一个生命周期事件JSON 长这样{ post_type: meta_event, meta_event_type: lifecycle, sub_type: connect, time: 1710000000, self_id: 10001 }看到这个meta_event就说明整个服务端链路没问题。接下来再发一条 API 请求测试{ action: get_login_info, params: {}, echo: test-1 }响应里如果返回了机器人的 QQ 号说明 NapCat 的 API 通道也正常。这一步做完底层就算验收通过了。3. SpringBoot 端实现 WebSocket 连接与心跳保活3.1 项目初始化和依赖引入SpringBoot 项目我用 2.7.x 版本Java 用 8。这个组合在兼容性上最省心。如果你的环境允许也可以尝试 SpringBoot 3.x Java 17但要注意部分 WebSocket 客户端的兼容问题这点后面踩坑部分我再细说。引入 WebSocket 客户端依赖我不太建议直接用 Spring 自带的WebSocketMessageBroker那是给服务端开发的。这里建议用Java-WebSocket这个轻量库dependency groupIdorg.java-websocket/groupId artifactIdJava-WebSocket/artifactId version1.5.4/version /dependency加上 Jackson 处理 JSON 就够了SpringBoot Web 依赖本身已经带了 Jackson。3.2 WebSocket 客户端完整代码直接上核心代码这个类负责连接 NapCat 的 OneBot WebSocket 服务端Component public class OneBotWebSocketClient extends WebSocketClient { private static final Logger log LoggerFactory.getLogger(OneBotWebSocketClient.class); private final MessageRouter messageRouter; public OneBotWebSocketClient(Value(${onebot.ws-url}) String wsUrl, Value(${onebot.token}) String token, MessageRouter messageRouter) { super(URI.create(wsUrl), new Draft_6455()); this.messageRouter messageRouter; // 构造时把 token 放到请求头里 MapString, String headers new HashMap(); headers.put(Authorization, Bearer token); this.addHeader(Authorization, Bearer token); } Override public void onOpen(ServerHandshake handshakedata) { log.info(OneBot WebSocket 连接已建立); } Override public void onMessage(String message) { messageRouter.route(message); } Override public void onClose(int code, String reason, boolean remote) { log.warn(OneBot WebSocket 连接关闭, code{}, reason{}, remote{}, code, reason, remote); } Override public void onError(Exception ex) { log.error(OneBot WebSocket 连接异常, ex); } }注意这里有一处比较隐蔽的地方Java-WebSocket库在多线程环境下发送消息并不是绝对线程安全的。所以我单独做了一个带锁的发送封装public void sendMessage(String payload) { synchronized (this) { if (this.isOpen()) { this.send(payload); } } }这行代码帮我避开了多次 WebSocket 并发调用时的偶发异常强烈建议你也加上。3.3 断线重连与心跳的坑QQ 机器人跑在服务器上网络不可能永远稳定。WebSocket 断线之后如果代码没有重连逻辑那机器人就悄无声息地死掉了。这里我建议用 Spring 的Scheduled定时检查Component public class WebSocketHealthChecker { private final OneBotWebSocketClient client; public WebSocketHealthChecker(OneBotWebSocketClient client) { this.client client; } Scheduled(fixedRate 15000) public void checkConnection() { if (!client.isOpen()) { client.reconnect(); } } }reconnect()是 Java-WebSocket 自带的方法会重新执行连接逻辑。这里有一个常见的误解很多人以为 OneBot 协议的心跳包是让客户端保持连接的实际上 OneBot 服务端默认每 3 秒会发送一个meta_event_typeheartbeat的心跳包。如果客户端长时间收不到消息应该主动判断连接状态。但如果你用 TCP 层的空闲超时来被动等大概率会在网络切换时错过重连时机最终表现为机器人不可用但进程还在跑。我的做法是双重保险定时主动检查 在onClose/onError里触发一次延迟重连延迟几秒避免服务端刚重启时疯狂重试。4. 消息事件的解析与分发机制4.1 OneBot 上报的消息结构长什么样一旦 WebSocket 连接建立所有消息都会主动推送到你的 SpringBoot 服务里。这些消息都有一个统一的post_type字段最常见的三种post_type含义举例message消息事件有人在群里发了一句话notice通知事件有人进群、撤回消息meta_event元事件心跳包、连接建立request请求事件加好友请求、加群申请我主要处理的是message。一条群消息的 JSON 是这样的{ post_type: message, message_type: group, group_id: 123456789, user_id: 987654321, message: 你好, raw_message: 你好, message_id: 10086, self_id: 10001 }这里message和raw_message的区别是raw_message保留最原始的内容而message可能会被框架按 OneBot 规范裁剪过。实际做业务逻辑时建议优先使用raw_message信息丢失最少处理起来更省心。4.2 消息类型与 CQ 码处理message_type有private私聊和group群聊之分。群聊里经常出现五花八门的内容有人发了张图片有人 了机器人有人发了条回复。这些内容在 OneBot 协议里会转成 CQ 码。CQ 码是一种嵌入纯文本消息里的标记典型的有[CQ:at,qq987654321] 你好 [CQ:image,filehttps://example.com/1.png] [CQ:reply,id10086] 这是一条回复如果你的机器人要解析是否有人 我基本上就是要判断消息里有没有[CQ:at,qq机器人自身QQ]这个字符串。要注意的是老版本的 NapCat 在群消息里可能会把at转成CQ:at但新版本里如果 at 对象是全体成员它会变成[CQ:at,qqall]处理时需要兼容。我封装了一个工具方法public static boolean isAtBot(String rawMessage, long botId) { if (rawMessage null) { return false; } return rawMessage.contains([CQ:at,qq botId ]) || rawMessage.contains([CQ:at,qqall]); }4.3 一个通用的消息分发器核心就是把收到的 JSON 解析成一个统一的事件对象再按类型路由到不同 Handler。我用了一个很轻量的实现方式Component public class MessageRouter { Resource private ListMessageHandler handlers; public void route(String json) { JsonNode root JsonMapper.parse(json); if (root null) { return; } String postType root.path(post_type).asText(); if (!message.equals(postType)) { return; } String messageType root.path(message_type).asText(); for (MessageHandler handler : handlers) { if (handler.support(messageType)) { handler.handle(root); } } } }MessageHandler是一个接口私聊和群聊分别实现public interface MessageHandler { boolean support(String messageType); void handle(JsonNode event); }这种做法的好处是以后每增加一个功能查天气、点歌、群管只需要新增一个 Handler 参与分发不用改核心类。5. 调用 OneBot API 实现主动交互5.1 通过 WebSocket 实现请求-响应收到的消息只是被动输入更多时候你需要主动发消息。OneBot 协议支持通过同一个 WebSocket 通道发起 API 调用也就是往服务端发一个带action和params的 JSON。请求{ action: send_group_msg, params: { group_id: 123456789, message: 你好呀 }, echo: 5678 }NapCat 处理完后会返回一个带相同echo的响应{ status: ok, retcode: 0, data: null, echo: 5678 }echo字段是客户端自己生成的请求唯一标识用来匹配请求和响应。因为 WebSocket 是异步的你发出的请求可能乱序返回如果没有echo机制根本不知道哪个响应对应哪个请求。但这个方案的缺点也明显请求和响应是异步的代码里容易出现发完请求不知道什么时候响应回来的困境。所以我更推荐另一个方案——直接走 HTTP API。5.2 用 HTTP API 封装常用操作OneBot 的标准 API 同时支持 HTTP 调用NapCat 也实现了这一套。地址一般是http://127.0.0.1:3000/send_group_msg?access_token你的token在 SpringBoot 里用RestTemplate或WebClient封装很简单。我先做了一个通用方法Service public class OneBotApiClient { private final RestTemplate restTemplate; private final String baseUrl http://127.0.0.1:3000; public JsonNode sendGroupMessage(long groupId, String message) { String url baseUrl /send_group_msg?access_token token; MapString, Object body new HashMap(); body.put(group_id, groupId); body.put(message, message); return restTemplate.postForObject(url, body, JsonNode.class); } public JsonNode sendPrivateMessage(long userId, String message) { String url baseUrl /send_private_msg?access_token token; MapString, Object body new HashMap(); body.put(user_id, userId); body.put(message, message); return restTemplate.postForObject(url, body, JsonNode.class); } }HTTP 方式的好处是可以随时调用不依赖 WebSocket 连接状态响应是同步的方便做结果判断代码可读性也好很多。缺点是每次调用都有一次 HTTP 往返开销但 QQ 机器人场景完全够用。5.3 实现一个简单的群聊响应机器人把前面的组件串起来一个能跑的最小闭环就出来了。我在GroupMessageHandler里写了一段Component public class GroupMessageHandler implements MessageHandler { private final OneBotApiClient apiClient; Override public boolean support(String messageType) { return group.equals(messageType); } Override public void handle(JsonNode event) { long groupId event.path(group_id).asLong(); long userId event.path(user_id).asLong(); String rawMessage event.path(raw_message).asText(); if (ping.equals(rawMessage)) { apiClient.sendGroupMessage(groupId, pong); } } }到这一步你的机器人已经具备了收到消息 - 处理 - 主动回复的最基本能力。后面所有花里胡哨的功能都是在这个模型上叠加。6. 实测过程中踩过的坑和优化建议6.1 SpringBoot 版本和依赖兼容性前面提到我建议 2.7.x这里展开说一说。有些人建项目时直接拉到 SpringBoot 3.2 或更新然后发现 Java-WebSocket 库跑起来没问题但某些内部配置类签名不兼容尤其是和Configuration、Bean相关的自动装配逻辑。如果你的项目是全新创建SpringBoot 3.x 也可以试但建议先明确你要用的库是不是都已经适配了 SpringBoot 3 的 Jakarta 命名空间改动。另外有一个细节RestTemplate在 SpringBoot 3 里的默认实例化方式和 2.x 不太一样如果遇到BeanCreationException请优先检查是不是版本差异导致的。6.2 WebSocket 发送的线程安全问题你在onMessage里可以直接send()但如果在多个线程里同时调用send()Java-WebSocket 的WebSocketImpl内部队列可能会在极端情况下出现异常。前面说的synchronized锁方案是最直接的解决办法。不要用ConcurrentHashMap或CopyOnWriteArrayList来绕问题根本不在这里。6.3 消息频率限制和风控意识QQ 官方对消息发送频率是有隐式限制的。NapCat 底层虽然是 NTQQ但高频操作一样会触发账号风控。我在实测时发现短时间内连续发几十条消息很容易导致账号被临时限制发言。所以建议在业务层加一个简单的限流Component public class RateLimiter { private final RateLimiter limiter RateLimiter.create(1.0); // 每秒1条 public void acquire() { limiter.acquire(); } }调用 API 前先acquire()一下。这种方式简单粗暴但足够有效。6.4 日志脱敏日志里会记录用户的 QQ 号、群号、消息内容。开发阶段无所谓但一旦机器人部署到公共服务上这些信息就属于敏感数据。我建议至少做到不要打印完整的access_token消息内容日志不要一直保留定期清理在线日志系统要有访问权限控制。具体可以实现一个简单的日志过滤器把消息内容里的 CQ 码文件链接去除只保留纯文本。6.5 WebSocket URL 的路径细节NapCat 的 WebSocket 服务端地址是ws://127.0.0.1:3001/onebot/v11/ws。这个路径在官方文档里有写但我见过不少人配成ws://127.0.0.1:3001/ws或者ws://127.0.0.1:3001/onebot/v11连上了但收不到任何事件。如果发现连接成功但无数据优先检查路径。6.6 多账号场景下的隔离有些场景需要跑多个机器人账号一个 NapCat 实例对应一个 QQ 账号。不同实例要用不同端口跑SpringBoot 端可以用多个WebSocketClient对象对应不同实例同时在路由层通过self_id区分消息来自哪个实例。业务逻辑里要避免把两个账号的会话状态混在一起建议把self_id作为一个贯穿整个调用链的参数往下传。最后再分享一个实际运维中的小技巧如果你跟我一样把 NapCat 跑在 Docker 里建议把容器日志单独收集一份然后给--restartalways加上依赖检查在 NapCat 容器启动之前先确认 socket 文件已经初始化否则偶尔会出现 NTQQ 进程先启动但 WebSocket 服务还没起来的情况SpringBoot 连一次失败就等着重连体验很不好。我在 Compose 文件里额外加了 healthcheck用 curl 探测 WebSocket 端口是否可连接healthcheck: test: [CMD, curl, -f, http://localhost:3001] interval: 30s timeout: 5s retries: 3这样 SpringBoot 端可以在 NapCat 完全就绪后才开始连接双重保障下来整个系统跑了一个多月没有出现过一次假死。