159 lines
8.8 KiB
Markdown
159 lines
8.8 KiB
Markdown
# 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。
|
||
|
||
**模式 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 <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 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)。
|