Day 13 / 共 20 天 · 第 3 周 技能与渠道

渠道抽象接口

昨天(Day 12)讲了扩展机制;今天用它的一个最重要用途——渠道。OpenClaw 支持二十多个聊天平台,它们千差万别(Telegram 长轮询、Slack Socket、Discord WebSocket…),却都实现同一个契约 ChannelPlugin。关键:这是"组合式 adapter"而非"基类继承"。今天讲抽象契约,明天(Day 14)拆三个真实渠道看它怎么落地。

📍 你在整门课的位置(第 3 周 · 技能与渠道)
W2 大脑· Day11 技能 Day12 扩展 Day13 渠道抽象 Day14 渠道实现 Day15 归一化· W4 安全/部署
💡 用一个类比先兜住今天("插座标准 vs 各家电器"世界观) 今天全程用这个类比:ChannelPlugin = 家里的插座标准(220V、两孔三孔);每个聊天平台 = 一件电器,都做成符合标准的插头;组合式 adapter = 电器按需带功能(有的带定时、有的带童锁,不强制每件都实现);能力声明(capabilities)= 电器铭牌,上层先看铭牌再决定用不用某功能。Gateway 只认插座标准,不管插的是 Telegram 还是飞书——这就是二十多个平台能统一管理的秘密。
L01

渠道两层架构

🤔 痛点:二十多个平台各说各话,上层怎么可能一套代码通吃? Telegram 用长轮询、Slack 用 Socket、Discord 用 WebSocket;有的支持线程、有的支持投票、有的只能发文字。如果 Gateway 和大脑要为每个平台写一套收发逻辑,加一个平台就得改一大片核心代码——这活儿没法维护。
💡 本质:定义一个"渠道插座标准",各平台做成合规插头 OpenClaw 定义统一契约 ChannelPlugin(插座标准),每个平台把自己实现成一个符合标准的对象(插头),注册进注册表。Gateway 只面向这个标准编程,完全不关心插的是 Telegram 还是飞书。加新平台 = 新写一个插头,核心代码一行不用改。

渠道代码分两层:

  • 抽象/契约层 src/channels/(~175 文件):接口定义、注册表、会话、鉴权门控等共享逻辑。
  • 平台实现层:每个真实平台是一个扩展 extensions/<平台>/(Day 12),其 src/channel.ts 定义 ChannelPlugin 对象;繁重的连接/收发代码在顶层专属目录 src/telegram/src/slack/src/discord/ 等。
"插座标准" vs "各家电器" 想象家里的插座标准(220V、两孔三孔)——这是契约层。冰箱、电视、微波炉各是不同电器,但都做成符合插座标准的插头——这是实现层OpenClaw 定义一个"渠道插座标准"(ChannelPlugin),每个聊天平台把自己做成符合标准的"插头",插上就能用。Gateway 只认插座标准,不关心插的是 Telegram 还是飞书。这就是二十多个平台能统一管理的秘密。
L02

注册表

// 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:74listChannelPlugins() / getChannelPlugin(id)

读法:注册表是"所有渠道的花名册"——按 CHAT_CHANNEL_ORDER 排序、附元数据。getChannelPlugin(id) 按 id 取出某渠道的实现。实际以插件形式实现的渠道远不止这 9 个核心:还有飞书、matrix、teams、mattermost、nostr、twitch 等二十多个(各在 extensions/*/src/channel.ts)。
L03

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。
ChannelPlugin = 一块插座面板,4 个必填孔 + 一排可选孔 必填(4) id meta capabilities config ★唯一必填 adapter 可选 adapter(约 30 个 · 有就填、没有就空) outbound 发 gateway 收 threading polls pairing …等 Telegram:config + outbound + gateway + threading + polls + …(插满) 极简"只能发"渠道:只插 config + outbound.sendText,其余全空
图注:绿=必填,紫=常填,虚线=按平台能力选填。同一块面板,不同渠道插的孔多少不同。
📝 举个例子:同一个契约,两种"填充密度" Telegram(能力全):{ id, meta, capabilities:{threads,polls,reactions,...}, config, outbound, gateway, threading, ... } —— 插满。
某个只能推送通知的极简渠道{ id, meta, capabilities:{}, config, outbound:{ sendText } } —— 只填必需 + 一个发送方法。
上层拿到任意一个都当 ChannelPlugin 用,用某功能前先查 capabilities——这就是"能力发现"(L07)。
L04

组合式 adapter(不是继承)

为什么用"组合"不用"继承"? 传统 OOP 会写 abstract class Channel,各平台 extends 它、重写方法。但聊天平台能力差异极大:有的支持线程、有的支持投票、有的能发语音、有的只能发文字。用继承+抽象方法,会逼每个渠道实现一堆它根本不支持的空方法。OpenClaw 改用组合:把契约切成 ~30 个小 adapter(outbound、gateway、threading、polls…),渠道按需实现哪几个。支持投票就填 sendPoll,不支持就不填。"组合优于继承"——这是现代 TS/Go 设计的主流,比僵硬的类继承灵活得多。统一性靠"一个大对象类型 + 归一化收口 + 注册表"三者保证,而非基类。
读法:教程里别找 abstract class Channel——它不存在。渠道 = 一个填了若干 adapter 的普通对象。adapter 类型定义在 types.adapters.ts
L05

发消息 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)。
L06

连接/收消息 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)带 abortSignalgetStatus/setStatus(回写运行态)、channelRuntime:238,含 reply/routing/text/session/media 等运行时能力)。

读法:startAccount 是"渠道的启动开关":Gateway 调它,渠道就连上平台、开始监听消息。abortSignal 让 Gateway 能干净地停掉它;setStatus 让渠道回报"我连上了/我挂了"。Day 14 看 Telegram 的 startAccount 实现。
L07

能力声明

ChannelCapabilitiestypes.core.ts:181)让渠道声明自己支持什么:reactions(表情回应)、threads(线程)、media、polls、nativeCommands 等。

读法:上层逻辑先查能力声明,再决定能不能用某功能。比如"给消息加表情回应"前先看 capabilities.reactions——不支持就不尝试,避免报错。能力声明 + 可选 adapter 配合:声明我支持投票(capabilities)+ 实现 sendPoll(adapter),两者对上才真能投票。这是"能力发现"模式,让统一上层能优雅处理异构渠道。

👶 小白:既然有了 sendPoll 这个 adapter,上层直接看它填没填不就行了,为什么还要一份 capabilities 声明?岂不是重复?

👨‍🏫 老师:两者分工不同。capabilities 是"铭牌",给上层决策用——它可以在还没碰底层实现时,就快速判断"这个渠道值不值得给用户展示'发起投票'按钮"。sendPoll 是"真插头",是实际执行。设想只有 adapter 没有铭牌:上层想知道能不能投票,就得去翻每个渠道的实现细节,还容易漏判。铭牌让"声明"和"实现"解耦:先看铭牌做 UI/路由决策,真要发时才落到 adapter。这也是为什么 capabilities 是必填、adapter 是选填。

L08

今日小结 + 动手

🧠 今天你应该能回答

  • 渠道两层架构:契约层 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
明天预告 · Day 14渠道实现举例——拆 Telegram(长轮询)、Slack(Socket Mode)、Discord(Gateway WebSocket)三个真实渠道:怎么连接、收消息、发回复,三种截然不同的连接方式如何统一到同一契约。
← Day 12 扩展 Day 14 · 渠道实现举例 →