当一个 AI 助手需要同时对接 WhatsApp、Telegram、Discord、Slack 等底层协议各异的即时通讯平台时,我们应该如何设计系统架构?今天,我们通过剖析开源项目 Clawdbot 的源码,来学习它如何用一套优雅的插件化架构,解决这个典型的“异构平台适配”难题。
核心挑战:多平台差异性与统一诉求
不同 IM 平台在认证方式、消息格式、群组管理逻辑以及推送机制上几乎毫无共同点。最直观的暴力解法是为每个平台单独编写一套对接代码,但这会带来巨大的维护成本。Clawdbot 采用的策略则高明许多:定义一套统一的插件接口,让每个平台去适配它,从而实现“一套核心逻辑,驱动多个前端”。
架构灵魂:ChannelPlugin 接口定义
整个多端适配架构的核心位于 src/channels/plugins/types.plugin.ts。这个文件定义了一个 ChannelPlugin 类型,它就是每个 IM 平台必须实现的“协议契约”。
export type ChannelPlugin<ResolvedAccount = any> = {
id: ChannelId;
meta: ChannelMeta;
capabilities: ChannelCapabilities;
// 各种可选适配器 —— 不支持的能力直接不实现
onboarding?: ChannelOnboardingAdapter;
config: ChannelConfigAdapter<ResolvedAccount>;
pairing?: ChannelPairingAdapter;
security?: ChannelSecurityAdapter<ResolvedAccount>;
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
outbound?: ChannelOutboundAdapter;
status?: ChannelStatusAdapter<ResolvedAccount>;
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;
agentTools?: ChannelAgentToolFactory | ChannelAgentTool[];
// ...更多适配器
};
这里的设计非常巧妙:除了 config 适配器是必需的,其他所有适配器都是 可选(optional) 的。这并非偷懒,而是一种高明的架构决策——不同平台的能力差异巨大,通过“可选字段 + 运行时能力检测”的方式,远比强迫每个平台实现一堆空方法要优雅和灵活得多。
相应地,每个平台插件还需要明确声明自己支持哪些能力:
export type ChannelCapabilities = {
chatTypes: Array<"direct" | "group" | "channel" | "thread">;
polls?: boolean; // 投票
reactions?: boolean; // 表情回应
edit?: boolean; // 消息编辑
unsend?: boolean; // 撤回
reply?: boolean; // 引用回复
threads?: boolean; // 线程
media?: boolean; // 媒体发送
nativeCommands?: boolean; // 平台原生命令(如 Telegram 的 /start)
blockStreaming?: boolean; // 是否支持流式输出阻断
};
这种 能力声明与具体实现解耦 的设计是一大亮点。上层的业务逻辑(如路由)只需要检查这个 capabilities 对象,就能知道当前渠道能否执行某项操作,彻底避免了 if (channel === ‘telegram’) 这类硬编码的平台判断,使得系统核心与具体平台实现高度隔离。
巧妙的分层:Plugin 与 Dock 的职责分离
架构中另一个有意思的设计是同时存在 ChannelPlugin 和 Dock 两个概念。
- ChannelPlugin:是一个完整的频道实现,包含
gateway 监听、消息处理、onboarding 等“重”逻辑。加载时通常会引入 Puppeteer(用于 WhatsApp)、Discord.js、slack-bolt 等重量级平台 SDK。
- Dock:是一个轻量级的元数据与行为适配层,专门用于共享代码路径,以规避重量级依赖的导入。
我们可以看看 src/channels/dock.ts 中 Telegram 的 dock 定义:
telegram: {
id: "telegram",
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
nativeCommands: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map(e => String(e).trim().replace(/^(telegram|tg):/i, "").toLowerCase()),
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.MessageThreadId ?? context.ReplyToId,
hasRepliedRef,
}),
},
}
这种分层有效地解决了一个经典工程问题:共享的工具函数不应该被迫导入所有平台的重量级 SDK。例如,路由模块、白名单校验、群组策略检查等逻辑需要跨所有平台复用,使用轻量的 Dock 来获取所需的行为描述就足够了,无需加载完整的 ChannelPlugin 及其所有依赖。
灵活的路由:基于 SessionKey 的层次化设计
每条进入系统的消息,最终都需要被路由到某个特定的 AI Agent 进行处理。这个路由系统的核心是 src/routing/session-key.ts 和 src/routing/resolve-route.ts。
Session Key 的格式是层次化的:agent:{agentId}:{channel}:{peerKind}:{peerId}。例如:
agent:main:telegram:user:123456 → 代表 Telegram 用户 123456 与主 Agent 的会话。
agent:main:discord:guild:987654 → 代表 Discord 服务器 987654 内的会话。
路由解析遵循严格的优先级顺序(定义在 src/routing/resolve-route.ts):
export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {
// 优先级从高到低:
// 1. binding.peer — 特定用户绑定(最精确)
// 2. binding.guild — Discord 服务器绑定
// 3. binding.team — Slack 工作区绑定
// 4. binding.account — 账号级绑定
// 5. binding.channel — 渠道级绑定(accountId: "*",通配)
// 6. default — 兜底 Agent
}
这套路由机制使得复杂的多租户、多 Agent 场景可以通过纯配置实现。例如,你可以在同一个 Discord 服务器的 #general 频道使用 Agent A,在 #dev 频道使用 Agent B,而与某个特定用户私聊时则启用 Agent C——所有这些都无需修改任何代码。
统一的出站管道:处理消息发送的“暗坑”
发送消息看似简单,实则隐藏着许多平台差异带来的“暗坑”:
- 消息格式:Telegram 支持 HTML,Discord 用 Markdown,Slack 有自有的 mrkdwn。
- 字数限制:各平台不同(如 Telegram 4000字,Discord 2000字)。
- 分块逻辑:长消息需要切割,但不能切断 Markdown 或 HTML 的语法结构。
src/infra/outbound/deliver.ts 统一处理了这些问题。每个平台在其 ChannelOutboundAdapter 中声明自己的发送逻辑:
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null; // 自定义分块函数
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
};
以 Telegram 为例,它使用了自定义的 markdownToTelegramHtmlChunks 分块器——先将 Markdown 转换为 Telegram 专用的 HTML 格式,再按照 4000 字限制进行切割,确保每个分块都是语法完整的 HTML(不会切断 <b> 这类标签)。
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks, // Markdown → Telegram HTML,再切块
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
return await send(to, text, {
textMode: "html",
messageThreadId: parseThreadId(threadId),
replyToMessageId: parseReplyToMessageId(replyToId),
accountId: accountId ?? undefined,
});
},
};
上层的 deliver.ts 模块完全无需了解 Telegram 的具体细节,它只负责三件事:
- 获取当前渠道的
outbound 适配器。
- 根据
textChunkLimit 和 chunker 函数对消息内容进行分块。
- 逐块调用适配器中定义的
sendText、sendMedia 等方法。
这种设计将平台差异封装在适配器内部,实现了发送逻辑的统一接口、差异实现。
消息归一化:跨平台的“通用语言”层
不同平台对于用户ID、群组ID的格式五花八门。Clawdbot 在每个渠道适配器内部都定义了一套归一化函数,用于将平台原生的格式统一转换为内部标准格式。
例如,Telegram 的归一化函数需要处理 telegram:123、tg:123、@username、t.me/username 等多种输入,最终输出统一的 telegram:123456789 格式。Signal 和 WhatsApp 也有类似的逻辑,分别处理 UUID、电话号码等不同格式的标识符。
这层“翻译”至关重要,它保证了无论消息来自哪个平台,一旦进入核心路由系统,就变为了统一的“内部格式”。下游的 AI Agent、日志系统、配置文件都无需再关心各种奇特的平台ID格式,大大降低了系统的复杂度。
可扩展的插件系统:如何轻松接入新平台
整个架构最精妙之处在于其出色的扩展性。以 extensions/zalo/src/channel.ts 为例,可以看到接入一个新平台(如 Zalo)是多么简洁:
- 声明
capabilities(例如,仅支持私聊,不支持线程)。
- 实现
config 适配器(用于解析账号配置)。
- 实现
outbound 适配器(设置2000字限制和自定义分块逻辑)。
- 实现
gateway 适配器(通过 webhook 或 polling 监听消息)。
- (可选)实现
pairing 适配器(定义白名单配对流程)。
你不需要触及任何核心系统代码。extensions/ 目录下的 msteams、matrix、zalouser、voice-call 等扩展模块,都是通过同样的插件化方式接入的。这充分展现了基于接口和适配器的插件化架构所带来的强大力量。
总结:三个值得借鉴的核心设计模式
- 能力声明优先:使用
ChannelCapabilities 对象进行能力声明,替代传统的 if/else 平台判断。这让上层业务逻辑与具体平台实现彻底解耦,新增平台只需声明能力,无需修改现有路由或处理逻辑。
- 轻重分离的双层架构:通过 Plugin(重) 和 Dock(轻) 的分离,让共享的工具函数和核心路由避免导入重量级的平台SDK。这种设计在大型单体仓库(Monorepo)中对于优化构建速度和依赖管理非常有价值。
- 层次化的路由键与优先级链:
SessionKey 的层次化设计(peer > guild/team > account > channel > default)配合明确的路由优先级,使得多租户、多 Agent 的复杂场景可以通过配置灵活管理,无需在代码层编写特殊判断逻辑。
Clawdbot 这套架构的核心思路,在于充分利用 TypeScript 的结构化类型系统,将“平台差异”封装为一组定义良好的、可选的适配器接口。每个平台插件只需实现自己支持的那部分能力,缺失的能力会被上层逻辑自动降级或忽略。
如果你正在构建一个需要对接多个第三方API或平台的服务,这种插件化、接口驱动的设计思路具有很高的复用价值。它不仅提升了代码的可维护性和可扩展性,也使得团队能够并行开发不同平台的适配器,显著提高开发效率。对于在 Node.js 生态下构建复杂集成系统的开发者而言,这是一次很好的架构示范。更多的系统设计思路和架构模式,你可以在 云栈社区 的后端与架构板块找到深入的讨论。