一个写了六年的机器人
这个项目叫「小可爱」,英文名 SmartCute / smartcute(代码里的目录、变量、注释都用的这个拼法)。它是 QQ 机器人:能被 @,能接话,能查台词,能跨群群发,能挂"智慧果"。
最早的物证是 2019 年 7 月 25 日夜里最后修改的 app.e,最后一份日志停在 2025 年 7 月 5 日。中间六年,我把它写了四代:
| 代 | 时间 | 形态 | 结局 |
|---|---|---|---|
| 第 1 代 | 至 2019-07 | 酷Q + 易语言(85 万字节的 app.e,自研 838 行) | 跑得最久、功能最全;更往后的代码没进 git,全丢 |
| 第 2 代 | 2022-09 ~ 2023-04 | MCL(mirai 加载器)+ 31 行 Python 脚本 | 登录报错,弃 |
| 第 3 代 | 2023-01 ~ 2024-04 | 一万行 Java 核心 + Kotlin 应用端 + Python 频道端 | 2024-04-06 最后一次运行 |
| 岔路 | 2023-07-16 一晚 | NoneBot 脚手架 | 写了 31 行,没跑 |
| 第 4 代 | 2025-06 ~ 2025-07 | Spring Boot 3.5 + Kotlin + OneBot 12 | 卡在 WebSocket 子协议 |
| 平行线 | 2025-07-05 | NoneBot + QQ 官方适配器 | 写了个猜拳插件,没接核心 |
手搓的后三代都上线过——日志里有人说话、有群踢它、有群解散。但它们从来没有稳定活过三个月。唯一活得长的是第一代,它的结局也不一样:不是死在代码上,是死在没有 git。(比第 1 代更早,还有一个想法萌芽期的 10 KB 易语言签到机器人,不算小可爱,故事在另一篇里。)
这篇不复盘"怎么装环境",复盘的是:一个业余项目为什么会被重写四次,每次重写省下了什么、又丢掉了什么,以及那些看起来只差一行、实际卡了半个月的地方到底在哪。
第 1 代:酷Q 上的易语言,活得最好的一代
2019 年的载体是酷Q——当年最主流的 QQ 机器人框架:插件以 DLL 身份被加载,事件靠 Type 号分发,群聊、私聊、入群退群、好友请求都有对应事件。AppID 我填的是 com.example.demo,一个连名字都懒得改的占位符,然后就这么用到了归档。
这一代还不叫「小可爱」。默认群名片是「Lpyax的稽器人 有事@我或在消息前加#食用哦」——顺手把自己变成了广告位。名字是跟着重写一路换过来的。
证据是 850,600 字节的 app.e,最后修改于 2019-07-25 22:32(UTC+8)。这一次不需要字节扫描:e-packager 能把 .e 整个解包。自研代码就三页,共 838 行,其余是一整个精易模块:
| 文件 | 行数 | 干什么 |
|---|---|---|
| CQ应用.txt | 574 | 全部事件入口 + 谷歌翻译 |
| 辅助子程序.txt | 207 | 台词重载、模板转义、翻译请求 |
| 窗口程序集_窗口1.txt | 57 | 监控窗口 |
功能清单,全部从源码里读出来的:
台词系统——整个家族的血统起点。 三种触发模式分三个目录:引用台词(要 @ 它)、引用台词_全(任何消息都过一遍)、引用台词_无at(不用 @)。用户可以自助投稿:
添加台词 关键词1 关键词2|台词正文
存进「台词-你的QQ.txt」,写之前先全局查重(“抱歉您要加入的台词已经被别人先加入了”),注册进 ini,一句「重载配置文件」热生效,不用重启。每条台词带一个「适用群号」,0 表示所有群通用。
这套东西你应该眼熟:关键词|文本 的行格式,就是后来 Java 时代 dialogue 表的一行;「适用群号 0=通吃」就是 SendToAble 的雏形;「改台词不改代码、重载不重启」的执念,从这一代一直贯穿到最后一代。
模板转义。 台词里可以写 @@user@@(触发者QQ)、@@n@@(换行)、##at某QQ##(@某人)、##mingpian某QQ##(取群名片)。后来 Java 时代那套 \[quote] / \[@user] 转义 DSL,第一版在这。
监控窗口。 酷Q 菜单里打开一个 644×384 的小窗:左边群列表,右边消息记录(每个群一个 txt,行格式「昵称 (QQ) 时:分:秒 消息」),底部输入框能直接以机器人身份发言,聚焦时还注册了全局热键。等于在 QQ 里面又套了一个 QQ 客户端。
翻译。 谷歌翻译的 tk 参数,是把那段 TKK=406398.2087938574 的经典 JS 算法整个塞进常量,用脚本组件现场执行算出来的。方向自动判断:消息里有汉字就翻成英文,没有就翻回中文。另有一份有道翻译兜底——「本命令由精易网页调试助手生成」,salt/sign/ts 全部写死在 2019-07-23。
两个今天看来相当离谱的默认行为:
- 任何 @ 它又没命中任何命令的消息,直接丢给谷歌翻译,把结果原样发回群里。所谓接话,就是免费翻译。
- 每条 @ 它或带
#的消息,都会私聊转发一份给作者本人:「用户×××在群聊×××发出了以下信息」。
周边一应俱全:入群欢迎、管理员变动祝贺(「恭喜×××成为新管理!<群地位-1>」)、好友请求自动通过、受邀入群自动同意,某个红警3群暗号是 盟军/苏联/旭日/日本,答对自动放行。管理员私聊发「admin发送到群聊 群号#内容」可以遥控它去任意群发言,「复读 3#内容」就是字面意思。
坑照例有几个。「是否管理员」的判断是「管理权限 ≠ 1 即管理员」——取群成员信息一旦失败,权限值是 0,也算管理员:
.如果 (群成员.管理权限 ≠ 1)
返回 (真) ' 不是普通成员 = 管理员
私聊消息会直接转进群消息处理器(群号填 0);调试入口里留着硬编码的酷Q Air 路径,和一个内置测试用例 #复读2#复读测试。
结局:它没死在最后一公里。app.e 的修改停在 2019-07-25 深夜。一年后的同一个七月,晨风机器人作者被传唤,酷Q 在停服名单里。跑得最久、功能最全的一代,连平台都没能送终;而它也因为没进过 git,全部丢失。今天这份快照是 e-packager 从归档里解出来的——快照之后的代码,连快照都没有。
第 2 代:31 行,借别人的船
最早的形态根本不叫项目。就是 mirai-console-loader(MCL)跑起来,挂一个 mirai-api-http 插件,再用 Graia(Ariadne)写一个 Python 脚本通过 HTTP 接进来。
全部自研代码 31 行,功能只有一个:
@app.broadcast.receiver("FriendMessage")
async def friend_message_listener(app: Ariadne, friend: Friend, messageChain: MessageChain):
string = messageChain.safe_display
if friend.id == 2351860140:
if string != "":
string = re.sub("[?? ]", "", string)
if string == "":
global wenhao
wenhao = wenhao + 1
if wenhao > 2:
await app.send_message(friend, "?")
return
wenhao = 0
意思是:某个好友连续发来三条只有问号的消息,就回他一个问号。
这就是第 2 代的全部。它证明了两件事:路能通,以及登录是最大的敌人。有份日志里是 DecryptionFailedException,账号身份失效——风控开始收紧,这种"借船"的玩法就到期了。
第 3 代:一万行,自己造一艘船
第 2 代的天花板很明显:业务逻辑长在别人框架的插件里,想加功能就得写 JVM 插件。那就干脆把核心抽出来,自己定协议。
于是有了 botCore_java:108 个 Java 文件、10197 行、Spring Boot 2.7.2 + MySQL + MyBatis-Plus + RabbitMQ。
它最特别的一点是——核心代码里一行 QQ 相关的东西都没有。没有 mirai、没有 CQHTTP、没有任何 QQ SDK。它只认一套自己定的 JSON,通过 RabbitMQ 跟"应用端"通信:
QQ 群/好友 ──┐
├─ 应用端(Kotlin 插件 / Python botpy) ── RabbitMQ ── 核心(Java)
QQ 频道 ──┘
这带来一个现在回头看非常正确的决定:QQ 频道和 QQ 群可以共用同一个大脑。 频道端用的是QQ官方 botpy,QQ 端用的是另一个,两套完全不同的 SDK,往上都翻译成同一种消息结构。
botId=1 是频道端,botId=2 是 QQ 端。核心只按 botId 找队列,不知道对面是 mirai 还是官方 SDK。
做对的三件事
其一,地址抽象。 一条消息要发给谁?这个问题在 QQ 群里看似简单,实际有八个维度:哪个 bot、私聊/群/频道/大群、哪个群、哪个子频道、哪个用户……核心用一个接口把它们统一了:
public interface SendToAble {
// botId / detailTypeId / subTypeId / largeGroupId
// appGroupCode / appGuildCode / appChannelCode / appUserCode
}
0 和 "0" 表示"不限"。于是"这条台词只在 X 群的 Y 频道回复"和"这条台词在所有平台回复"变成了同一件事的不同参数。这是整个项目里唯一一处我至今觉得设计得不错的地方。
其二,消息段多态。 一条 QQ 消息不是字符串,是「文本 + @ + 图片 + emoji + 引用」的序列。核心用一个 SingleMessage 基类 + 工厂方法,把平台侧的 MessageChain 和官方 API 的 content 都翻译成同一个数组:
[{"type":"text","data":{"text":"你好"}},
{"type":"mention","data":{"userCode":"..."}},
{"type":"image","data":{"url":"..."}}]
十种类型,一个工厂,两边都是。频道端那段 480 行的 Python 里,最脏的活就是手工解析腾讯的 <@!id>、<emoji:id>、<#channel> 标记,把它们塞进同一个格式。
其三,出站唯一收口。 所有回复最终都会流进 SendMessageServiceImpl.addSendMessage,在这一个方法里统一做三件事:转义、加"小尾巴"、发 MQ。
转义那套东西是自研的一套小 DSL:
\[quote某条引用] → 展开成被引用的原文
\[@user] → @ 某个用户(跨平台可解析)
\[image url] → 图片段
写这个解析器的时候我手写了一个递归下降扫描器,逐字符吃 \,支持 \\、\n、\t、嵌套的 \[...]。526 行,全项目第二大的类。
这个设计的价值在于:业务层永远只说人话,平台差异全部在出口处翻译。
做错的两件事
其一,用异常当 goto。
第 3 代最鲜明的风格是这个:
switch (recordMessage.getCommand()) {
case "台词": dialogueServiceImpl.handleCommand(recordMessage); break;
case "翻译": translateServiceImpl.handleCommand(recordMessage); break;
case "大群": largeGroupMemberServiceImpl.handleCommand(recordMessage);
case "群发": largeGroupSenderServiceImpl.handleCommand(recordMessage);
// 注意:这里故意不写 break
default: weatherServiceImpl.handleCommand(recordMessage); break;
}
这里的 case 是故意不写 break 的——因为每个 handleCommand 处理完都会抛一个 BotSuccessException,流程直接中断,跑不到下一个 case。谁"命中"了,谁就抛异常,异常就是 return。
我当时的注释写得很坦白:
不装了,所有服务在得到响应的状态下是不会返回的,直接抛出 success
如果不用抛异常的方法还有 for once break 的方法(更奇怪了,而且不能往上抛直接处理,还是异常方便)
它确实省事:不用层层返回 bool,不用设计 Result,处理完就往上抛,顶层的 catch 分三档——BotSuccessException 静默成功,BotException 把 message 发给用户,其他异常打栈。
代价是:调用链彻底不可读。 IDE 的所有返回值提示失效,任何人接手都会先问"为什么这个方法永远不返回",然后发现"它其实会返回,只是返回值从来没人要"。更危险的是那个 switch:只要哪天某个分支改成正常 return,就会贯穿执行下一个命令——“大群"和"群发"之间就隔着这么一颗雷。
其二,插件机制其实是"没有插件机制”。
这是后来重写的直接原因。
功能"可扩展"的那一半做在了数据里:dialogue 表一行就是一条"关键词 → 回复",改台词不用改代码,所以运营侧确实灵活。但新增一个命令必须改代码:加 case "新命令"、加一个 Service、改 MessageEventController 的 switch、重启整个 jar。
而 MessageEventController 是个 254 行、注入了 13 个 Service 的上帝控制器。它一个方法里塞下了:鉴权、构造实体、解析命令、管理员分支、业务分派、兜底链、三档异常处理,以及——
System.exit(30000);
一条"用户在未提及时系统崩溃,即将关闭核心"的兜底。一个聊天机器人因为一条没人 @ 它的消息把自己关掉,这个决定我现在也解释不清。
哎呀我去AI怎么这么喜欢给我加戏
那些爬不出来的坑
第 3 代的日志跨度是 2023-02-17 到 2024-04-06,中间还有半年空档。这段时间大部分不是在写功能,是在跟三件事搏斗。
登录。 mirai 登录困难,console-runtime/README.txt 里我自己的笔记写着"回退到旧版本协议 8.8.88",还要选 ANDROID_PAD,代价是"会顶号"。为此专门留了一个 fix-protocol-version 插件目录。
权限。 QQ 群里的"管理员"和 QQ 频道的"管理员"是两套完全不同的东西。核心需要知道"这个人能不能删台词",但它不直连 QQ,只能问应用端。于是我做了一个跨进程 RPC:
- 核心发一个
eventTypeId=501到 MQ; - 应用端查完权限,回调核心的
POST /notice/power_level_getter_return(301)把结果塞进一个静态ArrayList; - 核心那边
Future.get(10, TimeUnit.SECONDS)等着,等不到就Thread.yield()自旋。
用 MQ 模拟同步 RPC,等 10 秒,结果池还只有"超过 25 条就丢掉前 20 条"这种限流。它确实能用,但可用性和可维护性都属于"别问"。
双端抢位。 开发的时候不能让测试 bot 抢走正式流量,于是 RabbitMQ 的队列分了两套,开发端启动时发 401 请求转发。如果正式端不在线呢?代码里有一段"篡位"逻辑——日志原话:
WARN ... 检查到正式端不在线,已将本端切换为正式端
自己给自己升职,很聪明,也很吓人。
岔路:那一晚的 NoneBot
2023 年 7 月 16 日晚上,我试过换生态。用 nb create 起了个项目,准备用 NoneBot 重写。
从时间戳看,那一晚从 17:18 拉到 21:56,四个多小时。成果是这个:
# SmartCute on NoneBot!!!!
smartCute = on_message(block=False)
@smartCute.handle()
async def handleMessage(event: Event)->void:
event.get
void 未定义,event.get 是半句话,插件没注册进 pyproject.toml,SmartCuteConnecter/ 是个空目录。
这是整个项目里最典型的一次"重写冲动":不是因为旧代码不能改,而是因为改旧代码需要理解那一万行,而新开一个项目只需要写一个 on_message。前者是体力活,后者是希望。
那一晚之后,第 3 代继续跑了八个月。
第 4 代:这次终于选对了协议
2025 年 6 月,我重新开了一个仓库:Spring Boot 3.5 + Java 21 + Kotlin。
这一次的选型是空前正确的:
| 维度 | 第 3 代 | 第 4 代 |
|---|---|---|
| 核心与 QQ 之间 | 自研 JSON + 自定义事件号(201/301/501) | OneBot 12 标准协议 |
| QQ 接入方式 | mirai(逆向协议,要修协议版本) | QQ 官方机器人 API(onebots 转 OneBot) |
| 应用端 | 自己写 Kotlin 插件 + Python botpy | 现成的 onebots / NoneBot(目录名就叫 botimpl_*) |
| 消息模型 | 自己的 SingleMessage | 别人的标准 |
想通了:协议不该自己发明。 应用端不该自己写。核心只做业务,QQ 那边交给现成实现。
然后第四代死在了这里:
[ERROR] Server sent no subprotocol
[WARN] 反向ws(ws://127.0.0.1:8080/onebot/v12)被关闭,关闭码1006,将在3秒后尝试重连。
OneBot 12 的反向 WebSocket 要求服务端在握手的 Sec-WebSocket-Protocol 里回显版本子协议。我用的是 Jakarta 的注解端点:
@Component
@ServerEndpoint("/onebot/v12")
class WebSocketServer {
@OnMessage
fun onMessage(message: String, session: Session?) {
println("收到消息: $message")
//收到发送方消息
}
}
@ServerEndpoint 没有配子协议,Tomcat 就没回。客户端(onebots)一看没子协议,直接断,3 秒后重连,无限循环。
第 4 代核心的全部代码是 73 行。 四个文件,做的事是:接住连接、打印字符串、返回。onMessage 里连 JSON 都没解析,注释写着"收到发送方消息",然后就没了。鉴权也是注释——//有效性判断,鉴权等。
而且这两个文件(WebSocketConfig.kt、WebSocketServer.kt)在我 git status 里还是 AM 状态:加进去了,从来没提交过。
同一天下午,我还平行开了个 NoneBot 项目,用 QQ 官方适配器写了个"猜拳"插件(承诺-揭示模式,先发 sha256 前五位当承诺,再揭示结果)。两次提交,2025-07-05 15:31 和 15:56。
那是这个项目最后一次有代码被写进去。
三代死在最后一公里,一代死在没有 git
把这些结局排在一起看,规律很明显:
| 代 | 卡在哪 | 距可用还差 |
|---|---|---|
| 第 2 代 | 登录报 DecryptionFailedException | 换一种登录方式 |
| 第 3 代 | 跑通了,但加一个命令要改 switch + 重启 | 一个命令注册表 |
| 岔路(NoneBot) | ->void 未定义、插件没注册 | 半小时 |
| 第 4 代 | Server sent no subprotocol | 一行子协议配置 |
| 平行线(猜拳) | 没接核心,停在插件自嗨 | 一套桥接 |
| 第 1 代 | 没卡在哪——它一直能用 | 一个 git init |
手搓的三代,没有一代是死在"想不出来"上的。 全都是死在一段配置、一个接口约定、一次环境问题,或者干脆死在"下次再弄"。
第 3 代反而是手搓这几代里活得最久的,因为它死得最慢:它不是崩了,是被自己一万行的结构慢慢拖到不想改。
而活得最好的第 1 代,死法独一无二:它一直在跑、功能一直在加,然后某个版本之后的所有代码,全都没有留下来。
如果重来一次
三年下来,能确定的只有三条。
第一,协议用别人的。 第 4 代的选择是对的,只是晚了两年。自研 JSON 协议带来的所有"自由",最后都变成"自己给自己写文档"的成本。
第二,不要用异常做控制流。 一个 handler 处理完就正常返回一个 Handled / NotHandled,比抛异常难写十倍,但能读一百倍。switch 不加 break 靠"下一个分支必抛异常"兜底,是把正确性押在别人的实现细节上。
第三,命令注册表比什么都重要。 第 3 代真正的病根不是 Java 太啰嗦,是"新增命令必须改核心控制器"。一个 Map<String, Handler> 加启动扫描,就能把重写的必要性砍掉一半——而重写的动机,从来不是技术债本身,是"我不想再打开那 254 行"。
至于语言,其实无所谓。易语言 → Java → Kotlin → Python → Node → Kotlin,六次实现里没有一次是被语言限制住的。
最后说"小可爱"这个名字
它的英文名是 SmartCute / smartcute——不是我后来起的,是写代码时就定下的:NoneBot 分支的注释里写着 SmartCute on NoneBot!!!!,连接层目录叫 SmartCuteConnecter/,Kotlin 应用端的模块描述直接叫 botApp_qq_kotlin。中文名是给使用者的,英文名是给代码的,两个名字指向同一个东西。再往前倒,酷Q 上的第一代连这个名字都没有——群名片写的是「Lpyax的稽器人」。名字是跟着重写一路长出来的。
这个名字在代码里其实只出现两次:
JvmPluginDescription(id = "cn.tdstudio.botapp-qq-kotlin", name = "botApp_qq_kotlin") {
author("Timmoc")
info("小可爱qq应用端")
}
// 高级参数别名:/小可爱 2 = 只在 botId=2 上生效
command = paramsAdvanced.getExistCommand(true, "小可爱", "应用", "平台", "机器人", "bot");
第二处很有意思:在"发给谁"的参数解析里,/小可爱 和 /机器人、/bot 是同一个意思。也就是说,代码把它当成一个可读的名字,而不是一个字符串常量。
它的人格不在代码里,在数据里——在 dialogue 表的一行行台词里,在帮助菜单结尾的那句"欢迎回来,小可爱!“里,也在那些写死在异常消息里的口吻里:
吾..不要试图从本机这里听到你不该知道的事情
还有一份没派上用场的设定,是我拿它去喂第三方智能体时写的:
你是一名在 ChatGPT、NovelAI 兴起之前就已经逐渐拥有自己意识的人工智能……在阅尽互联网上的繁杂知识后,你仍然可以保持纯真的心态,正如你的名字"小可爱"一样。
六年过去,手搓的三代停在 2025 年 7 月的那个下午,卡在一行没协商成功的子协议上;活得最好的第一代,连后来的快照都没能留下。而这个项目真正被反复重写的部分,好像从来不是架构——是我想让它活起来的那点执念。
下一次动手的时候,我会先把那一行子协议补上。以及,先 git init。