OpenClaw 设计解析(四):多通道架构——从 WhatsApp 到 Discord 的统一抽象
OpenClaw 多通道架构:将不同消息平台统一到 ChannelPlugin 接口。
本篇介绍 OpenClaw 的多通道(multi-channel)架构 ——让同一个 AI 助手接入 WhatsApp、Telegram、Discord、Signal、Slack、iMessage、Matrix、IRC 等平台。
1. 为什么多通道是核心设计 OpenClaw 的目标是把 AI 助手放到已有的对话场景 。每个即时通讯平台的接入方式差异很大:
平台
接入方式
难点
Telegram
Bot API(HTTP long-polling / webhook)
最简单,注册 BotFather 即用
Discord
Bot API(WebSocket Gateway)
Guild/Channel/Thread 层级复杂
WhatsApp
WebSocket 逆向(Baileys)
无官方 Bot API,需 QR 配对
Signal
signal-cli(REST wrapper)
链接设备、身份验证复杂
Slack
Socket Mode
OAuth 应用注册 + 事件订阅
Matrix
Client-Server API
去中心化,homeserver 多样
OpenClaw 用 ChannelPlugin 体系统一这些差异。
2. Channel 抽象层 通道注册表 系统维护一个有序的通道 ID 列表,定义了内置通道的优先级排序 ,直接影响 CLI onboarding 时的通道选择顺序(Telegram 排第一,因为最容易上手):
1 2 3 4 5 6 7 8 9 10 11 const CHAT_CHANNEL_ORDER = [ "telegram" , "whatsapp" , "discord" , "irc" , "googlechat" , "slack" , "signal" , "imessage" , ]
每个通道都有元数据描述,包含 UI 展示名、文档路径、简介说明、图标等信息,用于 onboarding 向导展示:
1 2 3 4 5 6 7 8 9 { id : "telegram" , label : "Telegram" , selectionLabel : "Telegram (Bot API)" , docsPath : "/channels/telegram" , blurb : "simplest way to get started..." , systemImage : "paperplane" , }
通道 ID 归一化 通道 ID 支持别名(如 imsg → imessage、gchat → googlechat)。对于扩展通道(如 Matrix、MS Teams),归一化时会同时查询插件注册表,在已注册插件的 id 和 aliases 中匹配,从而统一处理内置通道和动态加载的扩展通道。
3. ChannelPlugin 接口:30+ 个 adapter 的协议 OpenClaw 通道体系的核心是 ChannelPlugin 类型。它是一个包含 30 多个可选 adapter 的大接口——每个 adapter 负责通道行为的一个切面:
flowchart TB
subgraph CP["ChannelPlugin 接口(所有 adapter 可选,按需实现)"]
direction LR
B["onboarding\n引导向导"] ~~~ C["config\n账号解析"] ~~~ D["security\nDM策略"] ~~~ E["outbound\n发送消息"] ~~~ F["gateway\n启动/停止"]
G["pairing\n配对授权"] ~~~ H["mentions\n@提及"] ~~~ I["streaming\n流式输出"] ~~~ J["threading\n线程/话题"] ~~~ K["messaging\n消息格式化"]
L["auth\n认证"] ~~~ M["groups\n群组策略"] ~~~ N["actions\n消息操作"] ~~~ O["heartbeat\n心跳检测"] ~~~ P["agentTools\n通道专属工具"]
end
Q["极简通道: 只需 id + meta + capabilities + config"]
R["完整通道(如 Telegram): 实现几乎所有 adapter"]
CP ~~~ Q ~~~ R
核心结构如下(伪代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 type ChannelPlugin = { id : ChannelId meta : ChannelMeta capabilities : ChannelCapabilities onboarding? config pairing? security? gateway? outbound? streaming? threading? messaging? mentions? actions? heartbeat? agentTools? }
capabilities 声明通道支持的特性:
1 2 3 4 5 6 7 capabilities : { chatTypes : ["direct" , "group" , "thread" ], polls : true , reactions : true , threads : true , media : true , }
设计要点:
所有 adapter 都是可选的 ——通道只需实现自己支持的部分
一个极简通道可能只有 id、meta、capabilities 和 config
Telegram 作为最成熟的通道实现了几乎所有 adapter
支持热重载 ——通过 reload.configPrefixes 配置,当用户修改相关配置时无需重启 gateway
4. 内置通道 vs 扩展通道 OpenClaw 的通道分两层:
graph TB
subgraph builtin["内置通道 (src/, 随核心发布)"]
B1["Telegram — 最易上手\nWhatsApp — QR 配对\nDiscord — Bot API"]
B2["Slack — Socket Mode\nSignal — signal-cli\niMessage — macOS only"]
end
subgraph ext["扩展通道 (extensions/, 独立 npm 包)"]
E1["Matrix · MS Teams · 飞书\nIRC · Google Chat · LINE"]
E2["Nostr · Twitch · Mattermost\nZalo · BlueBubbles · ..."]
end
builtin --> MC["统一 MsgContext"]
ext --> MC
MC --> AGENT["Agent 运行时"]
扩展通道是独立的 npm 包,runtime 通过 jiti 别名加载 openclaw/plugin-sdk,无需在 dependencies 中声明 workspace:*(那会破坏 npm install)。这意味着社区可以在不 fork 核心代码的情况下添加新通道 。
5. 通道实现剖析:以 Telegram 为例 Telegram 是最成熟的内置通道,也是推荐的入门通道。
Bot 初始化 Bot 启动时需要配置以下关键参数:
1 2 3 4 5 6 7 8 { token : string allowFrom : string [] mediaMaxMb : number requireMention : boolean updateOffset : number }
updateOffset(水位线)是生产环境的关键细节——它持久化了已处理的最新 update ID,确保 bot 重启后不会重复处理消息。
消息处理流程 Telegram 的消息处理采用工厂模式 + 依赖注入 :
flowchart LR
A["收到 Telegram Update"] --> B["聚合 Media Group"]
B --> C["构建统一 MsgContext"]
C --> D["Agent 处理"]
D --> E["响应发回通道"]
初始化时通过工厂函数注入所有依赖(config、logger、stream mode 等),返回一个纯函数处理器。这种模式方便测试——可以 mock 任何依赖而不需要启动真正的 Telegram bot。
Media Group 聚合 :Telegram 会把一次发送的多张图片拆成多个独立 update,处理器需要先将它们聚合回一条消息。
6. 扩展通道剖析:以 Matrix 为例 Matrix 通道展示了 ChannelPlugin 接口的完整实现模式。以下是核心结构(省略具体实现):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 const matrixPlugin : ChannelPlugin = { id : "matrix" , meta : { }, capabilities : { chatTypes : ["direct" , "group" , "thread" ], polls : true , reactions : true , threads : true , media : true , }, reload : { configPrefixes : ["channels.matrix" ] }, pairing : { normalizeAllowEntry : (entry ) => entry.replace (/^matrix:/i , "" ), notifyApproval : (id ) => sendMessage (id, "已批准" ), }, config : { listAccountIds, resolveAccount, defaultAccountId, isConfigured : (account ) => account.configured , }, security : { resolveDmPolicy : ({ account } ) => ({ policy : account.config .dm ?.policy ?? "pairing" , allowFrom : account.config .dm ?.allowFrom ?? [], }), }, threading : { resolveReplyToMode, buildToolContext }, messaging : { targetResolver : { looksLikeId : (raw ) => /^(matrix:)?[!#@]/ .test (raw), }, }, }
设计要点:
security 默认 "pairing" 模式,陌生人需先获批准才能对话,比 "open" 更安全
messaging.targetResolver 用正则判断输入是否为 Matrix ID(如 @user:matrix.org)
threading 将 Matrix 线程 ID 映射到统一的 tool context,让 Agent 在正确的线程中回复
7. 消息路由:7 层绑定匹配 当一条消息进来时,OpenClaw 需要决定交给哪个 Agent 处理 。
为什么需要分层路由 假设你有多个 Agent:support-agent 负责客服、admin-agent 负责管理、general-agent 做通用聊天。你在配置里写了一堆规则(bindings),每条规则说”满足条件 X 的消息交给 Agent Y”。
问题是:一条消息可能同时匹配多条规则 。Discord 的 #support 频道里的消息,既匹配”这个频道”的规则,也匹配”这个 Guild”的规则,还匹配”所有 Discord”的规则。谁优先?
OpenClaw 用 7 层优先级 解决这个冲突——从最精确到最宽泛,第一个匹配的就生效 :
flowchart TD
MSG["入站消息"] --> N["归一化\nchannel → 小写\naccountId → 默认 default\npeer.kind → dm 转 direct"]
N --> R{"按优先级逐层匹配"}
R -->|"① peer"| P1["精确匹配对话对象\n例:#support 频道"]
R -->|"② peer.parent"| P2["父级匹配\n例:#support 下的线程"]
R -->|"③ guild+roles"| P3["Guild + 角色\n例:Guild-X 的 admin"]
R -->|"④ guild"| P4["Guild 匹配\n例:Guild-X 所有消息"]
R -->|"⑤ team"| P5["团队匹配\n例:Slack workspace"]
R -->|"⑥ account"| P6["Bot 账号匹配\n例:bot-1 的所有消息"]
R -->|"⑦ channel"| P7["通道匹配(最宽泛)\n例:所有 Discord 消息"]
R -->|"ⓧ 都没匹配"| P8["兜底 → 默认 Agent"]
P1 & P2 & P3 & P4 & P5 & P6 & P7 & P8 --> KEY["构建 sessionKey\n决定会话隔离粒度"]
KEY --> RES["返回 { agentId, sessionKey, matchedBy }"]
具体例子:4 条消息的路由过程 假设你的 Discord 服务器配了这些规则:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 bindings: - agentId: support-agent match: { channel: discord , peer: { kind: channel , id: "C-SUPPORT" } } - agentId: admin-agent match: { channel: discord , guildId: "Guild-X" , roles: ["admin-role" ] } - agentId: community-agent match: { channel: discord , guildId: "Guild-X" } - agentId: general-agent match: { channel: discord , accountId: "*" }
现在来了 4 条不同的消息,看它们各自怎么被路由:
flowchart TD
subgraph msg1["消息 1: #support 频道里有人提问"]
direction LR
M1["peer=C-SUPPORT\nguild=Guild-X\nroles=无"]
M1 -->|"① peer 匹配规则 A ✓"| R1["→ support-agent"]
end
subgraph msg2["消息 2: admin 在 #general 发消息"]
direction LR
M2["peer=C-GENERAL\nguild=Guild-X\nroles=admin-role"]
M2 -->|"① peer 没匹配\n② parent 没有\n③ guild+roles 匹配规则 B ✓"| R2["→ admin-agent"]
end
flowchart TD
subgraph msg3["消息 3: 普通用户在 #general 发消息"]
direction LR
M3["peer=C-GENERAL\nguild=Guild-X\nroles=无"]
M3 -->|"①②③ 都没匹配\n④ guild 匹配规则 C ✓"| R3["→ community-agent"]
end
subgraph msg4["消息 4: 另一个 Guild 的消息"]
direction LR
M4["peer=C-OTHER\nguild=Guild-Y\nroles=无"]
M4 -->|"①②③④⑤⑥ 都没匹配\n⑦ channel 匹配规则 D ✓"| R4["→ general-agent"]
end
关键细节 :规则 A 同时指定了 peer 和隐含的 channel,但它只在 Tier 1(peer)尝试匹配。如果 peer 不匹配,它不会”降级”到更宽泛的层级去碰运气 ——每条规则只在它最精确的层级参与匹配。
线程继承(Tier 2: peer.parent) Discord 的线程(Thread)带来一个有趣问题:
flowchart LR
subgraph 没有Tier2["没有 Tier 2 的情况"]
direction TB
CH1["#support\nid=C-SUPPORT"] -->|"用户创建线程"| TH1["线程: 讨论 Bug-123\nid=thread-456"]
TH1 -->|"① peer: thread-456 ✗\n③④⑤⑥⑦ 逐层匹配..."| WRONG["可能落到 community-agent\n而不是 support-agent"]
end
subgraph 有Tier2["有 Tier 2 的情况"]
direction TB
CH2["#support\nid=C-SUPPORT"] -->|"用户创建线程"| TH2["线程: 讨论 Bug-123\nid=thread-456\nparentPeer=C-SUPPORT"]
TH2 -->|"① peer: thread-456 ✗\n② parent: C-SUPPORT ✓"| RIGHT["→ support-agent ✓\n线程继承父频道的 Agent"]
end
没有Tier2 ~~~ 有Tier2
线程的 parentPeer 指向它的父频道。Tier 2 用父频道的 ID 去匹配规则,这样线程里的消息也交给正确的 Agent。注意,session key 仍然用线程自己的 ID ,所以每个线程有独立的会话历史——只是 Agent 选择继承自父频道。
角色匹配(Tier 3: guild+roles) 角色匹配采用 OR 逻辑 ——只要用户拥有规则中列出的任意一个角色就算匹配:
1 2 3 4 规则:roles: ["moderator", "admin"] 用户角色: ["member", "admin"] → admin ∈ roles → 匹配 ✓ 用户角色: ["member"] → 无交集 → 不匹配 ✗
Session Key:路由之后的会话隔离 路由决定了”消息交给谁”,而 session key 决定了”在哪个对话里”。
群聊/频道 的 session key 是固定的——每个频道一个独立会话:
1 agent:support-agent:discord:channel:C-SUPPORT
私聊(DM) 则通过 dmScope 配置提供 4 种隔离粒度:
flowchart TD
DM["收到私聊消息\nagent=main, channel=discord, peer=alice"] --> SCOPE{"dmScope 配置?"}
SCOPE -->|"main(默认)"| S1["session key: agent:main:main\n所有私聊共享一个会话\nAlice 和 Bob 看到相同上下文"]
SCOPE -->|"per-peer"| S2["session key: agent:main:direct:alice\n每人独立会话\n但跨平台的 alice 共享"]
SCOPE -->|"per-channel-peer"| S3["session key: agent:main:discord:direct:alice\n每个平台每人独立\nTelegram 和 Discord 的 alice 分开"]
SCOPE -->|"per-account-channel-peer"| S4["session key: agent:main:discord:bot1:direct:alice\n最细粒度\n每个 bot 账号 × 平台 × 人"]
大多数多用户场景用 per-channel-peer(每个平台每个用户一个独立会话)。默认的 main 适合个人用——只有你一个人私聊 bot,所有消息在同一个上下文里。
跨平台身份合并(Identity Links) 如果 Alice 同时在 Telegram(ID: 111111)和 Discord(ID: 222222)私聊 bot,默认是两个独立会话。通过 identityLinks 可以合并:
1 2 3 4 session: dmScope: per-channel-peer identityLinks: alice: ["telegram:111111" , "discord:222222" ]
flowchart LR
subgraph before["没有 Identity Links"]
TG1["Telegram:111111"] --> SK1["session key:\nagent:main:telegram:direct:111111"]
DC1["Discord:222222"] --> SK2["session key:\nagent:main:discord:direct:222222"]
SK1 -.- NOTE1["两个独立会话\nAlice 换平台后丢失上下文"]
end
subgraph after["有 Identity Links"]
TG2["Telegram:111111"] --> LINK["查 identityLinks\n111111 → alice"]
DC2["Discord:222222"] --> LINK
LINK --> SK3["peerId 替换为 alice\n两个平台共享会话上下文"]
end
Alice 在 Telegram 聊到一半切到 Discord 继续,bot 仍然记得之前的对话。
防回环:Bot 不会回复自己 在群聊场景中,Agent 回复的消息会被平台重新推送给 bot 自身。如果不处理,就会出现 bot 回复自己、自己再回复、无限循环的情况。OpenClaw 在消息进入路由之前 就做了过滤,确保 bot 的回复永远不会进入 7 层匹配:
flowchart LR
U["用户发消息"] --> AGENT["Agent 处理并回复"]
AGENT --> CH["回复发到频道"]
CH --> PUSH["平台推送这条回复\n给 bot 自身"]
PUSH --> FILTER{"是自己发的消息?"}
FILTER -->|"是"| DROP["丢弃,不进入路由"]
FILTER -->|"否"| ROUTE["进入 7 层路由"]
各平台的过滤机制不同,但效果一致:
平台
自身消息过滤
其他 Bot 消息
Telegram
平台保证:Bot API 根本不推送 bot 自己的消息
群聊中平台也不推送其他 bot 的消息
Discord
检查 author.id === botUserId → 丢弃
默认丢弃(allowBots 可开启)
WhatsApp
检查 fromMe 标记 → 丢弃
—
Slack
检查 bot_id + user === botUserId → 丢弃
默认丢弃(allowBots 可开启)
Signal
对比发送者手机号/UUID → 丢弃
—
Discord 和 Slack 提供了 allowBots 配置——如果你故意 想让 Agent 响应其他 bot 的消息(比如 bot 之间协作),可以打开这个开关。但 bot 回复自己是永远屏蔽的,不可配置。
8. 会话记录与消息归一化 路由完成后,消息需要被记录到 session store。核心流程分两步:
flowchart LR
MSG["入站消息"] --> A["① 记录 session 元数据\n(发送者、通道、时间)"]
MSG --> B["② 更新 last route\n(最后消息的投递上下文)"]
A -.- NOTE1["异步执行,不阻塞消息处理"]
B --> C["Agent 主动发消息时\n知道该通过哪个通道投递"]
last route 记录了”最后一次收到消息的通道和目标”。当 Agent 需要主动发送消息时,就知道该通过哪个通道投递。
归一化细节 :session key 统一做 trim().toLowerCase() 处理,确保 Telegram:123 和 telegram:123 指向同一个 session。
9. 访问控制 安全是多通道架构不可忽略的一环。访问控制的核心判断逻辑:
flowchart LR
REQ["收到消息"] --> CHECK{"白名单\n是否为空?"}
CHECK -->|"空"| POLICY["由 DM 策略决定"]
CHECK -->|"有条目"| WILD{"包含\n通配符 * ?"}
WILD -->|"是"| ALLOW["放行"]
WILD -->|"否"| MATCH{"senderId\n在列表中?"}
MATCH -->|"是"| ALLOW
MATCH -->|"否"| DENY["拒绝"]
白名单来源有两个:配置文件 中明确写的 allowFrom 和 pairing store 中已配对的用户。两者合并后作为最终白名单。
DM 策略有三种模式:
"open" — 任何人都可以发消息
"pairing" — 陌生人需要配对审批,已配对用户自动放行(合并 config + pairing store)
"allowlist" — 只看配置中明确列出的用户(不合并 pairing store)
10. WhatsApp Web 的特殊实现 WhatsApp 没有官方 Bot API,OpenClaw 通过 Baileys 库(@whiskeysockets/baileys)实现 WebSocket 逆向接入。
QR 码配对 sequenceDiagram
participant User as 用户手机
participant OC as OpenClaw
participant WA as WhatsApp 服务器
OC->>WA: 创建 WebSocket 连接
WA-->>OC: 返回 QR 码数据
OC->>OC: 生成 QR 码(有效期 3 分钟)
User->>OC: 扫描 QR 码
OC->>WA: 完成配对
WA-->>OC: 连接成功
Note over OC,WA: 若收到 515 错误码,自动重连一次
关键设计:
QR 有效期 3 分钟 (WhatsApp 限制),超时后需重新生成
515 错误处理 :WhatsApp 有时在配对成功后要求重连,系统会自动重试一次
并发安全 :用 Map 管理多个账号的登录状态,通过 login ID 防止过期回调覆盖新连接
消息监听与防碎片化 WhatsApp 用户经常连发多条消息,每条都触发 AI 回复会导致碎片化响应。系统通过 Debouncer 解决:
flowchart LR
subgraph debounce["debounceMs 时间窗口内"]
M1["你好"] --> AGG["聚合"]
M2["帮我"] --> AGG
M3["查下天气"] --> AGG
end
AGG --> MERGED["合并后的消息:\n你好\n帮我\n查下天气"]
MERGED --> AGENT["Agent 处理"]
聚合策略:
聚合键 = accountId + conversationKey + senderKey(同一发送者、同一对话)
群聊中按发送者独立聚合,不会混淆不同人的消息
单条消息直接透传,多条消息拼接 body 并合并 @提及 列表
这样 AI 能看到完整的上下文,而不是一条条碎片
以上覆盖了 ChannelPlugin 接口、7 层绑定路由、内置与扩展通道、访问控制以及 WhatsApp 的 QR 配对和消息合并实现。