群里那句“能不能再查一下”,最后长成了 chunmao

chunmao 是一个运行在群聊消息链路上的机器人服务。它最初大概属于那种“这个小功能顺手加一下”的项目,后来小功能们互相拉帮结派,欢迎语、问答、复读、周报、角色查询、车车流程和图片生成全都住了进来。于是问题变成了:怎样让它继续加功能,却不把整个机器人变成一条巨大的 if...else?

我最后把它拆成接入、分发、数据模型、定时任务和外部服务几个层次。它不算庞大的平台,但足够完整地展示一个群聊机器人最容易踩到的坑:重复事件、平台差异、异步回调、半截失败,还有“文字能说清,图片更适合发出来”的产品取舍。(:з」∠)

一条消息的旅程

服务用 Express 提供 /notify 接口,接收 OneBot 风格的事件。请求进来后,消息服务先确认事件是不是自己发出的,再查黑名单、消息类型和是否已经处理过,全部过关才进入命令匹配和业务逻辑。

1
2
3
4
5
6
7
8
9
10
OneBot 事件
↓ POST /notify
Express 路由 / MessageService
├─ 事件去重、机器人自身过滤、黑名单
├─ @ / 名称 / 命令前缀判断
├─ Sequelize / SQLite 查询与更新
├─ 外部接口或 Puppeteer 图片渲染
└─ 定时统计与业务状态
↓
CQ HTTP API → send_group_msg → 群聊

最后的发送走 CQ HTTP API。业务层不需要直接操作 QQ 客户端,只管构造文本、@、图片这些消息段;调用 send_group_msg 是入口层的事。这层抽象看着薄薄的,却让命令逻辑不会被某个具体 QQ 客户端的调用方式绑死。

群消息不是普通的 HTTP 请求

写惯了 HTTP 接口很容易产生一种错觉:请求来了,函数执行完,事情就结束了。群聊环境完全不是这样。上游可能重试同一个事件,机器人可能收到自己刚发出的消息,群友可能同时触发多个耗时操作,某个外部 API 也可能在图片生成到一半时超时。

MessageService.handle 会维护最近处理过的消息 ID,最多保留约 1024 条,用来挡住同一事件的重复消费。它还会根据 post_type 和消息子类型判断这是普通群消息、入群通知、发送回执还是别的什么,不会把所有事件都当成“有人说了一句话”。

过滤顺序也很有讲究:先做不需要数据库的便宜判断,再做命令匹配,之后才碰数据库查询和外部请求,Puppeteer 这种成本最高的操作放在最后。这样至少不会因为一条机器人自己的回执,启动一整个浏览器进程。机器人开发里很少有真正的魔法,更多是把昂贵的事情尽可能往后放。

触发方式:既要明确,也要允许一点松弛

命令通过机器人名称或 @ 触发,进入明确的命令分支;部分问答允许不加前缀,直接查询 MsgNoPrefix。问答匹配也不是只做完全相等,还有包含匹配和 Levenshtein 距离判断——总不能要求群友每次都精准复刻数据库里的原句。

这种“确定入口 + 有限模糊”的组合比较适合群聊:会改数据或触发复杂动作的命令需要明显的触发方式,轻量问答可以稍微宽松一点。但模糊匹配的范围不能无限放大,否则机器人会变成一个过于热心、到处插话的群成员——而且还是无法禁言的那种。

当前命令包括欢迎消息、角色和联盟查询、绑定信息、问答学习、星力查询、复读统计、复读周报,以及“叫车、发车、到站”等群内流程。不同命令背后连的模型和外部服务各不相同,统一入口的价值就在于让它们共享过滤、日志和错误处理,而不是共享一锅粥。

SQLite 和 Sequelize:小机器人也需要数据边界

项目用 Sequelize、sequelize-typescript 和 SQLite。模型覆盖消息问答、无前缀问答、复读消息、复读用户、排行信息和车队状态等数据;migrations/ 记录表结构变化和唯一约束,省得每次升级都手动改数据库。

复读功能最能说明“状态”和“事件”的区别。它不是看到同一段文字就加一:服务要按群保存消息序列,还要确认是不是不同成员连续重复;同一个人连发的刷屏,和多人接龙是两回事。task.service 再按 ISO 周统计最长复读链和成员排行,生成周报。

数据落库之后,Node 进程重启也不会把历史清空。对一个看似玩具的群机器人来说,这已经很重要了:今天的复读榜、上周的问答和当前车队状态,都不应该因为进程更新就凭空失忆。

文字说不清的时候,让 Puppeteer 出场

角色排行、联盟信息和周报如果全塞在一条长消息里,群里很快就会变成一堵字墙。draw.service 用 Puppeteer 打开 HTML 模板并截图,把表格、排行、时间和装饰统一排版成图片,再由 message.service 组装成 CQ 图片消息。

这里的关键是把数据和视觉分开:业务服务负责查“谁排第几”,模板负责“第几名应该长什么样”。以后要改颜色、加字段或适配手机宽度,不用回头改命令解析器。代价也很明确:启动浏览器比拼一段字符串重得多,页面渲染失败时还得记录临时文件和错误上下文,所以这类能力应该留给真正需要它的命令。

当 OneBot 不再是唯一入口

