# Forge 钉钉 IM 集成 — 后续开发计划

> 基于 dingtalk-openclaw-connector 官方项目的对比分析，P0（AI Card 流式卡片 + 队列 ACK）已完成。
> 本文档覆盖 P1–P3 所有待实现项，每项包含：目标、参考实现、改动范围、具体步骤、验收标准。

---

## 已完成（P0）

| 功能 | 状态 | 涉及文件 |
|------|------|---------|
| AI Card 创建+投放+流式更新+完成+降级 | ✅ 已完成 | `dingtalk-card.ts`（新建）, `dingtalk.ts`, `bridge-manager.ts` |
| AI Card Markdown 内容标准化 | ✅ 已完成 | `dingtalk-card.ts` 的 `normalizeForCard` |
| 队列忙时 ACK（预创建 Card + 文案提示） | ✅ 已完成 | `bridge-manager.ts`, `dingtalk.ts` |
| 全局 QPS 限流器（20 QPS 令牌桶+退避+重试） | ✅ 已完成 | `dingtalk-card.ts` 的 `cardRateLimiter` |
| StreamingPhase 增加 `card_draft` 状态 | ✅ 已完成 | `types.ts`, `streaming-state.ts` |
| P1-1: Markdown 渲染钉钉适配 | ✅ 已完成 | `markdown-ir.ts`（`renderForDingTalk` + 导入 `ensureTableBlankLines`） |
| P1-3: 连接健康检查 | ✅ 已完成 | `dingtalk.ts`（`lastEventTime` + `CONNECTION_DEAD_MS` + 60s 轮询健康检查） |

---

## P1 — 高优先级（体验显著提升，风险可控）

### P1-1: Markdown 渲染钉钉适配 ✅ 已完成

**目标**：当前 `markdown-ir.ts` 的 DingTalk 分支直接 `return text`，导致通过非 Card 路径（降级文本/Markdown 消息）发送的内容在钉钉渲染时排版异常（换行丢失、表格错乱等）。

**参考实现**：`dingtalk-openclaw-connector/src/services/messaging/card.ts` 的 `fixNewlines()` + `ensureTableBlankLines()` — 已移植到 `dingtalk-card.ts` 的 `normalizeForCard()`。

**改动范围**：
1. `src/lib/im/markdown-ir.ts` — 新增 `renderForDingTalk()` 函数，调用 `dingtalk-card.ts` 导出的 `ensureTableBlankLines()`
2. `src/lib/im/dingtalk-card.ts` — `ensureTableBlankLines` 改为 `export`，供 `markdown-ir.ts` 复用（消除重复）

**验收标准**：
- [x] 通过 `sendViaSessionWebhook` 发送的 markdown 消息中，表格前有空行
- [x] 代码块内换行正常
- [x] 列表、标题渲染正确
- [x] AI Card 流式内容的换行渲染正常（不受此改动影响）

---

### P1-2: 单群精细配置

**目标**：允许每个群有独立的 `requireMention`、`enabled`、`allow_from`、`systemPrompt`、`groupSessionScope`、`tools_deny` 配置，而非仅全局 DM/群策略。

**参考实现**：`dingtalk-openclaw-connector/src/config/schema.ts` 的 `groups.<conversationId>` 配置。

**改动范围**：
1. `src/lib/db.ts` — 新增 `im_group_overrides` 表
2. `src/app/api/im-channels/` — 新增群覆盖配置 API
3. `src/lib/im/policy.ts` — 群策略检查时查询 override 表
4. `src/lib/im/channel-router.ts` — 群会话解析时考虑 `groupSessionScope`
5. `src/lib/im/bridge-manager.ts` — `processMessage` 中应用群级 systemPrompt

**数据库 Schema**：

```sql
CREATE TABLE IF NOT EXISTS im_group_overrides (
  id TEXT PRIMARY KEY,
  channel_id TEXT NOT NULL,
  conversation_id TEXT NOT NULL,
  enabled INTEGER NOT NULL DEFAULT 1,
  require_mention INTEGER NOT NULL DEFAULT 1,
  allow_from TEXT NOT NULL DEFAULT '[]',
  system_prompt TEXT NOT NULL DEFAULT '',
  session_scope TEXT NOT NULL DEFAULT 'group',  -- 'group' 或 'group_sender'
  tools_deny TEXT NOT NULL DEFAULT '[]',
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (channel_id) REFERENCES im_channels(id) ON DELETE CASCADE,
  UNIQUE(channel_id, conversation_id)
);
```

