Files
CodePilot/docs/handover/bridge-system.md
T
7418andClaude Opus 4.6 113e15e2f4 feat: Discord bridge adapter — 完整实现 Discord 作为第三个桥接渠道
新增文件:
- discord-adapter.ts: 基于 discord.js v14 的适配器,支持 DM/频道消息、
  按钮交互、流式预览(edit-based)、@提及过滤(兼容角色 mention)、
  图片附件下载、!→/ 命令归一化、消息去重、typing indicator
- markdown/discord.ts: Discord 2000 字符分段,代码块围栏自动平衡
- API routes: settings CRUD(Token 掩码)+ verify(Discord REST 验证)
- DiscordBridgeSection.tsx: 设置 UI(凭据、授权、服务器策略、配置指南含 ID 获取方法)
- discord-bridge.test.ts: 22 项单元测试(chunking/auth/command/HTML 转换)

修改文件:
- bridge-manager.ts: Discord 分段投递 + channel-aware stream config
  (Discord 默认 1500ms/40chars/1900max vs Telegram 700ms/20/3900)
- BridgeLayout/BridgeSection: Discord 渠道开关和侧栏入口
- bridge/settings/route.ts: 新增 14 个 Discord 设置键
- next.config.ts: serverExternalPackages 添加 discord.js/zlib-sync
- i18n: 中英文完整翻译(30+ keys)
- docs/handover/bridge-system.md: Discord 架构文档

依赖:discord.js ^14.18.0, zlib-sync ^0.1.9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +08:00