QQ 侧的接入方式变了之后,我又写了 chunmao-ll,作为 LiteLoaderQQNT / Electron 适配层。它在 Electron 主进程中 Hook QQNT 消息事件,把 QQNT 的文本、@ 和图片元素转换回机器人熟悉的 CQ 风格;机器人发送消息时,再把 CQ 消息转换回 QQNT 的消息元素。

两个服务通过本地 HTTP 保持边界:插件监听 5700 端口,原机器人继续在 6624 端口接收通知。这样平台变化被压缩到适配器里,chunmao 的问答、复读和统计逻辑不用跟着 QQNT 的内部事件结构一起重写。

适配器要处理的细节比“把文本转发一下”多得多。图片要通过 QQNT 文件 API 获取类型、MD5 和目标路径;@ 消息要结合群成员缓存映射账号信息;异步 ntCall 的返回值要通过 UUID 放进待处理 IPC 回调表,再交还给发起请求的逻辑。插件还提供 /send_group_msg 和 /get_group_member_list 等本地 Express 接口,继续模拟原有的 CQ HTTP 边界。

如果我要再加一个命令,会先经过什么

一个新命令通常要走这条小流程:先确定触发方式,再定义输入和权限;然后决定要不要新模型或迁移;接着把业务逻辑放进服务层,最后把返回结果转换成文本、@ 或图片消息。听起来只是“加一个分支”,实际上还要考虑重复事件、数据库失败、外部 API 超时和消息发送失败。

我会尽量让命令处理函数保持短一些,把查询、统计和图片渲染拆到各自的 service。入口层只做识别和编排,模型层只关心数据,cq.service 只负责和发送接口说话。这样新增命令时,修改范围能被限制在几个清楚的文件里,不会因为一个周报功能改坏欢迎消息。

命令的返回值也最好先形成一个内部消息结构,最后一步才转成 CQ 段。纯文本、@、图片和多段消息的差异就不会散落在每个命令里;以后换接入适配器时,只需要重写转换层。

去重不是“存一个 ID”这么简单

消息 ID 缓存解决的是短时间重复投递,但它替代不了业务幂等。比如一条学习问答可能已经写入数据库,却在发送回复前进程崩了;上游重试后,第二次请求既要避免再次插入同一条学习记录,又要决定要不要重新发回复。

所以处理过程最好分成“收到事件、完成业务写入、生成回复、发送回复”几个阶段来记录。内存里的最近 ID 适合挡普通重复事件,数据库的唯一约束和业务状态负责更长生命周期的重复。机器人越往后加功能,越需要分清这两层,不然一次网络抖动就可能变成两条记录、两张图片和两份周报。

日志和错误:失败也要能被看见

Webhook 处理经过 Express 错误中间件,异常写入 Winston 日志并返回 500,让上游知道这次没处理完。这个返回码很重要:如果渲染失败却返回成功,上游可能不重试,群里只会安静地少一条结果;如果所有错误都被吞掉,排查时就只能问“刚才是不是没反应”。

当然,返回 500 也不是万能的。一条消息有没有写入数据库、图片有没有落盘、发送 API 成没成功,都要在日志和状态设计里说清楚。异步机器人真正麻烦的往往不是抛异常,而是异常发生在“已经做了一半”的时候。

我为什么还觉得这个项目有意思

chunmao 不是一个宏大的 AI 平台,它更像一个群聊生活的缩影:需求来自一句随口的“能不能查一下”,最后却会碰到数据库迁移、幂等、模糊匹配、浏览器渲染、平台适配和异步协议。把这些小问题一个个收好,机器人才从“能回复”变成“可以长期运行”。

如果以后再加功能,我最希望保持的不是命令数量,而是每个功能都能说清楚自己的输入、状态、输出和失败方式。至于群里会不会因此更热闹——这个就交给群友决定啦 (`・ω・´)

平台适配真正隔离的是什么

chunmao-ll 的价值不只是“把 QQNT 消息转发到 5700 端口”。它把平台内部事件、消息元素、文件路径和 IPC 回调,翻译成机器人已经理解的 HTTP / CQ 语义。换平台时变的是适配器,“复读统计按周计算”这类业务规则纹丝不动。

这个边界也让故障好定位得多:/notify 根本没收到请求?先看插件事件 Hook 和本地 HTTP。请求到了但没有回复?再看 MessageService、数据库或外部渲染。回复创建成功却发不出去?最后检查 CQ API 和 QQNT 的发送转换。把问题按边界分层,群里那句“机器人又没反应了”才有机会变成可执行的排查步骤。

项目地址:github.com/Acceyuriko/chunmao,QQNT 适配插件见 github.com/Acceyuriko/chunmao-ll。

文章目录
  1. 1. 一条消息的旅程
  2. 2. 群消息不是普通的 HTTP 请求
  3. 3. 触发方式:既要明确,也要允许一点松弛
  4. 4. SQLite 和 Sequelize:小机器人也需要数据边界
  5. 5. 文字说不清的时候,让 Puppeteer 出场
  6. 6. 当 OneBot 不再是唯一入口
  7. 7. 如果我要再加一个命令,会先经过什么
  8. 8. 去重不是“存一个 ID”这么简单
  9. 9. 日志和错误:失败也要能被看见
  10. 10. 我为什么还觉得这个项目有意思
  11. 11. 平台适配真正隔离的是什么
|