**实现步骤**：

```
1. db.ts: 新增 im_group_overrides 表迁移
2. policy.ts: checkPolicy() 群消息时，查询 im_group_overrides
   - 有 override → 用 override 的 enabled/requireMention/allowFrom
   - 无 override → 用频道全局配置
3. channel-router.ts: resolveSession() 
   - session_scope='group_sender' 时用 ${conversationId}:${senderId} 作为 sessionPeerId
4. conversation-engine.ts: 检查群级 systemPrompt，有则追加
5. 新增 API: GET/POST/DELETE /api/im-channels/[id]/groups
6. 前端: 群配置管理界面（低优先级，可后续）
```

**验收标准**：
- [ ] 某群设为 `enabled=false` 后，Bot 不再响应该群消息
- [ ] 某群设为 `requireMention=false` 后，不用 @Bot 也能触发回复
- [ ] 某群设为 `session_scope=group_sender` 后，同群不同用户有独立会话
- [ ] 某群有 `system_prompt` 时，该 prompt 追加到 Agent 系统提示

---

### P1-3: 连接健康检查 ✅ 已完成

**目标**：当前 DingTalk adapter 依赖 SDK 的 `autoReconnect: true`，如果 SDK 连接死掉但未触发 disconnect 事件，BridgeManager 无法感知。需要像 Feishu 一样的主动健康检查机制。

**参考实现**：`src/lib/im/adapters/feishu.ts` — 每 60 秒检查 `lastEventTime`，5 分钟无事件判定为连接死亡。

**改动范围**：
1. `src/lib/im/adapters/dingtalk.ts` — 新增 `lastEventTime`、`CONNECTION_DEAD_MS` 和 `consumeOne()` 中的 60s 间隔健康检查

**实现步骤**：

```typescript
// 已完成实现：
// 1. 新增 lastEventTime = 0, CONNECTION_DEAD_MS = 300_000 (5分钟)
// 2. start() 中初始化 lastEventTime = Date.now()
// 3. stop() 中重置 lastEventTime = 0
// 4. handleRobotCallback() 中更新 lastEventTime = Date.now()
// 5. consumeOne() 中新增 setInterval 60 秒检查：
//    - lastEventTime > 0 && 距上次事件 >= 5分钟 → running=false, resolve(null)
//    - BridgeManager 收到 null 后触发 handleReconnect
```

**验收标准**：
- [x] 网络断开后 5 分钟内，DingTalk 频道状态变为 `error` 并触发重连
- [x] 正常消息收发不受健康检查影响

---

### P1-4: 🤔 Emoji 回执（处理开始/完成反馈）

**目标**：用户发消息后钉钉 Bot 对消息添加 🤔 emoji 表示"正在处理"，处理完成后自动撤回 emoji。

**参考实现**：`dingtalk-openclaw-connector` 使用 `POST /v1.0/message/emojiReaction` API。

**改动范围**：
1. `src/lib/im/adapters/dingtalk.ts` — 新增 `addEmojiReaction()` 和 `recallEmojiReaction()` 方法
2. `src/lib/im/adapters/base.ts` — `ChannelAdapter` 可选方法（不强制其他平台实现）
3. `src/lib/im/bridge-manager.ts` — 在 `processMessage` 开始时调用 addEmoji，结束时调用 recallEmoji

**实现步骤**：

```typescript
// dingtalk.ts 新增
async addEmojiReaction(chatId: string, messageId: string, emoji: string = '🤔'): Promise<void> {
  // POST /v1.0/message/emojiReaction
  // { conversationId: chatId.replace('group:',''), 
  //   operatorUserId: chatbotUserId,
  //   emoji: emoji }
  // 注意：需要 messageCreateThreadId（入站消息的原始 ID）
  // 此 API 可能需要不同的权限范围，需验证
}

async recallEmojiReaction(chatId: string, messageId: string, emoji: string = '🤝'): Promise<void> {
  // DELETE /v1.0/message/emojiReaction
}
```

