我搭这套机器人方案的初衷很简单:想让群里能自动回复一些常见问题、定时发通知、顺便做点小工具,但不想从零去啃 IM 协议。最后的路线是“NapCatQQ + Spring Boot”,两者通过 OneBot 协议通讯。跑通之后我最大的感受是:这条链路把协议适配和业务逻辑分得很干净,Java 开发者只需要关心事件处理和消息回复,技术门槛比想象中低很多。这篇内容就是完整的实现过程和我在实际部署中遇到的坑,适合刚接触 QQ 机器人、或者想在一个靠谱协议层上快速做业务功能的人。
1. 整体设计思路:为什么把协议和业务拆开
先理清这套方案里每个角色的职责。机器人的运行流程,本质上是一条“收消息 -> 分析语义 -> 决定回复”的链路。问题在于,IM 平台底层协议经常变化,而且不同客户端的消息格式差异很大。如果业务代码直接依赖某个协议实现,一旦底层升级,整个业务就得跟着返工。所以我第一步做的就是让协议适配层和业务逻辑层完全分离,中间用统一的事件格式通信。
1.1 从零写协议适配太累,OneBot 帮你省了
OneBot 是一套开放的机器人通信标准。它把消息、群事件、好友事件、请求事件等抽象成统一的 JSON 结构,定义了一组标准的动作接口,比如发送群消息、发送私聊消息、获取群成员信息等。简单说,只要协议端实现了 OneBot 标准,业务端就能用同一套数据结构去处理所有消息,不用关心底层是哪个客户端或哪种登录方式。
如果你的业务代码直接对接某个非标准协议,大概率会遇到这几个问题:消息类型不统一、心跳机制要靠自己保证、API 接口文档不齐全。而 OneBot 标准经过大量项目验证,结构清晰,示例多。我见过不少项目因为没走标准协议,换登录方案时改了一周代码。相反,采用 OneBot 后,后面就算把协议端从 A 换成 B,业务核心几乎零改动。
1.2 为什么选 NapCatQQ 做协议层
协议端我选择了 NapCatQQ。它基于新版 QQ 客户端框架,可以后台稳定运行,并提供 OneBot 标准的接口。实际使用中,它的配置界面把复杂的账号登录、连接方式管理做成了可视化操作,不需要像早期方案那样手动改一堆配置文件。
另外一个很重要的点是它支持多种 OneBot 连接方式:正向 WebSocket、反向 WebSocket、HTTP 回调。反向 WebSocket 是最稳健的模型,因为由 NapCat 主动连到你的后端服务,后端不需要公网监听端口,也更容易部署在内网环境。我在 Spring Boot 端只需要暴露一个 WebSocket 端点,然后等着它连上来。相比 HTTP 轮询或回调,WebSocket 是长连接,事件实时性高,不需要频繁握手和轮询,后续做群消息通知这类低延迟需求很合适。
1.3 Spring Boot 在业务层的优势
业务端我选了 Spring Boot,原因很实际:自动配置、依赖管理、线程池、定时任务、事务管理这些都是现成的。机器人功能往往不只是简单回复,还需要权限校验、数据库存取、任务调度,这些正好是 Spring 生态的强项。
比如我用@Scheduled就能做每天定时推送,用@Component就能把各个功能模块装配起来。配合统一的消息路由,以后加一个新命令,基本就是新增一个方法的事。而且 Java 社区对 WebSocket 和 JSON 处理的支持非常成熟,用 Spring 的TextWebSocketHandler和 Jackson 就能快速搞定 OneBot 协议的消息收发。
注意:如果你对 Java 不熟,也可以把业务端换成 Python、Go 或 Node。OneBot 协议是语言无关的。但本项目的核心是 Spring Boot,所以后面代码都围绕 Java 生态展开。
2. 环境准备与连接配置
动手之前先把环境理清楚。我本机用的是 Windows 开发环境,服务器上是 Linux,两个平台我都跑通过。关键点在于:协议端 NapCatQQ 需要能连上账号,业务端 Spring Boot 需要能接受 WebSocket 连接。
2.1 部署并初始化 NapCatQQ
第一步是下载对应平台的 NapCatQQ 包。它在 Linux 服务器上以无头模式运行比较常见,在 Windows 上有图形界面方便登录。这里有几个需要提前确认的点:
- Node.js 环境版本要满足要求,一般建议使用最新 LTS 版本。
- 首次运行需要扫码登录你的 QQ 账号,登录后把会话保持住。
- 尽量给机器人账号开一个专门的小号,不要用日常大号,避免消息干扰。
登录完成后,打开 NapCat 的 WebUI 管理界面。这里需要配置 OneBot 连接。选择“反向 WebSocket”,然后填入 Spring Boot 暴露出来的地址。
示例配置:
# NapCat 连接配置示意 网络配置: 反向WebSocket: - 名称: springbot 地址: ws://127.0.0.1:8080/onebot 连接Token: my_secret_token 心跳模式: 定时 心跳间隔: 30地址里的127.0.0.1:8080是你的 Spring Boot 服务。Token 是一把简单的握手锁,Spring Boot 端校验 Header 里的 token,避免随便谁都能连上来。
2.2 Spring Boot 初始化与核心依赖
接着创建 Spring Boot 工程。JDK 版本建议 17 或 21,Spring Boot 使用 3.x。如果你用的是企业老环境,必须用 JDK 8,那可以选 Spring Boot 2.7.x,但依赖版本需要相应调整。
核心依赖只需要两块:WebSocket 支持和 JSON 处理。Spring Boot 自带的 JSON 处理用的是 Jackson,已经够用。在pom.xml里加上:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> </dependencies>spring-boot-starter-web是提供 HTTP 服务的,虽然我们用 WebSocket,但开发调试时还需要一些 HTTP 接口。spring-boot-starter-websocket提供 WebSocket 服务端支持。这两个启动器足够跑通基础功能了。
2.3 配置 OneBot 反向 WebSocket 连接
配置一个 WebSocket 端点,让 NapCat 能连上来。这里用 Spring 原生 WebSocket 注册方式。
@Configuration public class OneBotWebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(oneBotWebSocketHandler(), "/onebot") .setAllowedOrigins("*"); } @Bean public OneBotWebSocketHandler oneBotWebSocketHandler() { return new OneBotWebSocketHandler(); } }这里有两个细节值得说。第一,/onebot这个路径要和 NapCat 配置里的地址路径完全一致。第二,setAllowedOrigins("*")在开发环境方便,但在生产环境建议限制成可信来源。虽然 WebSocket 不像普通接口那样容易被恶意抓取,但端口暴露在公网上时,加上 Token 校验是必须的。
处理器类继承TextWebSocketHandler。它的作用就是收到文本消息时回调我们。
@Slf4j public class OneBotWebSocketHandler extends TextWebSocketHandler { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { log.info("已接收新连接,来源地址: {}", session.getRemoteAddress()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode event = objectMapper.readTree(message.getPayload()); log.info("收到事件: {}", event.toString()); // 这里先打印出来,等一下我们会接入业务处理 } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("连接异常", exception); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { log.warn("连接关闭: {}", status); } }先把这段代码跑起来,能看到 Napoleon 连接上来的日志就说明链路通了。我第一次就是在这步卡了半小时,后来发现是路径拼错了,地址写成了ws://localhost:8080/onebot/,多了一个斜杠就握手失败。
3. 事件接收与消息返回核心细节
链路通了之后,接下来的核心就是把“收到 JSON”变成“能回复消息”。很多人以为收到消息后,直接向 WebSocket 连接发一条 JSON 回去就行,但实际操作有细节。
3.1 理解 OneBot 的消息事件结构
NapCat 连上来后,会把所有事件推给我们。一条典型的群消息事件长这样:
{ "post_type": "message", "message_type": "group", "group_id": 101232323, "user_id": 233314400, "self_id": 100001, "raw_message": "你好", "message": [ { "type": "text", "data": { "text": "你好" } } ], "sender": { "user_id": 233314400, "nickname": "小王", "card": "" }, "time": 1699888888 }这里面有几个字段是关键:
post_type表示事件类型,message是消息事件,meta_event是元事件,request是请求事件,notice是通知事件。message_type区分是群消息还是私聊消息。message是消息段数组,每个段有type和data。OneBot 的富文本本质上就是消息段,比如文本段、图片段、AT 段、表情段。
为什么要用消息段而不是直接传字符串?因为消息不能只是一个纯文本,可能是“@某人 + 文本 + 图片”的组合。比如[CQ:at,qq=123] 你看这个图片 [CQ:image,file=xxx.jpg],用消息段数组表示更清晰,后端可以直接解析出用户 ID 和图片路径。
3.2 接收事件并安全的回复消息
当长连接建立后,回复消息并不是调用外部接口,而是往同一个 WebSocket 连接发送一个类似请求的数据。这个请求里要有action和params。举个例子:
{ "action": "send_group_msg", "params": { "group_id": 101232323, "message": "你好呀" }, "echo": "sync_1" }在 Spring Boot 里,我们可以在收到事件后,用 WebSocketSession 直接发这个 JSON:
public void sendGroupMessage(long groupId, String text, WebSocketSession session) throws Exception { Map<String, Object> request = new LinkedHashMap<>(); request.put("action", "send_group_msg"); request.put("params", Map.of( "group_id", groupId, "message", text )); request.put("echo", UUID.randomUUID().toString()); String json = objectMapper.writeValueAsString(request); session.sendMessage(new TextMessage(json)); }有同学可能想问,为什么要带echo?因为 WebSocket 是用一个长连接做双向通信的,事件上送和 API 调用的回复都在同一条通路上。带上echo,就能知道这条返回是响应哪条请求的。尤其在异步发送时,这是唯一可靠的匹配方式。
如果你同时开着一个 HTTP API 端口,也可以从 Spring Boot 这边用 HTTP POST 去调 OneBot 的/send_group_msg接口。这在有些场景下更简单,但是多一层网络开销。我实战里优先走 WebSocket 通道,快,也少一个端口要维护。
3.3 多账号连接与会话管理
如果你的业务需要同时支持多个机器人账号,那就不能只保存一个 WebSocketSession。每个账号连上来都会建立一条独立的会话,必须为每个账号管理好自己的 session。
消息事件的self_id字段就是当前机器人的 QQ 号。在afterConnectionEstablished时我们不知道这是哪个账号,必须等收到第一条带self_id的事件后才能落库。常见做法是维护一个ConcurrentHashMap<Long, WebSocketSession>,以self_id为 key。
public class SessionManager { private final ConcurrentHashMap<Long, WebSocketSession> sessions = new ConcurrentHashMap<>(); public void register(long selfId, WebSocketSession session) { sessions.put(selfId, session); } public WebSocketSession getSession(long selfId) { return sessions.get(selfId); } public void remove(WebSocketSession session) { sessions.entrySet().removeIf(entry -> entry.getValue().equals(session)); } }这里最需要注意的是线程安全。消息事件是并发到达的,如果用普通HashMap,并发写入时会丢 session,后面发送消息就会偶发失败。我一开始没注意,线上每次“随机”丢消息,排查了半天,最后发现是 Map 的问题。
4. 实战功能开发:路由、组合消息与定时任务
基础设施准备好了,就可以开始写业务。这节我会从三个最常见的需求展开:命令路由、组合消息段、定时任务推送。
4.1 命令路由,把默认机器人变成业务助手
刚开始调试时,收到任何消息都回复“收到”。真正做功能,肯定要根据消息内容做路由。
我用一个简单但扩展性好的方式:定义一个注解@BotCommand,然后用反射扫描所有带注解的方法。不过这个小项目没必要上那么重的框架。第一步先写一个handleGroupMessage方法,判断消息前缀:
public void handleGroupMessage(long groupId, long userId, String rawMessage, WebSocketSession session) throws Exception { if (rawMessage.startsWith("/help")) { sendGroupMessage(groupId, """ 支持命令: /help - 帮助 /time - 当前时间 /joke - 讲个笑话 """, session); } else if (rawMessage.startsWith("/time")) { sendGroupMessage(groupId, String.format("现在是 %s", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))), session); } else { // 默认不回复,避免打扰群聊 } }这里有一个很多人容易犯的错:群里消息很多,不是每一条都需要机器人回复。如果无脑回复,机器人群很快就被踢了。所以要设置白名单或者只有带前缀的消息才回复。
为了更优雅,我会把所有命令处理聚合到一个 Map 里,key是命令名,value是函数式接口。这样加新命令只需要注册,不用一直写else if。
4.2 组合消息段:AT 人、图片和表情一起发
文本消息只是最低配。实际群里经常会用到:机器人被 AT 后才回话;回复时附带图片;用表情表达情绪。
OneBot 的消息段数组可以解决这些问题。拿 AT 来说,在message数组里插入一个at段:
{ "type": "at", "data": { "qq": "233314400" } }在 Spring Boot 中构建组合消息对象:
List<Map<String, Object>> messageSegments = new ArrayList<>(); // 文本段 messageSegments.add(Map.of( "type", "text", "data", Map.of("text", "你好,") )); // AT 段 messageSegments.add(Map.of( "type", "at", "data", Map.of("qq", String.valueOf(userId)) ));然后把message字段传成这个 List,而不是字符串。这种写法在机器人被 @ 的场景下很好用:我先判断群里有没有人 @ 我,如果raw_message里包含CQ:at,qq=<self_id>或者消息段里有at且qq等于机器人自身,才触发回复。
图片段稍微复杂一点,需要file字段,可以是本地路径、网络 URL,或者 Base64 编码。如果是本地路径,要确保 NapCat 所在的环境能访问到那个文件。网络 URL 需要能公网访问,否则 QQ 服务器下载不到。踩过一次坑:在服务器内部传了一个内网地址的图片,机器人发出来就是裂图。
4.3 定时任务与主动消息推送
机器人不只是被动回复,还能定时推送。比如每天早上给群友发天气提醒,或者某个时间点自动清理未完成事项。
这里用 Spring 的@Scheduled最方便。只需要在主类上开启@EnableScheduling,然后在方法上加表达式:
@Scheduled(cron = "0 0 8 * * ?") public void morningPush() { LocalDateTime now = LocalDateTime.now(); String msg = String.format("早上好,现在是 %s,别忘了打卡哦。", now.toLocalTime()); // 获取群的 session,发送消息 long groupId = 101232323; WebSocketSession session = sessionManager.getSession(robotSelfId); if (session != null && session.isOpen()) { sendGroupMessage(groupId, msg, session); } else { log.warn("机器人在线状态下没有可用连接,推送失败"); } }如果机器人在定时任务执行前掉线了,session可能为空或者关闭,需要做好空值判断。否则定时任务一触发,日志刷一堆空指针,看着很烦恼。
如果要做更复杂的持久化推送,比如从数据库读取待推送的群列表,那还需要把数据库集成进来。Spring Boot 的 JPA 或者 MyBatis 都能用,但这块已经不在本项目的核心范围里,先不展开。
5. 常见问题与排错实录
这套方案我在实际部署中踩了不少坑,有些问题光看报错信息根本看不出来。下面挑几个高频问题整理成实录,按症状、原因、解决方案三列给出。
5.1 WS 握手失败和连接闪断
症状:Navic 的日志显示连接被拒绝,或者 Spring Boot 端看不到任何扫描日志。
原因通常有三个:
- 地址写错:路径、端口、IP 对不上。最常见是多了末尾斜杠,路径变成
/onebot/。 - 端口没开放:Spring Boot 监听端口被防火墙拦截,NapCat 连不上。
- Token 不一致:NapCat 里配置了 Token,但 Spring Boot 端没有校验;或者相反,两边不匹配。
解决方案:先在 NapCat 的配置里暂时去掉 Token 选项,确认链路通不通。通了之后再加上 Token,然后保证 Header 里的键名和值两边完全一致。
手工验证可以用浏览器连接到ws://127.0.0.1:8080/onebot。浏览器会发握手请求,如果握手成功,Spring Boot 日志会打印连接进来的地址。如果没有,问题基本就是地址或端口。
5.2 消息乱码、丢失、重复
乱码问题大部分是编码不一致。OneBot 标准使用 UTF-8,Spring Boot 默认也是 UTF-8,一般不会乱。真正容易出问题的场景是发送某些特殊字符时,比如表情符号。建议发送消息时字符串统一走 UTF-8,不做其他转码。
消息丢失原因通常有两个:一是事件处理阻塞,二是并发丢 session。如果你的handleTextMessage方法里做了耗时的数据库查询,后到的事件排在一个线程后面,整体延迟会变高,用户会感觉消息“被吞了”。解决方案是事件接收后快速入队,异步执行业务逻辑。但要注意,异步后线程安全更复杂。
重复消息也很常见。比如消息发送超时后,NapCat 重试,业务端没有幂等处理,就会执行两次。我在项目中会为每一条事件生成一个基于messageId的去重 key,放到一个缓存集合里。重复事件到达时直接丢弃。
5.3 主动发消息返回失败
Spring Boot 往 WebSocket 发送send_group_msg动作后,如果没有反应,不能只检查发送流程。要去看 NapCat 这边返回的 JSON 响应,里面有status和retcode字段。retcode=0才算成功,非零表示失败。
常见返回码含义:
| 返回码 | 含义 | 常见原因 |
|---|---|---|
| 0 | 成功 | 无 |
| 100 | 参数错误 | group_id 或 message 为空 |
| 102 | 请求失败 | 账号异常或权限不足 |
| 103 | 消息过长 | 文本超长,需要分段发送 |
| 301 | 群不存在 | group_id 没有在当前机器人会话中 |
群里主动发消息还有一类隐形坑:机器人账号被禁言了。禁言状态下群消息发不出去,但 API 返回却是成功。这种一定要看日志里有没有被禁言的提示,或者直接让管理员检查群里状态。
5.4 上线后的稳定性经验
跑了一段时间后,我总结了几条提升稳定性的经验。
第一,给 Spring Boot 进程加一个健康检查接口,并且写一个小脚本,每 30 秒检查一次 WebSocket 连接数。如果为 0,触发重启。NapCat 在断线重连时还算聪明,但 Spring Boot 服务端如果挂了,它只会不停重连,不会自动拉起业务进程。
第二,WebSocket 消息处理函数要尽量快。不要在handleTextMessage里同步调用第三方 API,比如天气接口、搜索引擎。这些耗时操作会阻塞事件处理,导致群消息积压。建议用 Spring 的@Async或者自建线程池。
第三,日志一定要打全。我是用 logback 把所有 OneBot 收到的原始 JSON 和发送动作的 JSON 都打出来,级别开成 DEBUG,等系统稳定后再调成 INFO。这对接下来的排错帮助很大。如果线上出现奇怪问题,第一时间回翻原始日志。
最后还有一个习惯建议:开发时本地调试直接把 NapCat 跑在 Windows,断点打断在handleTextMessage里很舒服。但要注意,断点暂停时 WebSocket 是没法接收新消息的,NapCat 那边会一直等待超时,可能触发一个自动重连。所以调试尽量停一次就退出,不要让断点一直挂在那。
这套方案的扩展空间还很大。比如在 Spring Boot 里接入数据库,做关键词存表;或者把消息处理用设计模式改成责任链,让每个功能模块独立开发;再或者引入消息中间件,把收到的消息 Kafka 掉,用消费者慢慢处理。我目前正在做的是把多个群和多个机器人的关系用权限表管理起来,让集群里每个机器人只负责指定群的指令。整体来说,NapCat + OneBot + Spring Boot 这个组合,至少能让热爱 Java 的人专心写业务,而不是反复处理协议适配的问题。