Files
nanoclaw/docs/answers/08-channel-adapters.md

159 lines
8.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`
```typescript
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。
**模式 2Chat 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 <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](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 2000Telegram 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指数退避上限 1h5min 健康运行后重置计数器 |
| 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` 支持的 channelTelegram、WhatsApp、iMessage、email、Matrix`src/user-dm.ts` 直接把 user handle 当 DM platform_id——user 本身就是 DM chat。Bridge 仅当底层 `adapter.openDM` 存在时才附着 `openDM`line 544