**风险提示**：
- emoji reaction API 需要机器人有 `ripsi:emoji_reaction` 权限范围
- 群聊中机器人可能无法给自己添加 emoji reaction（权限限制）
- 如果 API 不可用，应静默降级（不影响主流程）

**验收标准**：
- [ ] 用户发消息后，Bot 对消息添加 🤔 emoji
- [ ] 处理完成后，🤔 emoji 被撤回
- [ ] API 不可用时静默失败，不影响消息收发

---

## P2 — 中优先级（体验优化，可按需排期）

### P2-1: 共享记忆（跨对话共享）

**目标**：允许不同对话共享 Agent 的记忆，实现"一次学习、处处可用"的效果。

**参考实现**：`dingtalk-openclaw-connector` 的 `sharedMemoryAcrossConversations` 配置。

**改动范围**：
1. `src/lib/im/channel-router.ts` — 当 `sharedMemoryAcrossConversations=true` 时，所有对话使用同一 `sessionPeerId`
2. `src/lib/db.ts` — `im_channels` 表新增 `shared_memory` 字段
3. `src/app/api/im-channels/` — 配置 API 支持新字段

**实现步骤**：

```
1. db.ts: ALTER TABLE im_channels ADD COLUMN shared_memory INTEGER NOT NULL DEFAULT 0
2. channel-router.ts: resolveSession() 中
   - 如果 channel.shared_memory = true
   - 则 sessionPeerId = accountId（而非 conversationId 或 senderId）
   - 这样所有对话共享同一个 workspace 的 MEMORY.md
3. 前端: IM 频道设置中增加"共享记忆"开关
```

**验收标准**：
- [ ] 开启共享记忆后，A 对话中 Agent 学到的内容在 B 对话中也能使用
- [ ] 关闭共享记忆后，对话间记忆隔离

---

### P2-2: 媒体标记系统

**目标**：在 SDK system prompt 中指示 Agent 使用 `[DINGTALK_VIDEO]...[/DINGTALK_VIDEO]` 等标记包裹媒体输出，delivery 层解析后自动上传媒体文件。

**参考实现**：`dingtalk-openclaw-connector` 的 `MEDIA: protocol` + `processVideoMarkers` / `processAudioMarkers` / `processFileMarkers`。

**改动范围**：
1. `src/lib/im/dingtalk-card.ts` — 流式更新时清除媒体标记，完成时处理媒体标记
2. `src/lib/im/bridge-manager.ts` — `onFinal` 回调中解析媒体标记并上传
3. `src/lib/sdk/system-prompt.ts` — IM prompt 中增加媒体标记指引

**实现步骤**：

```
1. dingtalk-card.ts: streamAICard 中清除媒体标记（避免打字机过程中出现标记文字）
   const displayContent = content
     .replace(/\[DINGTALK_VIDEO\][\s\S]*?\[\/DINGTALK_VIDEO\]/g, '')
     .replace(/\[DINGTALK_AUDIO\][\s\S]*?\[\/DINGTALK_AUDIO\]/g, '')
     .replace(/\[DINGTALK_FILE\][\s\S]*?\[\/DINGTALK_FILE\]/g, '')

2. bridge-manager.ts: onFinal 之后检查最终文本中的媒体标记
   - 解析标记中的文件路径
   - 通过 adapter.sendFile() / sendImage() 发送
   - 从文本中移除标记

3. system-prompt.ts: IM prompt 增加指引
   "当你需要发送视频文件时，请使用 [DINGTALK_VIDEO]文件路径[/DINGTALK_VIDEO] 标记"
```

**验收标准**：
- [ ] Agent 回复中包含 `[DINGTALK_FILE]/path/to/file.pdf[/DINGTALK_FILE]` 时，自动上传 PDF 并发送
- [ ] Card 流式更新中媒体标记被隐藏
- [ ] 标记外的文本正常显示

---

### P2-3: 卡片卡住修复命令

**目标**：新增 `/fixcard` IM 命令，强制将卡住的 AI Card 切换到 FINISHED 状态。

**参考实现**：`dingtalk-openclaw-connector` 的 `dingtalk-connector.fixStuckCards` gateway method。

