# 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` | 初始化连接,注册事件 handler | | `teardown()` | `() => Promise` | 优雅关闭 | | `isConnected()` | `() => boolean` | 连接状态 | | `deliver(platformId, threadId, message)` | `(string, string\|null, OutboundMessage) => Promise` | 发送出站消息;如有则返回平台消息 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`: ```typescript interface ChannelSetup { onInbound(platformId, threadId, message: InboundMessage): void | Promise; // 入站消息 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 要创建/修改的文件 1. **创建适配器模块:** - 如果用 Chat SDK:`src/channels/dingtalk.ts` 调 `createChatSdkBridge({...})` - 如果用原生:`src/channels/dingtalk.ts` 直接实现 `ChannelAdapter` 2. **自注册:** 调 `registerChannelAdapter('dingtalk', { factory, containerConfig? })` 3. **接入导入:** 在 `src/channels/index.ts` 添加 `import './dingtalk.js';`(启动时加载所有适配器的 barrel) 4. **Container config(可选):** 如果适配器需要在 agent 容器内额外 mount 或 env var: ```typescript registerChannelAdapter('dingtalk', { factory: () => createDingTalkAdapter(), containerConfig: { mounts: [{ hostPath: '/path/to/certs', containerPath: '/certs', readonly: true }], env: { DINGTALK_APP_KEY: process.env.DINGTALK_APP_KEY! }, }, }); ``` 5. **Skill 打包:** 按 `CONTRIBUTING.md` 规范写成 channel install skill: - `skills/add-dingtalk/SKILL.md` — 面向用户的指引 - Skill 从 `channels` 分支复制 `src/channels/dingtalk.ts`,追加 import 到 barrel,`pnpm install ` ### 关键边界情况 | 情况 | 如何处理 | |------|---------| | 缺少凭据 | 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](https://github.com/nicepkg/chat) `Adapter` 实例包装成 NanoClaw 兼容的 `ChannelAdapter`。它一次性处理平台无关的 concern,让每个具体 channel 模块只需提供平台特定配置。 核心工厂函数 `createChatSdkBridge(config)`(line 122),返回完整实现所有必需和可选方法的 `ChannelAdapter` 对象。 ### Bridge 集中化的 10 项 concern 1. **四种 dispatch 路径**(line 213-266)——Chat SDK 区分四种入站消息,bridge 把它们映射为正确的 `isMention` flag: - `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)` 2. **附件下载**(line 138-163)——序列化消息到 JSON 前先下载附件为 base64,使其在 `inbound.db` content 列中存活 3. **Reply context 提取**(line 164-169)——平台特定 hook(`extractReplyContext`) 4. **Sender 字段归一化**(line 173-180)——把 Chat SDK 的嵌套 `author` 对象投影为 router 需要的扁平 `senderId`/`sender`/`senderName` 5. **卡片渲染**(line 387-470)——把 `ask_question` 和 `send_card` MCP tool payload 渲染为带按钮的 Card。处理按钮编码(整数 index vs 全值,适配 Telegram 64 字节 callback_data 限制) 6. **文本拆分**(line 480-497,`splitForLimit` line 106-120)——按 paragraph→line→space→char 边界拆分消息,适配 `maxTextLength`(Discord 2000,Telegram 4096) 7. **Gateway listener**(line 303-359)——对支持 Gateway 的适配器(Discord),启动本地 HTTP server 接收转发事件,指数退避重启(上限 1h) 8. **Webhook 注册**(line 362)——对非 gateway 适配器(Slack、Teams、GitHub),注册到共享 webhook server 9. **`openDM` 委托**(line 534-549)——直接委托 `adapter.openDM()` 而非 `chat.openDM()` 10. **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)。