资讯详情 OneBot v11 QQ机器人实战:消息段解析、协议链路与高频避坑指南
📅 2026/10/7 15:15:29
简介MoltbotOneBotv11协议插件项目是一套面向QQ生态扩展的完整实现支持通过NapCat、Lagrange等第三方客户端连接QQ并完成私聊与群聊中文字、图片、语音、视频、文件等多类型消息的收发与解析。项目基于OneBot v11协议设计可帮助开发者在非官方客户端、自动化脚本或自定义工作流中集成QQ通信适合有一定编程基础、需要二次开发或搭建专属消息机器人的人群。资源包为zip压缩格式共12个文件、67KB主要包含TypeScript源码、JSON与Git配置以及说明书、Word文档和Markdown文档目录划分较清晰便于快速定位代码或查阅部署说明。目前已有86人学习/下载。值得关注的是插件内置自动解压zip包能力收到压缩文件后可即时查看处理同时附带安装配置指南和常见问题说明可显著降低上手门槛并为基于OneBot协议构建企业级或个性化通讯方案提供参考基础。1. 先把OneBot v11这条链路讲清楚你要搭的是一个QQ机器人不是黑匣子第一次用 OneBot v11 做 QQ 机器人的人十有八九会被消息段这个细节绊住。你从群里收到的那条消息不是一串干净的文本而是一个 JSON 数组混着文本、图片、at、语音甚至文件段。Moltbot 这个 OneBot v11 协议插件项目做的事就是把这层协议细节挡住让你只处理业务配合 NapCat、Lagrange 这类第三方客户端去连接 QQ把私有协议翻译成 OneBot v11 标准接口你真正要写的逻辑就只剩下收到什么、回什么。这篇笔记面向的是想自己搭一套 QQ 机器人、又不想从抓包开始造轮子的开发者会把协议边界、最小链路、五种消息处理和一个排查顺序都讲清楚。2. OneBot v11与NapCat/Lagrange协议、客户端与插件项目各自的边界2.1 OneBot v11不是QQ官方给的接口先对齐一件事OneBot v11 不是腾讯官方的开放接口而是社区在 CQHTTP 基础上发展出来的一套机器人接入规范。它解决的核心问题是不同 QQ 协议实现暴露出来的接口千差万别但业务方只想拿到「谁在哪个群说了什么、我该怎么回」这样的统一事件。OneBot v11 把这件事拆成两个方向——事件Event和动作Action。事件是 QQ 侧有动静时主动推给接入方的比如私聊消息、群消息、群成员变动动作是接入方主动请求的比如 send_private_msg、send_group_msg、get_group_member_info。两者都走 JSON底层连接可以选 HTTP、正向 WebSocket 或反向 WebSocket。除了消息事件你还要注意另外两类事件notice 和 request。notice 是群文件上传、管理员变动、成员撤回这类通知request 是加好友、入群邀请这类需要你决定同意还是拒绝的请求。很多第一次接协议的人只处理了 message结果好友申请和群邀请全被静默忽略这也是社区里经常被问到的问题。一个成熟的插件项目会在一开始就把这三类事件的分发入口都预留好而不是只留一个消息回调。选 OneBot v11 的理由很实际它生态最成熟。你可以在社区论坛里找到大量基于这个规范写的机器人框架、插件、教程遇到问题搜得到答案。更关键的是标准把客户端实现和业务代码隔离开了今天用 NapCat明天换成 Lagrange业务端代码基本不用动。这一点在客户端协议变更时特别值钱QQ 的私有协议经常有变化只要你把客户端版本固定在可用的状态上层插件项目不会跟着翻车——这就是标准化的好处。2.2 NapCat和Lagrange谁去碰QQ的登录态明白了协议之后就要选谁来提供这个协议端点。标题里提到的 NapCat 和 Lagrange本质上是同一类东西无头化的第三方 QQ 客户端实现。它们的区别在于实现路线。NapCat 是在 NTQQ 基础上做的安装时你需要有一个可用的 QQ 登录态它把 QQ 进程里进出的数据翻译成 OneBot v11 的事件和动作Lagrange 是另一套社区实现做得更模块化部署形态更轻适合想要更可控的接入环境的场景。实际选型时可以按下面的表格先过一遍再决定对比点NapCatLagrange实现基础依赖 NTQQ 客户端与登录态独立实现按模块装配上手成本下载后配 config 就能跑需要按文档装配服务生态成熟度社区例子多问题好搜相对轻需要多读文档适合场景快速跑通、群消息偏多想定制协议层、排查透明注意这两个实现都会持有你的 QQ 登录态这是敏感信息。我一般会把它们单独跑在一个低权限的目录里token 用随机生成的一长串不要图省事留空。登录态泄露等于别人能拿你的号发消息这个环节要当回事。2.3 Moltbot这类插件项目把协议细节挡在业务之外协议端点有了接下来才是标题里「Moltbot」这一类 OneBot v11 协议插件项目真正要解决的问题。你可以把它理解成一层胶水它负责连上 NapCat/Lagrange 暴露出来的 WebSocket 端点维护心跳和重连把收到的事件 JSON 解析成容易消费的数据结构再把你的回复动作封装成标准动作请求发回去。没有这层你的业务代码就得自己处理 WS 断线、JSON 里各种类型的消息段、回执匹配写完一圈真正的业务逻辑可能只占十分之一。所以常见做法是先固定一个插件项目作为业务底座在里面写消息分发、用户状态、回复策略把 NapCat/Lagrange 这种客户端当成可替换的适配层。只要它们都兼容 OneBot v11换客户端时你只需要重新配连接地址和 token。组织形态上插件项目一般会提供「注册事件处理函数」的能力你对某类事件感兴趣就注册一个回调框架层负责把事件广播出去。这是最省心的结构也是后面第五节里各种毛病能快速定位的前提。整条链路是QQ 侧消息 → NapCat/Lagrange 解析为 OneBot v11 事件 → 插件项目收到事件并分发 → 你的业务逻辑处理 → 插件项目带动作请求回写 QQ。3. 跑通最小收发链路配置反向WS、Python监听与第一个回话3.1 先把连接方式定下来反向WS与核心参数常见的 OneBot v11 连接方式有三种HTTP 轮询、正向 WebSocket、反向 WebSocket。真正做消息机器人我一般直接选反向 WebSocket插件侧作为 WS 服务端监听一个本地端口NapCat/Lagrange 连上来。这样做的好处是插件重启后客户端会自动重连不用反过来维护外连地址生产环境里也更容易用进程管理器托管。HTTP 轮询虽然实现最简单但拿实时消息很别扭要一直拖着请求或频繁轮询WebSocket 是事件驱动消息一来就推给你两者体验差距明显。配置项按标题这种规模的项目通常是这样一个组合配置项示例值作用反向WS端口8081Moltbot这类插件进程监听的端口反向WS地址ws://127.0.0.1:8081/wsNapCat/Lagrange 里填的连接地址Token一长串随机字符串握手时校验防止有人偷连message_formatarray决定消息体是数组还是字符串enable_heartbeattrue客户端定时推元事件便于保活地址填 127.0.0.1 还是 0.0.0.0取决于插件和客户端是否在同一台机器。本机调试就填 127.0.0.1省得端口暴露到局域网如果客户端跑在别的机器上再改成对应监听地址并保证防火墙放行。Token 两边必须一致否则握手会 401这个问题排起来很隐蔽。3.2 用Python起一个最小插件进程下面是最小可用的插件进程只做一件事收到私聊文本回一句「收到」。这里用 Python 的 websockets 库实现反向 WS 服务。import asyncio import json import logging from websockets.server import serve logging.basicConfig(levellogging.INFO) TOKEN moltdemo-token-2024 async def handle_ws(ws): # NapCat/Lagrange 连上来后双向都可以发 JSON async for raw in ws: evt json.loads(raw) # 元事件heartbeat / lifecycle只打印不处理 if evt.get(post_type) meta_event: logging.info(meta: %s, evt.get(meta_event_type)) continue # 消息事件私聊和群聊都走这个分支 if evt.get(post_type) message: msg_type evt.get(message_type) # private / group segments evt.get(message, []) text .join( seg.get(data, {}).get(text, ) for seg in segments if seg.get(type) text ) logging.info(%s from %s: %s, msg_type, evt.get(user_id), text) # 组织一个动作请求通过当前连接发回客户端 if msg_type private: action { action: send_private_msg, params: { user_id: evt[user_id], message: [{type: text, data: {text: f收到{text}}}], }, } else: action { action: send_group_msg, params: { group_id: evt[group_id], message: [{type: text, data: {text: f收到{text}}}], }, } # echo 用于匹配回执这里直接复用消息ID方便排查 action[echo] freply-{evt.get(message_id)} await ws.send(json.dumps(action)) async def main(): # 本机调试监听 127.0.0.1跨机部署再改成 0.0.0.0 async with serve(handle_ws, 127.0.0.1, 8081): await asyncio.Future() # 保持进程不退 if __name__ __main__: asyncio.run(main())逻辑说明handle_ws是每个连接的入口收到任何 JSON 先看post_type。meta_event是保活和生命周期上报必须处理否则客户端会认为接入方不健康。message事件里message_type区分私聊和群聊message字段在message_formatarray配置下是一个数组每项包含type和data。这里只取text类型拼成一句话用于演示。回复时发的是一整个动作action是 API 名字params是参数echo是任意字符串客户端执行完会把带同样 echo 的回执发回来方便你配对结果。这里私聊用user_id群聊用group_id两者不能互换是新手最容易传错的地方。3.3 验证链路是否真的通了跑起来之后用另一个 QQ 号给自己的号发一条文本消息。正常顺序应该是这样的日志连接事件客户端连上你的 8081 端口。meta_eventheartbeat 开始定时出现。message 事件你收到私聊文本日志打出private from xxx: 你好。回执客户端返回一个带echoreply-xxx的 JSON里面 status 为 ok。如果第 3 步没出现先检查message_format是否真的改成了 array很多配置改了要重启客户端才生效。如果回执没出现重点看 token 是否一致、进程是否被防火墙挡住。把这一步跑通后面所有东西都建立在这条链路上。我自己的习惯是第一条日志没出现之前先不要钻业务代码问题大概率在连接配置上。提示本地调试时不要同时开两个插件进程抢同一个端口ss -lnt看端口归属比猜快得多。4. 处理文字图片语音视频文件消息段解析与类型分发代码4.1 消息段数组收到的不是字符串OneBot v11 里一条消息体在message_formatarray时是一个 JSON 数组而不是字符串。比如用户发了「你好」加一张图实际事件里的message可能是这样[ {type: text, data: {text: 你好}}, {type: image, data: {file: abc123.jpg, url: https://...}} ]这个设计的价值在于每条消息内容都有明确类型和元数据不用从一段字符串里正则抠出图片路径。但代价是业务代码必须面对多种段类型。常见做法是写一个分发循环按type把消息段丢给不同处理器我下面这个例子就是直接在第四节的请求基础上扩展的。4.2 文字和图片最基本的两种段类型文字段最简单数据里就是text字段。图片段通常有file和urlfile是客户端侧的文件标识url是消息里附带的网络地址。回复图片时你可以填本地路径file:///绝对路径、远程http(s)://、或者base64://前缀的编码。具体支持哪种要看客户端实现踩坑最多的是直接塞网络地址对方客户端访问不到你的地址时图片就加载不出来。所以我的习惯是先下载到本地再用file://发给协议端。async def handle_segment(seg, target, ws): seg_type seg.get(type) data seg.get(data, {}) if seg_type text: # 文本直接取 data.text返回给上层做语义处理 return data.get(text, ) if seg_type image: # 本地路径必须是 file:// 开头否则部分客户端不识别 local_file /tmp/qq_cache/ data.get(file, ).replace(/, _) # 伪代码从 data.url 下载到 local_file失败则直接放弃 reply_msg [{type: image, data: {file: file:// local_file}}] await send_msg(ws, target, reply_msg)逻辑说明data.file可能是文件名也可能是带路径的标识直接拼路径有安全隐患所以我先用 replace 把斜杠替换掉再做下载缓存。回复图片时message里放的是图片段数组如果还想带文字就在数组里加一个 text 段。注意文件后缀要和实际内容一致常见客户端对 content-type 不敏感但后缀错了会显示损坏。4.3 语音和视频编码格式是主要的内容坑语音段的类型名是record视频段是video。接收侧看url或file都能拿到原文件发送侧才是麻烦来源。QQ 对语音的编码有要求常见客户端只认 silk 格式不是随便丢一个 mp3 进去就能发。一般做法是两步转码import subprocess import tempfile def to_silk(src_path: str) - str: # 第一步任意音频统一转成 16kHz 单声道 wav tmp_wav tempfile.mktemp(suffix.wav) subprocess.run( [ffmpeg, -i, src_path, -ar, 16000, -ac, 1, tmp_wav], checkTrue, ) # 第二步wav 转 silk编码器是社区开源实现编译后填实际路径 tmp_silk tempfile.mktemp(suffix.silk) subprocess.run( [/usr/local/bin/silk_encoder, tmp_wav, tmp_silk], checkTrue, ) return tmp_silk参数说明-ar 16000是采样率-ac 1是单声道这两项是多数 QQ 语音兼容的基础缺了容易变成杂音。silk 编码器不是 ffmpeg 自带的要单独编译命令路径写死到绝对位置方便排查问题时知道用的是哪个版本。视频段发送时相对宽容但注意视频文件大小和时长过大容易被客户端拒绝。接收语音后想转回文本就得再走一遍语音识别那是另一套工程不要在协议层纠结。4.4 文件消息下载、校验以及自动解压要慎重文件段的类型是file事件里带file、file_name、file_size等字段。收到文件消息时不管你用不用先按安全方式落盘再做后续处理。标题里那个「自动解」如果是自动解压这里要尤其慎重压缩包解压可能触发路径穿越文件名里带../时会把文件写到任意目录zip 炸弹会把磁盘打满。我一般这样处理import os SAFE_DIR /tmp/qq_files async def handle_file(data): # 只保留文件名最后一段杜绝 ../ 和绝对路径 fname os.path.basename(data.get(file_name, unnamed)) save_path os.path.join(SAFE_DIR, fname) # 伪代码从 data.url 或 data.file 下载内容到 save_path # 自动解压前必须校验文件名和大小上限 if fname.endswith(.zip): total_size 0 # 解压时逐条检查单文件超过阈值就中止 # 解压出来的文件名同样要过 os.path.basename ... return save_path这块的参数重点有两个一是os.path.basename是底线不要直接拿file_name拼接路径二是压缩包解压要限制总大小和单文件数量不要一键全解。QQ 机器人里收文件后自动落库、自动转存是很常见的场景但安全性只能自己兜底。5. 消息收发里的5个高频翻车点现象、原因与排查顺序这一节的坑集中出现在同一个阶段消息能收发、但体验不对。有的是回复失败有的是图片打不开有的是连接反复断每一条我都按现象、原因、解决的顺序写你可以直接对照排查。5.1 机器人收到了消息但回复总发不出去现象日志里已经有 message 事件调用 send 动作后回执里 status 是 failed或者干脆没回执。原因大多数是把user_id或group_id传错了私聊动作用user_id群聊动作用group_id不能按个人习惯混着传。还有一类是 echo 没配对回执回报时你没法确认哪条失败看起来就像没发出去。解决先在日志里打全 params确认 id 字段和事件里一致action 返回的错误码也看下常见是 100 参数错误少数是 101 没登录。如果回执始终不来检查客户端是否把「上报自身消息」关掉了有时你拿小号发消息机器人用同一个号回回执路径和上行路径不是一条容易让人误判。5.2 日志里出现「消息段格式非法」现象拿到消息后直接 json.loads 报错或者处理时发现 message 字段是字符串而不是数组。原因客户端配置里的 message_format 不是 array默认可能是 string而且配置改了但没重启进程。解决把 message_format 固定成 array并在启动插件前确认客户端日志里显示的是 array mode插件侧再做一个兜底如果拿到的是字符串先按 CQ 码文本处理提取其中的 text 直接回车不要再强行 json.loads。还有一种隐蔽情况客户端升级后配置字段被重置了检查一遍配置文件比反复改代码更快。5.3 图片发过去对方显示过期或无法加载现象插件里明明传了图片 url自己也访问过这个地址但 QQ 里就是加载不出来。原因大多是两种一是对方客户端访问不到你的内网地址二是 HTTPS 证书或防盗链拦截。解决不要赌网络地址先把图片下载到 NapCat/Lagrange 所在机器的本地目录用file://传。权限也要注意发文件前确认跑客户端的用户对目录有读权限这问题在 Linux 下用 systemd 托管时很常见进程用户和下载文件用户不是同一个读不了就加载失败。5.4 WebSocket 连上没几分钟就断开然后反复重连现象插件进程打印连接建立过一会又断日志里满屏重连。原因多数是心跳没对客户端侧开着 enable_heartbeat但插件服务端没有及时回应 WS 的 ping/pong个别实现还会因为长时间没有事件就把连接断开。解决把元事件处理逻辑保留heartbeat 出现时不要返回任何数据只 continue另外确认插件进程没有被系统休眠挂起。还有一个常见坑是端口被占用两个插件进程抢同一个 8081这个用ss -lnt看端口归属即可。5.5 语音能发出去但听起来是杂音现象文件和时长都对播放全是刺耳噪音。原因源音频不是 16kHz 单声道或者没有从 mp3/aac 转成 silk 就直接发了。解决先ffmpeg -i input -ar 16000 -ac 1 tmp.wav再用 silk 编码器转一次别用网上的在线转码工具参数不对很玄学。如果转完还是杂音检查你是不是把 wav 文件直接改了后缀当 silk 发编码器没跑通就发出去结果就是这种表现。这条线没做好后面接语音识别什么都白搭。6. 进阶技巧把消息接进业务流之前先做事件回放与幂等6.1 事件回放与幂等去重消息机器人做到能收发只是第一步真正接入业务前我习惯先做两件事事件回放和幂等。事件回放的思路是把本地历史收到的一批真实消息 JSON 保存成文件写一个回放脚本逐条喂给你的消息分发函数对比输出是否符合预期。这样不用反复打扰真实 QQ 号开发和回归都更快。幂等则是指用 message_id 做去重客户端断线重连后事件可能重复推送如果你在回复时带着同一个 echo就可能对同一条消息回复两次。简单做法是维护一个 LRU 集合存最近处理过的 message_id处理前先查一遍。6.2 连接复用与耗时任务分离另一个容易忽略的点是动作请求要复用同一个 WebSocket 连接不要为每次回复新建连接。每次新建连接会多一次握手客户端那边也可能触发频率限制。我一般把它包成一个 send_json 函数统一处理序列化和发送异常。还有就是把「收到文件→转码→回复」这类耗时操作放到队列里避免阻塞后面的消息处理。我自己踩过最深的一个坑是刚接好链路就急着写业务结果客户端升级后事件字段结构变了整个分发逻辑全部失效。后来养成的习惯是每天先看一眼 heartbeat 日志顺手记下当前客户端版本再跑业务。这个习惯帮我省了不少排查时间。希望帮到你。本文还有配套的精品资源点击获取