**改动范围**：
1. `src/lib/im/commands.ts` — 新增 `/fixcard` 命令
2. `src/lib/im/bridge-manager.ts` — 新增 `fixStuckCards()` 方法

**实现步骤**：

```typescript
// commands.ts 新增
{ name: 'fixcard', description: '修复钉钉中卡住的 AI Card（仅钉钉频道）' }

// bridge-manager.ts 新增
private async fixStuckCards(channelId: string, chatId: string, adapter: ChannelAdapter): Promise<string> {
  if (!(adapter instanceof DingtalkAdapter) || !adapter.supportsCardStreaming) {
    return '此命令仅适用于钉钉频道'
  }
  // 遍历 activeCards，对当前 chatId 的所有卡调用 finishCard
  let fixed = 0
  for (const [key, card] of this.activeCards) {
    if (key.endsWith(`:${chatId}`) || key.includes(chatId)) {
      try {
        await adapter.finishCard(card, '⏱ 此卡片已被手动关闭')
        this.activeCards.delete(key)
        fixed++
      } catch (err) {
        console.warn('[BridgeManager] fixStuckCards error:', err)
      }
    }
  }
  return fixed > 0 ? `已修复 ${fixed} 张卡住的卡片` : '没有找到卡住的卡片'
}
```

**验收标准**：
- [ ] 发送 `/fixcard` 后，卡在"思考中"状态的 AI Card 被关闭
- [ ] 非 DingTalk 频道返回"仅适用于钉钉频道"提示

---

### P2-4: 重复错误冷却

**目标**：同一错误（如 API 403）在 60 秒内不重复通知用户，避免刷屏。

**参考实现**：`dingtalk-openclaw-connector` 的 `deliveredErrorTypes` + 60 秒冷却。

**改动范围**：
1. `src/lib/im/bridge-manager.ts` — 在 `processMessage` 的 catch 块中增加错误去重

**实现步骤**：

```typescript
// bridge-manager.ts 新增字段
private lastErrorMessage = new Map<string, { message: string; time: number }>()
private static readonly ERROR_COOLDOWN_MS = 60_000 // 60 秒

// catch 块中替换原有逻辑
const errorKey = `${channelId}:${chatId}:${errorMsg.slice(0, 50)}`
const lastError = this.lastErrorMessage.get(errorKey)
if (lastError && Date.now() - lastError.time < BridgeManager.ERROR_COOLDOWN_MS) {
  console.warn(`[BridgeManager] Error cooldown, skipping duplicate: ${errorMsg.slice(0, 50)}`)
} else {
  this.lastErrorMessage.set(errorKey, { message: errorMsg, time: Date.now() })
  // ... 发送错误消息
}
```

**验收标准**：
- [ ] 同一错误消息在 60 秒内只通知用户一次
- [ ] 不同错误消息可以正常通知

---

### P2-5: 本地文件路径自动检测+上传

**目标**：Agent 回复中包含本地文件路径（如 `file:///path/to/file.pdf`）时，自动检测并上传到钉钉，替换为远程 URL。

**参考实现**：`dingtalk-openclaw-connector` 的 `processLocalImages`。

**改动范围**：
1. `src/lib/im/dingtalk.ts` — 新增 `processLocalFilePaths()` 方法
2. `src/lib/im/bridge-manager.ts` — `onFinal` 回调中对 DingTalk 频道调用

**验收标准**：
- [ ] Agent 回复包含 `![](file:///tmp/report.pdf)` 时，自动上传 PDF 并替换为钉钉文件链接
- [ ] 上传失败时降级为纯文本 `[文件: report.pdf]`

---

### P2-6: 连接统计

**目标**：每分钟输出连接统计日志（收到/处理/丢失/距上次消息）。

**参考实现**：`dingtalk-openclaw-connector` 的 `connectionStats`。

**改动范围**：
1. `src/lib/im/bridge-manager.ts` — 新增统计计数器和定时日志

**验收标准**：
- [ ] 日志中每分钟输出 `[BridgeManager] Stats: received=N, processed=N, lost=N, lastMsg=5s ago`

---

### P2-7: 凭证隔离

**目标**：确保钉钉 clientId/clientSecret 不泄漏到 `process.env`，防止 SDK 子进程继承环境变量。

**参考实现**：`dingtalk-openclaw-connector` 的 `dwsCredentialsByAccount` Map。

