渠道抽象接口
昨天(Day 12)讲了扩展机制;今天用它的一个最重要用途——渠道。OpenClaw 支持二十多个聊天平台,它们千差万别(Telegram 长轮询、Slack Socket、Discord WebSocket…),却都实现同一个契约 ChannelPlugin。关键:这是"组合式 adapter"而非"基类继承"。今天讲抽象契约,明天(Day 14)拆三个真实渠道看它怎么落地。
渠道两层架构
ChannelPlugin(插座标准),每个平台把自己实现成一个符合标准的对象(插头),注册进注册表。Gateway 只面向这个标准编程,完全不关心插的是 Telegram 还是飞书。加新平台 = 新写一个插头,核心代码一行不用改。渠道代码分两层:
- 抽象/契约层
src/channels/(~175 文件):接口定义、注册表、会话、鉴权门控等共享逻辑。 - 平台实现层:每个真实平台是一个扩展
extensions/<平台>/(Day 12),其src/channel.ts定义ChannelPlugin对象;繁重的连接/收发代码在顶层专属目录src/telegram/、src/slack/、src/discord/等。
注册表
// src/channels/registry.ts:7 —— 核心内置渠道排序
export const CHAT_CHANNEL_ORDER = [
"telegram","whatsapp","discord","irc","googlechat",
"slack","signal","imessage","line",
] as const;
// CHAT_CHANNEL_META(:27-121):每个渠道的 label/docsPath/图标等元数据
// CHAT_CHANNEL_ALIASES(:123):别名,如 imsg→imessage、gchat→googlechat
访问入口 src/channels/plugins/index.ts:74:listChannelPlugins() / getChannelPlugin(id)。
CHAT_CHANNEL_ORDER 排序、附元数据。getChannelPlugin(id) 按 id 取出某渠道的实现。实际以插件形式实现的渠道远不止这 9 个核心:还有飞书、matrix、teams、mattermost、nostr、twitch 等二十多个(各在 extensions/*/src/channel.ts)。ChannelPlugin 契约
// src/channels/plugins/types.plugin.ts:49
export type ChannelPlugin<ResolvedAccount = any, ...> = {
id: ChannelId;
meta: ChannelMeta;
capabilities: ChannelCapabilities; // 声明支持哪些能力(L07)
config: ChannelConfigAdapter; // ★ 唯一必填 adapter:账号解析
outbound?: ChannelOutboundAdapter; // 发消息(L05)
gateway?: ChannelGatewayAdapter; // 连接/收消息生命周期(L06)
onboarding?; setup?; pairing?; security?; groups?;
status?; threading?; messaging?; directory?; actions?;
heartbeat?; agentTools?; // …… 共约 30 个可选能力字段
};
id/meta/capabilities/config 必填,其余全是可选的能力 adapter。渠道"能做什么就填什么"。Telegram 填 outbound+gateway+很多;一个只能发不能收的极简渠道可能只填 config+outbound。{ id, meta, capabilities:{threads,polls,reactions,...}, config, outbound, gateway, threading, ... } —— 插满。某个只能推送通知的极简渠道:
{ id, meta, capabilities:{}, config, outbound:{ sendText } } —— 只填必需 + 一个发送方法。上层拿到任意一个都当
ChannelPlugin 用,用某功能前先查 capabilities——这就是"能力发现"(L07)。组合式 adapter(不是继承)
abstract class Channel,各平台 extends 它、重写方法。但聊天平台能力差异极大:有的支持线程、有的支持投票、有的能发语音、有的只能发文字。用继承+抽象方法,会逼每个渠道实现一堆它根本不支持的空方法。OpenClaw 改用组合:把契约切成 ~30 个小 adapter(outbound、gateway、threading、polls…),渠道按需实现哪几个。支持投票就填 sendPoll,不支持就不填。"组合优于继承"——这是现代 TS/Go 设计的主流,比僵硬的类继承灵活得多。统一性靠"一个大对象类型 + 归一化收口 + 注册表"三者保证,而非基类。abstract class Channel——它不存在。渠道 = 一个填了若干 adapter 的普通对象。adapter 类型定义在 types.adapters.ts。发消息 adapter
// src/channels/plugins/types.adapters.ts:108
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text, limit) => string[]) | null; // 长文本切分(各平台字数上限不同)
textChunkLimit?: number;
sendPayload?: (ctx) => Promise<OutboundDeliveryResult>; // 主发送
sendText?: (ctx) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx) => Promise<OutboundDeliveryResult>; // 图片/文件
sendPoll?: (ctx) => Promise<ChannelPollResult>; // 投票(支持的才填)
};
chunker,因为 Telegram 4096、其他平台各不同)。deliveryMode 决定是渠道自己直发(direct)还是走网关(gateway)。连接/收消息 adapter
// src/channels/plugins/types.adapters.ts:275
export type ChannelGatewayAdapter<ResolvedAccount> = {
startAccount?: (ctx) => Promise<unknown>; // 建立连接、开始收消息
stopAccount?: (ctx) => Promise<void>;
loginWithQrStart?: (...) => ...; // WhatsApp 二维码登录
loginWithQrWait?: (...) => ...;
logoutAccount?: (ctx) => ...;
};
关键是 startAccount 收到的 ChannelGatewayContext(:168)带 abortSignal、getStatus/setStatus(回写运行态)、channelRuntime(:238,含 reply/routing/text/session/media 等运行时能力)。
startAccount 是"渠道的启动开关":Gateway 调它,渠道就连上平台、开始监听消息。abortSignal 让 Gateway 能干净地停掉它;setStatus 让渠道回报"我连上了/我挂了"。Day 14 看 Telegram 的 startAccount 实现。能力声明
ChannelCapabilities(types.core.ts:181)让渠道声明自己支持什么:reactions(表情回应)、threads(线程)、media、polls、nativeCommands 等。
capabilities.reactions——不支持就不尝试,避免报错。能力声明 + 可选 adapter 配合:声明我支持投票(capabilities)+ 实现 sendPoll(adapter),两者对上才真能投票。这是"能力发现"模式,让统一上层能优雅处理异构渠道。👶 小白:既然有了 sendPoll 这个 adapter,上层直接看它填没填不就行了,为什么还要一份 capabilities 声明?岂不是重复?
👨🏫 老师:两者分工不同。capabilities 是"铭牌",给上层决策用——它可以在还没碰底层实现时,就快速判断"这个渠道值不值得给用户展示'发起投票'按钮"。sendPoll 是"真插头",是实际执行。设想只有 adapter 没有铭牌:上层想知道能不能投票,就得去翻每个渠道的实现细节,还容易漏判。铭牌让"声明"和"实现"解耦:先看铭牌做 UI/路由决策,真要发时才落到 adapter。这也是为什么 capabilities 是必填、adapter 是选填。
今日小结 + 动手
🧠 今天你应该能回答
- 渠道两层架构:契约层 vs 实现层各在哪?
- 注册表干什么?getChannelPlugin 怎么用?
- ChannelPlugin 里哪个 adapter 必填?为什么其余可选?
- 为什么用组合式 adapter 而不是基类继承?
- outbound 和 gateway adapter 各管什么?能力声明有什么用?
✋ 动手
cd /Users/bitmart/work/codes/github/openclaw
sed -n '1,20p' src/channels/registry.ts
sed -n '49,90p' src/channels/plugins/types.plugin.ts # ChannelPlugin
grep -n 'ChannelOutboundAdapter\|ChannelGatewayAdapter' src/channels/plugins/types.adapters.ts