从零开发一个 OpenClaw Channel 插件:架构解析与实战踩坑
⚠️ 版本说明:本文基于 OpenClaw
2026.3.23及更早版本的插件机制编写。自
2026.3.28版本起,OpenClaw 对 Channel Plugin SDK 进行了重大重构,引入了全新的两层架构(Plugin SDK + Plugin Runtime)。新版本提供了defineChannelPluginEntry、createChatChannelPlugin、OpenClawPluginApi等高级 API,插件不再直接导出ChannelPlugin对象,而是通过openclaw/plugin-sdk/core和openclaw/plugin-sdk/plugin-entry统一入口注册。本文介绍的是旧版"直接导出 ChannelPlugin 对象"的模式,适用于理解 Channel 的核心概念、Inbound/Outbound 消息流转和接口设计哲学。如果你正在开发新插件,请优先参考 官方文档 中的新版 SDK 指南,并以
openclaw/plugin-sdk的最新类型定义为准。运行
openclaw --version查看你的当前版本。
在上一篇文章中,我们从宏观视角概览了 OpenClaw 的五层架构。今天,我们将深入探讨其中最核心的抽象之一 —— Channel(通道)机制,并结合实际插件开发经验,手把手带你实现一个新的消息通道。
你能学到什么? Channel 接口的设计哲学、Inbound/Outbound 的完整消息流转、插件生命周期,以及我们在实战中踩过的那些坑。
一、为什么需要 Channel 抽象?
想象这样一个场景:你正在开发一个 AI 助手,希望它能同时接入 Telegram、Discord、Slack、飞书等多个消息平台。如果每个平台都独立实现一套消息收发逻辑,代码会变得难以维护。更糟糕的是,当你想添加新平台时,需要深入核心代码进行修改。
OpenClaw 通过 Channel 抽象 解决了这个问题:
- 统一的接口契约:所有消息通道实现同一套
ChannelPlugin接口 - 插件化架构:新通道以插件形式加载,无需修改核心代码
- 关注点分离:核心只关心"发送消息"和"接收消息",不关心具体平台细节
用一句话概括:Channel 是 OpenClaw 与外部消息世界对话的"翻译官"。
二、核心概念速览
2.1 openclaw/plugin-sdk 是什么?
这是 OpenClaw 提供的插件开发工具包。它导出:
- 所有类型定义(
ChannelPlugin、RuntimeEnv等) - 工具函数(
normalizeAccountId、chunkTextForOutbound等) - 平台无关的基础能力
插件开发者只需 import { ... } from "openclaw/plugin-sdk" 即可获得所有必需的工具。
2.2 RuntimeEnv 运行时环境
type RuntimeEnv = {
log: (...args: unknown[]) => void; // 日志输出
error: (...args: unknown[]) => void; // 错误输出
exit: (code: number) => void; // 进程退出
};
为什么需要它? 在 CLI 中日志输出到终端;在 Gateway 中日志可能输出到文件或日志服务;测试时可以 mock 来捕获日志。RuntimeEnv 让插件不需要关心"我运行在哪"。
2.3 OpenClawConfig(别名 ClawdbotConfig)
这是 OpenClaw 的全局配置对象,存储在 ~/.openclaw/config.json:
{
"channels": {
"telegram": { "enabled": true, "token": "xxx" },
"feishu": {
"enabled": true,
"appId": "cli_xxx",
"appSecret": "xxx",
"accounts": {
"work": { "name": "工作账号", "appId": "cli_yyy" }
}
}
},
"agent": { ... },
"routing": { ... }
}
设计要点:
- JSON 文件,易于版本管理和迁移
- 每个通道有自己的配置节点(
channels.feishu) - 支持多账户(
accounts子对象)
⚠️ 实战踩坑:配置节点不能只有
enabled!
OpenClaw 的hasMeaningfulChannelConfig函数会检查 channel 配置对象中是否有除"enabled"之外的至少一个 key。如果你只写了"my-channel": { "enabled": true },插件不会被加载,webhook 路由直接 404。至少要加一个业务字段:"my-channel": { "enabled": true, "groupPolicy": "open" }我们第一次写插件时没注意这个,webhook 一直 404,排查了很久才发现原因。
三、ChannelPlugin 接口设计
3.1 核心类型定义
ChannelPlugin 是所有通道插件必须实现的接口,定义在 src/channels/plugins/types.plugin.ts:
export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
// 标识与元数据
id: ChannelId; // 通道唯一标识,如 "telegram"、"feishu"
meta: ChannelMeta; // 显示标签、文档路径等
capabilities: ChannelCapabilities; // 能力声明
// 配置管理
config: ChannelConfigAdapter<ResolvedAccount>;
configSchema?: ChannelConfigSchema;
// 入站(接收消息)
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
// 出站(发送消息)
outbound?: ChannelOutboundAdapter;
// 可选功能适配器
security?: ChannelSecurityAdapter<ResolvedAccount>;
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
directory?: ChannelDirectoryAdapter;
status?: ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;
// ... 更多可选适配器
};
3.2 接口设计哲学
这个接口设计体现了几个重要原则:
1. 必选与可选分离
核心功能(id、meta、capabilities、config)是必须实现的,而高级功能(security、mentions、directory)则是可选的。这种设计让新通道可以快速实现基础功能,再逐步完善。
2. 适配器模式
每个功能域都是一个独立的适配器接口:
ChannelConfigAdapter负责配置管理ChannelOutboundAdapter负责消息发送ChannelSecurityAdapter负责安全策略
好处是:每个适配器可以独立测试、独立演进。
3. 泛型参数的巧妙运用
ChannelPlugin<ResolvedAccount, Probe, Audit>
ResolvedAccount:解析后的账户类型,不同通道有不同的账户配置结构Probe:健康检查结果类型Audit:审计结果类型
这保证了类型安全的同时,又保持了接口的灵活性。
四、Inbound/Outbound 机制详解
4.1 完整消息处理时序图
下面展示一条消息从用户发送到收到 AI 回复的完整流程:
┌───────────────────────────────────────────────────────────────────┐
│ 用户发送消息 │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ 消息平台服务器 │
│ 通过 WebSocket / Webhook / 轮询 等方式将事件推送到 OpenClaw │
└───────────────────────────────────────────────────────────────────┘
│
▼ 原始事件数据
┌───────────────────────────────────────────────────────────────────┐
│ 插件 monitor 层 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ monitorProvider() │ │
│ │ ├─ 建立连接(WebSocket / HTTP Server / 轮询) │ │
│ │ └─ registerEventHandlers(eventDispatcher) │ │
│ └───────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
│
▼ 事件分发
┌───────────────────────────────────────────────────────────────────┐
│ 插件 bot 层 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ handleMessage({ event, cfg, accountId }) │ │
│ │ ├─ 解析发送者、会话 ID、消息内容 │ │
│ │ ├─ 调用 core.channel.reply.finalizeInboundContext({...}) │ │
│ │ │ └─ 返回归一化的消息上下文(FinalizedMsgContext) │ │
│ │ └─ 调用 dispatchReplyFromConfig({ ctx, cfg, ... }) │ │
│ └───────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
│
▼ dispatchReplyFromConfig()
┌───────────────────────────────────────────────────────────────────┐
│ OpenClaw 核心处理层 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ dispatchReplyFromConfig() — 核心入口函数 │ │
│ │ ├─ 消息去重 (shouldSkipDuplicateInbound) │ │
│ │ ├─ 解析 Session 配置 │ │
│ │ ├─ Hook 触发 (hookRunner.runMessageReceived) │ │
│ │ ├─ 路由决策:由哪个 Agent 处理? │ │
│ │ ├─ 调用 runEmbeddedPiAgent → 入队 → 调 LLM │ │
│ │ └─ dispatcher.sendFinalReply(payload) │ │
│ └───────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
│
▼ plugin.outbound.sendText()
┌───────────────────────────────────────────────────────────────────┐
│ 插件 outbound 层 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ sendText({ cfg, to, text, accountId }) │ │
│ │ └─ 调用平台 API 发送消息 │ │
│ └───────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ 用户收到 AI 回复 │
└───────────────────────────────────────────────────────────────────┘
4.2 dispatchReplyFromConfig 核心入口函数
这是整个消息处理流程的心脏,定义在 src/auto-reply/reply/dispatch-from-config.ts:
export async function dispatchReplyFromConfig(params: {
ctx: FinalizedMsgContext; // 归一化的消息上下文
cfg: OpenClawConfig; // 全局配置
dispatcher: ReplyDispatcher; // 回复分发器
}): Promise<DispatchFromConfigResult> {
const { ctx, cfg, dispatcher } = params;
// 1. 消息去重检查
if (shouldSkipDuplicateInbound(ctx)) {
return { queuedFinal: false, counts: {} };
}
// 2. 触发 Hook
if (hookRunner?.hasHooks("message_received")) {
await hookRunner.runMessageReceived(ctx);
}
// 3. 执行 Agent,获取回复
const replyResult = await getReplyFromConfig(ctx, cfg);
const replies = replyResult ?? [];
// 4. 发送回复
for (const reply of replies) {
dispatcher.sendFinalReply(reply);
}
return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
}
这个函数的四个步骤清晰明确:去重 → Hook → Agent → 发送。作为插件开发者,你不需要关心这里面的实现,只需要把归一化的 FinalizedMsgContext 正确地传进去。
4.3 另一条路:dispatchReplyWithBufferedBlockDispatcher
飞书作为 OpenClaw 的内置通道(这里是指OpenClaw自带的飞书插件,而不是飞书官方提供的插件),使用的是 dispatchReplyFromConfig——核心全权控制消息派发流程,插件只需提供 outbound.sendText 被动等待调用即可。这对内置通道来说没问题,但对于外部插件开发者,这条路走不通。
原因很简单:dispatchReplyFromConfig 是核心内部函数,外部插件无法(也不应该)直接 import src/** 下的模块。OpenClaw 的 Plugin Runtime API 提供了另一个入口:
api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx, // 消息上下文(插件自己构建)
cfg, // OpenClaw 全局配置
dispatcherOptions: {
deliver: (payload, info) => {
// payload.text — 本次交付的文本片段
// info.kind — "final" 表示最后一块,其余为中间块
await platformApi.sendText(to, payload.text);
},
onError: (err, info) => {
console.error(`[my-channel] deliver failed (${info.kind}):`, err);
},
},
});
两者的核心差异:
dispatchReplyFromConfig |
dispatchReplyWithBufferedBlockDispatcher |
|
|---|---|---|
| 所属层 | 核心内部函数 | Plugin Runtime API(api.runtime.channel.reply.*) |
| 派发控制权 | 核心拥有 ReplyDispatcher,回调插件的 outbound.sendText |
插件提供 deliver 回调,自己决定怎么发 |
| 分块(Block)感知 | 内部处理,插件只看到最终结果 | 每个 block 都会触发 deliver,插件可区分中间块和最终块 |
| 流式能力 | 插件无法介入 | 插件可以做流式更新(如"正在输入"→逐步替换文本) |
| 适用对象 | 内置通道(飞书、Telegram、Discord 等) | 外部 / 自定义通道插件 |
为什么我们选择了 dispatchReplyWithBufferedBlockDispatcher?
- 外部插件的唯一正道。作为通过
openclaw/plugin-sdk开发的外部插件,我们只能使用 Plugin Runtime API,不能直接调用核心内部函数。 - 对投递过程有完全控制权。
deliver回调由插件实现,可以自由决定:调哪个平台 API、是否累积文本后再发、是否在中间块做流式更新、失败时如何降级。 - Block 粒度的感知。LLM 的输出会被 OpenClaw 的
EmbeddedBlockChunker按段落/换行/句子等边界切成多个 block 逐步交付。通过info.kind可以区分中间块和最终块——这让"打字机效果"或"先发占位消息再更新"等 UX 成为可能。 - 生命周期钩子。部分实现还支持
onReplyStart回调,可以在 AI 开始生成回复时发送"思考中…"等提示,提升用户体验。
简而言之:dispatchReplyFromConfig 是引擎内部的曲轴,dispatchReplyWithBufferedBlockDispatcher 是给插件开发者的方向盘。你不需要自己造引擎,但你能决定往哪开。
完整图景:OpenClaw 的三种 Reply Dispatch 方式
| # | 方法 | 所属层 | 外部插件可用 | 状态 |
|---|---|---|---|---|
| 1 | dispatchReplyFromConfig |
核心内部 | 否 | 已稳定,内置通道使用 |
| 2 | dispatchReplyWithBufferedBlockDispatcher |
Plugin Runtime API | 是 | 已稳定,外部插件使用 |
| 3 | createReplyDispatcherWithTyping |
Plugin Runtime API | 尚不可用 | 占位预留(unknown) |
第三种 createReplyDispatcherWithTyping 目前在 SDK 类型定义中标注为 unknown,注释为 “adapter for Teams-style flows”——为 MS Teams 等需要平台原生 Typing Indicator 管理的通道预留(AI 开始生成时自动发送"正在输入",回复完成时自动停止)。截至本文写作时尚未实装,但说明 OpenClaw 团队已经规划了更细粒度的 Dispatch 抽象。
选型建议: 对于外部插件开发者,
dispatchReplyWithBufferedBlockDispatcher是当前唯一可用且推荐的 Dispatch 方式。如果你的平台需要 Typing Indicator,目前可以通过onReplyStart钩子自行实现,未来可关注createReplyDispatcherWithTyping的正式发布。
4.4 入站:从原始事件到统一上下文
入站的核心任务是将各平台的事件格式归一化。无论消息来自哪个平台,最终都会被转换成 FinalizedMsgContext:
type FinalizedMsgContext = {
channel: string; // "telegram" | "discord" | "feishu" | ...
accountId: string; // 账户ID,支持多账号
peerId: string; // 对话ID(群组/私聊)
userId: string; // 发送者ID
text: string; // 消息正文
attachments: Attachment[]; // 附件列表
timestamp: number; // 消息时间戳
// ...
};
为什么需要归一化? 因为不同平台的事件结构差异巨大:
| 平台 | 发送者字段 | 消息ID字段 | 群组ID字段 |
|---|---|---|---|
| Telegram | message.from.id |
message.message_id |
message.chat.id |
| Discord | author.id |
id |
channel_id |
| 飞书 | sender.sender_id.open_id |
message.message_id |
message.chat_id |
归一化后,核心处理逻辑只需面对一种数据结构,大大降低了复杂度。
实战经验:群消息需要额外解析
很多平台的群消息格式和私聊有显著差异。比如有些平台群消息的
text字段会包含"发送者昵称:\n消息内容"这样的前缀,你需要在归一化时把它截取干净。同样,@mention检测也因平台而异——有的平台通过pushContent字段标识,有的通过entities数组。建议:用
fromUser或类似字段判断群聊/私聊(比如是否包含@chatroom后缀),在归一化阶段就把这些差异抹平。
4.5 出站:从统一回复到平台 API
出站适配器负责将统一格式的回复转换成平台特定的 API 调用:
type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
chunker?: (text: string, limit: number) => string[];
textChunkLimit?: number;
};
deliveryMode 详解
deliveryMode 控制谁在哪个进程里真正调用平台 API 发消息:
| 枚举值 | 谁真正发消息 | 典型场景 | 举例 |
|---|---|---|---|
| direct | 当前进程直接调插件的 outbound | 发消息只需 HTTP API + 凭据 | Telegram、飞书、Slack |
| gateway | Gateway 进程代发 | 凭据或连接只存在于 Gateway 机器上 | WhatsApp(Web 登录态在 Gateway) |
| hybrid | 行为与 direct 相同 | 类型预留 | 一般不使用 |
如何选择? 很简单:
- 发消息只需 HTTP API + 凭据(如 Bot Token) → 用 direct
- 发消息依赖 仅在 Gateway 上的状态(浏览器登录态等)→ 用 gateway
五、以飞书插件为例:完整实现剖析
代码位于 extensions/feishu/src/ 目录,我们逐层来看。
5.1 插件入口:定义 ChannelPlugin
channel.ts 是插件的入口文件:
import type { ChannelMeta, ChannelPlugin } from "openclaw/plugin-sdk";
// 1. 定义元数据
const meta: ChannelMeta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu/Lark (飞书)",
docsPath: "/channels/feishu",
blurb: "飞书/Lark enterprise messaging.",
aliases: ["lark"],
order: 70,
};
// 2. 导出插件对象
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: { ...meta },
capabilities: {
chatTypes: ["direct", "channel"],
polls: false,
threads: true,
media: true,
reactions: true,
edit: true,
reply: true,
},
// ... 各适配器实现
};
三个核心要素:
- 元数据(meta):告诉 OpenClaw 这个通道叫什么、文档在哪
- 能力声明(capabilities):声明支持私聊、群聊、媒体、反应等
- 泛型参数:
ResolvedFeishuAccount是飞书特有的账户类型
5.2 配置管理:ChannelConfigAdapter
每个平台有不同的配置需求。配置适配器需要实现账户的增删改查:
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => { /* ... */ },
deleteAccount: ({ cfg, accountId }) => { /* ... */ },
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
name: account.name,
appId: account.appId,
domain: account.domain,
}),
},
设计亮点:配置合并
飞书插件支持"顶级默认配置 + 账户级覆盖"的模式:
{
"channels": {
"feishu": {
"appId": "默认应用ID",
"appSecret": "默认密钥",
"domain": "feishu",
"accounts": {
"work": {
"name": "工作账号",
"appId": "工作应用ID",
"appSecret": "工作密钥"
}
}
}
}
}
work 账户继承顶级的 domain,但使用自己的 appId 和 appSecret。在你的插件中也建议支持这种模式——配置的灵活性直接影响用户体验。
5.3 入站实现:ChannelGatewayAdapter
入站的核心是 monitor.ts,负责接收事件并转换为统一格式:
gateway: {
startAccount: async (ctx) => {
const { monitorFeishuProvider } = await import("./monitor.js");
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
ctx.log?.info(
`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`
);
return monitorFeishuProvider({
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: ctx.accountId,
});
},
},
飞书支持两种连接模式:
| 模式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| WebSocket | 实时性好、无需公网 IP | 需要维持长连接 | 开发测试、内网部署 |
| Webhook | 无需维持连接、更稳定 | 需要公网可访问的 URL | 生产环境、云部署 |
实战经验:Webhook 的签名验证要做成可选
不是所有第三方服务都会对 Webhook 请求做 HMAC 签名。我们遇到过这样的情况:上游服务的
Signature字段永远是空字符串。如果你的插件强制做签名验证,所有真实消息都会被 401 拒绝。建议:让签名验证在
webhookSecret配置为空时自动跳过:if (secret) { // 做 HMAC 验证 } else { // 跳过验证(日志里提醒一下) }
5.4 出站实现:ChannelOutboundAdapter
export const feishuOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 4000,
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
sendText: async ({ cfg, to, text, accountId }) => {
const result = await sendMessageFeishu({ cfg, to, text, accountId });
return { channel: "feishu", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
if (text?.trim()) {
await sendMessageFeishu({ cfg, to, text, accountId });
}
if (mediaUrl) {
const result = await sendMediaFeishu({ cfg, to, mediaUrl, accountId });
return { channel: "feishu", ...result };
}
},
};
注意 textChunkLimit:很多平台有消息长度限制(飞书 4000 字符),AI 的回复可能很长,chunker 负责将长文本智能分割。这个容易被忽略,但不处理的话长回复会被平台截断或拒绝。
5.5 错误处理:降级策略
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
if (mediaUrl) {
try {
const result = await sendMediaFeishu({ cfg, to, mediaUrl, accountId });
return { channel: "feishu", ...result };
} catch (err) {
// 媒体上传失败时,降级为发送链接
console.error(`[feishu] sendMediaFeishu failed:`, err);
const fallbackText = `[附件] ${mediaUrl}`;
const result = await sendMessageFeishu({ cfg, to, text: fallbackText, accountId });
return { channel: "feishu", ...result };
}
}
},
核心原则:不阻塞流程。媒体发送失败就降级为发链接,记录错误便于排查,但永远返回结果。
六、插件生命周期
6.1 生命周期阶段
发现 → 加载 → 注册 → 初始化 → 运行 → 停止
| 阶段 | 入口文件 | 关键函数 | 说明 |
|---|---|---|---|
| 发现/加载 | src/plugins/loader.ts |
loadPlugins() |
扫描 extensions/ 目录 |
| 注册 | src/channels/registry.ts |
registerChannel() |
建立 id → plugin 映射 |
| 初始化 | src/commands/configure.channels.ts |
configureChannel() |
加载配置、验证 Schema |
| 运行 | src/gateway/boot.ts |
startGateway() |
启动 gateway 监听 |
| 停止 | src/gateway/server.impl.ts |
stopGateway() |
关闭连接、清理资源 |
⚠️ 实战踩坑:插件加载的两次
loadGatewayPluginsOpenClaw gateway 启动时会做两次插件加载。HTTP server 的路由 registry 只认第一次加载的结果。也就是说,如果你的插件在第一次加载时没有被包含(比如配置节点缺失),即使第二次加载成功了,注册的 webhook 路由也永远返回 404。
判断标准是
listPotentialConfiguredChannelIds→hasMeaningfulChannelConfig:配置对象必须有除"enabled"之外的字段。
七、实战开发指南:从零开始
7.1 最小可行插件
一个最小的 Channel 插件只需要实现四件事:
id+meta:告诉核心"我是谁"capabilities:声明能力config:账户配置outbound:能发消息
然后可以逐步添加 gateway(接收消息)、security(安全策略)、mentions(@ 提及处理)等。
7.2 插件目录结构
推荐的目录结构:
extensions/my-channel/
├── openclaw.plugin.json # 插件声明文件(必须)
├── src/
│ ├── channel.ts # 插件入口,导出 ChannelPlugin
│ ├── accounts.ts # 账户配置解析
│ ├── monitor.ts # 入站:监听事件
│ ├── outbound.ts # 出站:发送消息
│ ├── bot.ts # 消息处理逻辑
│ └── client.ts # 平台 SDK 封装
├── build.mjs # 构建脚本
├── package.json
└── tsconfig.json
7.3 消息去重
如果你的平台可能重复推送同一条消息(Webhook 重试、网络抖动等),需要在插件层做去重。推荐使用 Redis SETNX:
const dedupKey = `dedup:my-channel:${messageId}`;
const isNew = await redis.set(dedupKey, "1", "EX", 3600, "NX");
if (!isNew) {
// 重复消息,跳过
return;
}
注意:如果消息 ID 是大整数,JS 中要用 String() 处理精度问题。
7.4 按群 / 按用户路由到不同 Agent
OpenClaw 的 bindings 配置支持按 channel + peer 精确路由。精确规则放前面,通配兜底放后面:
[
{
"type": "route",
"agentId": "vip-agent",
"match": {
"channel": "my-channel",
"peer": { "kind": "group", "id": "group_123" }
}
},
{
"type": "route",
"agentId": "default-agent",
"match": { "channel": "my-channel" }
}
]
也支持按私聊用户路由:"peer": { "kind": "direct", "id": "user_456" }。
7.5 部署注意事项
⚠️ 血泪教训:部署时必须先清空扩展目录
部署时绝对不要
cp -r .整目录拷贝。正确做法是:先rm -rf目标目录,再只拷贝必需文件。# 构建 rm -rf dist && node build.mjs # 部署 rm -rf ~/.openclaw/extensions/my-channel mkdir -p ~/.openclaw/extensions/my-channel cp openclaw.plugin.json dist/index.js ~/.openclaw/extensions/my-channel/ # 重启 Gateway launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway # macOS我们曾经因为不清空直接覆盖,导致旧版本代码残留,OpenClaw 重启后加载了旧文件中的过期逻辑,花了大量时间排查。必需文件只有两个:
openclaw.plugin.json和index.js。
八、总结
Channel 机制的设计精髓可以归纳为四个字:契约驱动。
| 设计原则 | 体现 |
|---|---|
| 关注点分离 | 每个适配器独立负责一个功能域 |
| 契约优先 | 所有通道实现统一的 ChannelPlugin 接口 |
| 渐进式实现 | 最小插件只需 4 个必填项,高级功能按需添加 |
| 类型安全 | 泛型参数确保配置和状态类型正确 |
如果你正在计划为 OpenClaw 开发一个新的 Channel 插件,建议按这个顺序推进:
- 先搞定
config+outbound(能发消息) - 再实现
gateway(能收消息) - 最后补充
security、mentions等高级功能
每一步都可以独立测试、独立验证,这就是好架构的价值。
附录:关键文件索引
| 文件路径 | 职责 |
|---|---|
src/channels/plugins/types.plugin.ts |
ChannelPlugin 接口定义 |
src/channels/plugins/types.adapters.ts |
各适配器接口定义 |
src/channels/plugins/types.core.ts |
核心类型定义 |
src/plugin-sdk/index.ts |
插件 SDK 导出入口 |
src/runtime.ts |
RuntimeEnv 定义 |
src/auto-reply/reply/dispatch-from-config.ts |
消息分发核心函数 |
extensions/feishu/src/channel.ts |
飞书插件入口(参考实现) |
extensions/feishu/src/monitor.ts |
飞书入站监听(参考实现) |
extensions/feishu/src/outbound.ts |
飞书出站发送(参考实现) |
本文基于 OpenClaw Channel 插件的实际开发经验撰写,结合架构分析与踩坑记录,希望能帮助你少走弯路。
更多内容请参考 OpenClaw 官方文档
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)