渠道适配器
职责
渠道适配器 (channel adapter) 是把"22 种不同协议的 chat 平台"翻译成 OpenClaw 内部统一事件流的胶水层。每个渠道实现一组适配器接口,网关只跟这组接口打交道,不关心下面是 Telegram polling 还是 Slack Socket Mode 还是 Discord Gateway。
适配器分三组:
- GatewayAdapter —
startAccount/stopAccount,负责拉起一个账号的长连接 (polling / webhook / WebSocket) 并把平台原始事件喂进 agent 主循环;loginWithQrStart/loginWithQrWait/logoutAccount处理 WhatsApp 这类需要扫码登录的渠道。 - MessageAdapter —
send.text/send.media/send.payload/send.poll四个出站方法,加receive的 ack 策略声明、live的流式预览能力声明。这是 outbound 方向的统一发送接口。 - 细分能力 adapter —
config/setup/pairing/security/groups/mentions/outbound/status/gateway/auth/approval/elevated/commands/lifecycle/secrets/allowlist/doctor/bindings/conversationBindings/streaming/threading/message/messaging/agentPrompt/directory/resolver/actions/heartbeat,每个都是可选的——渠道按能力实现,不实现的就留 undefined。
设计动机
把 gateway / message / 一堆小 adapter 拆开,最直接原因是渠道之间的能力差异很大:
Telegram 既支持 webhook 也支持 long polling,WhatsApp 必须扫码,Slack 走 Socket Mode (WebSocket),Discord 走 Gateway (WebSocket),iMessage 走 AppleScript 本地桥。如果用一个统一 Channel 接口囊括,要么塞一堆可选字段,要么每个渠道实现大量空方法。
消息出站方向,有的渠道支持 replyToId,有的支持 threadId,有的支持 nativeQuote,有的支持流式预览 (draft preview)。这些能力是声明式的——适配器在 capabilities 里声明 nativeStreaming: true,核心代码看到才用,看不到就走降级路径。
startAccount 接收的 ChannelGatewayContext 里有 abortSignal、channelRuntime、setStatus、getStatus——这些是网关给渠道注入的能力,不是渠道自己造的。channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher 就是网关让渠道把 inbound 事件喂进 agent 主循环的入口。
所以适配器层的设计是:接口分细 + 全部可选 + 能力声明式。渠道按需实现,核心代码按声明的能力选用策略。
关键文件
types.plugin.ts:66-111—ChannelPlugin根类型,把所有 adapter surface 组合起来。types.adapters.ts:244-359—ChannelGatewayContext与ChannelGatewayAdapter,定义startAccount/stopAccount/loginWithQrStart等。message/types.ts:306-410—ChannelMessageAdapterShape由 send / durableFinal / live / receive 四个 facet 组成。message/adapter.ts:11-32—defineChannelMessageAdapterhelper,默认 receive 走manualack。telegram/channel.ts:992-1106— Telegram 的gatewayadapter,startAccount同时处理 webhook 与 polling。slack/channel.ts:756-776— Slack 的gateway.startAccount委托给monitorSlackProvider。discord/channel.ts:682-721— Discord 启动带startupDelayMs防 rate limit。whatsapp/channel.ts:345-368— WhatsApp 的loginWithQrStart/loginWithQrWait/logoutAccount。
数据流
渠道启动从 gateway.startAccount(ctx) 进入(telegram/channel.ts:993):
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
// ... probe token、resolve bot info
return startTelegramMonitor({
token,
accountId: account.accountId,
cfg: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),
webhookUrl: account.config.webhookUrl,
webhookSecret: account.config.webhookSecret,
// ...
});
},
stopAccount: async ({ account, accountId, log }) => {
const released = await releaseStoppedTelegramPollingLease({ token, accountId });
if (released) {
log?.info?.(`[${accountId}] released stopped Telegram polling lease`);
}
},
}注意几点:ctx.account 是 渠道配置 解析后的结构化账号;ctx.channelRuntime 是网关注入的能力包,里面有 reply / routing / text / session / media / commands / groups / pairing 八个子模块;abortSignal 是网关生命周期信号,网关停时要让长连接立即断。
Slack 的 startAccount(slack/channel.ts:757)更薄,直接委托给 monitorSlackProvider:botToken + appToken 两个 token trim 后传入(appToken 是 Socket Mode 专用、botToken 是普通 bot API 用的),其余字段跟 telegram 类似——channelRuntime / abortSignal / setStatus / getStatus。Slack Socket Mode 是 WebSocket 长连接,实现在 monitorSlackProvider 内部;setStatus / getStatus 让 monitor 反馈账号运行状态到 渠道注册表 的快照里。
Discord 启动多了一个 rate-limit 防护(discord/channel.ts:690):
const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId);
if (startupDelayMs > 0) {
ctx.log?.info(
`[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`,
);
try {
await sleepWithAbort(startupDelayMs, ctx.abortSignal);
} catch {
return;
}
}sleepWithAbort 让等待期间也能响应 abortSignal——网关停了就立即放弃启动,不要傻等完再退出。Discord 的 token 状态也要先校验,configured_unavailable 抛错(discord/channel.ts:685),避免用未解析的 SecretRef 启动。
WhatsApp 的 QR 登录是两段式(whatsapp/channel.ts:345):loginWithQrStart 拉起 web login 返回二维码,loginWithQrWait 轮询等待用户扫码完成,logoutAccount 调 logoutWeb 清 authDir。两个方法配合让 setup 界面渲染二维码并阻塞等待结果,而不是把整个登录塞在一个超长 promise 里。
出站方向走 MessageAdapter(message/types.ts:306):
export type ChannelMessageSendAdapter<
TConfig = OpenClawConfig,
TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
poll?: (ctx: ChannelMessageSendPollContext<TConfig>) => Promise<TSendResult>;
lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};四个方法对应四种出站 payload:text 纯文本、media 媒体、payload 富卡片、poll 投票。lifecycle 是 hook: beforeSend / afterSendSuccess / afterSendFailure / afterCommit,给渠道在发送前后插自己的逻辑(比如 Slack 要更新 parent message 的 reply 计数)。
接收方向 (inbound) 走 ack 策略(message/types.ts:374):
export type ChannelMessageReceiveAckPolicy =
| "after_receive_record"
| "after_agent_dispatch"
| "after_durable_send"
| "manual";四种策略控制"什么时候向平台 ack 这条消息已收到"——after_receive_record 是最早、刚落库就 ack;after_agent_dispatch 是 agent 拿到事件后;after_durable_send 是回复 durably 投递成功后;manual 是渠道自己控制。defineChannelMessageAdapter 默认给 manual(message/adapter.ts:12),因为大多数渠道需要自己决定 ack 时机。
整个 inbound → outbound 链路:
边界与失败
- SecretRef 未解析就启动会抛:Discord 在
startAccount里检查tokenStatus === "configured_unavailable"(discord/channel.ts:685),直接抛错而不是用空 token 去连,避免日志泄漏"token 是空字符串"这种边界状态。 - polling lease 释放:Telegram 的
stopAccount必须调releaseStoppedTelegramPollingLease(telegram/channel.ts:1094),否则重启时上一轮的 polling lease 还在,会撞 token 冲突。 - rate limit 主动延迟:Discord 启动前
startupDelayMs等待且能被abortSignal中断(discord/channel.ts:696)。这是把"防止 Discord 限制并发启动"做成显式 sleep,而不是失败重试。 - bundled 加载失败只 warn:
describeBundledChannelLoadError(bundled.ts:318)提示跑openclaw doctor --fix,一个渠道挂了不影响其他渠道。 - channelRuntime 可选:
ChannelGatewayContext.channelRuntime是可选字段,外部插件应该检查undefined再用(types.adapters.ts:313),否则 SDK 版本不对会 NPE。 - ack 默认 manual:
defineChannelMessageAdapter把 receive 默认成manual(message/adapter.ts:12),渠道不主动声明 ack 策略时核心代码不会替它自动 ack——防止"消息被以为已处理其实丢了"。 - capabilities 声明式降级:
durableFinalDeliveryCapabilities(message/types.ts:17)列出 12 个能力,渠道在capabilities里声明 true 的核心代码才会走对应可靠投递路径,没声明的走降级,不抛错。
小结
适配器层把 22 个渠道的差异封装在一组细粒度、全部可选、能力声明式的接口背后。GatewayAdapter 管启停与登录,MessageAdapter 管出站发送与入站 ack,细分 adapter 各管一个能力切面。网关通过 channelRuntime 注入能力,通过 abortSignal 控制生命周期,通过 capabilities 声明式选择策略。
怎么从 渠道注册表 拿到 plugin 条目再调它的 startAccount,以及 agent 事件怎么到 MessageAdapter 发回平台,看 聊天广播。每个渠道在 openclaw.json 里怎么配 token、allowFrom、accounts,看 渠道配置。