OpenClaw源码解析(六):Channels渠道插件是如何设计的
核心文件:– src/channels/plugins/types.plugin.ts– src/channels/plugins/types.core.ts– src/channels/plugins/types.adapters.ts
这一篇怎么读
正确顺序是:
-
先看 types.core.ts -
再看 types.adapters.ts -
最后回到 types.plugin.ts
-
types.core.ts 定义“渠道里有哪些基本对象” -
types.adapters.ts 定义“这些对象有哪些行为钩子” -
types.plugin.ts 把前两层装配成一个总接口
这篇要回答的问题是:
OpenClaw 是如何在不写满 if-else 的前提下,统一接入 20+ 消息渠道的?
写作最前面
OpenClaw 的渠道系统不是“一个大接口 + 每个平台全量实现”。
它更接近下面这个结构:
ChannelPlugin├─ identity/meta/capabilities├─ config├─ gateway├─ outbound├─ status├─ security├─ threading├─ messaging└─ ... 一系列按需实现的 adapter
这意味着:
-
只有少数字段是必填 -
大部分能力都是可选 adapter -
每个平台只需要实现自己真正支持的那部分
这就是 OpenClaw 能同时支持很多平台、又不把核心层写爆的关键。
第 1 层:types.core.ts 先定义“渠道领域对象”
先看 src/channels/plugins/types.core.ts。
这里的类型是在定义:
-
渠道是什么 -
账户状态长什么样 -
能力如何声明 -
消息动作、线程、目录这些对象怎么表达
第 1 段:ChannelId
export type ChannelId = ChatChannelId | (string & {});
它说明:
-
core 里存在一组已知 ChatChannelId -
但系统仍允许扩展渠道使用新的字符串 id
所以渠道系统从类型层面就不是封闭枚举,而是开放扩展的。
这和后面的插件体系是完全一致的。
第 2 段:ChannelMeta
export type ChannelMeta = {id: ChannelId;label: string;selectionLabel: string;docsPath: string;docsLabel?: string;blurb: string;order?: number;aliases?: string[];...};
它承担了几个职责:
-
UI 展示 -
CLI/选择器显示 -
文档跳转 -
别名和排序 -
某些平台偏好表达
所以一个渠道插件首先要回答的不是“怎么连 API”,而是:
我是谁?在系统里怎么被展示和选择?
第 3 段:ChannelAccountSnapshot
export type ChannelAccountSnapshot = {accountId: string;name?: string;enabled?: boolean;configured?: boolean;linked?: boolean;running?: boolean;connected?: boolean;...};
这个类型非常重要,因为它揭示了 OpenClaw 的状态模型不是布尔类型。
一个渠道账户可能同时处于不同维度的状态:
-
是否配置完成 -
是否启用 -
是否已连接 -
是否正在运行 -
最近错误是什么 -
最近事件是什么时候
这会直接影响:
-
status 命令 -
健康检查 -
渠道运维体验
第 4 段:ChannelCapabilities
export type ChannelCapabilities = {chatTypes: Array<ChatType | "thread">;polls?: boolean;reactions?: boolean;edit?: boolean;unsend?: boolean;reply?: boolean;effects?: boolean;groupManagement?: boolean;threads?: boolean;media?: boolean;nativeCommands?: boolean;blockStreaming?: boolean;};
这是渠道抽象里值得学习的设计。
它不是写:
-
if channel === telegram -
if channel === slack
而是让渠道自己声明能力,再由上层按能力做决策。
这类设计的好处非常大:
-
新平台加入时不必修改所有上层分支判断 -
业务逻辑围绕 capability,而不是平台名字 -
平台差异被压缩成统一的能力表述
所以 ChannelCapabilities 本质上是“平台差异的声明式接口”。
第 5 段:线程、消息动作、目录这些“次级领域对象”
types.core.ts 后半段还定义了很多次级对象,例如:
-
ChannelMentionAdapter 对应的数据上下文 -
ChannelThreadingContext -
ChannelThreadingToolContext -
ChannelDirectoryEntry -
ChannelMessageActionContext -
ChannelAgentTool
这里值得注意的是:
OpenClaw 并没有把“渠道能力”只理解成收发文本消息。
它还统一抽象了:
-
线程回复 -
联系人/群组目录 -
动作按钮 -
agent 可发现工具
这说明渠道层不是一个 webhook wrapper,而是一个较完整的交互能力层。
第 2 层:types.adapters.ts 定义“行为插槽”
如果说 types.core.ts 定义的是名词,那 types.adapters.ts 定义的就是动词。
这里每个 adapter 都代表一块独立行为面,例如:
-
配置 -
setup -
outbound -
status -
gateway -
auth -
heartbeat -
directory -
resolver -
security
理解这一层时,最重要的原则是:
一个渠道不是实现一个大而全类,而是按需提供多个小行为模块。
第 6 段:ChannelConfigAdapter 是真正的必经之路
export type ChannelConfigAdapter<ResolvedAccount> = {listAccountIds: (cfg: OpenClawConfig) => string[];resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;...};
这层为什么重要?
因为系统无论做什么,几乎都得先知道:
-
这个渠道有哪些账户 -
当前账户配置如何解析成运行时对象
所以在 ChannelPlugin 顶层里,config 是少数必填字段之一。
这也解释了为什么 ResolvedAccount 这个泛型存在:
-
各平台配置长得完全不同 -
但核心层只要求“你能把配置解析成一个可用账户对象”
这是一种非常实用的抽象:
-
保留平台自由度 -
统一核心调用入口
第 7 段:ChannelSetupAdapter
export type ChannelSetupAdapter = {resolveAccountId?: ...resolveBindingAccountId?: ...applyAccountName?: ...applyAccountConfig: ...validateInput?: ...};
这一层处理的是“初始化配置写入”。
也就是说,渠道插件不只是运行时适配器,还参与:
-
初始账户建立 -
配置写入 -
输入校验
这意味着 onboarding / configure 等 CLI 流程并不是写死平台细节,而是可以调用渠道自己的 setup 能力。
第 8 段:ChannelOutboundAdapter
export type ChannelOutboundAdapter = {deliveryMode: "direct" | "gateway" | "hybrid";chunker?: ...chunkerMode?: "text" | "markdown";textChunkLimit?: number;resolveTarget?: ...sendPayload?: ...sendText?: ...sendMedia?: ...sendPoll?: ...};
这一层是“发消息”抽象,不只是一个 sendText()。
里面已经体现出很多真实平台差异:
-
发送路径可能不同:direct/gateway/hybrid -
文本分块规则不同 -
目标解析规则不同 -
平台对 poll/media/payload 的支持不同
所以 OpenClaw 的 outbound 层本质上是在做:
统一发送接口 + 保留平台发送策略差异
第 9 段:ChannelStatusAdapter
export type ChannelStatusAdapter<ResolvedAccount, Probe = unknown, Audit = unknown> = {defaultRuntime?: ChannelAccountSnapshot;buildChannelSummary?: ...probeAccount?: ...auditAccount?: ...buildAccountSnapshot?: ......};
这说明“状态检查”不是核心层自己定义,而是交由渠道补充。
为什么需要这样?
因为每个平台判断健康的方法都不一样:
-
有的平台靠 token 探测 -
有的平台靠 websocket / long poll 状态 -
有的平台要做额外 audit
所以 core 只定义状态框架,具体 probe/audit 由渠道给出。
第 10 段:ChannelGatewayAdapter 是运行时入口
export type ChannelGatewayAdapter<ResolvedAccount = unknown> = {startAccount?: (ctx: ChannelGatewayContext<ResolvedAccount>) => Promise<unknown>;stopAccount?: (ctx: ChannelGatewayContext<ResolvedAccount>) => Promise<void>;loginWithQrStart?: ...loginWithQrWait?: ...logoutAccount?: ...};
这是整个渠道系统最核心的 adapter 之一。
它控制的是:
-
gateway 启动时如何拉起该渠道账户 -
gateway 停止时如何清理 -
某些平台如何完成二维码登录 -
如何注销账户
第 11 段:ChannelGatewayContext 的真正意义
export type ChannelGatewayContext<ResolvedAccount = unknown> = {cfg: OpenClawConfig;accountId: string;account: ResolvedAccount;runtime: RuntimeEnv;abortSignal: AbortSignal;log?: ChannelLogSink;getStatus: () => ChannelAccountSnapshot;setStatus: (next: ChannelAccountSnapshot) => void;channelRuntime?: PluginRuntime["channel"];};
这里至少暴露了 3 个设计意图:
1. 渠道运行时有自己的状态面
通过 getStatus / setStatus,渠道可以维护自身运行态,而不是把状态塞到全局黑盒里。
2. 渠道生命周期受统一 runtime 管理
通过 abortSignal,系统可以优雅关闭渠道,而不是靠每个平台自己处理退出。
3. 外部插件和内置渠道的能力边界不同
channelRuntime?: PluginRuntime[“channel”] 这点很关键。
源码注释中写得很清楚:
-
外部插件可以通过这个字段拿到高级 runtime helpers -
内置渠道通常直接 import 内部模块
这说明 OpenClaw 很明确地区分了:
-
core 内置实现 -
插件 SDK 可见实现
所以插件系统不是简单源码复用,而是有明确 API 边界。
第 12 段:ChannelSecurityAdapter
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {resolveDmPolicy?: ...collectWarnings?: ...};
这一层很能体现 OpenClaw 的安全观。
系统没有把安全策略写死成“所有平台都一样”,而是允许渠道决定:
-
DM policy 如何解析 -
是否有特定平台警告
这很合理,因为不同平台的身份模型和消息模型并不相同。
所以安全不是单独外挂的一层,而是渠道契约的一部分。
第 13 段:其他 adapter 的意义
除了上面几块主干,types.adapters.ts 还定义了很多补充行为面:
-
ChannelGroupAdapter -
ChannelPairingAdapter -
ChannelAuthAdapter -
ChannelHeartbeatAdapter -
ChannelDirectoryAdapter -
ChannelResolverAdapter -
ChannelElevatedAdapter -
ChannelCommandAdapter
这些 adapter 合起来说明一件事:
渠道插件不只负责“消息收发”,还负责消息边界、安全边界、查找边界、登录边界和运维边界。
这也是为什么 OpenClaw 的渠道抽象看起来会比一般 bot 框架重很多。
第 3 层:最后回到 types.plugin.ts
看完 core types 和 adapters 之后,再回头看 ChannelPlugin 就清楚多了:
export type ChannelPlugin<ResolvedAccount = any,Probe = unknown,Audit = unknown> = {id: ChannelId;meta: ChannelMeta;capabilities: ChannelCapabilities;defaults?: ...reload?: ...onboarding?: ChannelOnboardingAdapter;config: ChannelConfigAdapter<ResolvedAccount>;configSchema?: ChannelConfigSchema;setup?: ChannelSetupAdapter;pairing?: ChannelPairingAdapter;security?: ChannelSecurityAdapter<ResolvedAccount>;groups?: ChannelGroupAdapter;mentions?: ChannelMentionAdapter;outbound?: ChannelOutboundAdapter;status?: ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;gatewayMethods?: string[];gateway?: ChannelGatewayAdapter<ResolvedAccount>;auth?: ChannelAuthAdapter;elevated?: ChannelElevatedAdapter;commands?: ChannelCommandAdapter;streaming?: ChannelStreamingAdapter;threading?: ChannelThreadingAdapter;messaging?: ChannelMessagingAdapter;agentPrompt?: ChannelAgentPromptAdapter;directory?: ChannelDirectoryAdapter;resolver?: ChannelResolverAdapter;actions?: ChannelMessageActionAdapter;heartbeat?: ChannelHeartbeatAdapter;agentTools?: ChannelAgentToolFactory | ChannelAgentTool[];};
现在你就能明白这不是“大接口”,而是“装配表”。
ChannelPlugin 的职责其实很简单:
-
声明身份 -
声明能力 -
提供配置入口 -
按需挂接若干行为 adapter
这就是 OpenClaw 渠道扩展机制最核心的设计。
哪些字段最关键
如果只抓主干,最关键的是这些:
-
id -
meta -
capabilities -
config -
gateway -
outbound -
status
其中:
-
id/meta/capabilities 决定“这个渠道是什么” -
config 决定“系统怎么理解它的账户” -
gateway 决定“系统怎么运行它” -
outbound 决定“系统怎么通过它发消息” -
status 决定“系统怎么观测它”
如何增加一个最小可用渠道
应该先问 4 个问题:
-
我的账户配置怎么解析?config -
我的运行监听怎么启动和停止?gateway -
我怎么把文本发出去?outbound -
我想向 core 声明哪些能力?capabilities
也就是说,一个最小可用渠道,通常只需要:
-
id -
meta -
capabilities -
config -
gateway -
outbound
其余能力按需补充。
为什么这种设计能撑住 20+ 平台
核心原因有 4 个:
-
平台差异被拆成多个小 adapter,而不是一个巨型实现接口。 -
上层逻辑尽量依赖 capability 和 adapter,而不是平台名字。 -
内置渠道和外部插件共享同一套主契约。 -
运行时、安全、状态、目录、线程这些复杂能力都被提前纳入抽象边界。
这让 OpenClaw 的渠道系统既能扩展,又不至于把核心层写成平台特例地狱。
把三层文件压缩成一句话
你可以这样记:
types.core.ts 定义渠道对象types.adapters.ts 定义对象可实现的行为types.plugin.ts 把对象和行为装配成一个 ChannelPlugin
读完这一篇后你应该能回答
-
ChannelConfigAdapter 为什么是少数必填能力之一? -
ChannelGatewayAdapter 和 ChannelOutboundAdapter 各自负责什么? -
外部插件为什么需要 channelRuntime 这种 SDK 边界?
下一篇预告
开始沿真实消息执行链进入agent大脑。
夜雨聆风