docs: add detailed answers for 21 learning roadmap questions

This commit is contained in:
2026-05-13 03:40:13 +00:00
parent a8d90d2980
commit c4753da8f5
10 changed files with 1308 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
# 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