**改动范围**：
1. `src/lib/im/adapters/dingtalk.ts` — 确认当前实现已使用实例属性（非 process.env）
2. `src/lib/sdk/client.ts` — 在创建子进程时显式清理 `DINGTALK_CLIENT_ID`/`DINGTALK_CLIENT_SECRET`

**验收标准**：
- [ ] SDK 子进程的 `process.env` 中不包含钉钉凭证

---

### P2-8: 多 HTTP 客户端隔离

**目标**：为不同类型的钉钉 API 调用使用不同超时的 HTTP 客户端（常规 30s、OAPI 60s、上传 120s）。

**参考实现**：`dingtalk-openclaw-connector` 的 `dingtalkHttp`/`dingtalkOapiHttp`/`dingtalkUploadHttp`。

**改动范围**：
1. `src/lib/im/adapters/dingtalk.ts` — 用 `fetch` + `AbortSignal.timeout()` 实现不同超时

**验收标准**：
- [ ] 常规 API 调用 30 秒超时
- [ ] OAPI（媒体下载）60 秒超时
- [ ] 文件上传 120 秒超时

---

### P2-9: 异步模式

**目标**：支持 `asyncMode` 配置，消息到达时立即回复 ACK，后台处理完成后主动推送结果。

**参考实现**：`dingtalk-openclaw-connector` 的 `asyncMode: true` + 自定义 `ackText`。

**改动范围**：
1. `src/lib/db.ts` — `im_channels` 新增 `async_mode` 和 `ack_text` 字段
2. `src/lib/im/bridge-manager.ts` — 根据 asyncMode 决定同步还是异步处理

**验收标准**：
- [ ] 开启 asyncMode 后，用户发消息立即收到 ACK 回复
- [ ] 处理完成后通过 AI Card 或主动消息推送结果

---

## P3 — 低优先级（架构变更或外部依赖）

### P3-1: 出站视频/音频上传

**目标**：支持 Agent 回复中包含的视频/音频文件自动上传到钉钉。

**依赖**：需要服务器安装 `ffmpeg`（提取视频封面帧、音频时长）。

**改动范围**：
1. `src/lib/im/adapters/dingtalk.ts` — 新增 `sendVideo()` 和 `sendAudio()` 方法
2. 可选：新增 `ffmpeg` 依赖检测和 graceful fallback

**验收标准**：
- [ ] Agent 生成视频文件后，自动上传到钉钉并发送 `sampleVideoMsg`
- [ ] 上传失败时降级为文件链接
- [ ] ffmpeg 不可用时优雅降级

---

### P3-2: 权限交互式按钮

**目标**：将钉钉权限确认从纯文本问答升级为 AI Card 内嵌的交互式按钮。

**依赖**：需要公网可达的 HTTPS 回调端点接收 `cardCallback` 事件。

**改动范围**：
1. `src/lib/im/adapters/dingtalk.ts` — 权限提示改为 Card 按钮
2. `src/app/api/im/dingtalk/` — 新增 Card 回调 API 路由
3. `src/lib/im/permission-broker.ts` — 支持按钮回调方式的权限确认

**实现步骤**：

```
1. 权限请求时，创建 AI Card：
   标题: "需要授权"
   内容: "Forge 想要使用 {toolName}：{inputSummary}"
   按钮: [✅ 允许] [❌ 拒绝]
   
2. 新增路由: POST /api/im/dingtalk/card-callback
   - 接收钉钉 Card 回调
   - 解析按钮点击事件
   - 调用 permissionBroker.resolvePermission()
   
3. 权限超时（120秒）后更新 Card 状态为 FINISHED
```

**风险提示**：
- 钉钉 Card 回调需要公网 HTTPS 端点
- 可以优先考虑使用 Stream Mode 的 Card 回调能力（无需公网）
- 需要验证钉钉机器人是否有 Card 回调权限

**验收标准**：
- [ ] 权限请求以 Card 按钮形式展示
- [ ] 点击"允许"/"拒绝"按钮后权限立即生效
- [ ] 120 秒无操作后 Card 自动过期

---

### P3-3: 多 Bot @路由

**目标**：支持同一钉钉群内多个 Bot 实例，根据 @提及路由到不同的 Agent。

