数据截至 (上游 commit 53ea1e8ba6fd)
一条消息的一生(主线)
本章讲什么: 把「你在 Telegram 里 @ 了一句话」到「助理回你一句话」这条链路,按主机侧的真实代码顺序走一遍。读完你会知道每一步在哪个文件、失败了会怎样。
1. 先看全景:七步链路
怎么读这张图:竖线是时间,左侧是主机进程,右侧是容器;⇢ 表示「写文件后对方轮询发现」,不是同步调用。
[平台] ①收到消息
│
▼
[适配器] ②onInbound → 盖上 instance 戳
│
▼
[router] ③查 messaging_group + 接线数(一次 SQL)
│ ④对每个接线的 agent 独立判断:该不该 engage
▼
[session] ⑤resolveSession → 写 inbound.db 一行
│
▼
[runner] ⑥wakeContainer → docker run
│
⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ ⇢ [容器轮询到,干活,写 outbound.db]
⇣
[delivery] ⑦轮询 outbound.db → 适配器发回平台
2. ①②入口:适配器把消息交出来
适配器是什么
渠道适配器是「某个 IM 平台」到 NanoClaw 的翻译层。接口定义在 src/channels/adapter.ts:188(ChannelAdapter),核心只有四件事:setup / teardown / isConnected / deliver。
主干里一个具体适配器都没有。 Telegram、Slack 这些住在一个长期分支 channels 上,由 /add-telegram 这类技能拷进来。主干只提供注册表和桥接(第 6 章细说)。
主机唯一盖戳的地方
src/index.ts:88 的 initChannelAdapters 回调里,主机给每条入站事件盖上 instance:
// src/index.ts:95 附近
instance: adapter.instance ?? adapter.channelType,
为什么重要: 一个平台可能同时跑多个 bot(比如同一个 Slack workspace 里三个 App)。channelType 是语义上的平台名(slack),instance 是路由用的实例名。适配器自己不需要知道它是哪个实例——主机统一盖戳,注释里管这叫 “the one host-side stamping seam”。
3. ③④路由:决定给谁、要不要理
主函数是 src/router.ts:215 的 routeInbound。它的顺序是刻意排过的,每一步都在为下一步省事。
3.1 先给模块一次「截胡」机会
// src/router.ts:220 附近
for (const intercept of messageInterceptors) {
if (await intercept(event)) return;
}
注册进来的拦截器按注册顺序跑,第一个返回 true 的吃掉这条消息,路由到此为止。用途很具体:审批流程里「请回复一个 agent 名字」这种多步对话,需要把用户下一条自由文本抓走,而不是当普通聊天路由。
3.2 一次 SQL 拿到「群 + 接了几个 agent」
// src/router.ts:241
const found = getMessagingGroupWithAgentCount(
event.channelType, event.platformId, event.instance ?? event.channelType);
合并查询是为了给最常见的情况最短的路径:一个你只是「呆在里面」的群聊,每天几百条闲聊,一次 DB 读就返回,不建行、不解析发送者、不打日志。
没有记录行时,只有在「被 @ 或私聊」(isMention)的情况下才自动建 messaging_groups 行;纯闲聊直接静默丢弃。
3.3 没有接线怎么办:升级给主人
agentCount === 0 且被 @ 了,路由会记一条 dropped_messages 审计行(理由 no_agent_wired),然后 fire-and-forget 调 channelRequestGate——权限模块注册的钩子,负责给主人发一张审批卡「有人在某某群里叫你的 bot,要不要接?」(src/router.ts:299-336)。
这里有个诚实的设计声明: 用户这条消息照样是丢掉的,审批通过后由钩子自己重放 routeInbound。