8.8 KiB
Q20-Q21: Channel 适配器
Q20: 如果要加一个新的 channel(比如钉钉),需要实现什么接口、改哪些文件?
答案
ChannelAdapter 接口(完整合约)
定义在 src/channels/adapter.ts:111-167:
必需属性:
| 属性 | 类型 | 说明 |
|---|---|---|
name |
string |
适配器显示名称 |
channelType |
string |
唯一类型标识(如 'dingtalk') |
supportsThreads |
boolean |
true=平台以线程为主要对话单元,false=频道本身就是对话 |
必需方法:
| 方法 | 签名 | 说明 |
|---|---|---|
setup(config) |
(config: ChannelSetup) => Promise<void> |
初始化连接,注册事件 handler |
teardown() |
() => Promise<void> |
优雅关闭 |
isConnected() |
() => boolean |
连接状态 |
deliver(platformId, threadId, message) |
(string, string|null, OutboundMessage) => Promise<string|undefined> |
发送出站消息;如有则返回平台消息 ID |
可选方法:
| 方法 | 何时实现 | 用途 |
|---|---|---|
setTyping?(platformId, threadId) |
有 typing 指示器的聊天平台 | 显示 "bot is typing..." |
syncConversations?() |
有频道发现的平台 | 列出所有可访问的对话 |
resolveChannelName?(platformId) |
显示需要 | 频道 ID 的人类可读名称 |
subscribe?(platformId, threadId) |
有线程订阅的平台 | 订阅 bot 到线程;幂等——调用两次是 no-op |
openDM?(userHandle) |
需要区分 user ID 和 DM channel ID 的平台 | 打开或获取 DM 频道,返回 DM 的 platform_id。仅 Discord、Slack、Teams、Webex、gChat 需要。Telegram、WhatsApp、iMessage 等跳过——user handle 就是 DM chat ID |
ChannelSetup 接口(适配器拿到的回调)
定义在 adapter.ts:9-26:
interface ChannelSetup {
onInbound(platformId, threadId, message: InboundMessage): void | Promise<void>; // 入站消息
onInboundEvent(event: InboundEvent): void; // CLI/admin 传输
onMetadata(platformId, name?, isGroup?): void; // 频道元数据
onAction(questionId, selectedOption, userId): void; // 按钮点击
}
两种实现模式
模式 1:原生 Adapter — 直接实现 ChannelAdapter,处理平台原始协议(HTTP API、WebSocket 等)。示例:WhatsApp (Baileys)、Signal、iMessage。
模式 2:Chat SDK Bridge — 用 createChatSdkBridge(config)(chat-sdk-bridge.ts:122)把已有 Chat SDK Adapter 包装成 ChannelAdapter。用于 Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat。
加新 Channel 要创建/修改的文件
-
创建适配器模块:
- 如果用 Chat SDK:
src/channels/dingtalk.ts调createChatSdkBridge({...}) - 如果用原生:
src/channels/dingtalk.ts直接实现ChannelAdapter
- 如果用 Chat SDK:
-
自注册: 调
registerChannelAdapter('dingtalk', { factory, containerConfig? }) -
接入导入: 在
src/channels/index.ts添加import './dingtalk.js';(启动时加载所有适配器的 barrel) -
Container config(可选): 如果适配器需要在 agent 容器内额外 mount 或 env var:
registerChannelAdapter('dingtalk', { factory: () => createDingTalkAdapter(), containerConfig: { mounts: [{ hostPath: '/path/to/certs', containerPath: '/certs', readonly: true }], env: { DINGTALK_APP_KEY: process.env.DINGTALK_APP_KEY! }, }, }); -
Skill 打包: 按
CONTRIBUTING.md规范写成 channel install skill:skills/add-dingtalk/SKILL.md— 面向用户的指引- Skill 从
channels分支复制src/channels/dingtalk.ts,追加 import 到 barrel,pnpm install <pkg>
关键边界情况
| 情况 | 如何处理 |
|---|---|
| 缺少凭据 | Factory 返回 null → initChannelAdapters 跳过并 warn(channel-registry.ts:57-59) |
| Setup 时网络错误 | 指数退避重试:[2s, 5s, 10s](channel-registry.ts:10,68-87) |
| 重复注册 | registerChannelAdapter(line 26):registry.set() 静默覆盖 |
isMention flag 传播 |
知道平台 mention 语义的适配器设置 InboundMessage.isMention——router 用它替代正则 name-matching |
Q21: Chat SDK bridge 是什么?为什么 Discord/Slack/Telegram 等共用它?
答案
什么是 Chat SDK Bridge
Chat SDK bridge(src/channels/chat-sdk-bridge.ts,680 行)是一个通用适配器 shim,把任何 Chat SDK Adapter 实例包装成 NanoClaw 兼容的 ChannelAdapter。它一次性处理平台无关的 concern,让每个具体 channel 模块只需提供平台特定配置。
核心工厂函数 createChatSdkBridge(config)(line 122),返回完整实现所有必需和可选方法的 ChannelAdapter 对象。
Bridge 集中化的 10 项 concern
-
四种 dispatch 路径(line 213-266)——Chat SDK 区分四种入站消息,bridge 把它们映射为正确的
isMentionflag:onSubscribedMessage→onInbound(channelId, threadId, message, isMention=message.isMention)onNewMention→onInbound(channelId, threadId, message, isMention=true)onDirectMessage→onInbound(channelId, threadId, message, isMention=true, isGroup=false)onNewMessage(/[\s\S]*/)→onInbound(channelId, threadId, message, isMention=false, isGroup=true)
-
附件下载(line 138-163)——序列化消息到 JSON 前先下载附件为 base64,使其在
inbound.dbcontent 列中存活 -
Reply context 提取(line 164-169)——平台特定 hook(
extractReplyContext) -
Sender 字段归一化(line 173-180)——把 Chat SDK 的嵌套
author对象投影为 router 需要的扁平senderId/sender/senderName -
卡片渲染(line 387-470)——把
ask_question和send_cardMCP tool payload 渲染为带按钮的 Card。处理按钮编码(整数 index vs 全值,适配 Telegram 64 字节 callback_data 限制) -
文本拆分(line 480-497,
splitForLimitline 106-120)——按 paragraph→line→space→char 边界拆分消息,适配maxTextLength(Discord 2000,Telegram 4096) -
Gateway listener(line 303-359)——对支持 Gateway 的适配器(Discord),启动本地 HTTP server 接收转发事件,指数退避重启(上限 1h)
-
Webhook 注册(line 362)——对非 gateway 适配器(Slack、Teams、GitHub),注册到共享 webhook server
-
openDM委托(line 534-549)——直接委托adapter.openDM()而非chat.openDM() -
Transform hook(line 124)——
transformOutboundText允许 per-platform 文本清理
原生 Adapter vs Chat SDK Bridge
| 方面 | Chat SDK Bridge | 原生 Adapter |
|---|---|---|
| 平台协议 | 由 Chat SDK 的 Adapter 处理 |
直接处理(如 Baileys 处理 WhatsApp WebSocket) |
| 消息解析 | Chat SDK 归一化为 Message 类型;bridge 序列化为 JSON |
Adapter 解析原始平台 payload,直接构造 InboundMessage |
| Dispatch | Bridge 映射 4 个 SDK dispatch 路径到 onInbound |
Adapter 从自己的 event handler 直接调用 onInbound |
| 卡片/按钮渲染 | Bridge 用 Chat SDK 的 Card/Button/Actions 组件渲染 |
Adapter 必须实现平台特定的交互式消息渲染 |
| Gateway/Webhook | Bridge 处理 Gateway listener 生命周期 | Adapter 自己处理连接生命周期 |
| openDM | 委托 adapter.openDM() |
Adapter 通过平台 API 直接实现 |
| Channel ID 编码 | adapter.channelIdFromThreadId() |
Adapter 用自定义 ID 方案 |
| 示例 | Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat、Resend | WhatsApp (Baileys)、Signal、iMessage |
Bridge 处理的边界情况
| 情况 | 位置 |
|---|---|
| Gateway crash → IP block 保护 | Line 314-357:指数退避上限 1h,5min 健康运行后重置计数器 |
| Telegram 64 字节 callback_data 限制 | Line 400-408:把 button value 编码为整数 index |
| Discord interaction 更新 | Line 607-668:通过 Discord REST API 更新卡片,再 dispatch 到 onAction |
| 序列化时附件数据丢失 | Line 138-163:调用 toJSON() 前先下载附件数据为 base64 |
| 媒体 only 空文本消息 | Line 263:onNewMessage(/[\s\S]*/) 匹配所有消息包括空文本 |
chat.openDM 对非标准 user ID 抛错 |
Line 534-549:直接委托 adapter.openDM() |
| Raw message 字段过大 | Line 183:serialized.raw = undefined 省 DB 空间 |
user-dm.ts 兜底
对于没有 openDM 支持的 channel(Telegram、WhatsApp、iMessage、email、Matrix),src/user-dm.ts 直接把 user handle 当 DM platform_id——user 本身就是 DM chat。Bridge 仅当底层 adapter.openDM 存在时才附着 openDM(line 544)。