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。这一章回答:

  1. ChannelPlugin 这个契约由哪些适配器拼出来;
  2. 渠道作者写代码时最小要实现什么(message adapter 与 ack 策略);
  3. envelope 归一化在两处(channels/inbound-event 与 auto-reply)各做什么;
  4. 路由如何把「渠道 + 账号 + peer」映射成 agent 与 session key;
  5. 为什么多用户是归因而不是隔离。

一、契约: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 的分类集合成为必须持续扩充的资产。

自测题

  1. 一个渠道只实现 config + outbound、不实现 ChannelDirectoryAdapter,在核心侧会走哪条不同路径?请指出这个「按适配器存在性分叉」的设计在什么情况下会变成隐式耦合。
  2. ack 策略默认 manual 且 supportedAckPolicies: ["manual"]。如果某渠道要实现「处理失败时回一个错误提示」,它应该挂在哪个适配器上,为什么不能改默认 ack 策略?
  3. resolveUnknownDirectMessageRoute 用空 peer 再调一次 resolveAgentRoute。这个「空 peer」在 session key 构造里会落成什么?它和「已知 peer 但无 binding」是同一回事吗?
  4. classifySessionKeyShape 把 legacy_or_alias 单独成一态。请说明为什么不能把它并入 agent 态,以及并入后会在迁移路径上引入什么风险。
  5. 群聊场景中,要得到「同一个人在不同渠道的对话互相独立、但在同一渠道内跨群共享记忆」的效果,你会怎么配置 binding 与 session 粒度?请说明这个需求为什么不能用「按人隔离」直接表达。

进入 keel 阅读