**参考实现**：`dingtalk-openclaw-connector` 的多账号配置 + bot mention table + alias resolution。

**当前架构限制**：Forge IM Bridge 是 per-channel 单 Bot 架构。

**改动范围**：
1. `src/lib/db.ts` — `im_channels` 可能需要支持一个平台配置多个频道
2. `src/lib/im/adapters/dingtalk.ts` — 入站消息时根据 `chatbotUserId`/`atIds` 路由到不同 channel
3. `src/lib/im/bridge-manager.ts` — 消息处理时查找匹配的 channel
4. `src/lib/im/policy.ts` — 多 Bot 各自的 DM/群策略

**替代方案**：利用 Forge 已有的 Agent 机制，一个 Bot 实例内部通过 Agent 路由实现多 Agent 响应，无需修改核心管道。

**验收标准**：
- [ ] 群内 @BotA 回复由 Agent A 处理
- [ ] 群内 @BotB 回复由 Agent B 处理
- [ ] 不 @任何 Bot 时不响应

---

### P3-4: 单群 Tools 策略

**目标**：允许每个群配置允许/禁止的工具列表，实现精细化的工具权限控制。

**当前限制**：Claude Agent SDK 不支持运行时动态过滤工具列表。

**Soft enforcement 方案**：
1. `im_group_overrides` 表的 `tools_deny` 字段存储禁止的工具列表
2. `src/lib/im/permission-broker.ts` — 检查群级工具策略，自动拒绝被禁用的工具
3. `src/lib/sdk/system-prompt.ts` — 在 IM prompt 中注入"以下工具不可用"的提示

**验收标准**：
- [ ] 群配置 `tools_deny: ["Bash", "Write"]` 后，Agent 使用 Bash/Write 时自动被拒绝
- [ ] Agent 被提示不使用被禁用的工具（soft enforcement）
- [ ] 其他群不受影响

---

## 实现优先级排序建议

| 序号 | 项目 | 优先级 | 预估工期 | 依赖 |
|------|------|--------|---------|------|
| 1 | P1-1: Markdown 渲染钉钉适配 | P1 | ~~0.5 天~~ ✅ 已完成 | 无 |
| 2 | P1-3: 连接健康检查 | P1 | ~~0.5 天~~ ✅ 已完成 | 无 |
| 3 | P1-4: Emoji 回执 | P1 | 1 天 | 需验证 API 权限 |
| 4 | P2-4: 重复错误冷却 | P2 | 0.5 天 | 无 |
| 5 | P2-7: 凭证隔离 | P2 | 0.5 天 | 无 |
| 6 | P2-8: 多 HTTP 客户端 | P2 | 0.5 天 | 无 |
| 7 | P2-3: 卡片卡住修复命令 | P2 | 0.5 天 | 无 |
| 8 | P1-2: 单群精细配置 | P1 | 2 天 | DB 迁移+前端 |
| 9 | P2-1: 共享记忆 | P2 | 1 天 | DB 迁移 |
| 10 | P2-2: 媒体标记系统 | P2 | 1.5 天 | 无 |
| 11 | P2-5: 本地文件路径检测 | P2 | 1 天 | 无 |
| 12 | P2-6: 连接统计 | P2 | 0.5 天 | 无 |
| 13 | P2-9: 异步模式 | P2 | 1 天 | DB 迁移 |
| 14 | P3-2: 权限交互式按钮 | P3 | 3 天 | 公网端点 |
| 15 | P1-2: 单群精细配置(完整) | P1 | 2 天 | P1-2 基础+前端 |
| 16 | P3-1: 视频/音频上传 | P3 | 2 天 | ffmpeg |
| 17 | P3-3: 多 Bot @路由 | P3 | 5 天 | 架构变更 |
| 18 | P3-4: 单群 Tools 策略 | P3 | 1.5 天 | SDK 限制 |

**建议实施路线**：

```
第1周: P1-1 → P1-3 → P2-4 → P2-7 → P2-8 → P2-3
第2周: P1-2(基础) → P2-1 → P2-2 → P1-4(验证后)
第3周: P2-5 → P2-6 → P2-9 → P1-2(前端)
第4周+: P3-2(如需) → P3-1 → P3-4 → P3-3
```