261 lines
20 KiB
Markdown
Raw 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.
# 多 IM 远程会话桥接系统
## 核心思路
让用户通过 Telegram(后续可扩展 Discord/飞书等)远程操控 CodePilot 中的 Claude 会话。复用现有 `streamClaude()` 管线,在服务端消费 SSE 流,而非通过浏览器。
## 目录结构
```
src/lib/bridge/
├── types.ts # 共享类型(ChannelBinding, BridgeStatus, InboundMessage 等)
├── channel-adapter.ts # 抽象基类 + adapter 注册表(registerAdapterFactory/createAdapter)
├── channel-router.ts # (channel, user, thread) → session 映射,自动创建/绑定会话
├── conversation-engine.ts # 服务端消费 streamClaude() SSE 流,保存消息到 DB,onPartialText 流式回调
├── permission-broker.ts # 权限请求转发到 IM 内联按钮,处理回调审批
├── delivery-layer.ts # 出站消息分片、限流、重试退避、HTML 降级
├── bridge-manager.ts # 生命周期编排,adapter 事件循环,流式预览状态机,deliverResponse 渲染分发
├── markdown/
│ ├── ir.ts # Markdown → IR 中间表示解析器(基于 markdown-it)
│ ├── render.ts # IR → 格式化输出的通用标记渲染器
│ └── telegram.ts # Telegram HTML 渲染 + 文件引用保护 + render-first 分片
├── markdown/
│ ├── ... # (见上)
│ └── feishu.ts # 飞书 Markdown 处理:hasComplexMarkdown / buildCardContent / buildPostContent / htmlToFeishuMarkdown
├── adapters/
│ ├── index.ts # Adapter 目录文件(side-effect import 自注册所有 adapter)
│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
│ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换
│ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
│ ├── feishu-adapter.ts # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册
│ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册
├── markdown/
│ └── discord.ts # Discord 消息分片(2000 字符限制)+ 代码围栏平衡
└── security/
├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟)
└── validators.ts # 路径/SessionID/危险输入校验
```
## 数据流
### 飞书
```
飞书消息 → WSClient(WebSocket) → EventDispatcher
→ im.message.receive_v1 → handleIncomingEvent()
→ 去重(message_id LRU 1000) → 授权检查 → 群策略过滤 → @提及检查
→ text → parseTextContent() → enqueue()
→ image → downloadResource(stream/writeFile) → base64 FileAttachment → enqueue()
→ post → parsePostContent() 提取文本+图片 → enqueue()
→ /perm 文本命令 → 构造 callbackData → enqueue()
→ BridgeManager.runAdapterLoop() → handleMessage()
→ deliverResponse():
→ hasComplexMarkdown(代码块/表格)? → sendAsCard() [schema 2.0 markdown]
→ 纯文本? → sendAsPost() [msg_type: post, md tag]
→ 权限请求 → sendPermissionCard() [schema 2.0 卡片 + /perm 文本命令]
```
### Discord
```
Discord 消息 → discord.js Client (Gateway WebSocket)
→ messageCreate → processMessage()
→ bot/self 过滤 → 去重(messageId Set 1000) → 授权检查(user+channel)
→ guild 策略过滤(allowed_guilds + group_policy) → @提及检查
→ !command → /command 规范化
→ 图片附件 → fetch(url) → base64 FileAttachment
→ enqueue()
→ interactionCreate → handleInteraction()
→ deferUpdate() (3s Discord 超时) → 存储 Interaction(60s TTL) → enqueue(callbackData)
→ BridgeManager.runAdapterLoop() → handleMessage()
→ deliverResponse():
→ markdownToDiscordChunks(2000 字符, 代码围栏平衡) → 逐块发送
→ 权限请求 → ActionRowBuilder + ButtonBuilder 组件
→ 流式预览 → channel.send() 首次 / message.edit() 后续 / delete 结束
→ typing → channel.sendTyping() 每 8s
```
**Discord 关键设计决策:**
- **原生 Markdown**:Discord 原生支持 Markdown,无需 IR→HTML 转换(不同于 Telegram)
- **保守流式默认值**:Discord 编辑限速 5/5s/channel,默认 interval 1500ms, minDelta 40 chars
- **按钮交互**:deferUpdate() 立即响应(3s Discord 超时),存储 Interaction 对象供 answerCallback 使用,60s TTL 清理
- **授权默认拒绝**:空白允许列表 = 拒绝所有(安全优先,同飞书模式)
- **`!` 命令别名**:在 adapter 层规范化为 `/` 命令后入队——bridge-manager 命令处理器无需改动
### Telegram
```
Telegram 消息 → TelegramAdapter.pollLoop()
→ 纯文本/caption → enqueue()
→ 单图 → telegram-media.downloadPhoto() → base64 FileAttachment → enqueue(msg + attachments)
→ 相册(media_group_id) → bufferMediaGroup() → 500ms 防抖 → flushMediaGroup() 批量下载 → enqueue()
→ BridgeManager.runAdapterLoop() → handleMessage()
→ 命令? → handleCommand() 处理 /new /bind /cwd /mode /stop 等
→ 普通消息/图片? → ChannelRouter.resolve() 获取 ChannelBinding
→ ConversationEngine.processMessage(binding, text, ..., files?, onPartialText?)
→ 有图片时:写入 .codepilot-uploads/ + <!--files:JSON-->text 格式存 DB(桌面 UI 可渲染)
→ streamClaude({ prompt, files }) → Claude vision API
→ consumeStream() 服务端消费
→ permission_request → 立即回调 → PermissionBroker 转发到 IM
→ text → 累积 currentText + previewText → onPartialText(previewText) 回调
→ tool_use/tool_result → 累积内容块(currentText 清零,previewText 不清零)
→ result → 捕获 tokenUsage + sdkSessionId
→ addMessage() 保存到 DB
→ deliverResponse() 按 channelType 分发渲染:
→ Telegram: markdownToTelegramChunks() → deliverRendered() → 限流 + HTML/plain 双通道
→ 其他 IM: deliver() → 纯文本分块发送
→ finally: adapter.acknowledgeUpdate(updateId) → 推进 committedOffset 并持久化
```
## DB 表(在 db.ts migrateDb 中)
| 表 | 用途 |
|---|------|
| channel_bindings | IM 地址 → CodePilot session 映射 |
| channel_offsets | 轮询 offset 持久化(key 为 bot user ID,通过 getMe API 获取) |
| channel_dedupe | 出站消息幂等去重 |
| channel_outbound_refs | 平台消息 ID 映射 |
| channel_audit_logs | 审计日志 |
| channel_permission_links | 权限请求 → IM 消息映射(含 resolved 标记) |
## 关键设计决策
**1. 权限请求死锁解决**
SSE 流在 `permission_request` 事件处会阻塞等待审批。`consumeStream()` 通过 `onPermissionRequest` 回调在流消费过程中立即转发到 IM,而非等流结束后再转发。
**2. Offset 安全水位**
分离 `fetchOffset`(用于 getUpdates API)和 `committedOffset`(持久化到 DB)。消息入队时仅推进 fetchOffset,只有在 bridge-manager 完整处理完消息后(handleMessage 的 finally 块),才调用 `adapter.acknowledgeUpdate(updateId)` 推进 committedOffset 并持久化到 DB。`markUpdateProcessed()` 使用连续水位推进(contiguous walk):仅当 `recentUpdateIds` 中存在当前 committedOffset 时才前进,避免跳过仍在 media group buffer 中的相册更新 ID。相册 flush 时预注册所有 buffered ID 到 recentUpdateIds,保证 ack 时水位能连续推过。内存 dedup set 防止重启后重复处理。
**2a. Bot 身份标识**
Offset 的 DB key 使用 Telegram `getMe` API 返回的 bot user ID(如 `telegram:bot123456`),而非 token hash。好处是 token 轮换后 offset 不丢失。首次迁移时自动将旧 token-hash key 的值复制到新 bot-ID key。
**3. 并发模型**
`processWithSessionLock()` 实现同会话串行、跨会话并行。不同用户的消息不互相阻塞。
**4. Adapter 注册式架构**
新 IM 只需实现 `BaseChannelAdapter` 并调用 `registerAdapterFactory()` 自注册,然后在 `adapters/index.ts` 中添加一行 side-effect import。bridge-manager 通过 `import './adapters'` 加载目录,registry 自动发现所有已注册的 adapter,无硬编码依赖。
**5. 权限回调安全**
PermissionBroker 在处理 IM 内联按钮回调时,验证 callbackData 中的 chatId 和 messageId 与存储的 permission_link 记录匹配,防止跨聊天伪造审批。`markPermissionLinkResolved()` 使用 `AND resolved = 0` 原子条件更新,确保同一权限请求不被重复审批。
**6. 输入校验**
`security/validators.ts` 对所有 IM 入站命令参数做校验:工作目录路径(拒绝 `..`、null 字节、shell 元字符)、session ID(hex/UUID 格式)、危险输入检测(命令注入、管道符)。`sanitizeInput()` 剥离控制字符并限制 32K 长度。
**7. runAdapterLoop 必须在 state.running = true 之后启动**
`runAdapterLoop` 内部是 fire-and-forget 的 async IIFE,循环条件 `while (state.running && ...)` 在第一个 `await` 之前同步求值。如果调用时 `state.running` 还是 `false`,循环直接跳过,消费者永远不会启动,消息入队后无人消费。`start()` 中必须先设 `state.running = true`,再调用 `runAdapterLoop`。
**8. 出站限流**
`security/rate-limiter.ts` 按 chatId 滑动窗口限流(默认 20 条/分钟)。`DeliveryLayer` 在每次发送前调用 `rateLimiter.acquire(chatId)` 阻塞等待配额,分片间额外加 300ms 节流。错误分类:429 尊重 `retry_after`、5xx 指数退避、4xx 不重试、解析错误降级纯文本。
**9. Telegram 图片接收**
复用已有 `streamClaude({ files })` vision 管道,不引入 sharp 等 native 依赖。`telegram-media.ts` 负责图片下载:`selectOptimalPhoto()` 从 Telegram 的 photo[] 多尺寸数组中选最小且长边 ≥ 1568px(Claude vision 最优值)的版本;`downloadFileById()` 含 3 次重试 + 指数退避 + 双重大小校验。统一返回 `MediaDownloadResult { attachment, rejected, rejectedMessage }`,拒绝时直接发 Telegram 通知,禁止静默丢弃。相册消息通过 500ms 防抖合并(`media_group_id` → `mediaGroupBuffers` Map)。`InboundMessage.attachments` 透传到 `conversation-engine` 和 `streamClaude`。
**10. 图片消息 DB 格式统一**
Bridge 和桌面端使用相同的消息存储格式:图片写入 `.codepilot-uploads/`,消息 content 以 `<!--files:[{id,name,type,size,filePath}]-->text` 格式保存。桌面 UI 的 `MessageItem.parseMessageFiles()` 解析后通过 `FileAttachmentDisplay` + `/api/uploads?path=` 渲染缩略图。`conversation-engine.ts` 中 `getSession()` 提前到文件持久化之前调用,确保 workingDirectory 可用。
**11. Telegram 出站 Markdown 渲染**
Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i/s/code/pre+code/blockquote/a)。采用三层架构将 Markdown 转换为 Telegram HTML:
- **IR 层**(`markdown/ir.ts`):使用 markdown-it 将 Markdown 解析为中间表示 `MarkdownIR = { text, styles[], links[] }`。text 是纯文本,styles 是 `{ start, end, style }` 区间标记。支持 bold/italic/strikethrough/code/code_block/blockquote/links/lists/headings/tables/hr。表格使用 code-block 模式渲染为 ASCII 表格(包裹在 `<pre><code>` 中保留对齐)。HTML 内联标签中的 `<br>` 被转换为换行符。
- **渲染层**(`markdown/render.ts`):通用标记渲染器 `renderMarkdownWithMarkers(ir, options)`,接受样式→标签映射表 + escapeText + buildLink 回调,输出格式化文本。使用 boundary tracking + LIFO stack 处理嵌套。
- **Telegram 层**(`markdown/telegram.ts`):组合 IR+渲染器,映射样式到 Telegram HTML 标签。`wrapFileReferencesInHtml()` 防止 `README.md`、`main.go` 等文件名被 Telegram linkify 误识别为 URL(用 `<code>` 包裹)。`markdownToTelegramChunks(text, limit)` 实现 render-first 分片:先按 IR text 长度分块,再渲染每块为 HTML,若 HTML 超出 4096 限制则按比例重新分割。
`bridge-manager.ts` 通过 `deliverResponse()` 按 `adapter.channelType` 分发渲染:Telegram 走 `markdownToTelegramChunks()` + `deliverRendered()`(HTML/plain 双通道),其他 IM 走 `deliver()` 纯文本。`deliverRendered()` 在分块部分失败时继续投递剩余 chunk 并追发截断提示,最终返回 `ok: false` 标识不完整投递。命令响应和错误消息仍使用 `escapeHtml()` + `deliver()`。
**12. Telegram 流式预览(sendMessageDraft)**
利用 Telegram Bot API 9.5 的 `sendMessageDraft` 方法,在 Claude 生成过程中以草稿形式实时展示文本预览。架构上抽象为通道级可选能力(`BaseChannelAdapter` 的 `getPreviewCapabilities`/`sendPreview`/`endPreview` 三个可选方法),未实现这些方法的 adapter 自动跳过。
- **引擎层**:`consumeStream()` 维护独立的 `previewText` 变量(只累积、不因 `tool_use` 清零),通过 `onPartialText` 回调同步传递完整预览文本。
- **编排层**:`bridge-manager.handleMessage()` 检查 adapter 能力 → 分配 `draftId` → 构建节流闭包(间隔 700ms + 最小增量 20 字符 + trailing-edge timer)→ `flushPreview()` fire-and-forget 发送 → finally 清理 timer + `endPreview()`。
- **降级**:`sendPreview` 返回 `'sent'|'skip'|'degrade'` 三态。400/404(API 不支持)→ 永久降级该 chatId;429/网络错误 → 仅跳过本次。`previewDegraded` Set 在 adapter `stop()` 时清空。
- **线程安全**:`processWithSessionLock` 保证同 session 串行 → 同时刻只有一个 `previewState`。多个 in-flight `sendMessageDraft` 安全:Telegram 对同 `draft_id` last-write-wins。
**13. 飞书适配器 — WSClient + 渲染分流**
飞书使用 `@larksuiteoapi/node-sdk` 的 `WSClient`(长连接 WebSocket)接收事件,`Client`(REST)发送消息和下载资源。与 Telegram 的 HTTP 长轮询不同,WSClient 由 SDK 管理重连。消息去重使用内存 Map LRU(上限 1000),无需持久化 offset。
**14. 飞书渲染策略 — Card vs Post**
Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- 含代码块(` ``` `)或表格 → `msg_type: 'interactive'`,schema 2.0 卡片(`{ tag: 'markdown', content }` 元素),代码高亮和表格正常渲染。
- 纯文本 → `msg_type: 'post'`,`{ tag: 'md', text }` 格式,渲染粗体、斜体、行内代码、链接。
- 每层发送失败自动降级:card → post → text。
`markdown/feishu.ts` 的 `hasComplexMarkdown()` 负责路由判断,`buildCardContent()` / `buildPostContent()` 构建消息体。
**15. 飞书权限交互 — 无按钮,文本命令兜底**
**关键限制**:飞书卡片交互回调(card.action.trigger)需要 HTTP webhook 端点,不支持通过 WSClient 长连接接收。Openclaw 通过 `http.createServer()` + `Lark.adaptDefault()` 暴露公网 webhook 解决。CodePilot 是桌面应用无公网端点,因此:
- Schema 2.0 不支持 `action` 标签(错误码 200861)
- Schema 1.0 的 `action` 标签可渲染按钮,但点击报 200340(无 webhook 端点接收回调)
- **最终方案**:权限卡片使用 schema 2.0 markdown 展示信息 + `/perm` 文本命令。用户复制命令发送即可审批。`processIncomingEvent()` 检测 `/perm` 前缀并构造 `callbackData`,走 `permission-broker.handlePermissionCallback()` 标准流程。
**16. 飞书 Typing 指示器 — Emoji Reaction**
飞书无 typing indicator API。使用 Openclaw 方案:`onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastIncomingMessageId` Map 追踪每个 chat 的最新消息 ID。非关键路径,fire-and-forget。
**17. 飞书 @提及检测**
通过 `/bot/v3/info/` REST API 获取 bot 的 `open_id`/`bot_id`,存入 `botIds` Set。群聊消息检查 `event.message.mentions` 数组中是否有匹配的 ID。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。
**18. Telegram 通知模式互斥**
`telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。
## 设置项(settings 表)
| Key | 说明 |
|-----|------|
| remote_bridge_enabled | 总开关 |
| bridge_telegram_enabled | Telegram 通道开关 |
| bridge_auto_start | 服务启动时自动拉起桥接 |
| bridge_default_work_dir | 新建会话默认工作目录 |
| bridge_default_model | 新建会话默认模型 |
| bridge_default_provider_id | 新建会话默认服务商 |
| telegram_bridge_allowed_users | 白名单用户 ID(逗号分隔) |
| bridge_telegram_image_enabled | Telegram 图片接收开关(默认 true,设为 false 关闭) |
| bridge_telegram_max_image_size | 图片大小上限(字节,默认 20MB) |
| bridge_telegram_stream_enabled | 流式预览总开关(默认启用,设为 `false` 关闭) |
| bridge_telegram_stream_interval_ms | 预览节流间隔(默认 700ms) |
| bridge_telegram_stream_min_delta_chars | 最小增量字符数(默认 20) |
| bridge_telegram_stream_max_chars | 草稿截断阈值(默认 3900) |
| bridge_telegram_stream_private_only | 仅私聊启用预览(默认 true,群聊自动跳过) |
| bridge_feishu_enabled | 飞书通道开关 |
| bridge_feishu_app_id | 飞书应用 App ID |
| bridge_feishu_app_secret | 飞书应用 App Secret(API 返回脱敏) |
| bridge_feishu_domain | 平台域名:`feishu`(默认)或 `lark` |
| bridge_feishu_allowed_users | 允许的 open_id/chat_id(逗号分隔,空=不限) |
| bridge_feishu_group_policy | 群消息策略:`open`(默认)/ `allowlist` / `disabled` |
| bridge_feishu_group_allow_from | 群聊白名单 chat_id(逗号分隔) |
| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 true) |
## API 路由
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/bridge | GET | 返回 BridgeStatus(纯查询,无副作用) |
| /api/bridge | POST | `{ action: 'start' \| 'stop' \| 'auto-start' }` |
| /api/bridge/channels | GET | 列出活跃通道(支持 `?active=true/false` 过滤) |
| /api/bridge/settings | GET/PUT | 读写 bridge 设置 |
## Telegram 命令
| 命令 | 功能 |
|------|------|
| /new [path] | 新建会话 |
| /bind \<session_id\> | 绑定已有会话 |
| /cwd /path | 切换工作目录 |
| /mode plan\|code\|ask | 切换模式 |
| /status | 当前状态 |
| /sessions | 列出会话 |
| /stop | 中止运行中任务 |
| /help | 帮助 |
## 相关文件(bridge 之外)
- `src/lib/telegram-bot.ts` — 通知模式(UI 发起会话的通知),与 bridge 模式互斥
- `src/lib/permission-registry.ts` — 权限 Promise 注册表,bridge 和 UI 共用
- `src/lib/claude-client.ts` — streamClaude(),bridge 和 UI 共用
- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/飞书通道开关
- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram + Feishu 入口)
- `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram)
- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书凭据 + 群聊策略 + 域名选择 UI(/bridge#feishu)
- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API
- `src/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info)
- `electron/main.ts` — 窗口关闭时 bridge 活跃则保持后台运行;启动时通过 POST `auto-start` 触发桥接恢复
- `src/app/api/settings/telegram/verify/route.ts` — 支持 `register_commands` action 注册 Telegram 命令菜单