KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 渠道与路由:把 20+ 消息渠道收敛成统一事件 — keel 龙骨
渠道是 OpenClaw 最外显的那一层:extensions/AGENTS.md 把整个 extensions/ 目录定义成 bundled plugin 的地盘,而其中一大批插件的 package.json 里挂着 openclaw.channel 元数据。已核对含该元数据的包括 whatsapp、telegram、slack、discord、matrix、mattermost、msteams、feishu、googlechat、i
渠道是 OpenClaw 最外显的那一层:extensions/AGENTS.md 把整个 extensions/ 目录定义成 bundled plugin 的地盘,而其中一大批插件的 package.json 里挂着 openclaw.channel 元数据。已核对含该元数据的包括 whatsapp、telegram、slack、discord、matrix、mattermost、msteams、feishu、googlechat、imessage、irc、line、sms、twitch、nostr、tlon、zalo、zalouser、synology-chat、nextcloud-talk、clickclack、buzz;另有 meeting 类的 google-meet / teams-meetings / zoom-meetings,以及 webhooks 与 qa-channel。
渠道多、协议杂、能力差异大,但只有一条主线:所有入站消息都必须被折叠成同一件事——一条带路由身份、带发送者归因、带时间戳的 inbound envelope。这一章回答:
ChannelPlugin这个契约由哪些适配器拼出来;- 渠道作者写代码时最小要实现什么(message adapter 与 ack 策略);
- envelope 归一化在两处(
channels/inbound-event与auto-reply)各做什么; - 路由如何把「渠道 + 账号 + peer」映射成 agent 与 session key;
- 为什么多用户是归因而不是隔离。
一、契约:ChannelPlugin 是适配器的组合
契约类型在 src/channels/plugins/types.plugin.ts:ChannelPlugin(第 60 行):
export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = { ... }
三个类型参数对应三种探测能力:解析出的账号、健康探测结果、安全审计结果。第 80 行还有一个可选的 setupWizard(ChannelPluginSetupWizard,第 49 行)。
真正定义「一个渠道要提供什么」的是 src/channels/plugins/types.adapters.ts,它是若干小适配器的拼装点。已核对到的适配器类型包括:ChannelConfigAdapter(第 81 行)、ChannelSecretsAdapter(第 131 行)、ChannelGroupAdapter(第 145 行)、ChannelStatusAdapter(第 149 行)、ChannelGatewayAdapter(第 311 行)、ChannelAuthAdapter(第 330 行)、ChannelHeartbeatAdapter(第 340 行)、ChannelDirectoryAdapter(第 384 行)、ChannelResolverAdapter(第 405 行)、ChannelElevatedAdapter(第 415 行)、ChannelCommandAdapter(第 422 行),以及从别处重导出的 ChannelSetupAdapter(第 39 行)与 ChannelPairingAdapter(第 48 行)。
这个拆法的意图很直接:能力可选、但形状统一。一个只读通知渠道可以只实现 config + outbound;支持群组管理的渠道再补 ChannelGroupAdapter 与 ChannelDirectoryAdapter;需要配对的上补 ChannelPairingAdapter。核心侧只按适配器是否存在决定走哪条路径,不需要为每个渠道写 if channel === "x"。
二、消息适配器与 ack 策略
发消息这件的统一入口是 src/channels/message/adapter.ts:defineChannelMessageAdapter()(第 25 行)。它的默认值写在文件头部:
defaultAckPolicy: "manual", // 第 13 行
supportedAckPolicies: ["manual"], // 第 14 行
注释解释了为什么默认是 manual:「Supplies manual receive acknowledgement defaults while preserving adapter-specific types.」——收到消息不会自动回一个「已收到」,ack 由渠道实现自己决定何时发。这是个重要的产品选择:多数聊天渠道里,机器人自动回「收到」是噪音;把它设成默认开启会逼每个渠道作者去关掉它,反过来设成默认关闭则只逼需要的渠道去打开。
三、bundled 渠道怎么被加载
src/channels/plugins/bundled.ts 是 bundled 渠道的装载点,导出 listBundledChannelPlugins()(第 589 行)、getBundledChannelPlugin(id)(第 612 行)、listBundledChannelPluginIds()(第 410 行)、listBundledChannelSetupPlugins()(第 597 行)、getBundledChannelAccountInspector()(第 605 行)、getBundledChannelSecrets(id)(第 617 行)。运行时绑定用 setBundledChannelRuntime(id, runtime)(第 638 行)——渠道插件拿到的是 host 注入的 runtime,而不是自己 import 核心,这正好对上 extensions/AGENTS.md 的边界要求。ID 清单与目录检索分别在 src/channels/plugins/bundled-ids.ts 与 catalog.ts。
四、envelope 归一化:两处协作
归一化分两段,位置不同、职责不同。
第一段在 src/channels/inbound-event/。核心是 envelope.ts 里的 createChannelInboundEnvelopeBuilder()(第 19 行)与 resolveChannelInboundRouteEnvelope()(第 41 行)——后者把「事件」与「路由」接起来,内部 buildEnvelope 就是前者:createChannelInboundEnvelopeBuilder({ cfg, route })(第 45 行)。另有泛型化的 createInboundEnvelopeBuilder<TConfig, TEnvelope>()(第 75 行)与 resolveInboundRouteEnvelopeBuilder()(第 105 行)、resolveInboundRouteEnvelopeBuilderWithRuntime()(第 163 行)。
同目录还有两个决定「这条消息算不算数」的模块:kind.ts 与 classification.ts(另有 context.ts 负责补全上下文)。这里的关键设计是:分类先于格式化。 一条消息先被判定为哪一类(普通消息 / 编辑 / 反应 / 附件 / 系统事件),只有该类别才进入 envelope 管线。
第二段在 src/auto-reply/envelope.ts,负责排版成人能读、模型能理解的提示词片段。formatAgentEnvelope()(第 172 行)接受 AgentEnvelopeParams(第 17 行)并调用 formatAgentEnvelopeTimestamp()(第 114 行)拼时间;formatInboundEnvelope()(第 214 行)是入站专用包装(第 240 行内部转调 formatAgentEnvelope)。发送者标签来自 src/channels/sender-label.js:resolveSenderLabel(第 8 行导入)。文件头注释直接说明了职责:「Formats inbound message envelopes with sender, timing, and channel metadata for agent prompts.」
所以分界线是:channels/inbound-event 产出结构化的 envelope,auto-reply/envelope 产出可读文本。前者是数据契约,后者是提示词渲染。
五、路由与 session key
路由的输入与输出都在 src/routing/resolve-route.ts:ResolveAgentRouteInput(第 40 行)与 ResolvedAgentRoute(第 57 行),核心函数 resolveAgentRoute()(第 616 行),判定过程有 debug 日志(第 729 行打印 channel / accountId / peer / guildId / teamId / bindings 数量)。
它的语义是:渠道账号 + peer 组合 → 命中一条 binding → 决定用哪个 agent。没有 binding 命中时有兜底路径 resolveUnknownDirectMessageRoute()(第 893 行),它内部直接用空 peer 再调一次 resolveAgentRoute。此外还有 deriveLastRoutePolicy()(第 83 行)、resolveInboundLastRouteSessionKey()(第 90 行)、listEffectiveGroupRouteBindings()(第 832 行)这些辅助查询。
session key 的构造在 src/routing/session-key.ts。对外的主函数是 buildAgentPeerSessionKey()(第 215 行),默认值常量是 LEGACY_IMPLICIT_AGENT_ID = "main"(第 39 行)、DEFAULT_AGENT_ID(第 41 行,已标 deprecated,指向 roster 默认解析)、DEFAULT_MAIN_KEY = "main"(第 42 行)。会话 key 的形状被 classifySessionKeyShape()(第 159 行)判成四种:"missing" | "agent" | "legacy_or_alias" | "malformed_agent"——形状分类是一等公民,因为旧 key 需要迁移、别名需要解析、畸形 key 必须拒绝而不是猜测。
resolve-route.ts 第 97 行的 buildAgentSessionKey() 内部就是转调 buildAgentPeerSessionKey()(第 110 行),保证「路由算出的 key」与「会话 key 工具算出的 key」是同一个函数的结果。
六、配对:默认 DM 需要配对
直聊的入口在 src/channels/direct-dm.ts:dispatchInboundDirectDm()(第 122 行)与带 runtime 的 dispatchInboundDirectDmWithRuntime()(第 192 行)。第 60 行有一条关键注释:「Set only after the channel's sender/pairing guard admits this event.」——即配对校验是入站事件被承认的前置条件,不是事后过滤。
这就是「designed for a single operator」在渠道层的体现:任何人私聊机器人都不等于能指挥它,默认要过一次配对。配对适配器类型是 ChannelPairingAdapter(types.adapters.ts:48 重导出),CLI 侧对应 pairing 子命令。
七、多用户是归因,不是隔离
群聊里的多个发送者会被如实标注,但不会各自获得独立会话。
归因证据在入站上下文:src/channels/inbound-event/context.ts 的测试用例里反复出现 sender、senderAllowed、inboundHistory: [{ sender, body, timestamp }] 这类字段,还有「drops supplemental context with unknown sender allow state in restrictive modes」这样的用例名——说明发送者是否被允许是一个显式判定,且在不确定的严格模式下会丢弃rather than 放行。渲染侧则把发送者写进提示词(resolveSenderLabel)。
隔离粒度则在 session key:buildAgentPeerSessionKey() 的输入是 peer(RoutePeer,resolve-route.ts:35,配 RoutePeerKind 第 33 行)。群聊只有一个 peer,因此群里所有人共享同一条会话与同一份 transcript。
结论很清楚:想在多用户场景下真正隔离,只有两条路——给不同人拆到不同 agent(靠 binding 与 route 分离),或拆成不同 Gateway(靠锁目录、端口、状态目录分离,见第 01 章)。进程内的会话结构不提供「按人隔离」这一档。
九、能力差异被两件事吸收
20 多个渠道的差异没有变成 20 多条 if,而是被两件事吸收:声明式元数据与可选适配器。
元数据的形态可以从 extensions/telegram/package.json 的 openclaw.channel 块(第 26 行起)读出来:configuredState.env.anyOf 列出 TELEGRAM_BOT_TOKEN(第 28 到 34 行)——核心据此在不执行插件代码的前提下判断该渠道是否已配置;approvalFlags: ["native"](第 35 到 37 行)声明该渠道原生支持审批交互;docsPath(第 41 行)、selectionLabel(第 39 行)、systemImage(第 44 行)是 onboarding 与文档渲染用的展示元数据。
这正是 extensions/AGENTS.md 第 62 到 64 行要求的「控制平面元数据与运行时逻辑分离」在渠道层的落实:openclaw channels / onboard 能在不 import 任何渠道实现的情况下把「有哪些渠道、哪些已就绪、各自怎么配」算出来。
适配器则吸收行为差异:ChannelGroupAdapter / ChannelDirectoryAdapter / ChannelResolverAdapter 只在支持的渠道上存在,核心按存在性分叉。另外「渠道」的边界比聊天宽——extensions/ 下的 google-meet / teams-meetings / zoom-meetings 是会议类渠道,webhooks 是纯事件入口,qa-channel 是测试用渠道,说明契约面向的是「消息事件来源」而非「聊天软件」。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 渠道契约 | src/channels/plugins/types.plugin.ts: ChannelPlugin |
第 60 行;三个类型参数对应账号/探测/审计 |
| 适配器集合 | src/channels/plugins/types.adapters.ts: ChannelConfigAdapter |
第 81 行;另有 auth 330 / outbound 侧 gateway 311 |
| 群组与目录适配 | src/channels/plugins/types.adapters.ts: ChannelGroupAdapter |
第 145 行;ChannelDirectoryAdapter 第 384 行 |
| 状态与心跳 | src/channels/plugins/types.adapters.ts: ChannelStatusAdapter |
第 149 行;ChannelHeartbeatAdapter 第 340 行 |
| 消息适配器 | src/channels/message/adapter.ts: defineChannelMessageAdapter |
第 25 行;默认 ack 策略 manual 在第 13 行 |
| bundled 渠道装载 | src/channels/plugins/bundled.ts: listBundledChannelPlugins |
第 589 行;单查 getBundledChannelPlugin 第 612 行 |
| 运行时注入 | src/channels/plugins/bundled.ts: setBundledChannelRuntime |
第 638 行;渠道拿 host 注入的 runtime |
| bundled 渠道 ID | src/channels/plugins/bundled-ids.ts |
与 catalog.ts 一同做目录检索 |
| envelope 构造 | src/channels/inbound-event/envelope.ts: createChannelInboundEnvelopeBuilder |
第 19 行;resolveChannelInboundRouteEnvelope 第 41 行 |
| 事件分类 | src/channels/inbound-event/kind.ts |
与 classification.ts 同目录;分类先于格式化 |
| 入站上下文构建 | src/channels/inbound-event/context.ts |
承载 sender / senderAllowed 判定 |
| 媒体入站处理 | src/channels/inbound-event/media.ts |
附件类事件的独立处理路径 |
| envelope 渲染 | src/auto-reply/envelope.ts: formatAgentEnvelope |
第 172 行;参数类型 AgentEnvelopeParams 第 17 行 |
| 入站渲染包装 | src/auto-reply/envelope.ts: formatInboundEnvelope |
第 214 行;第 240 行转调 formatAgentEnvelope |
| 发送者标签 | src/channels/sender-label.ts: resolveSenderLabel |
由 envelope.ts 第 8 行导入 |
| 路由主函数 | src/routing/resolve-route.ts: resolveAgentRoute |
第 616 行;输入输出类型第 40 / 57 行 |
| 未知 DM 兜底 | src/routing/resolve-route.ts: resolveUnknownDirectMessageRoute |
第 893 行;用空 peer 再路由一次 |
| session key 构造 | src/routing/session-key.ts: buildAgentPeerSessionKey |
第 215 行 |
| key 形状分类 | src/routing/session-key.ts: classifySessionKeyShape |
第 159 行;四态含 malformed_agent |
| 路由侧 key 转调 | src/routing/resolve-route.ts: buildAgentSessionKey |
第 97 行;第 110 行转调 buildAgentPeerSessionKey |
| 直聊分发 | src/channels/direct-dm.ts: dispatchInboundDirectDm |
第 122 行;配对守卫注释第 60 行 |
| DM 配对适配器 | src/channels/plugins/types.adapters.ts: ChannelPairingAdapter |
第 48 行重导出 |
| 账号元数据 | extensions/telegram/package.json: openclaw.channel |
第 26 行;含 configuredState / approvalFlags / docsPath |
| 就绪判定元数据 | extensions/telegram/package.json: configuredState |
第 28-34 行;env.anyOf 列 TELEGRAM_BOT_TOKEN |
| 原生审批声明 | extensions/telegram/package.json: approvalFlags |
第 35-37 行;值为 native |
| 会议类与其它渠道 | extensions/teams-meetings/package.json |
与 google-meet / zoom-meetings / webhooks / qa-channel 并列 |
关键取舍
契约拆成十几个小适配器,代价是实现者要在文档里找自己需要哪几个。ChannelPlugin 的字段全部可选,能力靠适配器存在性推导。收益是核心侧不需要渠道白名单,新增渠道永远只改 extensions/ 一侧;代价是「最小可运行渠道」不是靠类型系统强制出来的,而是靠文档与 setupWizard 引导,漏实现会在运行时才暴露。
ack 默认 manual,代价是渠道作者要显式处理「消息已处理」的语义。
自动 ack 在群聊里是噪音,且不同渠道的 ack 手段差异极大(加表情、回帖、标记已读)。默认 manual 让「什么都不做」成为合法实现;代价是消息处理失败时用户可能完全感知不到,需要渠道自己补失败反馈。
envelope 分两段(结构化 + 文本),代价是同一份信息有两处表示。channels/inbound-event 的 envelope 是给程序看的,auto-reply/envelope 的是给模型看的。收益是渲染格式(时间格式、时区、星期前缀)可以独立演进而不动路由逻辑;代价是新增字段要考虑是否要进入提示词,以及两处命名的一致性维护。
session key 带形状分类,代价是每个入口都要处理 legacy / malformed 分支。classifySessionKeyShape() 把 key 分成四态而不是「合法 / 非法」两态,因为历史 key 需要迁移、别名需要解析。收益是迁移可判定、错误可定位;代价是所有消费 session key 的代码都得先分类再使用,不能直接当字符串用。
多用户只做归因不做隔离,代价是群聊里所有人共享上下文。
这是一条明确的产品边界:不试图在进程内做「每人一个沙箱」,而是把隔离责任交给部署形态(拆 agent / 拆 Gateway)。收益是会话模型保持线性、transcript 保持单一事实来源;代价是在一个群里放多个互不信任的用户时,必须靠 sender allowlist 与配对把关,而不是靠会话隔离。
「渠道」被定义成事件来源而非聊天软件,代价是契约要容纳非对话语义。extensions/ 里既有聊天渠道,也有 google-meet / teams-meetings / zoom-meetings 这类会议渠道、纯事件入口 webhooks 与测试用 qa-channel。收益是新增渠道类型不需要动核心,只要满足「能产出 inbound event」这一条;代价是 envelope 与分类器必须容忍没有「回复」语义的事件(例如会议状态变更),这让 inbound-event/kind.ts 的分类集合成为必须持续扩充的资产。
自测题
- 一个渠道只实现 config + outbound、不实现
ChannelDirectoryAdapter,在核心侧会走哪条不同路径?请指出这个「按适配器存在性分叉」的设计在什么情况下会变成隐式耦合。 - ack 策略默认
manual且supportedAckPolicies: ["manual"]。如果某渠道要实现「处理失败时回一个错误提示」,它应该挂在哪个适配器上,为什么不能改默认 ack 策略? resolveUnknownDirectMessageRoute用空 peer 再调一次resolveAgentRoute。这个「空 peer」在 session key 构造里会落成什么?它和「已知 peer 但无 binding」是同一回事吗?classifySessionKeyShape把legacy_or_alias单独成一态。请说明为什么不能把它并入agent态,以及并入后会在迁移路径上引入什么风险。- 群聊场景中,要得到「同一个人在不同渠道的对话互相独立、但在同一渠道内跨群共享记忆」的效果,你会怎么配置 binding 与 session 粒度?请说明这个需求为什么不能用「按人隔离」直接表达。