一个写了六年的机器人

这个项目叫「小可爱」,英文名 SmartCute / smartcute(代码里的目录、变量、注释都用的这个拼法)。它是 QQ 机器人:能被 @,能接话,能查台词,能跨群群发,能挂"智慧果"。

最早的物证是 2019 年 7 月 25 日夜里最后修改的 app.e,最后一份日志停在 2025 年 7 月 5 日。中间六年,我把它写了四代:

代时间形态结局
第 1 代至 2019-07酷Q + 易语言(85 万字节的 app.e,自研 838 行)跑得最久、功能最全;更往后的代码没进 git,全丢
第 2 代2022-09 ~ 2023-04MCL(mirai 加载器)+ 31 行 Python 脚本登录报错,弃
第 3 代2023-01 ~ 2024-04一万行 Java 核心 + Kotlin 应用端 + Python 频道端2024-04-06 最后一次运行
岔路2023-07-16 一晚NoneBot 脚手架写了 31 行,没跑
第 4 代2025-06 ~ 2025-07Spring Boot 3.5 + Kotlin + OneBot 12卡在 WebSocket 子协议
平行线2025-07-05NoneBot + 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应用.txt574全部事件入口 + 谷歌翻译
辅助子程序.txt207台词重载、模板转义、翻译请求
窗口程序集_窗口1.txt57监控窗口

功能清单,全部从源码里读出来的:

台词系统——整个家族的血统起点。 三种触发模式分三个目录:引用台词(要 @ 它)、引用台词_全(任何消息都过一遍)、引用台词_无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. 任何 @ 它又没命中任何命令的消息,直接丢给谷歌翻译,把结果原样发回群里。所谓接话,就是免费翻译。
  2. 每条 @ 它或带 # 的消息,都会私聊转发一份给作者本人:「用户×××在群聊×××发出了以下信息」。

周边一应俱全:入群欢迎、管理员变动祝贺(「恭喜×××成为新管理!<群地位-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。