diff --git a/AGENTS.md b/AGENTS.md index 6e4e173..a12d1f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ npm run build **3. 创建飞书配置文件** `~/.config/opencode/plugins/feishu.json`: ```json -{ "appId": "cli_xxxxxxxxxxxx", "appSecret": "your_secret", "maxResourceSize": 524288000 } +{ "appId": "cli_xxxxxxxxxxxx", "appSecret": "your_secret" } ``` ## 架构设计 @@ -116,16 +116,11 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) └── startFeishuGateway(larkClient) → 启动 WebSocket 长连接(复用 Client) ├── im.message.receive_v1 → enqueueMessage() [session-queue] │ ├── shouldReply=false → handleChat() 静默转发(绕过队列) - │ ├── P2P + shouldReply=true → 可中断策略(abort + 立即处理新消息) - │ │ └── handleChat() → 返回 AutoPromptContext - │ │ └── runP2PAutoPrompt(signal) → abortableSleep + 单次迭代 + 空闲检测 - │ └── Group + shouldReply=true → 串行排队(FIFO 顺序依次处理) - │ └── drainLoop: Phase 1 用户消息 → Phase 2 空闲 auto-prompt - │ ├── handleChat(ctx, deps, signal) → 返回 AutoPromptContext - │ │ ├── StreamingCard.start() → 流式卡片(fallback 纯文本占位) - │ │ ├── subscribe(action-bus) → text/tool/permission/question 更新卡片 - │ │ └── promptAsync() → 轮询(session.idle 提前退出)→ card.close() - │ └── 队列空 → sleep(1s 粒度检查队列) → runOneAutoPromptIteration → 空闲检测 + │ └── shouldReply=true → 按 sessionKey 进入统一 FIFO 串行队列 + │ └── handleChat(ctx, deps, signal?) + │ ├── StreamingCard.start() → 流式卡片(fallback 纯文本占位) + │ ├── subscribe(action-bus) → text/tool/permission/question 更新卡片 + │ └── promptAsync() → 轮询(session.idle 提前退出)→ card.close() ├── im.chat.member.bot.added_v1 → ingestGroupHistory() └── card.action.trigger → handleCardAction() → v2Client.permission/question.reply() event 钩子 → handleEvent() @@ -151,32 +146,26 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - 创建 `WSClient`(WebSocket 长连接,独立代理配置) - 处理 `im.message.receive_v1` 事件 - 处理 `card.action.trigger` 卡片回调(权限/问答按钮,3 秒内返回 toast) -- 消息去重(10 分钟窗口,通过 `dedup.ts`) +- 消息去重(默认 10 分钟,可通过 `dedupTtl` 配置,通过 `dedup.ts`) - 群消息 @提及过滤(通过 `group-filter.ts`) - 处理 `im.chat.member.bot.added_v1` 用于历史摄入 **消息队列调度器 (`src/handler/session-queue.ts`):** -- per-sessionKey 并发控制,防止占位消息竞态覆盖 -- P2P 可中断策略:`AbortController.abort()` + `session.abort()` 中断当前处理 + auto-prompt 后续阶段 -- 群聊串行排队策略:FIFO 顺序依次处理,所有 @bot 消息都得到回复 -- 群聊 auto-prompt:`drainLoop` 队列耗尽后进入空闲 auto-prompt 阶段,每秒检查队列实现用户消息优先 -- P2P auto-prompt:`runP2PAutoPrompt` 使用 `abortableSleep` 可被新消息 abort 中断 +- per-sessionKey FIFO 串行处理,防止占位消息/流式卡片并发覆盖 +- 单聊和群聊统一采用顺序消费模型,避免 IM 场景里的“新消息打断旧消息”造成残留撤回或丢回复 - 静默消息(shouldReply=false)完全绕过队列 +- 队列只负责用户消息串行化;`session.idle` 之后的催促由 `event.ts` 的 nudge 逻辑驱动 - 暴露 `enqueueMessage()` 作为唯一入口 **对话处理器 (`src/handler/chat.ts`):** - 使用 `client.session.promptAsync()` 异步发送消息(不阻塞) -- 接受可选 `signal?: AbortSignal` 参数,支持被队列中断 -- 返回 `AutoPromptContext | undefined`:供 session-queue 驱动 auto-prompt 后续阶段 +- 接受可选 `signal?: AbortSignal` 参数,为轮询等待等可取消路径保留统一签名 - 会话键格式:`feishu-p2p-` 或 `feishu-group-` - 会话标题格式:`Feishu--` - 静默监听模式:`promptAsync({ noReply: true })` - 主动回复模式:`StreamingCard.start()` → action-bus 订阅 → 轮询(session.idle 提前退出)→ `card.close()` -- `runOneAutoPromptIteration()`:单次 auto-prompt 迭代(发送提示 → poll → 空闲检测 → 发送有效响应) -- `isIdleResponse()`:双重条件空闲检测(长度 < idleMaxLength AND 关键词匹配) - action-bus 订阅:text-updated → 卡片文本更新、tool-state-changed → 工具进度、permission/question → 交互卡片 - StreamingCard 回退:CardKit 创建失败时自动降级为纯文本占位消息 -- AbortError 处理:被中断时调用 `card.destroy()` 删除消息,静默退出 **事件处理器 (`src/handler/event.ts`):** - 处理 `message.part.updated`:实时更新占位消息 + emit `text-updated`/`tool-state-changed` 到 action-bus @@ -191,8 +180,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) **事件总线 (`src/handler/action-bus.ts`):** - per-session 事件订阅/发布:`subscribe(sessionId, cb)` 返回 unsubscribe 函数 - `emit(sessionId, action)` fire-and-forget 分发,错误不阻塞 -- `unsubscribeAll(sessionId)` 清理所有订阅 -- `ProcessedAction` 联合类型:7 种事件(text-updated、tool-state-changed、subtask-discovered、permission-requested、question-requested、session-idle、session-error) +- `ProcessedAction` 联合类型:5 种事件(text-updated、tool-state-changed、permission-requested、question-requested、session-idle) **交互处理器 (`src/handler/interactive.ts`):** - `handlePermissionRequested`/`handleQuestionRequested`:使用 `buildCardFromDSL` 构建交互卡片并发送到飞书 @@ -220,7 +208,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - `truncateMarkdown(text, limit)`:截断到 28KB 并添加提示后缀 **历史摄入 (`src/feishu/history.ts`):** -- 通过飞书 API 获取最近 50 条群消息 +- 通过飞书 API 按 `maxHistoryMessages` 拉取最近群消息(单次请求 50 条分页) - 以 `noReply: true` 发送到 OpenCode(仅上下文) **消息发送器 (`src/feishu/sender.ts`):** @@ -240,9 +228,9 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - 供 feishu_send_card tool 和 system prompt 注入使用 **辅助模块:** -- `src/feishu/dedup.ts` - 10 分钟消息去重窗口 +- `src/feishu/dedup.ts` - 消息去重窗口(默认 10 分钟,可通过 `dedupTtl` 配置) - `src/feishu/group-filter.ts` - @提及检测 -- `src/types.ts` - 类型定义(FeishuMessageContext, ResolvedConfig, LogFn, ProcessedAction) +- `src/types.ts` - 类型定义(FeishuMessageContext, ResolvedConfig, LogFn, PermissionRequest, QuestionRequest) ## 配置 @@ -257,15 +245,26 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) ```json { "appId": "cli_xxxxxxxxxxxx", - "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "timeout": 120000, - "thinkingDelay": 2500 + "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } ``` 必需字段:`appId`, `appSecret` -可选字段:`timeout`(默认 120000ms)、`thinkingDelay`(默认 2500ms)、`logLevel`(默认 `"info"`,控制 Lark SDK 日志级别)、`maxResourceSize`(默认 500MB,最大 500MB) -自动提示:`autoPrompt` 对象 — `enabled`(默认 false)、`intervalSeconds`(默认 30)、`maxIterations`(默认 10)、`message`(默认 "请同步当前进度,如需帮助请说明")、`idleThreshold`(连续空闲次数阈值,默认 2)、`idleMaxLength`(空闲判定文本长度上限,默认 50) +可选字段: +- `timeout`:对话轮询总超时(毫秒);默认不设置固定超时。仅在显式配置时,超时后返回 `⚠️ 响应超时` +- `thinkingDelay`:默认 `2500ms` +- `logLevel`:默认 `"info"`,控制 Lark SDK 日志级别 +- `maxHistoryMessages`:默认 `200`,最大 `500`;飞书接口按每页 `50` 条分页拉取 +- `pollInterval`:默认 `1000ms` +- `stablePolls`:默认 `3` +- `dedupTtl`:默认 `600000ms` +- `maxResourceSize`:默认 `500MB`,最大 `500MB` +- `directory`:默认使用 OpenCode 当前工作目录(`ctx.directory`);若 OpenCode 未提供则为空字符串;支持 `~` 和 `${ENV_VAR}` 展开 +- `nudge.enabled`:默认 `false` +- `nudge.message`:默认“上一步操作已完成。请继续执行下一步,同步当前进度。如果全部完成,给出完整结果和结论。” +- `nudge.intervalSeconds`:默认 `30` +- `nudge.maxIterations`:默认 `3` +- `nudge` 真实行为:仅在 `session.idle` 且最后一条 assistant message 以 `tool` part 结尾时,向 OpenCode 发送 `synthetic prompt`;不会直接向飞书用户新增一条可见消息 ## 群聊行为 @@ -281,7 +280,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) ### 入群历史摄入 - 由 `im.chat.member.bot.added_v1` 事件触发 -- 获取群聊最近 50 条消息 +- 按 `maxHistoryMessages` 拉取最近群消息(飞书接口按 50/页分页) - 以 `noReply: true` 发送所有消息到 OpenCode(仅上下文) ## 消息流程变体 @@ -292,7 +291,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) | 群聊 + 被 @提及 | 是 | 否 | 是 | | 群聊 + 未被 @提及 | 是 | **是** | **否** | | Bot 加入群(历史) | 是 | **是** | **否** | -| 自动提示循环 | 是("继续") | 否 | 是(有效响应)/ 否(空闲响应) | +| session.idle 催促 | 是(synthetic prompt) | 否 | 否(仅驱动 OpenCode 继续执行) | ## TypeScript 配置 @@ -322,8 +321,8 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) ## 错误处理 - `open_id` 获取失败:直接抛出错误,阻止插件启动 -- 提示超时:`timeout` 后返回"⚠️ 响应超时" -- 消息去重:10 分钟窗口防止重复处理 +- 提示超时:仅在显式配置 `timeout` 时,超时后返回"⚠️ 响应超时" +- 消息去重:按 `dedupTtl` 窗口防止重复处理(默认 10 分钟) - 飞书消息发送失败:尽力更新占位消息,回退到发送新消息 - 所有错误向飞书用户发送友好消息(不静默失败) @@ -337,7 +336,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) **L2 轮询期间 SSE 错误检测**(chat.ts pollForResponse):每次 poll 周期检查 `getSessionError()` - `pollForResponse()` 在 sleep 后、API 调用前检查 SSE 缓存的 session error - 检测到错误时抛出 `SessionErrorDetected` 异常(携带 sessionError 信息),立即终止轮询 -- 使模型异步失败(prompt 成功但模型报错)在 ~1 秒内被检测,而非等待 120 秒超时 +- 使模型异步失败(prompt 成功但模型报错)在下一次轮询(默认约 1 秒)内被检测,而非依赖固定超时 **L3 模型不兼容自动恢复**(chat.ts):检测模型错误时用全局默认模型重试 - `getGlobalDefaultModel()`:通过 `client.config.get()` 读取 `Config.model` 字段(如 `"aigw/Codex-opus-4-6-v1"`),解析为 `{ providerID, modelID }` @@ -345,11 +344,9 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - 重试计数器:每 sessionKey 最多重试 2 次,防止无限循环;成功后重置计数 - 全局默认模型未配置时,直接向用户显示错误,不重试 -**L4 并发控制**(session-queue.ts):per-sessionKey 消息队列防止竞态 -- 私聊可中断:`AbortController.abort()` + `session.abort()` 中断当前处理,立即处理新消息 -- 群聊串行排队:FIFO 顺序依次处理,所有 @bot 消息都得到回复 +**L4 并发控制**(session-queue.ts):per-sessionKey FIFO 消息队列防止竞态 +- 单聊和群聊统一串行排队,保证同一逻辑会话里的消息顺序稳定 - 静默消息绕过队列:`shouldReply=false` 直接转发,不受队列影响 -- `AbortError` 处理:被中断时静默退出,不向用户发送错误 - 使用 `promptAsync()` 异步发送(不再有 prompt() HTTP 错误与 SSE 的竞态问题) - 错误消息统一由 chat.ts catch 块发送给用户(event.ts 不发送,避免双重发送) @@ -392,5 +389,5 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - **不使用公网 webhook**:仅使用飞书 WebSocket 长连接 - **单一 OpenCode 实例**:作为插件运行在 OpenCode 进程内 - **会话恢复**:依赖标题前缀匹配(修改标题的会话可能无法恢复) -- **消息去重**:仅 10 分钟窗口 +- **消息去重**:按 `dedupTtl` 窗口处理,默认 10 分钟 - **插件生命周期**:由 OpenCode 管理,无独立进程 diff --git a/CLAUDE.md b/CLAUDE.md index f08293b..79b5bf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,16 +116,11 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) └── startFeishuGateway(larkClient) → 启动 WebSocket 长连接(复用 Client) ├── im.message.receive_v1 → enqueueMessage() [session-queue] │ ├── shouldReply=false → handleChat() 静默转发(绕过队列) - │ ├── P2P + shouldReply=true → 可中断策略(abort + 立即处理新消息) - │ │ └── handleChat() → 返回 AutoPromptContext - │ │ └── runP2PAutoPrompt(signal) → abortableSleep + 单次迭代 + 空闲检测 - │ └── Group + shouldReply=true → 串行排队(FIFO 顺序依次处理) - │ └── drainLoop: Phase 1 用户消息 → Phase 2 空闲 auto-prompt - │ ├── handleChat(ctx, deps, signal) → 返回 AutoPromptContext - │ │ ├── StreamingCard.start() → 流式卡片(fallback 纯文本占位) - │ │ ├── subscribe(action-bus) → text/tool/permission/question 更新卡片 - │ │ └── promptAsync() → 轮询(session.idle 提前退出)→ card.close() - │ └── 队列空 → sleep(1s 粒度检查队列) → runOneAutoPromptIteration → 空闲检测 + │ └── shouldReply=true → 按 sessionKey 进入统一 FIFO 串行队列 + │ └── handleChat(ctx, deps, signal?) + │ ├── StreamingCard.start() → 流式卡片(fallback 纯文本占位) + │ ├── subscribe(action-bus) → text/tool/permission/question 更新卡片 + │ └── promptAsync() → 轮询(session.idle 提前退出)→ card.close() ├── im.chat.member.bot.added_v1 → ingestGroupHistory() └── card.action.trigger → handleCardAction() → v2Client.permission/question.reply() event 钩子 → handleEvent() @@ -151,32 +146,26 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - 创建 `WSClient`(WebSocket 长连接,独立代理配置) - 处理 `im.message.receive_v1` 事件 - 处理 `card.action.trigger` 卡片回调(权限/问答按钮,3 秒内返回 toast) -- 消息去重(10 分钟窗口,通过 `dedup.ts`) +- 消息去重(默认 10 分钟,可通过 `dedupTtl` 配置,通过 `dedup.ts`) - 群消息 @提及过滤(通过 `group-filter.ts`) - 处理 `im.chat.member.bot.added_v1` 用于历史摄入 **消息队列调度器 (`src/handler/session-queue.ts`):** -- per-sessionKey 并发控制,防止占位消息竞态覆盖 -- P2P 可中断策略:`AbortController.abort()` + `session.abort()` 中断当前处理 + auto-prompt 后续阶段 -- 群聊串行排队策略:FIFO 顺序依次处理,所有 @bot 消息都得到回复 -- 群聊 auto-prompt:`drainLoop` 队列耗尽后进入空闲 auto-prompt 阶段,每秒检查队列实现用户消息优先 -- P2P auto-prompt:`runP2PAutoPrompt` 使用 `abortableSleep` 可被新消息 abort 中断 +- per-sessionKey FIFO 串行处理,防止占位消息/流式卡片并发覆盖 +- 单聊和群聊统一采用顺序消费模型,避免 IM 场景里的“新消息打断旧消息”造成残留撤回或丢回复 - 静默消息(shouldReply=false)完全绕过队列 +- 队列只负责用户消息串行化;`session.idle` 之后的催促由 `event.ts` 的 nudge 逻辑驱动 - 暴露 `enqueueMessage()` 作为唯一入口 **对话处理器 (`src/handler/chat.ts`):** - 使用 `client.session.promptAsync()` 异步发送消息(不阻塞) -- 接受可选 `signal?: AbortSignal` 参数,支持被队列中断 -- 返回 `AutoPromptContext | undefined`:供 session-queue 驱动 auto-prompt 后续阶段 +- 接受可选 `signal?: AbortSignal` 参数,为轮询等待等可取消路径保留统一签名 - 会话键格式:`feishu-p2p-` 或 `feishu-group-` - 会话标题格式:`Feishu--` - 静默监听模式:`promptAsync({ noReply: true })` - 主动回复模式:`StreamingCard.start()` → action-bus 订阅 → 轮询(session.idle 提前退出)→ `card.close()` -- `runOneAutoPromptIteration()`:单次 auto-prompt 迭代(发送提示 → poll → 空闲检测 → 发送有效响应) -- `isIdleResponse()`:双重条件空闲检测(长度 < idleMaxLength AND 关键词匹配) - action-bus 订阅:text-updated → 卡片文本更新、tool-state-changed → 工具进度、permission/question → 交互卡片 - StreamingCard 回退:CardKit 创建失败时自动降级为纯文本占位消息 -- AbortError 处理:被中断时调用 `card.destroy()` 删除消息,静默退出 **事件处理器 (`src/handler/event.ts`):** - 处理 `message.part.updated`:实时更新占位消息 + emit `text-updated`/`tool-state-changed` 到 action-bus @@ -191,8 +180,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) **事件总线 (`src/handler/action-bus.ts`):** - per-session 事件订阅/发布:`subscribe(sessionId, cb)` 返回 unsubscribe 函数 - `emit(sessionId, action)` fire-and-forget 分发,错误不阻塞 -- `unsubscribeAll(sessionId)` 清理所有订阅 -- `ProcessedAction` 联合类型:7 种事件(text-updated、tool-state-changed、subtask-discovered、permission-requested、question-requested、session-idle、session-error) +- `ProcessedAction` 联合类型:5 种事件(text-updated、tool-state-changed、permission-requested、question-requested、session-idle) **交互处理器 (`src/handler/interactive.ts`):** - `handlePermissionRequested`/`handleQuestionRequested`:使用 `buildCardFromDSL` 构建交互卡片并发送到飞书 @@ -220,7 +208,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - `truncateMarkdown(text, limit)`:截断到 28KB 并添加提示后缀 **历史摄入 (`src/feishu/history.ts`):** -- 通过飞书 API 获取最近 50 条群消息 +- 通过飞书 API 按 `maxHistoryMessages` 拉取最近群消息(单次请求 50 条分页) - 以 `noReply: true` 发送到 OpenCode(仅上下文) **消息发送器 (`src/feishu/sender.ts`):** @@ -240,9 +228,9 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - 供 feishu_send_card tool 和 system prompt 注入使用 **辅助模块:** -- `src/feishu/dedup.ts` - 10 分钟消息去重窗口 +- `src/feishu/dedup.ts` - 消息去重窗口(默认 10 分钟,可通过 `dedupTtl` 配置) - `src/feishu/group-filter.ts` - @提及检测 -- `src/types.ts` - 类型定义(FeishuMessageContext, ResolvedConfig, LogFn, ProcessedAction) +- `src/types.ts` - 类型定义(FeishuMessageContext, ResolvedConfig, LogFn, PermissionRequest, QuestionRequest) ## 配置 @@ -250,22 +238,33 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) **1. OpenCode 插件声明**(`~/.config/opencode/opencode.json`): ```json -{ "plugin": ["opencode-feishu"] } +{ "plugin": ["D:/path/to/opencode-feishu"] } ``` **2. 飞书配置**(`~/.config/opencode/plugins/feishu.json`): ```json { "appId": "cli_xxxxxxxxxxxx", - "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "timeout": 120000, - "thinkingDelay": 2500 + "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } ``` 必需字段:`appId`, `appSecret` -可选字段:`timeout`(默认 120000ms)、`thinkingDelay`(默认 2500ms)、`logLevel`(默认 `"info"`,控制 Lark SDK 日志级别) -自动提示:`autoPrompt` 对象 — `enabled`(默认 false)、`intervalSeconds`(默认 30)、`maxIterations`(默认 10)、`message`(默认 "请同步当前进度,如需帮助请说明")、`idleThreshold`(连续空闲次数阈值,默认 2)、`idleMaxLength`(空闲判定文本长度上限,默认 50) +可选字段: +- `timeout`:对话轮询总超时(毫秒);默认不设置固定超时。仅在显式配置时,超时后返回 `⚠️ 响应超时` +- `thinkingDelay`:默认 `2500ms` +- `logLevel`:默认 `"info"`,控制 Lark SDK 日志级别 +- `maxHistoryMessages`:默认 `200`,最大 `500`;飞书接口按每页 `50` 条分页拉取 +- `pollInterval`:默认 `1000ms` +- `stablePolls`:默认 `3` +- `dedupTtl`:默认 `600000ms` +- `maxResourceSize`:默认 `500MB`,最大 `500MB` +- `directory`:默认使用 OpenCode 当前工作目录(`ctx.directory`);若 OpenCode 未提供则为空字符串;支持 `~` 和 `${ENV_VAR}` 展开 +- `nudge.enabled`:默认 `false` +- `nudge.message`:默认“上一步操作已完成。请继续执行下一步,同步当前进度。如果全部完成,给出完整结果和结论。” +- `nudge.intervalSeconds`:默认 `30` +- `nudge.maxIterations`:默认 `3` +- `nudge` 真实行为:仅在 `session.idle` 且最后一条 assistant message 以 `tool` part 结尾时,向 OpenCode 发送 `synthetic prompt`;不会直接向飞书用户新增一条可见消息 ## 群聊行为 @@ -281,7 +280,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) ### 入群历史摄入 - 由 `im.chat.member.bot.added_v1` 事件触发 -- 获取群聊最近 50 条消息 +- 按 `maxHistoryMessages` 拉取最近群消息(飞书接口按 50/页分页) - 以 `noReply: true` 发送所有消息到 OpenCode(仅上下文) ## 消息流程变体 @@ -292,7 +291,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) | 群聊 + 被 @提及 | 是 | 否 | 是 | | 群聊 + 未被 @提及 | 是 | **是** | **否** | | Bot 加入群(历史) | 是 | **是** | **否** | -| 自动提示循环 | 是("继续") | 否 | 是(有效响应)/ 否(空闲响应) | +| session.idle 催促 | 是(synthetic prompt) | 否 | 否(仅驱动 OpenCode 继续执行) | ## TypeScript 配置 @@ -322,8 +321,8 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) ## 错误处理 - `open_id` 获取失败:直接抛出错误,阻止插件启动 -- 提示超时:`timeout` 后返回"⚠️ 响应超时" -- 消息去重:10 分钟窗口防止重复处理 +- 提示超时:仅在显式配置 `timeout` 时,超时后返回"⚠️ 响应超时" +- 消息去重:按 `dedupTtl` 窗口防止重复处理(默认 10 分钟) - 飞书消息发送失败:尽力更新占位消息,回退到发送新消息 - 所有错误向飞书用户发送友好消息(不静默失败) @@ -337,7 +336,7 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) **L2 轮询期间 SSE 错误检测**(chat.ts pollForResponse):每次 poll 周期检查 `getSessionError()` - `pollForResponse()` 在 sleep 后、API 调用前检查 SSE 缓存的 session error - 检测到错误时抛出 `SessionErrorDetected` 异常(携带 sessionError 信息),立即终止轮询 -- 使模型异步失败(prompt 成功但模型报错)在 ~1 秒内被检测,而非等待 120 秒超时 +- 使模型异步失败(prompt 成功但模型报错)在下一次轮询(默认约 1 秒)内被检测,而非依赖固定超时 **L3 模型不兼容自动恢复**(chat.ts):检测模型错误时用全局默认模型重试 - `getGlobalDefaultModel()`:通过 `client.config.get()` 读取 `Config.model` 字段(如 `"aigw/claude-opus-4-6-v1"`),解析为 `{ providerID, modelID }` @@ -345,11 +344,9 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - 重试计数器:每 sessionKey 最多重试 2 次,防止无限循环;成功后重置计数 - 全局默认模型未配置时,直接向用户显示错误,不重试 -**L4 并发控制**(session-queue.ts):per-sessionKey 消息队列防止竞态 -- 私聊可中断:`AbortController.abort()` + `session.abort()` 中断当前处理,立即处理新消息 -- 群聊串行排队:FIFO 顺序依次处理,所有 @bot 消息都得到回复 +**L4 并发控制**(session-queue.ts):per-sessionKey FIFO 消息队列防止竞态 +- 单聊和群聊统一串行排队,保证同一逻辑会话里的消息顺序稳定 - 静默消息绕过队列:`shouldReply=false` 直接转发,不受队列影响 -- `AbortError` 处理:被中断时静默退出,不向用户发送错误 - 使用 `promptAsync()` 异步发送(不再有 prompt() HTTP 错误与 SSE 的竞态问题) - 错误消息统一由 chat.ts catch 块发送给用户(event.ts 不发送,避免双重发送) @@ -392,5 +389,5 @@ OpenCode 加载插件 → src/index.ts (FeishuPlugin) - **不使用公网 webhook**:仅使用飞书 WebSocket 长连接 - **单一 OpenCode 实例**:作为插件运行在 OpenCode 进程内 - **会话恢复**:依赖标题前缀匹配(修改标题的会话可能无法恢复) -- **消息去重**:仅 10 分钟窗口 +- **消息去重**:按 `dedupTtl` 窗口处理,默认 10 分钟 - **插件生命周期**:由 OpenCode 管理,无独立进程 diff --git a/README.md b/README.md index 883f92f..f483ba8 100644 --- a/README.md +++ b/README.md @@ -66,19 +66,19 @@ opencode |------|------|:----:|--------|------| | `appId` | string | 是 | — | 飞书应用 App ID | | `appSecret` | string | 是 | — | 飞书应用 App Secret | -| `timeout` | number | 否 | `120000` | AI 响应超时(毫秒) | +| `timeout` | number | 否 | `未设置` | 对话轮询总超时(毫秒);未配置时不设固定超时,持续等待直到响应稳定、检测到 SSE 错误或请求被中断 | | `thinkingDelay` | number | 否 | `2500` | 发送"正在思考…"前的延迟(毫秒),设为 0 禁用 | | `logLevel` | string | 否 | `"info"` | 日志级别:fatal/error/warn/info/debug/trace | -| `maxHistoryMessages` | number | 否 | `200` | 入群时拉取历史消息的最大条数 | +| `maxHistoryMessages` | number | 否 | `200` | 入群时最多摄入的历史消息条数(飞书接口按 50/页分页拉取) | | `pollInterval` | number | 否 | `1000` | 轮询 AI 响应的间隔(毫秒) | | `stablePolls` | number | 否 | `3` | 连续几次轮询内容不变视为回复完成 | | `dedupTtl` | number | 否 | `600000` | 消息去重缓存过期时间(毫秒) | | `maxResourceSize` | number | 否 | `524288000` | 单个资源最大下载大小(字节,默认 500MB) | -| `directory` | string | 否 | `""` | 默认工作目录,支持 `~` 和 `${ENV_VAR}` 展开 | -| `nudge.enabled` | boolean | 否 | `false` | 启用 session.idle 催促(AI 工具调用后停止时自动发送催促消息) | +| `directory` | string | 否 | `OpenCode 当前工作目录` | 默认工作目录,支持 `~` 和 `${ENV_VAR}` 展开;若 OpenCode 未提供则为空字符串 | +| `nudge.enabled` | boolean | 否 | `false` | 启用 session.idle 催促;命中条件时向 OpenCode 发送 synthetic prompt,而不是直接向飞书新增可见消息 | | `nudge.intervalSeconds` | number | 否 | `30` | 同一 session 连续催促的最小间隔(秒) | | `nudge.maxIterations` | number | 否 | `3` | 同一 session 最大催促次数(用户新消息后重置) | -| `nudge.message` | string | 否 | `"上一步操作已完成。请继续执行下一步..."` | 催促消息内容 | +| `nudge.message` | string | 否 | `"上一步操作已完成。请继续执行下一步,同步当前进度。如果全部完成,给出完整结果和结论。"` | 发送给 OpenCode 的 synthetic prompt 内容 | ## 特性 @@ -92,7 +92,7 @@ opencode - **群聊静默监听** — 所有群消息作为上下文积累,仅 @提及时回复 - **FIFO 消息队列** — P2P 和群聊统一串行队列,消息按顺序处理不互相中断 - **入群自动摄入历史消息** -- **session.idle 催促** — AI 工具调用后停止时按需催促继续(可配置) +- **session.idle 催促** — 仅在工具调用后停止时,按需向 OpenCode 注入 synthetic prompt 继续执行(可配置) - **Langfuse 用户关联** — 每条消息 fire-and-forget 发送 trace 到 Langfuse,关联 sessionId 和飞书 userId - **代理支持** — `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` - **消息去重** — 可配置 TTL(默认 10 分钟) diff --git a/package-lock.json b/package-lock.json index 81b830f..0b5acce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-feishu", - "version": "1.3.0", + "version": "1.7.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-feishu", - "version": "1.3.0", + "version": "1.7.8", "license": "MIT", "dependencies": { "@larksuiteoapi/node-sdk": "^1.56.1", diff --git a/package.json b/package.json index 773fe17..98430bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-feishu", - "version": "1.7.6", + "version": "1.7.8", "description": "OpenCode 飞书插件 — 通过飞书 WebSocket 长连接接入 OpenCode AI 对话", "type": "module", "main": "dist/index.js", diff --git a/src/feishu/cardkit.ts b/src/feishu/cardkit.ts index c23bcb5..77f4d6b 100644 --- a/src/feishu/cardkit.ts +++ b/src/feishu/cardkit.ts @@ -1,16 +1,26 @@ /** - * CardKit 2.0 thin wrapper:委托 Lark SDK client.cardkit.v1.* 方法 - * 保留 3-method 接口,解耦 StreamingCard 与 SDK 细节 + * CardKit SDK 薄封装。 + * + * 目的不是重新抽象一套卡片系统,而是把: + * - SDK 调用路径 + * - JSON 序列化细节 + * - 错误整理 + * 集中到一个地方,供 `StreamingCard` 按“创建 / 更新 / 追加 / 关闭”这组语义使用。 */ import type * as Lark from "@larksuiteoapi/node-sdk" import type { LogFn } from "../types.js" +/** 本仓库构造 CardKit 卡片时关心的最小 schema。 */ export interface CardKitSchema { data: { + /** 固定使用 Card 2.0。 */ schema: "2.0" + /** 可选配置,例如 streaming_mode。 */ config?: Record + /** 可选卡头。 */ header?: Record body: { + /** 卡片正文元素数组。 */ elements: Array> } } @@ -18,12 +28,16 @@ export interface CardKitSchema { export class CardKitClient { constructor( + /** 共享飞书 SDK client;内部已经处理 token 管理。 */ private readonly larkClient: InstanceType, + /** 可选日志函数;本层以 best-effort 方式记录失败。 */ private readonly log?: LogFn, ) {} /** - * 创建 CardKit 2.0 卡片 → cardId + * 创建 CardKit 卡片实体并返回 `card_id`。 + * + * 这一步只是创建“卡片资源”,真正把卡片发到聊天里还要再调用 sender。 */ async createCard(schema: CardKitSchema): Promise { let res @@ -35,6 +49,7 @@ export class CardKitClient { }, }) } catch (err: unknown) { + // 尽量提取底层 HTTP response body,方便定位飞书接口报错原因。 let detail = "no response body" if (err && typeof err === "object" && "response" in err) { const axiosData = (err as { response?: { data?: unknown } }).response @@ -42,11 +57,18 @@ export class CardKitClient { if (axiosData) { try { detail = JSON.stringify(axiosData) - } catch { + } catch (detailErr) { + this.log?.("error", "序列化 CardKit 错误响应体失败", { + error: detailErr instanceof Error ? detailErr.message : String(detailErr), + }) detail = String(axiosData) } } } + this.log?.("error", "CardKit createCard 异常", { + error: err instanceof Error ? err.message : String(err), + detail, + }) throw new Error( `CardKit createCard HTTP 错误: ${err instanceof Error ? err.message : String(err)} | detail: ${detail}`, ) @@ -54,6 +76,10 @@ export class CardKitClient { const cardId = res?.data?.card_id if (!cardId) { + this.log?.("error", "CardKit createCard 返回缺少 card_id", { + code: res?.code, + msg: res?.msg, + }) throw new Error( `CardKit createCard 失败: ${res?.msg ?? "unknown"} (code: ${res?.code})`, ) @@ -63,7 +89,10 @@ export class CardKitClient { } /** - * 更新卡片中指定元素的内容(best-effort,失败只 log) + * 更新指定 element 的 `content` 字段。 + * + * 这是流式文本更新最常走的接口。 + * 失败只记 error 日志,不抛错,避免 UI 更新失败反向影响主流程。 */ async updateElement( cardId: string, @@ -84,7 +113,8 @@ export class CardKitClient { }) if (res?.code !== 0) { - this.log?.("warn", "CardKit updateElement 失败", { + // 飞书返回业务失败时不抛异常,只记录下来,继续主链路。 + this.log?.("error", "CardKit updateElement 失败", { cardId, elementId, code: res?.code, @@ -92,7 +122,7 @@ export class CardKitClient { }) } } catch (err) { - this.log?.("warn", "CardKit updateElement 异常", { + this.log?.("error", "CardKit updateElement 异常", { cardId, elementId, error: err instanceof Error ? err.message : String(err), @@ -101,7 +131,10 @@ export class CardKitClient { } /** - * 在卡片末尾追加新组件(用于流式卡片动态添加元素) + * 在卡片末尾追加新元素。 + * + * 典型场景:首次出现工具状态时动态补一个 `tools` 区块。 + * 与 updateElement 不同,这里失败会抛错,因为调用方通常需要感知“新增元素失败”。 */ async addElement( cardId: string, @@ -120,7 +153,7 @@ export class CardKitClient { }) if (res?.code !== 0) { - this.log?.("warn", "CardKit addElement 失败", { + this.log?.("error", "CardKit addElement 失败", { cardId, code: res?.code, msg: res?.msg, @@ -130,7 +163,9 @@ export class CardKitClient { } /** - * 关闭卡片流式模式 + * 关闭卡片的流式模式。 + * + * 这是一个收尾动作;即使失败,用户通常也已经看到最终内容,所以只记 error 日志。 */ async closeStreaming(cardId: string, sequence: number): Promise { try { @@ -144,7 +179,7 @@ export class CardKitClient { }, }) } catch (err) { - this.log?.("warn", "CardKit closeStreaming 异常", { + this.log?.("error", "CardKit closeStreaming 异常", { cardId, error: err instanceof Error ? err.message : String(err), }) diff --git a/src/feishu/content-extractor.ts b/src/feishu/content-extractor.ts index 2e3ab8d..c5e93fa 100644 --- a/src/feishu/content-extractor.ts +++ b/src/feishu/content-extractor.ts @@ -1,19 +1,25 @@ /** - * 飞书消息内容提取:将不同类型的飞书消息转换为 OpenCode SDK 的 parts 数组 + * 飞书消息内容提取层。 + * + * 目标是把飞书侧丰富且异构的消息类型, + * 统一翻译成 OpenCode 可消费的 `PromptPart[]`。 */ import type * as Lark from "@larksuiteoapi/node-sdk" import type { LogFn } from "../types.js" import { downloadMessageResource, guessMimeByFilename, type DownloadResult } from "./resource.js" -/** OpenCode SDK 兼容的 part 输入类型 */ +/** OpenCode 侧当前真正会消费的 part 结构。 */ export type PromptPart = | { type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string } +type PostElement = { tag?: string; text?: string; href?: string; image_key?: string } +type PostContent = { title?: string; content?: Array> } + /** - * 将飞书消息转换为 OpenCode parts 数组 + * 将飞书消息转换为 OpenCode parts 数组。 * - * @param feishuClient 飞书 SDK 客户端 + * @param feishuClient 飞书 SDK 客户端 * @param messageId 飞书消息 ID * @param messageType 消息类型(text, image, post, file, audio, media, etc.) * @param rawContent 原始 JSON content 字符串 @@ -29,34 +35,52 @@ export async function extractParts( maxResourceSize: number, ): Promise { try { + let parts: PromptPart[] + + // 先按消息类型分发到专用提取函数,再由各函数处理自己的细节。 switch (messageType) { case "text": - return extractText(rawContent) + parts = extractText(rawContent) + break case "image": - return await extractImage(feishuClient, messageId, rawContent, log, maxResourceSize) + parts = await extractImage(feishuClient, messageId, rawContent, log, maxResourceSize) + break case "post": - return await extractPost(feishuClient, messageId, rawContent, log, maxResourceSize) + parts = await extractPost(feishuClient, messageId, rawContent, log, maxResourceSize) + break case "file": - return await extractFile(feishuClient, messageId, rawContent, log, maxResourceSize) + parts = await extractFile(feishuClient, messageId, rawContent, log, maxResourceSize) + break case "audio": - return await extractAudio(feishuClient, messageId, rawContent, log, maxResourceSize) + parts = await extractAudio(feishuClient, messageId, rawContent, log, maxResourceSize) + break case "media": - return extractMediaFallback() + parts = extractMediaFallback() + break case "sticker": - return [{ type: "text", text: "[表情包]" }] + parts = [{ type: "text", text: "[表情包]" }] + break case "interactive": - return extractInteractive(rawContent) + parts = extractInteractive(rawContent, log) + break case "share_chat": - return extractShareChat(rawContent) + parts = extractShareChat(rawContent, log) + break case "share_user": - return [{ type: "text", text: "[分享了一个用户名片]" }] + parts = [{ type: "text", text: "[分享了一个用户名片]" }] + break case "merge_forward": - return [{ type: "text", text: "[合并转发消息]" }] + parts = [{ type: "text", text: "[合并转发消息]" }] + break default: - return [{ type: "text", text: `[不支持的消息类型: ${messageType}]` }] + parts = [{ type: "text", text: `[不支持的消息类型: ${messageType}]` }] + break } + + return normalizeExtractedParts(parts, messageType) } catch (err) { - log("warn", "消息内容提取失败", { + // 内容提取失败不应让整条消息链路崩溃,统一降级为可读文本提示。 + log("error", "消息内容提取失败", { messageId, messageType, error: err instanceof Error ? err.message : String(err), @@ -66,27 +90,38 @@ export async function extractParts( } /** - * 为历史摄入生成文本描述(不下载资源) + * 为“历史摄入”场景生成纯文本描述。 + * + * 与 `extractParts()` 不同,这里不会下载资源,只返回简短说明, + * 因为历史摄入的目标是上下文摘要而不是完整复现。 */ -export function describeMessageType(messageType: string, rawContent: string): string { +export function describeMessageType(messageType: string, rawContent: string, log?: LogFn): string { switch (messageType) { case "text": { try { const parsed = JSON.parse(rawContent) as { text?: string } return (parsed.text ?? "").trim() - } catch { + } catch (err) { + log?.("error", "解析 text 消息内容失败", { + messageType, + error: err instanceof Error ? err.message : String(err), + }) return "" } } case "image": return "[图片]" case "post": - return extractPostText(rawContent) + return extractPostText(rawContent, log) case "file": { try { const parsed = JSON.parse(rawContent) as { file_name?: string } return `[文件: ${parsed.file_name ?? "未知文件"}]` - } catch { + } catch (err) { + log?.("error", "解析 file 消息内容失败", { + messageType, + error: err instanceof Error ? err.message : String(err), + }) return "[文件]" } } @@ -97,9 +132,9 @@ export function describeMessageType(messageType: string, rawContent: string): st case "sticker": return "[表情包]" case "interactive": - return "[卡片消息]" + return firstTextPart(extractInteractive(rawContent, log), "[卡片消息]") case "share_chat": - return "[群分享]" + return firstTextPart(extractShareChat(rawContent, log), "[群分享]") case "share_user": return "[用户名片]" case "merge_forward": @@ -109,13 +144,65 @@ export function describeMessageType(messageType: string, rawContent: string): st } } -/** 判断 MIME 是否为文本类型(OpenCode 只对 text/plain 做内联转换) */ +/** + * 兜底保证 extractor 的输出永远非空,避免调用方把消息静默丢弃。 + */ +function normalizeExtractedParts(parts: PromptPart[], messageType: string): PromptPart[] { + if (parts.length > 0) return parts + return [{ type: "text", text: `[消息内容为空或解析失败: ${messageType}]` }] +} + +/** + * 从 part 数组里提取首个文本 part,用于历史摄入/预览这类“只要一句概览”的场景。 + */ +function firstTextPart(parts: PromptPart[], fallback: string): string { + const first = parts[0] + return first?.type === "text" ? first.text : fallback +} + +/** + * 判断 MIME 是否可视为文本类型。 + * + * OpenCode 对文本文件通常会内联读取,因此这里把常见源码/配置格式也归入文本类。 + */ function isTextualMime(mime: string): boolean { if (mime.startsWith("text/")) return true return ["application/json", "application/xml", "application/yaml", "application/javascript", "application/typescript"].includes(mime) } +/** + * 解析富文本 post 的原始 JSON。 + * + * 这个 JSON 结构会被“实时提取”和“历史摘要”两条路径复用, + * 因此把解析与 error 日志收敛到同一个 helper。 + */ +function parsePostContent(rawContent: string, log?: LogFn): PostContent | undefined { + try { + return JSON.parse(rawContent) as PostContent + } catch (err) { + log?.("error", "解析 post 消息内容失败", { + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } +} + +/** + * 把单个 post 元素转成可内联的文本片段。 + * + * 图片元素不在这里处理,因为下载资源和文字占位由上层决定。 + */ +function formatPostInlineText(element: PostElement): string | undefined { + if ((element.tag === "text" || element.tag === "at") && element.text) { + return element.text + } + if (element.tag === "a" && element.text) { + return element.href ? `${element.text}(${element.href})` : element.text + } + return undefined +} + // ── 各类型提取逻辑 ── function extractText(rawContent: string): PromptPart[] { @@ -125,6 +212,12 @@ function extractText(rawContent: string): PromptPart[] { return [{ type: "text", text }] } +/** + * 解析图片消息。 + * + * 成功时下载原图并作为 `file` part 交给 OpenCode; + * 失败时降级成一条文本说明。 + */ async function extractImage( client: InstanceType, messageId: string, @@ -144,6 +237,17 @@ async function extractImage( return [{ type: "file", mime: result.resource.mime, url: result.resource.dataUrl }] } +/** + * 解析富文本 `post` 消息。 + * + * 该类型最复杂,因为一条 post 里可能同时混有: + * - 文字 + * - 超链接 + * - @人 + * - 内嵌图片 + * + * 因此这里会把文本和图片拆成多个 prompt parts。 + */ async function extractPost( client: InstanceType, messageId: string, @@ -151,91 +255,96 @@ async function extractPost( log: LogFn, maxResourceSize: number, ): Promise { - try { - const parsed = JSON.parse(rawContent) as { - title?: string - content?: Array> - } - const parts: PromptPart[] = [] - const textLines: string[] = [] - - if (parsed.title) textLines.push(parsed.title) - - if (Array.isArray(parsed.content)) { - for (const paragraph of parsed.content) { - if (!Array.isArray(paragraph)) continue - const segments: string[] = [] - for (const element of paragraph) { - if (element.tag === "text" && element.text) { - segments.push(element.text) - } else if (element.tag === "a" && element.text) { - segments.push(element.href ? `${element.text}(${element.href})` : element.text) - } else if (element.tag === "at" && element.text) { - segments.push(element.text) - } else if (element.tag === "img" && element.image_key) { - // 先把之前积累的文本作为一个 text part - if (segments.length) { - textLines.push(segments.join("")) - segments.length = 0 - } - if (textLines.length) { - parts.push({ type: "text", text: textLines.join("\n") }) - textLines.length = 0 - } - // 下载内嵌图片 - const result = await downloadMessageResource(client, messageId, element.image_key, "image", log, maxResourceSize) - if (result.resource) { - parts.push({ type: "file", mime: result.resource.mime, url: result.resource.dataUrl }) - } else { - parts.push({ type: "text", text: formatDownloadFailure("富文本图片", result, maxResourceSize) }) - } - } else if (element.tag === "img") { - segments.push("[图片]") + const parsed = parsePostContent(rawContent, log) + if (!parsed) { + // post 结构解析失败时返回空,交由上层统一兜底。 + return [] + } + + const parts: PromptPart[] = [] + const textLines: string[] = [] + + if (parsed.title) textLines.push(parsed.title) + + if (Array.isArray(parsed.content)) { + for (const paragraph of parsed.content) { + if (!Array.isArray(paragraph)) continue + const segments: string[] = [] + for (const element of paragraph) { + const inlineText = formatPostInlineText(element) + if (inlineText) { + segments.push(inlineText) + continue + } + if (element.tag === "img" && element.image_key) { + // 先把当前段落已经积累的文本刷出去,避免文本和图片顺序错乱。 + if (segments.length) { + textLines.push(segments.join("")) + segments.length = 0 + } + if (textLines.length) { + parts.push({ type: "text", text: textLines.join("\n") }) + textLines.length = 0 } + // 再单独下载并输出内嵌图片。 + const result = await downloadMessageResource(client, messageId, element.image_key, "image", log, maxResourceSize) + if (result.resource) { + parts.push({ type: "file", mime: result.resource.mime, url: result.resource.dataUrl }) + } else { + parts.push({ type: "text", text: formatDownloadFailure("富文本图片", result, maxResourceSize) }) + } + } else if (element.tag === "img") { + segments.push("[图片]") } - if (segments.length) textLines.push(segments.join("")) } + if (segments.length) textLines.push(segments.join("")) } + } - // 剩余文本 - if (textLines.length) { - parts.push({ type: "text", text: textLines.join("\n").trim() }) - } - - return parts.length ? parts : [] - } catch { - return [] + // 剩余文本 + if (textLines.length) { + parts.push({ type: "text", text: textLines.join("\n").trim() }) } + + return parts.length ? parts : [] } -/** 纯文本提取(用于 describeMessageType 和 history) */ -function extractPostText(rawContent: string): string { - try { - const parsed = JSON.parse(rawContent) as { - title?: string - content?: Array> - } - const lines: string[] = [] - if (parsed.title) lines.push(parsed.title) - if (Array.isArray(parsed.content)) { - for (const paragraph of parsed.content) { - if (!Array.isArray(paragraph)) continue - const segments: string[] = [] - for (const element of paragraph) { - if (element.tag === "text" && element.text) segments.push(element.text) - else if (element.tag === "a" && element.text) segments.push(element.href ? `${element.text}(${element.href})` : element.text) - else if (element.tag === "at" && element.text) segments.push(element.text) - else if (element.tag === "img") segments.push("[图片]") - } - if (segments.length) lines.push(segments.join("")) +/** + * 从 post 中提取纯文本描述。 + * + * 用于历史摄入和日志预览,不涉及资源下载。 + */ +function extractPostText(rawContent: string, log?: LogFn): string { + const parsed = parsePostContent(rawContent, log) + if (!parsed) { + return "" + } + + const lines: string[] = [] + if (parsed.title) lines.push(parsed.title) + if (Array.isArray(parsed.content)) { + for (const paragraph of parsed.content) { + if (!Array.isArray(paragraph)) continue + const segments: string[] = [] + for (const element of paragraph) { + const inlineText = formatPostInlineText(element) + if (inlineText) segments.push(inlineText) + else if (element.tag === "img") segments.push("[图片]") } + if (segments.length) lines.push(segments.join("")) } - return lines.join("\n").trim() - } catch { - return "" } + return lines.join("\n").trim() } +/** + * 解析文件消息。 + * + * 根据 MIME 做三段式策略: + * - 文本类文件:作为 `text/plain` file part 内联 + * - 图片类文件:保留原始图片 MIME + * - 其他二进制文件:降级成文本描述 + */ async function extractFile( client: InstanceType, messageId: string, @@ -256,10 +365,32 @@ async function extractFile( } const detectedMime = result.resource.mime === "application/octet-stream" ? mime : (result.resource.mime || mime) - const finalMime = isTextualMime(detectedMime) ? "text/plain" : detectedMime - return [{ type: "file", mime: finalMime, url: result.resource.dataUrl, filename: fileName }] + + // 文本类文件 → text/plain(OpenCode 会倾向于内联处理)。 + if (isTextualMime(detectedMime)) { + const semi = result.resource.dataUrl.indexOf(";") + const url = "data:text/plain" + result.resource.dataUrl.slice(semi) + return [{ type: "file", mime: "text/plain", url, filename: fileName }] + } + + // 图片 → 保持原始 MIME(AI SDK 支持 image/*)。 + if (detectedMime.startsWith("image/")) { + return [{ type: "file", mime: detectedMime, url: result.resource.dataUrl, filename: fileName }] + } + + // 其他二进制文件(PDF/DOCX/XLSX/ZIP 等)→ 当前不直接喂给 SDK,降级为文本描述。 + // data URL 的前缀不是文件内容本身,估算体积时只统计逗号后的 base64 payload。 + const commaIndex = result.resource.dataUrl.indexOf(",") + const base64Data = commaIndex >= 0 ? result.resource.dataUrl.slice(commaIndex + 1) : "" + const sizeMB = (base64Data.length * 0.75 / (1024 * 1024)).toFixed(1) + return [{ type: "text", text: `[文件: ${fileName}, ${sizeMB}MB]` }] } +/** + * 解析语音消息。 + * + * 飞书侧通常把语音也走 file 下载通道,因此这里直接复用资源下载逻辑。 + */ async function extractAudio( client: InstanceType, messageId: string, @@ -280,11 +411,18 @@ async function extractAudio( return [{ type: "file", mime: result.resource.mime || "audio/opus", url: result.resource.dataUrl }] } +/** + * 视频消息的保守降级方案。 + * + * 视频通常体积较大,当前默认不下载,只保留文字说明。 + */ function extractMediaFallback(): PromptPart[] { - // 视频通常较大,默认不下载,仅文本描述 return [{ type: "text", text: "[视频消息]" }] } +/** + * 根据下载结果拼一个对用户/模型更友好的失败说明。 + */ function formatDownloadFailure(label: string, result: DownloadResult, maxSize: number): string { if (result.reason === "too_large" && result.totalSize) { const sizeMB = (result.totalSize / (1024 * 1024)).toFixed(1) @@ -294,16 +432,21 @@ function formatDownloadFailure(label: string, result: DownloadResult, maxSize: n return `[下载失败: ${label}]` } -function extractInteractive(rawContent: string): PromptPart[] { +/** + * 解析飞书卡片消息。 + * + * 这里不尝试完整还原卡片结构,只递归提取“对模型理解有帮助的文本信息”。 + */ +function extractInteractive(rawContent: string, log?: LogFn): PromptPart[] { try { const parsed = JSON.parse(rawContent) as Record const texts: string[] = [] - // header title + // 先收集 header 标题。 const header = parsed.header as { title?: { content?: string } } | undefined if (header?.title?.content) texts.push(header.title.content) - // Card 2.0: body.elements; Card 1.0: top-level elements + // Card 2.0 在 body.elements;老格式可能直接挂在顶层 elements。 const body = parsed.body as { elements?: unknown[] } | undefined const elements = (body?.elements ?? parsed.elements) as Array> | undefined if (Array.isArray(elements)) { @@ -312,39 +455,46 @@ function extractInteractive(rawContent: string): PromptPart[] { const text = texts.join("\n").trim() return text ? [{ type: "text", text: `[卡片消息]\n${text}` }] : [{ type: "text", text: "[卡片消息]" }] - } catch { + } catch (err) { + log?.("error", "解析卡片消息内容失败", { + error: err instanceof Error ? err.message : String(err), + }) return [{ type: "text", text: "[卡片消息]" }] } } -/** 递归提取卡片元素中的文本内容 */ +/** + * 递归提取卡片元素中的可读文本。 + * + * 只挑“对语义理解有帮助”的字段,不追求 1:1 还原视觉结构。 + */ function collectTexts(elements: Array>, out: string[]): void { for (const el of elements) { const tag = el.tag as string | undefined if (!tag) continue - // markdown / plain_text 直接取 content + // markdown / plain_text:直接收集 content。 if ((tag === "markdown" || tag === "plain_text") && typeof el.content === "string") { out.push(el.content) } - // div 取 text.content + // div:收集其 text.content。 else if (tag === "div") { const text = el.text as { content?: string } | undefined if (text?.content) out.push(text.content) } - // note 递归 elements + // note:递归其内部元素。 else if (tag === "note" && Array.isArray(el.elements)) { collectTexts(el.elements as Array>, out) } - // table: 提取表头和行数据为 markdown 表格 + // table:提取表头和行数据,转成 markdown 表格样式文本。 else if (tag === "table") { extractTable(el, out) } - // column_set / column: 递归子元素 + // column_set / column:递归子元素。 else if ((tag === "column_set" || tag === "column") && Array.isArray(el.columns ?? el.elements)) { collectTexts((el.columns ?? el.elements) as Array>, out) } - // action 按钮组: 提取按钮文本 + // action 按钮组:把按钮文本收集成提示性字符串。 else if (tag === "action" && Array.isArray(el.actions)) { for (const btn of el.actions as Array>) { const btnText = btn.text as { content?: string } | undefined @@ -354,18 +504,20 @@ function collectTexts(elements: Array>, out: string[]): } } -/** 提取 table 元素为 markdown 表格格式 */ +/** + * 把 table 元素提取成 markdown 表格文本。 + */ function extractTable(el: Record, out: string[]): void { const columns = el.columns as Array<{ name?: string; data_type?: string }> | undefined const rows = el.rows as Array> | undefined if (!columns?.length) return - // 表头 + // 表头。 const headers = columns.map(c => c.name ?? "") out.push("| " + headers.join(" | ") + " |") out.push("| " + headers.map(() => "---").join(" | ") + " |") - // 行数据 + // 行数据。 if (Array.isArray(rows)) { for (const row of rows) { const cells = headers.map(h => { @@ -377,11 +529,17 @@ function extractTable(el: Record, out: string[]): void { } } -function extractShareChat(rawContent: string): PromptPart[] { +/** + * 解析群分享消息。 + */ +function extractShareChat(rawContent: string, log?: LogFn): PromptPart[] { try { const parsed = JSON.parse(rawContent) as { chat_id?: string } return [{ type: "text", text: `[分享了一个群聊${parsed.chat_id ? `: ${parsed.chat_id}` : ""}]` }] - } catch { + } catch (err) { + log?.("error", "解析 share_chat 消息内容失败", { + error: err instanceof Error ? err.message : String(err), + }) return [{ type: "text", text: "[群分享]" }] } } diff --git a/src/feishu/dedup.ts b/src/feishu/dedup.ts index cac05f6..f7e8f3c 100644 --- a/src/feishu/dedup.ts +++ b/src/feishu/dedup.ts @@ -1,19 +1,48 @@ /** - * 消息去重 — 飞书 WebSocket 可能重复投递同一事件 + * 消息去重模块 — 防止飞书 WebSocket 重复投递同一事件 + * + * 飞书 WebSocket 长连接在网络抖动或重连时可能重复投递同一条消息事件。 + * 本模块使用 TtlMap 记录已处理的 messageId,在 TTL 窗口内自动去重。 + * 默认窗口 10 分钟,可通过 initDedup() 自定义。 */ import { TtlMap } from "../utils/ttl-map.js" +/** + * 去重缓存实例 + * key: 飞书消息 messageId + * value: true(仅作为存在标记,值无实际含义) + * 默认 TTL: 10 分钟(600,000 毫秒) + */ let dedup = new TtlMap(10 * 60 * 1_000) -/** 初始化去重缓存的过期时间 */ +/** + * 初始化(或重置)去重缓存的过期时间 + * + * 在插件启动时调用,允许通过配置文件自定义去重窗口。 + * 调用后会创建全新的 TtlMap 实例,旧缓存被丢弃。 + * + * @param ttl 去重窗口时长(毫秒) + */ export function initDedup(ttl: number): void { dedup = new TtlMap(ttl) } -/** 判断是否重复(首次出现返回 false,后续返回 true) */ +/** + * 判断指定 messageId 是否为重复消息 + * + * - 首次出现:记录到缓存并返回 false(非重复,应该处理) + * - 再次出现(TTL 窗口内):返回 true(重复,应该跳过) + * - messageId 为空/undefined/null:返回 false(无法去重,放行处理) + * + * @param messageId 飞书消息的唯一标识 + * @returns true 表示重复消息(应跳过),false 表示首次出现(应处理) + */ export function isDuplicate(messageId: string | undefined | null): boolean { + // 空 messageId 无法去重,直接放行 if (!messageId) return false + // 缓存中已存在,说明是重复投递 if (dedup.has(messageId)) return true + // 首次出现,记录到缓存并放行 dedup.set(messageId, true) return false } diff --git a/src/feishu/gateway.ts b/src/feishu/gateway.ts index 7c183ce..c46b018 100644 --- a/src/feishu/gateway.ts +++ b/src/feishu/gateway.ts @@ -1,19 +1,25 @@ /** - * 飞书 WebSocket 长连接:接收消息并回调 + * 飞书 WebSocket 网关。 + * + * 它负责把飞书事件世界翻译成仓库内部可消费的三个入口: + * - `onMessage`:收到一条可处理消息 + * - `onBotAdded`:bot 被拉入群 + * - `onCardAction`:用户点击卡片按钮 */ import * as Lark from "@larksuiteoapi/node-sdk" import type { Agent } from "node:https" import { randomUUID } from "node:crypto" import * as httpsProxyAgent from "https-proxy-agent" import type { FeishuMessageContext, ResolvedConfig, LogFn } from "../types.js" -import { type CardActionData, buildCallbackResponse } from "../handler/interactive.js" +import { type CardActionData, buildCallbackResponse, parseCardActionValue } from "../handler/interactive.js" import { isDuplicate } from "./dedup.js" import { describeMessageType } from "./content-extractor.js" import { isBotMentioned } from "./group-filter.js" -// 兼容 Bun 和 Node.js 的 CJS/ESM interop +// 兼容 Bun 和 Node.js 的 CJS/ESM interop。 const { HttpsProxyAgent } = httpsProxyAgent +/** 启动飞书网关所需的外部依赖。 */ export interface FeishuGatewayOptions { config: ResolvedConfig /** 外部创建的 Lark Client(复用 token 管理和 HTTP 客户端) */ @@ -29,16 +35,91 @@ export interface FeishuGatewayOptions { } export interface FeishuGatewayResult { + /** 复用的飞书 SDK client,供发送模块继续使用。 */ client: InstanceType + /** 主动关闭 WebSocket 连接的函数。 */ stop: () => void } +/** + * 将飞书会话类型压缩成仓库内部统一使用的 `"group" | "p2p"`。 + * + * 飞书 REST `chat.get` 返回的 `chat_mode` / `chat_type` 可能出现不同字面量, + * 这里只做最小必要映射;无法识别时返回 `undefined`,交由上层决定是否拒绝。 + */ +function normalizeResolvedChatType(rawValue: string | undefined): "group" | "p2p" | undefined { + const normalized = rawValue?.trim().toLowerCase() + if (!normalized) return undefined + if (normalized.includes("p2p")) return "p2p" + if (normalized.includes("group") || normalized.includes("topic")) return "group" + return undefined +} + +/** + * 为卡片 `send_message` 回调确定最终 chatType。 + * + * 普通新卡片会自带 `chatType`;只有旧卡片缺字段,或 payload/chat 回调上下文不一致时, + * 才额外查询飞书会话信息做权威兜底,避免把群聊误路由到 p2p session。 + */ +async function resolveCardActionChatType(params: { + chatId: string + payloadChatType?: "p2p" | "group" + requireAuthoritativeLookup: boolean + larkClient: InstanceType + log: LogFn +}): Promise<"p2p" | "group" | undefined> { + const { chatId, payloadChatType, requireAuthoritativeLookup, larkClient, log } = params + if (!requireAuthoritativeLookup && payloadChatType) { + return payloadChatType + } + + try { + const response = await larkClient.im.chat.get({ + path: { chat_id: chatId }, + }) + const resolvedFromResponse = + normalizeResolvedChatType(response.data?.chat_mode) ?? + normalizeResolvedChatType(response.data?.chat_type) + + if (resolvedFromResponse) { + if (payloadChatType && payloadChatType !== resolvedFromResponse) { + log("warn", "send_message 按钮 chatType 与飞书会话信息不一致,使用飞书会话类型", { + chatId, + payloadChatType, + resolvedChatType: resolvedFromResponse, + chatMode: response.data?.chat_mode ?? "", + rawChatType: response.data?.chat_type ?? "", + }) + } + return resolvedFromResponse + } + + log("error", "无法从飞书会话信息推断 send_message 按钮 chatType", { + chatId, + payloadChatType: payloadChatType ?? "", + chatMode: response.data?.chat_mode ?? "", + rawChatType: response.data?.chat_type ?? "", + code: response.code ?? 0, + msg: response.msg ?? "", + }) + } catch (err) { + log("error", "查询 send_message 按钮会话信息失败", { + chatId, + payloadChatType: payloadChatType ?? "", + error: err instanceof Error ? err.message : String(err), + }) + } + + return requireAuthoritativeLookup ? undefined : payloadChatType +} + /** * 启动飞书 WebSocket 网关,返回 Client(供 sender 使用)和 stop 函数 */ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGatewayResult { const { config, larkClient, botOpenId = "", onMessage, onBotAdded, onCardAction, log } = options const { appId, appSecret } = config + // 优先读取常见代理环境变量,让 WebSocket 也能跟随企业网络设置。 const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || @@ -48,9 +129,11 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway let wsAgent: Agent | undefined if (proxyUrl) { wsAgent = new HttpsProxyAgent(proxyUrl) - log("info", "WS proxy enabled", { proxy: proxyUrl }) + // 代理地址可能带账号密码;日志里只保留脱敏后的可定位信息,避免敏感凭据落盘。 + log("info", "WS proxy enabled", { proxy: redactProxyUrlForLog(proxyUrl) }) } + // EventDispatcher 是飞书 SDK 的事件分发核心;这里只注册我们真正关心的几类事件。 const dispatcher = new Lark.EventDispatcher({}).register({ "im.message.receive_v1": async (data: Record) => { try { @@ -64,6 +147,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway if (!chatId) return const messageId = message.message_id as string | undefined + // 去重必须尽早做,避免后面一整条消息链路重复执行。 if (isDuplicate(messageId)) return const messageType = (message.message_type as string) ?? "text" @@ -77,15 +161,16 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway if (!rawContent) return // 提取文本内容(用于 @提及清理和空消息过滤) - let text = describeMessageType(messageType, rawContent) + let text = describeMessageType(messageType, rawContent, log) if (messageType === "text") { + // text 消息里的 @mention token 在决定 shouldReply 后就没有必要再传给模型。 text = text.replace(/@_user_\d+\s*/g, "").trim() } if (!text) return const chatType = (message.chat_type as string) === "group" ? "group" : "p2p" - // 群聊:仅在被 @ 时回复(静默监听) + // 群聊默认静默监听;只有真的 @到 bot 才转入“需要回复”的链路。 let shouldReply = true if (chatType === "group") { const mentions = Array.isArray(message.mentions) ? message.mentions : [] @@ -101,6 +186,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway const parentId = message.parent_id as string | undefined const createTime = message.create_time as string | undefined + // 把飞书原始事件折叠成仓库内部统一消息上下文。 const ctx: FeishuMessageContext = { chatId: String(chatId), messageId: messageId ?? "", @@ -134,6 +220,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway try { const chatId = data.chat_id as string | undefined if (chatId && onBotAdded) { + // Bot 刚被拉入群时,异步触发历史消息摄入。 log("info", "Bot 被添加到群聊", { chatId }) await onBotAdded(chatId) } @@ -145,7 +232,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway }, "card.action.trigger": async (data: Record) => { try { - // 类型化事件 payload(双路径兼容 SDK v1/v2 格式) + // 类型化事件 payload(双路径兼容 SDK v1/v2 格式)。 const evt = data as { action?: { value?: unknown; tag?: string } context?: { open_message_id?: string; open_chat_id?: string } @@ -163,16 +250,54 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway operatorId: String(evt.operator?.open_id ?? ""), } - // 检测 send_message 按钮:构造合成消息,走正常消息流程 - const sendMsg = parseSendMessageAction(action) - if (sendMsg) { + // 特判 send_message 按钮:把按钮点击伪装成一条新的用户文本消息,复用正常消息链路。 + const parsedAction = parseCardActionValue(action.actionValue, log) + if (parsedAction?.action === "send_message") { + // 飞书回调上下文里的 chatId 才是本次点击发生位置的权威来源,按钮 payload 只做冗余校验。 + const callbackChatId = action.chatId?.trim() ?? "" + if (callbackChatId && callbackChatId !== parsedAction.chatId) { + log("warn", "send_message 按钮 chatId 与回调上下文不一致,使用回调 chatId", { + callbackChatId, + payloadChatId: parsedAction.chatId, + }) + } + const targetChatId = callbackChatId || parsedAction.chatId + const chatIdMismatch = !!callbackChatId && callbackChatId !== parsedAction.chatId + const resolvedChatType = await resolveCardActionChatType({ + chatId: targetChatId, + payloadChatType: parsedAction.chatType, + // 旧卡片缺 chatType 或 payload chatId 已明显过期时,必须查飞书权威会话信息再继续。 + requireAuthoritativeLookup: !parsedAction.chatType || chatIdMismatch, + larkClient, + log, + }) + if (!resolvedChatType) { + log("error", "send_message 按钮 chatType 无法确定,已拒绝处理", { + callbackChatId, + payloadChatId: parsedAction.chatId, + payloadChatType: parsedAction.chatType ?? "", + targetChatId, + operatorId: action.operatorId, + }) + return { + toast: { type: "warning", content: "⚠️ 卡片已过期,请重新触发" }, + } + } + if (!parsedAction.chatType) { + log("warn", "send_message 按钮缺少 chatType,已按飞书会话信息兼容推断", { + callbackChatId, + payloadChatId: parsedAction.chatId, + targetChatId, + resolvedChatType, + }) + } const syntheticCtx: FeishuMessageContext = { - chatId: sendMsg.chatId, + chatId: targetChatId, messageId: `btn-${randomUUID()}`, messageType: "text", - content: sendMsg.text, - rawContent: JSON.stringify({ text: sendMsg.text }), - chatType: sendMsg.chatType, + content: parsedAction.text, + rawContent: JSON.stringify({ text: parsedAction.text }), + chatType: resolvedChatType, senderId: action.operatorId ?? "", shouldReply: true, } @@ -181,10 +306,11 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway error: err instanceof Error ? err.message : String(err), }) }) - return buildCallbackResponse(action) + // 即使后台还没处理完,也要马上给飞书回一个 toast。 + return buildCallbackResponse(action, log) } - // fire-and-forget(必须 3s 内返回) + // 其他交互统一走 onCardAction,后台异步处理,避免卡住飞书 3 秒响应窗口。 if (onCardAction) { void onCardAction(action).catch((err) => { log("error", "card action 处理失败", { @@ -194,7 +320,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway } // 即时返回 toast - return buildCallbackResponse(action) + return buildCallbackResponse(action, log) } catch (err) { log("error", "card.action.trigger 处理异常", { error: err instanceof Error ? err.message : String(err), @@ -220,6 +346,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway ...(wsAgent ? { agent: wsAgent } : {}), loggerLevel: logLevelMap[config.logLevel] ?? Lark.LoggerLevel.info, logger: { + // 飞书 SDK 的不同级别统一桥接到项目日志系统。 error: (...msg: unknown[]) => log("error", "[lark.ws]", { msg }), warn: (...msg: unknown[]) => log("warn", "[lark.ws]", { msg }), info: (...msg: unknown[]) => log("info", "[lark.ws]", { msg }), @@ -232,6 +359,7 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway log("info", "飞书 WebSocket 网关已启动", { appIdPrefix: appId.slice(0, 8) + "..." }) const stop = () => { + // 停止时只需要关闭 WSClient;飞书 SDK 自身会处理底层连接资源。 log("info", "飞书 WebSocket 网关停止中") wsClient.close() log("info", "飞书 WebSocket 网关已停止") @@ -240,26 +368,19 @@ export function startFeishuGateway(options: FeishuGatewayOptions): FeishuGateway return { client: larkClient, stop } } -interface SendMessagePayload { - chatId: string - chatType: "p2p" | "group" - text: string -} - /** - * 解析 send_message 类型的按钮回调 value + * 代理 URL 可能带有 `user:pass@host` 形式的凭据。 + * 日志里统一脱敏,既保留排障所需的地址信息,也避免明文暴露敏感字段。 */ -function parseSendMessageAction(action: CardActionData): SendMessagePayload | undefined { - if (!action.actionValue) return undefined +function redactProxyUrlForLog(proxyUrl: string): string { try { - const value = JSON.parse(action.actionValue) as Record - if (value.action !== "send_message") return undefined - const text = typeof value.text === "string" ? value.text : "" - const chatId = typeof value.chatId === "string" ? value.chatId : "" - if (!text || !chatId) return undefined - const chatType = value.chatType === "group" ? "group" as const : "p2p" as const - return { chatId, chatType, text } + const parsed = new URL(proxyUrl) + if (parsed.username || parsed.password) { + parsed.username = "***" + parsed.password = "***" + } + return parsed.toString() } catch { - return undefined + return "[invalid-proxy-url]" } } diff --git a/src/feishu/group-filter.ts b/src/feishu/group-filter.ts index b9327e4..dfcbde2 100644 --- a/src/feishu/group-filter.ts +++ b/src/feishu/group-filter.ts @@ -1,16 +1,26 @@ /** - * 群聊过滤:仅在 bot 被直接 @提及时回复 + * 群聊过滤模块 — 检测 bot 是否被直接 @提及 + * + * 群聊中 bot 默认静默监听(转发所有消息作为上下文), + * 仅在被直接 @提及时才生成 AI 回复。 + * 本模块提供 @提及检测逻辑,由 gateway.ts 在收到群消息时调用。 */ /** * 检查 bot 是否被直接 @提及 - * @param mentions 飞书事件中的 @ 提及列表 - * @param botOpenId bot 自身的 open_id(启动时获取) - * @returns 当 bot 被 @提及时返回 true + * + * 飞书消息事件中,@提及信息存储在 mentions 数组中, + * 每个 mention 对象包含被 @ 用户的 open_id。 + * 本函数遍历该数组,与 bot 自身的 open_id 比较。 + * + * @param mentions 飞书消息事件中的 @ 提及列表,每项包含 id.open_id 字段 + * @param botOpenId bot 自身的 open_id(插件启动时通过 fetchBotOpenId 获取) + * @returns 当 bot 被 @提及时返回 true,否则返回 false */ export function isBotMentioned( mentions: Array<{ id?: { open_id?: string }; [key: string]: unknown }>, botOpenId: string ): boolean { + // 遍历 mentions 数组,匹配任一提及的 open_id 等于 bot 的 open_id return mentions.some((m) => m.id?.open_id === botOpenId); } diff --git a/src/feishu/history.ts b/src/feishu/history.ts index 1b29bee..e1855e0 100644 --- a/src/feishu/history.ts +++ b/src/feishu/history.ts @@ -1,5 +1,8 @@ /** - * 群聊历史上下文摄入:bot 被拉入群聊时,读取历史消息并发送给 OpenCode 作为上下文 + * 群聊历史上下文摄入。 + * + * 当 bot 被新拉入群聊时,这个模块会补录一段历史消息给 OpenCode, + * 让后续对话一开始就拥有最基本的背景信息。 */ import type * as Lark from "@larksuiteoapi/node-sdk" import type { OpencodeClient } from "@opencode-ai/sdk" @@ -7,6 +10,7 @@ import type { LogFn } from "../types.js" import { buildSessionKey, getOrCreateSession } from "../session.js" import { describeMessageType } from "./content-extractor.js" +/** 历史消息的轻量归一化结构。 */ interface HistoryMessage { senderType: string senderId: string @@ -14,10 +18,13 @@ interface HistoryMessage { createTime: string } +/** 单次调用飞书 list 接口时的页面大小上限。 */ const DEFAULT_PAGE_SIZE = 50 /** - * 拉取群聊历史消息并注入 OpenCode 会话作为背景上下文 + * 拉取群聊历史消息并注入 OpenCode 会话。 + * + * 整个过程不会触发 AI 回复,只会把内容作为 `noReply` 上下文同步进去。 */ export async function ingestGroupHistory( feishuClient: InstanceType, @@ -34,21 +41,21 @@ export async function ingestGroupHistory( log("info", "开始摄入群聊历史上下文", { chatId, maxMessages }) - // 1. 拉取历史消息 + // 1. 先从飞书侧拉取最近历史消息。 const messages = await fetchRecentMessages(feishuClient, chatId, maxMessages, log) if (!messages.length) { log("info", "群聊无历史消息,跳过摄入", { chatId }) return } - // 2. 获取/创建 OpenCode 会话 + // 2. 复用该群聊对应的 OpenCode 会话。 const sessionKey = buildSessionKey("group", chatId) const session = await getOrCreateSession(opencodeClient, sessionKey, directory) - // 3. 格式化为上下文文本 + // 3. 把历史消息格式化成一段连续的上下文文本。 const contextText = formatHistoryAsContext(messages) - // 4. 发送到 OpenCode(noReply: true,仅记录上下文,不触发 AI 回复) + // 4. 以 noReply 方式送入 OpenCode,只记上下文,不要求模型立即回应。 await opencodeClient.session.promptAsync({ path: { id: session.id }, query, @@ -62,7 +69,12 @@ export async function ingestGroupHistory( } /** - * 通过飞书 API 拉取群聊最近的文本消息 + * 通过飞书 API 分页拉取群聊最近消息。 + * + * 返回值已经做过: + * - 删除消息过滤 + * - 空内容过滤 + * - 消息类型转文本描述 */ async function fetchRecentMessages( client: InstanceType, @@ -89,12 +101,13 @@ async function fetchRecentMessages( if (!items || items.length === 0) break for (const item of items) { + // 已删除或无正文的消息没有摄入价值。 if (item.deleted) continue if (!item.body?.content) continue const msgType = item.msg_type ?? "text" const rawContent = item.body.content - const text = describeMessageType(msgType, rawContent) + const text = describeMessageType(msgType, rawContent, log) if (!text) continue result.push({ @@ -107,23 +120,24 @@ async function fetchRecentMessages( if (result.length >= maxMessages) break } + // 飞书服务端已无更多页时结束。 if (!res?.data?.has_more) break pageToken = res.data.page_token ?? undefined } } catch (err) { - log("warn", "拉取群聊历史消息失败", { + log("error", "拉取群聊历史消息失败", { chatId, error: err instanceof Error ? err.message : String(err), }) } - // API 返回倒序(最新在前),翻转为正序(最早在前) + // API 返回倒序(最新在前),翻转为正序(最早在前),更适合模型顺序阅读。 result.reverse() return result } /** - * 将历史消息格式化为上下文文本 + * 将历史消息格式化为适合 prompt 注入的文本。 */ function formatHistoryAsContext(messages: HistoryMessage[]): string { const header = [ @@ -134,9 +148,11 @@ function formatHistoryAsContext(messages: HistoryMessage[]): string { const body = messages .map((m) => { + // 时间统一转成中文可读格式,帮助模型理解先后关系。 const time = m.createTime ? new Date(Number(m.createTime)).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }) : "unknown" + // app 发送者通常是机器人或系统实体,单独标记。 const senderLabel = m.senderType === "app" ? "[Bot]" : `[${m.senderId}]` return `[${time}] ${senderLabel}: ${m.content}` }) diff --git a/src/feishu/markdown.ts b/src/feishu/markdown.ts index b4a2353..368a94b 100644 --- a/src/feishu/markdown.ts +++ b/src/feishu/markdown.ts @@ -1,74 +1,149 @@ /** * 飞书卡片 Markdown 清理工具 + * + * 飞书 CardKit 2.0 支持的 Markdown 子集有限,不兼容标准 HTML 标签。 + * 本模块负责: + * 1. 清理 AI 输出中的 HTML 标签(保护代码块中的泛型语法如 Map) + * 2. 确保代码块正确闭合(流式输出可能在代码块中间截断) + * 3. 截断超长内容以符合飞书卡片大小限制(~30KB) */ +/** + * 飞书卡片内容最大字节数 + * 飞书实际上限约 30KB,这里预留 2KB 余量(28KB = 28,672 字节) + * 用于容纳截断后缀和代码块闭合标记 + */ const MAX_CARD_BYTES = 28 * 1024 // 留 2KB 余量(飞书上限 ~30KB) + +/** 内容截断时追加的提示后缀 */ const TRUNCATION_SUFFIX = "\n\n*内容过长,已截断*" + +/** 截断后缀的 UTF-8 字节长度(预计算,避免每次截断时重复编码) */ const TRUNCATION_SUFFIX_BYTES = new TextEncoder().encode(TRUNCATION_SUFFIX).length + +/** 代码块闭合标记 "\n```" 的字节长度,截断时需要预留此空间 */ const CODE_FENCE_BYTES = 4 // "\n```".length -/** 只匹配明确的 HTML 标签(带属性或已知标签名),保护代码中的泛型如 Map */ +/** + * HTML 标签正则表达式 + * 只匹配明确的 HTML 标签(带属性或已知标签名), + * 设计为不误匹配代码中的泛型语法如 Map + * 例如:

会被匹配 + * 而:Map 不会被匹配(因为 string, number 不是有效的标签属性格式) + */ const HTML_TAG_RE = /<\/?\w+(?:\s[^>]*)?\/?>/g /** * 清理 markdown 使其兼容飞书卡片渲染 - * - 移除 HTML 标签(保护代码中的泛型角括号) - * - 确保代码块正确闭合 + * + * 处理流程: + * 1. 将
标签转换为换行符 + * 2. 提取代码块内容(保护其不被 HTML 清理影响) + * 3. 对非代码段移除 HTML 标签 + * 4. 还原代码块 + * 5. 确保代码块正确闭合 + * + * @param text 原始 markdown 文本(可能包含 HTML 标签) + * @returns 清理后的纯 markdown 文本 */ export function cleanMarkdown(text: string): string { - //
→ 换行 + // 第一步:将
/
转换为换行符(飞书不支持
标签) let result = text.replace(//gi, "\n") + // 第二步:先补全未闭合代码块,避免半截代码块被当成普通文本做 HTML 清洗。 + result = closeCodeBlocks(result) - // 保护代码块中的内容不被 HTML 清理 + // 第三步:提取代码块,用 NUL 字符占位,避免代码块中的泛型语法被误删 const { segments, codeBlocks } = extractCodeBlocks(result) + // 只对非代码段执行 HTML 标签清理 result = segments.map(seg => seg.replace(HTML_TAG_RE, "")).join("\0") - // 还原代码块 + // 第四步:将 NUL 占位符替换回原始代码块内容 let idx = 0 result = result.replace(/\0/g, () => codeBlocks[idx++] ?? "") + // 第五步:兜底再检查一次,兼容清洗过程中新插入换行后的代码块状态。 result = closeCodeBlocks(result) return result } /** * 截断超长内容,确保不超过飞书卡片大小限制 + * + * 截断策略: + * 1. 计算有效截断点(预留后缀和代码块闭合的字节数) + * 2. 按字节截断(使用 TextEncoder/TextDecoder 处理 UTF-8 多字节字符) + * 3. 尽量在最后一个完整行处截断(避免截断在行中间) + * 4. 确保截断后的代码块正确闭合 + * 5. 追加截断提示后缀 + * + * @param text 待截断的 markdown 文本 + * @param limit 最大允许字节数,默认 MAX_CARD_BYTES(28KB) + * @returns 截断后的文本(未超限则原样返回) */ export function truncateMarkdown(text: string, limit = MAX_CARD_BYTES): string { const bytes = new TextEncoder().encode(text) + // 未超限,直接返回原文 if (bytes.length <= limit) return text - // 预留后缀 + 可能的代码块闭合占用的字节数 + // 计算有效截断上限:总限制 - 截断后缀 - 可能的代码块闭合标记 const effectiveLimit = limit - TRUNCATION_SUFFIX_BYTES - CODE_FENCE_BYTES + // 极端情况:如果有效限制为零或负数,只返回后缀 if (effectiveLimit <= 0) return TRUNCATION_SUFFIX - // 按字节截断,确保不截断 UTF-8 多字节字符 + // 按字节截断(TextDecoder 自动处理截断的 UTF-8 多字节字符,避免产生乱码) const truncated = new TextDecoder().decode(bytes.slice(0, effectiveLimit)) - // 找最后一个完整行 + // 尽量在最后一个换行符处截断,避免在行中间切断 + // 只在换行符位置超过有效限制 80% 时才使用,否则截断太多内容 const lastNewline = truncated.lastIndexOf("\n") const cutPoint = lastNewline > effectiveLimit * 0.8 ? lastNewline : truncated.length let result = truncated.slice(0, cutPoint) + // 确保截断后的代码块闭合 result = closeCodeBlocks(result) return result + TRUNCATION_SUFFIX } -/** 将文本分割为非代码段和代码块,便于只对非代码段做 HTML 清理 */ +/** + * 将文本分割为"非代码段"和"代码块"两个数组 + * + * 用于在清理 HTML 标签时保护代码块中的内容(如泛型语法 Map)。 + * segments 数组比 codeBlocks 多一个元素(segments[i] 和 segments[i+1] 之间夹着 codeBlocks[i])。 + * + * @param text 原始文本 + * @returns segments(非代码段数组)和 codeBlocks(代码块数组) + */ function extractCodeBlocks(text: string): { segments: string[]; codeBlocks: string[] } { const segments: string[] = [] const codeBlocks: string[] = [] + // 匹配完整的代码块(``` ... ```),非贪婪模式 const re = /```[\s\S]*?```/g let lastIndex = 0 let match: RegExpExecArray | null + // 遍历所有代码块,收集代码块和非代码段 while ((match = re.exec(text)) !== null) { + // 代码块之前的文本作为非代码段 segments.push(text.slice(lastIndex, match.index)) + // 代码块本身 codeBlocks.push(match[0]) lastIndex = match.index + match[0].length } + // 最后一个代码块之后的文本(或没有代码块时的全部文本) segments.push(text.slice(lastIndex)) return { segments, codeBlocks } } +/** + * 确保 markdown 中的代码块正确闭合 + * + * 流式输出场景下,AI 可能在代码块中间被截断, + * 导致 ``` 标记数量为奇数(未闭合)。 + * 此函数检测并追加缺少的闭合标记。 + * + * @param text markdown 文本 + * @returns 代码块已闭合的文本 + */ function closeCodeBlocks(text: string): string { + // 统计 ``` 出现次数 const matches = text.match(/```/g) + // 奇数个 ``` 表示有未闭合的代码块,追加闭合标记 if (matches && matches.length % 2 !== 0) { return text + "\n```" } diff --git a/src/feishu/quote.ts b/src/feishu/quote.ts index e0e829e..5590c53 100644 --- a/src/feishu/quote.ts +++ b/src/feishu/quote.ts @@ -1,32 +1,58 @@ /** - * 飞书引用消息解析:获取被回复消息的内容 + * 飞书引用消息解析模块 + * + * 当用户在飞书中回复(引用)某条消息时,消息事件会携带 parent_id 字段。 + * 本模块通过飞书 API 获取被引用消息的原始内容, + * 截断到安全长度后返回,供 chat.ts 拼接到发送给 OpenCode 的上下文中。 */ import type * as Lark from "@larksuiteoapi/node-sdk" import type { LogFn } from "../types.js" import { describeMessageType } from "./content-extractor.js" +/** + * 引用消息内容的最大字符数 + * 防止超长引用消息撑爆上下文,500 字符足以提供必要背景 + */ const MAX_QUOTE_LENGTH = 500 +/** + * 获取被引用(回复)消息的文本内容 + * + * 通过飞书 im.message.get API 读取 parent_id 对应的消息, + * 解析其消息类型和内容,返回人类可读的文本描述。 + * 超过 MAX_QUOTE_LENGTH 的内容会被截断并追加省略号。 + * + * @param client 飞书 SDK Client 实例(自动处理 token 认证) + * @param parentId 被引用消息的 message_id + * @param log 日志函数,用于记录获取失败的 error 日志 + * @returns 引用消息的文本内容,获取失败或消息不存在时返回 undefined + */ export async function fetchQuotedMessage( client: InstanceType, parentId: string, log: LogFn, ): Promise { try { + // 通过飞书 API 获取指定 message_id 的消息详情 const res = await client.im.message.get({ path: { message_id: parentId }, }) + // API 返回的 items 数组中取第一条(get 接口只返回一条) const msg = res?.data?.items?.[0] if (!msg) return undefined + // 提取消息类型和正文内容 const msgType = (msg.msg_type as string) ?? "text" const body = (msg.body as { content?: string })?.content ?? "" - const text = describeMessageType(msgType, body) + // 将消息类型+内容转换为人类可读的文本描述 + const text = describeMessageType(msgType, body, log) if (!text) return undefined + // 超长内容截断,防止引用内容过大 return text.length > MAX_QUOTE_LENGTH ? text.slice(0, MAX_QUOTE_LENGTH) + "..." : text } catch (err) { - log("warn", "引用消息获取失败", { + // 获取失败时记录 error 日志,但不阻断主流程(引用消息是可选上下文) + log("error", "引用消息获取失败", { parentId, error: err instanceof Error ? err.message : String(err), }) diff --git a/src/feishu/resource.ts b/src/feishu/resource.ts index 7c6ebfe..0877e1d 100644 --- a/src/feishu/resource.ts +++ b/src/feishu/resource.ts @@ -1,9 +1,13 @@ /** - * 飞书消息资源下载:将图片、文件、音频等资源转换为 data URL + * 飞书资源下载层:把消息里的文件/图片/音频拉下来并转成 data URL。 + * + * 之所以统一转成 data URL,是为了后续能直接塞进 OpenCode file parts, + * 不依赖临时文件或额外公网地址。 */ import type * as Lark from "@larksuiteoapi/node-sdk" import type { LogFn } from "../types.js" +/** 下载成功后的标准资源结构。 */ export interface DownloadedResource { /** data:;base64, */ dataUrl: string @@ -11,16 +15,23 @@ export interface DownloadedResource { filename?: string } +/** 下载流程的统一结果。 */ export interface DownloadResult { + /** 下载成功时的资源;失败则为 null。 */ resource: DownloadedResource | null + /** 失败原因枚举,便于上层生成更友好的提示。 */ reason: "ok" | "too_large" | "error" + /** 下载超限时记录当时累计字节数。 */ totalSize?: number } /** - * 下载飞书消息中的资源文件,返回 data URL + * 下载飞书消息中的资源并返回 data URL。 * - * 使用 im.messageResource.get API,支持图片、文件、音频、视频 + * 核心策略: + * - 使用流式读取,边下边统计大小 + * - 一旦超过 `maxSize` 立刻中断,避免把大文件完整拉进内存 + * - 不把错误向上抛,而是统一折叠成 `DownloadResult` */ export async function downloadMessageResource( client: InstanceType, @@ -37,7 +48,7 @@ export async function downloadMessageResource( }) if (!res) { - log("warn", "资源下载返回空数据", { messageId, fileKey, type }) + log("error", "资源下载返回空数据", { messageId, fileKey, type }) return { resource: null, reason: "error" } } @@ -46,10 +57,12 @@ export async function downloadMessageResource( let totalSize = 0 for await (const chunk of stream) { + // 兼容 Buffer 和 Uint8Array 两种 chunk 形态。 const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as unknown as Uint8Array) totalSize += buf.length if (totalSize > maxSize) { - log("warn", "资源过大,跳过下载", { messageId, fileKey, totalSize, maxSize }) + log("error", "资源过大,跳过下载", { messageId, fileKey, totalSize, maxSize }) + // 主动销毁流,尽快释放网络和内存占用。 stream.destroy() return { resource: null, reason: "too_large", totalSize } } @@ -64,7 +77,7 @@ export async function downloadMessageResource( return { resource: { dataUrl, mime: contentType }, reason: "ok" } } catch (err) { - log("warn", "资源下载失败", { + log("error", "资源下载失败", { messageId, fileKey, type, @@ -74,12 +87,18 @@ export async function downloadMessageResource( } } +/** + * 响应头缺失时,根据资源类别给一个保守 MIME 默认值。 + */ function guessMimeByType(type: "image" | "file"): string { return type === "image" ? "image/png" : "application/octet-stream" } /** - * 根据文件名推断 MIME 类型 + * 根据文件名扩展名推断 MIME 类型。 + * + * 这份映射主要用于“服务端只返回 octet-stream”时给上层二次判断, + * 例如决定是否把文件按文本内联给 OpenCode。 */ export function guessMimeByFilename(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() ?? "" diff --git a/src/feishu/sender.ts b/src/feishu/sender.ts index a9c54fa..de821d6 100644 --- a/src/feishu/sender.ts +++ b/src/feishu/sender.ts @@ -1,63 +1,132 @@ /** - * 飞书消息发送:文本、更新、删除 + * 飞书消息发送模块 + * + * 封装飞书 IM API 的消息操作,提供统一的错误处理和结果格式。 + * 支持:发送文本消息、更新已有消息、删除消息、发送交互式卡片、发送 CardKit 2.0 卡片。 + * 所有发送函数返回 FeishuSendResult 统一结构,便于调用方处理成功/失败。 */ import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { LogFn } from "../types.js" +/** + * 飞书消息发送操作的统一返回结构 + */ export interface FeishuSendResult { - ok: boolean; - messageId?: string; - error?: string; + /** 操作是否成功 */ + ok: boolean + /** 成功时返回飞书消息 ID(可用于后续更新/删除) */ + messageId?: string + /** 失败时返回错误描述(包含飞书 API 的 code/msg/logId 信息) */ + error?: string } +/** + * 通用的飞书 API 调用包装器 + * + * 将任意飞书 SDK 调用封装为统一的 FeishuSendResult 格式, + * 自动提取 Lark SDK 错误中的 code、msg、logId 等诊断信息。 + * + * @template T 飞书 API 返回值类型 + * @param fn 待执行的飞书 API 调用(惰性执行,传入无参函数) + * @param idExtractor 从 API 响应中提取 message_id 的函数,默认从 data.message_id 提取 + * @returns 统一的发送结果,包含 ok/messageId/error 字段 + */ async function wrapSendCall( fn: () => Promise, + log: LogFn | undefined, + action: string, idExtractor: (res: T) => string = (res) => (res as { data?: { message_id?: string } })?.data?.message_id ?? "", ): Promise { try { + // 惰性执行具体 SDK 调用,便于不同发送函数共用统一包装逻辑。 const res = await fn() return { ok: true, messageId: idExtractor(res) } } catch (err) { + // 提取 Lark SDK 错误对象中的诊断字段(code/msg/logId) const larkErr = (err != null && typeof err === "object") ? err as Record : {} + // 拼接 Lark 特有的错误信息片段,便于排查问题 const parts = [ larkErr.code !== undefined ? `code=${larkErr.code}` : null, typeof larkErr.msg === "string" ? `msg=${larkErr.msg}` : null, typeof larkErr.logId === "string" ? `logId=${larkErr.logId}` : null, ].filter(Boolean).join(", ") + // 基础错误消息:优先使用 Error.message,否则转为字符串 const message = err instanceof Error ? err.message : String(err) + // sender 层直接记 error 日志,避免调用方只消费返回值时丢失异常现场。 + log?.("error", `飞书消息操作失败: ${action}`, { + error: message, + ...(parts ? { diagnostics: parts } : {}), + }) + // 如果有 Lark 诊断信息,附加在括号中 return { ok: false, error: parts ? `${message} (${parts})` : message } } } /** - * 发送文本消息到飞书会话 + * 统一走 `im.message.create` 的发送路径。 + * + * 这里把 `chatId` 校验、`receive_id` 组装和 SDK 调用收敛到一处, + * 避免文本、交互卡片和 CardKit 卡片各自复制一遍相同模板。 */ -export async function sendTextMessage( +function createChatMessage( client: InstanceType, chatId: string, - text: string, + msgType: "text" | "interactive", + content: string, + log?: LogFn, ): Promise { - if (!chatId?.trim()) { - return { ok: false, error: "No chat_id provided" }; + const receiveId = chatId.trim() + if (!receiveId) { + log?.("error", "飞书消息发送失败: 缺少 chat_id", { msgType }) + return Promise.resolve({ ok: false, error: "No chat_id provided" }) } return wrapSendCall(() => client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { - receive_id: chatId.trim(), - msg_type: "text", - content: JSON.stringify({ text }), + receive_id: receiveId, + msg_type: msgType, + content, }, }), + log, + `create:${msgType}`, ) } /** - * 更新已有消息(如替换「正在思考…」占位) + * 发送纯文本消息到飞书会话 + * + * @param client 飞书 SDK Client 实例(自动处理 token 认证) + * @param chatId 目标会话 ID(飞书 chat_id) + * @param text 消息文本内容 + * @returns 发送结果,成功时包含 messageId + */ +export async function sendTextMessage( + client: InstanceType, + chatId: string, + text: string, + log?: LogFn, +): Promise { + return createChatMessage(client, chatId, "text", JSON.stringify({ text }), log) +} + +/** + * 更新已有消息的文本内容 + * + * 典型场景:替换「正在思考...」占位消息为最终 AI 回复。 + * 使用飞书 im.message.update API,通过 message_id 定位要更新的消息。 + * + * @param client 飞书 SDK Client 实例 + * @param messageId 要更新的消息 ID + * @param text 新的文本内容 + * @returns 发送结果(messageId 固定为传入的 messageId) */ export async function updateMessage( client: InstanceType, messageId: string, - text: string + text: string, + log?: LogFn, ): Promise { return wrapSendCall( () => client.im.message.update({ @@ -67,66 +136,83 @@ export async function updateMessage( content: JSON.stringify({ text }), }, }), + log, + "update", + // update API 不返回 message_id,因此直接回填调用方传入的 messageId。 () => messageId, ) } /** - * 删除消息(如移除占位消息) + * 删除指定消息 + * + * 典型场景:abort 中断时删除占位消息(StreamingCard.destroy)。 + * 采用"尽力而为"策略 — 删除失败时记录 error 日志,但不阻断主流程。 + * + * @param client 飞书 SDK Client 实例 + * @param messageId 要删除的消息 ID */ export async function deleteMessage( client: InstanceType, - messageId: string + messageId: string, + log?: LogFn, ): Promise { try { - await client.im.message.delete({ path: { message_id: messageId } }); - } catch { - // 尽力清理,忽略失败 + await client.im.message.delete({ path: { message_id: messageId } }) + } catch (err) { + // 即便是 best-effort 删除,也要保留 error 级日志,便于排查残留占位消息。 + log?.("error", "删除飞书消息失败", { + messageId, + error: err instanceof Error ? err.message : String(err), + }) } } /** - * 发送交互式卡片消息(用于权限/问答卡片) + * 发送交互式卡片消息(msg_type: interactive) + * + * 用于权限审批卡片、问答卡片等需要用户点击按钮交互的场景。 + * 卡片内容为 Card 2.0 JSON 对象,由 buildCardFromDSL 构建。 + * + * @param client 飞书 SDK Client 实例 + * @param chatId 目标会话 ID + * @param card 卡片 JSON 对象(Card 2.0 schema) + * @returns 发送结果,成功时包含 messageId */ export async function sendInteractiveCard( client: InstanceType, chatId: string, card: object, + log?: LogFn, ): Promise { - if (!chatId?.trim()) { - return { ok: false, error: "No chat_id provided" } - } - return wrapSendCall(() => - client.im.message.create({ - params: { receive_id_type: "chat_id" }, - data: { - receive_id: chatId.trim(), - msg_type: "interactive", - content: JSON.stringify(card), - }, - }), - ) + // 飞书 interactive 消息要求 content 是序列化后的卡片 JSON。 + return createChatMessage(client, chatId, "interactive", JSON.stringify(card), log) } /** - * 发送 CardKit 2.0 卡片消息到飞书会话 + * 发送 CardKit 2.0 流式卡片消息 + * + * CardKit 2.0 卡片通过 cardId 引用(卡片实体由 CardKitClient 预先创建), + * 消息发送时只需传递 cardId,飞书服务端自动关联卡片内容。 + * 后续可通过 CardKitClient.updateElement 实时更新卡片内容(流式效果)。 + * + * @param client 飞书 SDK Client 实例 + * @param chatId 目标会话 ID + * @param cardId CardKit 2.0 卡片 ID(由 CardKitClient.createCard 返回) + * @returns 发送结果,成功时包含 messageId */ export async function sendCardMessage( client: InstanceType, chatId: string, cardId: string, + log?: LogFn, ): Promise { - if (!chatId?.trim()) { - return { ok: false, error: "No chat_id provided" }; - } - return wrapSendCall(() => - client.im.message.create({ - params: { receive_id_type: "chat_id" }, - data: { - receive_id: chatId.trim(), - msg_type: "interactive", - content: JSON.stringify({ type: "card", data: { card_id: cardId } }), - }, - }), + // CardKit 2.0 引用格式:type=card + data.card_id。 + return createChatMessage( + client, + chatId, + "interactive", + JSON.stringify({ type: "card", data: { card_id: cardId } }), + log, ) } diff --git a/src/feishu/session-chat-map.ts b/src/feishu/session-chat-map.ts index 1f3d056..8aa15fb 100644 --- a/src/feishu/session-chat-map.ts +++ b/src/feishu/session-chat-map.ts @@ -1,27 +1,64 @@ /** - * sessionId → chatInfo 映射:tool execute 时通过 sessionID 查找飞书 chatId 和 chatType + * sessionId → chatInfo 映射模块 + * + * 在 OpenCode 中,tool execute 回调只能拿到 sessionID, + * 但发送飞书消息需要知道 chatId 和 chatType。 + * 本模块维护 sessionId → { chatId, chatType } 的映射关系, + * 供 feishu_send_card tool 和 system prompt 注入使用。 * * 使用 TtlMap 自动清理过期条目,避免长时间运行后内存增长。 * 条目数量受限于唯一聊天数(通常很少),TTL 仅作为额外安全保障。 */ import { TtlMap } from "../utils/ttl-map.js" +/** 聊天信息结构:包含飞书 chatId 和聊天类型 */ interface ChatInfo { + /** 飞书会话 ID */ chatId: string + /** 聊天类型:p2p(单聊)或 group(群聊) */ chatType: "p2p" | "group" } -/** 24 小时 TTL — 条目在每次 registerSessionChat 时刷新 */ +/** + * sessionId → ChatInfo 的映射存储 + * TTL 24 小时 — 条目在每次 registerSessionChat 调用时刷新 + */ const sessionToChat = new TtlMap(24 * 60 * 60 * 1_000) +/** + * 注册 sessionId 与飞书聊天的映射关系 + * + * 在 handleChat() 创建或恢复 OpenCode 会话时调用, + * 使后续的 tool execute 能通过 sessionID 找到对应的飞书聊天。 + * 重复注册同一 sessionId 会刷新 TTL。 + * + * @param sessionId OpenCode 会话 ID + * @param chatId 飞书会话 ID + * @param chatType 聊天类型(p2p 单聊 / group 群聊) + */ export function registerSessionChat(sessionId: string, chatId: string, chatType: "p2p" | "group"): void { sessionToChat.set(sessionId, { chatId, chatType }) } +/** + * 通过 sessionId 查询对应的飞书 chatId + * + * @param sessionId OpenCode 会话 ID + * @returns 飞书 chatId,未找到返回 undefined + */ export function getChatIdBySession(sessionId: string): string | undefined { return sessionToChat.get(sessionId)?.chatId } +/** + * 通过 sessionId 查询完整的聊天信息(chatId + chatType) + * + * 相比 getChatIdBySession,额外返回 chatType, + * 供 buildCardFromDSL 等需要区分聊天类型的场景使用。 + * + * @param sessionId OpenCode 会话 ID + * @returns ChatInfo 对象,未找到返回 undefined + */ export function getChatInfoBySession(sessionId: string): ChatInfo | undefined { return sessionToChat.get(sessionId) } diff --git a/src/feishu/streaming-card.ts b/src/feishu/streaming-card.ts index 6dc95cb..e619257 100644 --- a/src/feishu/streaming-card.ts +++ b/src/feishu/streaming-card.ts @@ -1,6 +1,11 @@ /** - * StreamingCard: 流式卡片会话管理器 - * 管理单个 AI 回复的飞书流式卡片生命周期 + * StreamingCard:单次 AI 回复对应的一张流式卡片。 + * + * 它负责管理卡片从创建到关闭的整个生命周期,并保证多次更新串行落盘: + * - 文本增量更新 + * - 工具状态更新 + * - 最终收尾关闭 + * - 中断时删除消息 */ import type { CardKitClient, CardKitSchema } from "./cardkit.js" import type { LogFn } from "../types.js" @@ -8,12 +13,13 @@ import { cleanMarkdown, truncateMarkdown } from "./markdown.js" import * as sender from "./sender.js" import type * as Lark from "@larksuiteoapi/node-sdk" +/** 单个工具调用在卡片上的展示状态。 */ interface ToolState { tool: string state: "running" | "completed" | "error" - title?: string } +/** 卡片底部可选附带的调试/上下文信息。 */ export interface StreamingCardMeta { sessionId?: string directory?: string @@ -21,13 +27,21 @@ export interface StreamingCardMeta { } export class StreamingCard { + /** CardKit 卡片实体 ID。 */ private cardId?: string + /** 飞书聊天里的消息 ID。 */ private messageId?: string + /** CardKit sequence,每次修改都必须单调递增。 */ private seq = 0 + /** 串行更新队列,保证多次异步更新按顺序执行。 */ private queue: Promise = Promise.resolve() + /** 当前完整文本缓冲。 */ private textBuffer = "" + /** callID → 工具状态。 */ private toolStates = new Map() + /** 卡片是否已关闭/销毁。 */ private closed = false + /** tools 元素是否已经动态插入过。 */ private toolsElementAdded = false constructor( @@ -39,9 +53,10 @@ export class StreamingCard { ) {} /** - * 创建卡片 + 发送 interactive 消息 → messageId + * 创建卡片实体并发到飞书聊天,返回对应的消息 ID。 */ async start(): Promise { + // 底部元信息仅用于辅助定位上下文,不影响主要展示内容。 const footer = [this.meta?.sessionId, this.meta?.directory, this.meta?.model].filter(Boolean).join(" | ") const schema: CardKitSchema = { @@ -63,7 +78,7 @@ export class StreamingCard { this.cardId = await this.cardkit.createCard(schema) - const res = await sender.sendCardMessage(this.feishuClient, this.chatId, this.cardId) + const res = await sender.sendCardMessage(this.feishuClient, this.chatId, this.cardId, this.log) if (!res.ok || !res.messageId) { throw new Error(`发送卡片消息失败: ${res.error ?? "unknown"}`) } @@ -73,16 +88,19 @@ export class StreamingCard { } /** - * 追加文本到 content 元素 + * 追加一段文本增量到 `content` 区块。 */ async updateText(delta: string): Promise { if (this.closed || !this.cardId) return + // 先更新本地 buffer,再把真正的飞书更新操作排进串行队列。 this.textBuffer += delta this.enqueue(() => this.doUpdateContent()) } /** - * 替换整个文本内容(用于 snapshot-style 事件) + * 用完整文本替换当前缓冲区。 + * + * 用于 snapshot-style 事件,而不是 delta 流。 */ async replaceText(fullText: string): Promise { if (this.closed || !this.cardId) return @@ -91,7 +109,7 @@ export class StreamingCard { } /** - * 更新工具状态到 tools 元素 + * 更新某个工具调用在卡片中的显示状态。 */ async setToolStatus(callID: string, tool: string, state: "running" | "completed" | "error"): Promise { if (this.closed || !this.cardId) return @@ -100,7 +118,7 @@ export class StreamingCard { } /** - * 关闭流式模式,写入最终内容 + * 关闭流式模式,并把最终文本写入卡片。 */ async close(finalMarkdown?: string): Promise { if (this.closed) return @@ -111,40 +129,46 @@ export class StreamingCard { this.textBuffer = finalMarkdown } - // 最终内容经过 markdown 清理和截断 + // 最终文本统一做 markdown 清理与截断,避免卡片渲染异常或超限。 this.textBuffer = truncateMarkdown(cleanMarkdown(this.textBuffer)) + // 先等之前的更新队列跑完,再写最终内容,避免顺序错乱。 await this.drain() await this.doUpdateContent() await this.cardkit.closeStreaming(this.cardId, ++this.seq) } /** - * 删除消息(abort 场景) + * 删除整条飞书消息。 + * + * 主要用于 abort 或早期失败场景,避免用户看到半成品卡片。 */ async destroy(): Promise { this.closed = true if (this.messageId) { - await sender.deleteMessage(this.feishuClient, this.messageId) + await sender.deleteMessage(this.feishuClient, this.messageId, this.log) } } - get currentMessageId(): string | undefined { - return this.messageId - } - + /** + * 把一次异步卡片更新串到内部队列尾部。 + * + * 这样即使多个 SSE 事件并发到来,也能确保 sequence 严格递增、更新顺序稳定。 + */ private enqueue(fn: () => Promise): void { this.queue = this.queue.then(fn).catch((err) => { - this.log("warn", "StreamingCard queue 操作失败", { + this.log("error", "StreamingCard queue 操作失败", { error: err instanceof Error ? err.message : String(err), }) }) } + /** 等待当前所有排队更新执行完毕。 */ private async drain(): Promise { await this.queue } + /** 把当前文本缓冲写回 content 元素。 */ private async doUpdateContent(): Promise { if (!this.cardId) return await this.cardkit.updateElement( @@ -155,10 +179,17 @@ export class StreamingCard { ) } + /** + * 把工具状态区块写回卡片。 + * + * 首次写入时动态 append 一个 `tools` element; + * 之后都走 updateElement 增量更新。 + */ private async doUpdateTools(): Promise { if (!this.cardId) return const lines: string[] = [] for (const [, ts] of this.toolStates) { + // 用图标直接表达状态,用户不需要理解内部枚举值。 const icon = ts.state === "completed" ? "✅" : ts.state === "error" ? "❌" : "🔄" lines.push(`${icon} ${ts.tool}`) } diff --git a/src/feishu/user-name.ts b/src/feishu/user-name.ts index 1739fe3..17503cc 100644 --- a/src/feishu/user-name.ts +++ b/src/feishu/user-name.ts @@ -1,35 +1,68 @@ /** - * 飞书用户名解析:open_id → 真实用户名(24h 缓存) + * 飞书用户名解析模块 — open_id → 真实用户名 + * + * 飞书消息事件中只携带 open_id(如 ou_xxxxxxxxxx), + * 本模块通过飞书通讯录 API 将 open_id 解析为用户真实姓名, + * 并使用 24 小时 TTL 缓存避免重复请求。 + * + * 用途:在发送给 OpenCode 的上下文中显示友好的用户名, + * 而非难以辨识的 open_id。 */ import type * as Lark from "@larksuiteoapi/node-sdk" import type { LogFn } from "../types.js" import { TtlMap } from "../utils/ttl-map.js" +/** + * 用户名缓存 + * key: 飞书 open_id + * value: 用户真实姓名 + * TTL: 24 小时(86,400,000 毫秒)— 用户名变更频率极低,长缓存合理 + */ const nameCache = new TtlMap(24 * 60 * 60 * 1_000) // 24h TTL +/** + * 将飞书 open_id 解析为用户真实姓名 + * + * 解析流程: + * 1. 优先从本地缓存读取(24h TTL) + * 2. 缓存未命中时调用飞书通讯录 API(contact.user.get) + * 3. 解析成功则写入缓存并返回姓名 + * 4. 解析失败(API 错误或无权限)时回退返回原始 open_id + * + * @param client 飞书 SDK Client 实例(自动处理 token 认证) + * @param openId 飞书用户的 open_id + * @param log 日志函数,用于记录解析失败的 error 日志 + * @returns 用户真实姓名;解析失败时回退返回 open_id 本身 + */ export async function resolveUserName( client: InstanceType, openId: string, log: LogFn, ): Promise { + // 优先从缓存读取,避免重复调用飞书 API const cached = nameCache.get(openId) if (cached) return cached try { + // 调用飞书通讯录 API 获取用户信息 const res = await client.contact.user.get({ path: { user_id: openId }, params: { user_id_type: "open_id" }, }) + // 提取用户姓名(SDK 类型定义不完整,需类型断言) const name = (res?.data?.user as { name?: string })?.name if (name) { + // 写入缓存,后续 24 小时内直接使用缓存 nameCache.set(openId, name) return name } } catch (err) { - log("warn", "用户名解析失败", { + // API 调用失败(如网络异常、权限不足)时记录 error 日志,不阻断主流程 + log("error", "用户名解析失败", { openId, error: err instanceof Error ? err.message : String(err), }) } + // 兜底:解析失败时返回原始 open_id,确保上下文中至少有标识信息 return openId // fallback to open_id } diff --git a/src/handler/action-bus.ts b/src/handler/action-bus.ts index bfa540b..f5b2479 100644 --- a/src/handler/action-bus.ts +++ b/src/handler/action-bus.ts @@ -1,27 +1,41 @@ /** - * Action Bus: per-session 事件订阅/发布 + * Action Bus:同一个 session 内部的轻量事件总线。 + * + * 它的职责不是全局消息总线,而是把: + * - SSE 事件处理 + * - 流式卡片渲染 + * - 权限/问答交互卡片 + * 这几个关注点松耦合地串起来。 */ -import type { PermissionRequest, QuestionRequest } from "../types.js" +import type { LogFn, PermissionRequest, QuestionRequest } from "../types.js" /** - * 事件总线标准化 Action 类型 + * 仓库内部统一的“处理后事件”类型。 + * + * 任何上游原始事件都应先被转换成这里的结构,再向下游广播。 */ export type ProcessedAction = - | { type: "text-updated"; sessionId: string; messageId?: string; delta?: string; fullText?: string } - | { type: "tool-state-changed"; sessionId: string; callID: string; tool: string; state: "running" | "completed" | "error"; title?: string } - | { type: "subtask-discovered"; sessionId: string; description: string; agent?: string } + /** 文本内容更新;可能是 delta,也可能是整段快照。 */ + | { type: "text-updated"; sessionId: string; delta?: string; fullText?: string } + /** 工具调用状态变化。 */ + | { type: "tool-state-changed"; sessionId: string; callID: string; tool: string; state: "running" | "completed" | "error" } + /** OpenCode 发来权限请求。 */ | { type: "permission-requested"; sessionId: string; request: PermissionRequest } + /** OpenCode 发来问答请求。 */ | { type: "question-requested"; sessionId: string; request: QuestionRequest } + /** Session 进入 idle。 */ | { type: "session-idle"; sessionId: string } - | { type: "session-error"; sessionId: string; error: string; fields: string[] } +/** 订阅回调可以同步也可以异步。 */ type ActionCallback = (action: ProcessedAction) => void | Promise +/** sessionId → 订阅回调集合。 */ const subscribers = new Map>() /** - * 注册 per-session 事件订阅 - * @returns unsubscribe 函数(幂等,多次调用安全) + * 注册某个 session 的事件订阅。 + * + * @returns 取消订阅函数;幂等,多次调用安全 */ export function subscribe( sessionId: string, @@ -29,6 +43,7 @@ export function subscribe( ): () => void { let subs = subscribers.get(sessionId) if (!subs) { + // 第一次订阅某个 session 时,初始化其订阅集合。 subs = new Set() subscribers.set(sessionId, subs) } @@ -38,11 +53,11 @@ export function subscribe( return () => { if (removed) return removed = true - // 从当前 sessionId 的订阅集合中移除,使用 subscribers.get() 获取最新引用 - // 避免闭包捕获的旧 Set 引用与新 Set 不一致 + // 重新从 Map 取最新集合,避免闭包里拿到过期引用。 const current = subscribers.get(sessionId) if (current) { current.delete(cb) + // 最后一个订阅者移除后,把空集合也顺手清掉。 if (current.size === 0) { subscribers.delete(sessionId) } @@ -51,16 +66,26 @@ export function subscribe( } /** - * 向指定 session 的所有订阅者发布 action(fire-and-forget) + * 向指定 session 的订阅者广播事件。 + * + * 这里采用 fire-and-forget: + * 单个订阅者抛错不会阻塞其他订阅者,也不会把主流程打断。 */ -export function emit(sessionId: string, action: ProcessedAction): void { +export function emit(sessionId: string, action: ProcessedAction, log?: LogFn): void { const subs = subscribers.get(sessionId) if (!subs) return for (const cb of subs) { Promise.resolve() .then(() => cb(action)) - .catch(() => {}) + .catch((err) => { + // 事件总线依然保持 fire-and-forget,但不再把订阅者异常静默吞掉。 + log?.("error", "action-bus 订阅回调执行失败", { + sessionId, + actionType: action.type, + error: err instanceof Error ? err.message : String(err), + }) + }) } } diff --git a/src/handler/chat.ts b/src/handler/chat.ts index 69fd0c8..15b5433 100644 --- a/src/handler/chat.ts +++ b/src/handler/chat.ts @@ -1,5 +1,13 @@ /** - * 对话处理:会话管理、占位消息、prompt 发送、轮询等待、最终回复 + * 对话主处理链路。 + * + * 负责把一条飞书消息完整走完: + * 1. 绑定/恢复 OpenCode session + * 2. 构造 prompt parts + * 3. 发送 prompt + * 4. 轮询等待输出稳定 + * 5. 把结果写回飞书 + * 6. 在异常时做恢复或友好报错 */ import type { FeishuMessageContext, ResolvedConfig, LogFn } from "../types.js" import type { OpencodeClient } from "@opencode-ai/sdk" @@ -7,10 +15,10 @@ import * as sender from "../feishu/sender.js" import { registerPending, unregisterPending, getSessionError, clearSessionError, clearRetryAttempts, - clearNudge, + clearNudge, isSessionPoisoned, } from "./event.js" import { SessionErrorDetected, extractSessionError, tryModelRecovery } from "./error-recovery.js" -import { buildSessionKey, getOrCreateSession } from "../session.js" +import { buildSessionKey, getOrCreateSession, invalidateSession } from "../session.js" import { registerSessionChat } from "../feishu/session-chat-map.js" import { extractParts, type PromptPart } from "../feishu/content-extractor.js" import { resolveUserName } from "../feishu/user-name.js" @@ -20,9 +28,10 @@ import { subscribe } from "./action-bus.js" import type { CardKitClient } from "../feishu/cardkit.js" import { StreamingCard } from "../feishu/streaming-card.js" import { handlePermissionRequested, handleQuestionRequested, type InteractiveDeps } from "./interactive.js" + /** * 向 Langfuse 发送轻量 trace,关联 sessionId 和飞书 userId。 - * Fire-and-forget:不阻塞主流程,失败只记 warn 日志。 + * Fire-and-forget:不阻塞主流程,失败只记 error 日志。 */ function traceLangfuseUser( sessionId: string, @@ -36,6 +45,7 @@ function traceLangfuseUser( const baseUrl = process.env.LANGFUSE_BASEURL ?? "https://cloud.langfuse.com" const auth = Buffer.from(`${publicKey}:${secretKey}`).toString("base64") + // 完全异步上报,绝不等待它完成。 fetch(`${baseUrl}/api/public/ingestion`, { method: "POST", headers: { @@ -51,25 +61,47 @@ function traceLangfuseUser( }), }).then(async (res) => { if (!res.ok) { - const body = await res.text().catch(() => "") - log("warn", "Langfuse trace API 失败", { status: res.status, body }) + const body = await res.text().catch((err) => { + log("error", "读取 Langfuse 错误响应失败", { + status: res.status, + error: err instanceof Error ? err.message : String(err), + }) + return "" + }) + log("error", "Langfuse trace API 失败", { status: res.status, body }) } }).catch((err) => { - log("warn", "Langfuse trace 网络失败", { + log("error", "Langfuse trace 网络失败", { error: err instanceof Error ? err.message : String(err), }) }) } - -async function fetchModel(client: OpencodeClient, query?: { directory?: string }): Promise { +/** + * 读取当前 OpenCode 配置中的模型名。 + * + * 这是 UI 增强信息,不是主链路必需,因此读取失败只返回 `undefined`, + * 同时留下 error 日志供排查。 + */ +async function fetchModel( + client: OpencodeClient, + log: LogFn, + query?: { directory?: string }, +): Promise { try { const cfg = await client.config.get({ query }) const m = cfg?.data?.model return typeof m === "string" ? m : undefined - } catch { return undefined } + } catch (err) { + // 模型信息只影响卡片辅助展示,但异常仍要留下 error 日志。 + log("error", "读取当前模型配置失败", { + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } } +/** 对话处理所需的运行依赖。 */ export interface ChatDeps { config: ResolvedConfig client: OpencodeClient @@ -80,34 +112,46 @@ export interface ChatDeps { interactiveDeps?: InteractiveDeps } -/** 最终回复:关闭流式卡片或更新占位消息 */ +/** + * 收尾回复:优先关闭流式卡片,否则回落到更新/发送普通文本消息。 + */ async function finalizeReply( streamingCard: StreamingCard | undefined, feishuClient: InstanceType, chatId: string, placeholderId: string, text: string, + log: LogFn, ): Promise { if (streamingCard) { await streamingCard.close(text) } else { - await replyOrUpdate(feishuClient, chatId, placeholderId, text) + await replyOrUpdate(feishuClient, chatId, placeholderId, text, log) } } - +/** + * 处理一条飞书消息。 + * + * `signal` 主要供未来可中断队列或外部取消场景使用; + * 当前最重要的是把它继续透传给轮询等待逻辑。 + */ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, signal?: AbortSignal): Promise { const { content, chatId, chatType, senderId, shouldReply, messageType, rawContent, messageId, parentId } = ctx + // 纯文本空消息没有任何处理价值,直接忽略。 if (!content.trim() && messageType === "text") return undefined const { config, client, feishuClient, log, directory } = deps const query = directory ? { directory } : undefined + // 同一飞书聊天会稳定映射到同一个逻辑 sessionKey。 const sessionKey = buildSessionKey(chatType, chatType === "p2p" ? senderId : chatId) + // 绑定或恢复 OpenCode session,并刷新 session → 飞书聊天映射。 const session = await getOrCreateSession(client, sessionKey, directory) registerSessionChat(session.id, chatId, chatType) traceLangfuseUser(session.id, senderId, log) + // 用户有新消息时,说明插件不该再沿用之前的 idle 催促计数。 clearNudge(session.id) // 提取消息内容为 OpenCode parts @@ -127,7 +171,7 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign const baseBody = { parts } - // 静默监听模式:消息发给 OpenCode 作为上下文,不触发 AI 回复 + // 静默监听模式:消息只作为上下文送入 OpenCode,不给用户看到任何回复。 if (!shouldReply) { try { await client.session.promptAsync({ @@ -136,7 +180,7 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign body: { ...baseBody, noReply: true }, }) } catch (err) { - log("warn", "静默转发失败", { + log("error", "静默转发失败", { error: err instanceof Error ? err.message : String(err), }) } @@ -149,45 +193,65 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign const stablePolls = config.stablePolls let placeholderId = "" + // `done` 用于避免 thinking timer 在主流程已结束后再异步发出占位消息。 let done = false let activeSessionId = session.id let streamingCard: StreamingCard | undefined - // 尝试创建流式卡片(fallback 到纯文本占位) + // 优先尝试 CardKit 流式卡片;失败时自动降级到纯文本占位。 if (thinkingDelay > 0 && deps.cardkit) { try { streamingCard = new StreamingCard(deps.cardkit, feishuClient, chatId, log, { sessionId: session.id, directory, - model: await fetchModel(client, query), + model: await fetchModel(client, log, query), }) placeholderId = await streamingCard.start() } catch (err) { - log("warn", "CardKit 创建失败,回退纯文本", { + log("error", "CardKit 创建失败,回退纯文本", { error: err instanceof Error ? err.message : String(err), }) - // 清理可能部分创建的卡片资源 + // 清理可能部分创建成功的卡片/消息资源。 if (streamingCard) { - await streamingCard.destroy().catch(() => {}) + await streamingCard.destroy().catch((destroyErr) => { + log("error", "回退前清理 StreamingCard 失败", { + error: destroyErr instanceof Error ? destroyErr.message : String(destroyErr), + }) + }) } streamingCard = undefined } } - // 如果没有流式卡片,使用传统占位消息 + // 如果没有流式卡片,则在 thinkingDelay 到达后发一条传统“正在思考…”消息。 const timer = !streamingCard && thinkingDelay > 0 ? setTimeout(async () => { if (done) return try { - const res = await sender.sendTextMessage(feishuClient, chatId, "正在思考…") - if (done) return // 重新检查,防止发送期间主流程已结束 - if (res.ok && res.messageId) { + const res = await sender.sendTextMessage(feishuClient, chatId, "正在思考…", log) + // 发送是异步的;如果主流程已经结束,要把这条“迟到”的占位消息删掉。 + if (done) { + if (res.ok && res.messageId) { + await sender.deleteMessage(feishuClient, res.messageId, log) + } + return + } + if (!res.ok) { + log("error", "发送占位消息失败", { + chatId, + sessionId: activeSessionId, + error: res.error ?? "unknown", + }) + return + } + if (res.messageId) { placeholderId = res.messageId - registerPending(activeSessionId, { chatId, placeholderId, feishuClient }) + // 只有传统占位消息路径需要注册 pending,让 event.ts 直接更新飞书消息内容。 + registerPending(activeSessionId, { placeholderId, feishuClient }) } } catch (err) { - log("warn", "发送占位消息失败", { + log("error", "发送占位消息失败", { chatId, error: err instanceof Error ? err.message : String(err), }) @@ -195,7 +259,7 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign }, thinkingDelay) : null - // 订阅 action-bus 更新流式卡片 + 交互式卡片分发 + // 订阅 action-bus:文本/工具更新驱动流式卡片,权限/问答事件驱动交互卡片。 let cardUnsub: (() => void) | undefined { const card = streamingCard @@ -216,11 +280,13 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign break case "permission-requested": if (deps.interactiveDeps) { + // 权限请求本身不阻塞主回复;作为独立交互卡片发给用户。 handlePermissionRequested(action.request, chatId, deps.interactiveDeps, chatType) } break case "question-requested": if (deps.interactiveDeps) { + // 问答请求同理,交由交互层处理。 handleQuestionRequested(action.request, chatId, deps.interactiveDeps, chatType) } break @@ -229,7 +295,7 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign } try { - // 清除前次遗留的 session error 缓存,避免 pollForResponse 误检测旧错误 + // 清除前次遗留的 session error 缓存,避免 pollForResponse 误检测旧错误。 clearSessionError(session.id) await client.session.promptAsync({ @@ -246,16 +312,27 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign output: finalText || "(empty)", }) - // prompt 成功:重置 fork 计数 + // prompt 成功:清空该 sessionKey 的自动恢复计数。 clearRetryAttempts(sessionKey) - await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, finalText || "⚠️ 响应超时") + await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, finalText || "⚠️ 响应超时", log) } catch (err) { // 提取会话错误信息(来自 SessionErrorDetected 或 SSE 缓存) const sessionError = extractSessionError(err, session.id) let displayError = sessionError - // 模型不兼容错误恢复 + // Session 历史中毒检测优先于模型恢复:这类问题靠重试几乎不会好。 + if (sessionError && isSessionPoisoned(sessionError.fields)) { + log("error", "检测到 session 历史数据中毒,创建新 session", { + sessionKey, oldSessionId: session.id, error: sessionError.message, + }) + invalidateSession(sessionKey) + await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, + "⚠️ 会话历史包含不兼容数据,已自动重置。请重新发送消息。", log) + return + } + + // 只有拿到了结构化 sessionError,才尝试做模型错误恢复。 if (sessionError) { try { const recovery = await tryModelRecovery({ @@ -265,7 +342,7 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign }) if (recovery.recovered) { - await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, recovery.text || "⚠️ 响应超时") + await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, recovery.text || "⚠️ 响应超时", log) return } displayError = recovery.sessionError @@ -274,7 +351,7 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign } } - // 正常错误处理 + // 普通错误路径:把最合适的错误文案展示给用户。 const thrownError = err instanceof Error ? err.message : String(err) const errorMessage = displayError?.message || thrownError log("error", "对话处理失败", { @@ -282,9 +359,10 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign error: thrownError, ...(displayError ? { sessionError: displayError.message } : {}), }) - await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, "❌ " + errorMessage) + await finalizeReply(streamingCard, feishuClient, chatId, placeholderId, "❌ " + errorMessage, log) } finally { done = true + // 无论成功失败,都要把延迟占位计时器、订阅和 pending 状态回收掉。 if (timer) clearTimeout(timer) if (cardUnsub) cardUnsub() unregisterPending(activeSessionId) @@ -292,8 +370,12 @@ export async function handleChat(ctx: FeishuMessageContext, deps: ChatDeps, sign } /** - * 将飞书消息转换为 OpenCode prompt parts - * 文本类型添加群聊发送者前缀;其他类型通过 content-extractor 提取 + * 将飞书消息转换为 OpenCode prompt parts。 + * + * 额外处理: + * - 引用消息会作为前缀注入 + * - 群聊消息会补发送者名称,避免模型分不清谁说的 + * - 文本消息走轻路径,非文本消息交给 content-extractor 深度解析 */ async function buildPromptParts( feishuClient: InstanceType, @@ -307,7 +389,7 @@ async function buildPromptParts( maxResourceSize: number, parentId?: string, ): Promise { - // 引用消息前缀 + // 引用消息前缀。 let quotePrefix = "" if (parentId) { const quoted = await fetchQuotedMessage(feishuClient, parentId, log) @@ -316,7 +398,7 @@ async function buildPromptParts( } } - // 群聊:解析用户名(一次解析,两个路径复用) + // 群聊:解析用户名,便于给模型更清晰的上下文。 const senderName = (chatType === "group" && senderId) ? await resolveUserName(feishuClient, senderId, log) : "" @@ -324,15 +406,16 @@ async function buildPromptParts( if (messageType === "text") { let promptText = textContent if (senderName) { + // 群聊文本消息前面补 `[用户名]:`,帮助模型区分多说话人场景。 promptText = `[${senderName}]: ${textContent}` } return [{ type: "text", text: quotePrefix + promptText }] } - // 非文本消息:通过 content-extractor 提取 + // 非文本消息:交给更专门的 extractor 处理资源、富文本和卡片结构。 const parts = await extractParts(feishuClient, messageId, messageType, rawContent, log, maxResourceSize) - // 组装前缀 + // 非文本消息如果也有引用或群聊用户名前缀,则在最前面插一个 text part。 const prefix = [quotePrefix, senderName ? `[${senderName}]:` : ""].filter(Boolean).join("") if (prefix && parts.length > 0) { return [{ type: "text", text: prefix }, ...parts] @@ -357,10 +440,14 @@ async function pollForResponse( }, ): Promise { const { timeout, pollInterval, stablePolls, query, signal } = opts + // 轮询开始时间,用于超时判断。 const start = Date.now() + // 最近一次看到的 assistant 文本。 let lastText = "" + // 连续多少次轮询结果完全相同。 let sameCount = 0 + // 通过 action-bus 感知 `session.idle`,让轮询可以提前结束。 let sessionIdle = false const unsub = subscribe(sessionId, (action) => { if (action.type === "session-idle") { @@ -371,18 +458,19 @@ async function pollForResponse( try { while (!timeout || Date.now() - start < timeout) { if (signal) { + // 可中断睡眠:外部 abort 时能立刻退出,不必等整个 pollInterval。 await abortableSleep(pollInterval, signal) } else { await new Promise((r) => setTimeout(r, pollInterval)) } - // 检查 SSE 缓存的 session error(FR-001) + // 每个轮询周期都先检查 SSE 错误,保证异步失败能尽早终止。 const sseError = getSessionError(sessionId) if (sseError) { throw new SessionErrorDetected(sseError) } - // session.idle 提前退出:收到信号后最后 fetch 一次 + // session.idle 提前退出:收到信号后跳出循环,再做最后一次 fetch。 if (sessionIdle) { break } @@ -391,20 +479,23 @@ async function pollForResponse( const text = extractLastAssistantText(messages ?? []) if (text && text !== lastText) { + // 看到新文本:更新快照并重置稳定计数。 lastText = text sameCount = 0 } else if (text && text.length > 0) { + // 文本没变:累计稳定次数,达到阈值后可认为输出基本结束。 sameCount++ if (sameCount >= stablePolls) break } } - // 返回前再次检查 SSE 错误,防止 break 后遗漏的竞态错误 + // 返回前再次检查 SSE 错误,防止 break 后遗漏竞态。 const finalSseError = getSessionError(sessionId) if (finalSseError) { throw new SessionErrorDetected(finalSseError) } + // 再 fetch 一次最终消息列表,尽可能拿到最完整文本。 const { data: finalMessages } = await client.session.messages({ path: { id: sessionId }, query }) return extractLastAssistantText(finalMessages ?? []) || lastText } finally { @@ -412,24 +503,49 @@ async function pollForResponse( } } +/** + * 如果已有占位消息则优先更新;更新失败再退回发送新文本消息。 + */ async function replyOrUpdate( feishuClient: InstanceType, chatId: string, placeholderId: string, text: string, + log: LogFn, ): Promise { if (placeholderId) { - const res = await sender.updateMessage(feishuClient, placeholderId, text) + const res = await sender.updateMessage(feishuClient, placeholderId, text, log) if (!res.ok) { - await sender.sendTextMessage(feishuClient, chatId, text) + // 占位消息更新失败时,至少要保证用户能看到最终文本。 + log("error", "更新占位消息失败,回退发送新消息", { + chatId, + placeholderId, + error: res.error ?? "unknown", + }) + const fallbackRes = await sender.sendTextMessage(feishuClient, chatId, text, log) + if (!fallbackRes.ok) { + log("error", "回退发送飞书文本消息失败", { + chatId, + placeholderId, + error: fallbackRes.error ?? "unknown", + }) + } } } else { - await sender.sendTextMessage(feishuClient, chatId, text) + const res = await sender.sendTextMessage(feishuClient, chatId, text, log) + if (!res.ok) { + log("error", "发送飞书文本消息失败", { + chatId, + error: res.error ?? "unknown", + }) + } } } /** - * 可被 AbortSignal 中断的 sleep + * 一个可被 AbortSignal 中断的 sleep。 + * + * 轮询等待和未来可中断处理链路都依赖它。 */ export function abortableSleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -453,6 +569,12 @@ export function abortableSleep(ms: number, signal: AbortSignal): Promise { }) } +/** + * 从 session 消息列表里抽取最后一条 assistant 文本。 + * + * 当前策略只拼接 `type === "text"` 的 parts, + * 因为真正展示给用户的最终回复也只关心这些文本块。 + */ function extractLastAssistantText( messages: Array<{ info: { role?: string; [key: string]: unknown } diff --git a/src/handler/error-recovery.ts b/src/handler/error-recovery.ts index f9414e6..de016bf 100644 --- a/src/handler/error-recovery.ts +++ b/src/handler/error-recovery.ts @@ -1,5 +1,8 @@ /** - * 模型错误恢复:检测模型不兼容错误,使用全局默认模型重试 + * 模型错误恢复层。 + * + * 当会话因模型不兼容、模型不存在、provider 不支持等原因失败时, + * 这里负责判断是否值得自动重试,并尝试切回全局默认模型。 */ import type { OpencodeClient } from "@opencode-ai/sdk" import type { LogFn } from "../types.js" @@ -10,7 +13,11 @@ import { type CachedSessionError, } from "./event.js" -/** pollForResponse 检测到 SSE 错误时抛出的异常 */ +/** + * `pollForResponse()` 在轮询期间发现 SSE 错误时抛出的专用异常。 + * + * 这样调用方能区分“普通异常”和“session 已经明确报错”两类失败。 + */ export class SessionErrorDetected extends Error { constructor(public readonly sessionError: CachedSessionError) { super(sessionError.message) @@ -18,15 +25,21 @@ export class SessionErrorDetected extends Error { } } +/** 恢复流程的统一返回值。 */ export interface RecoveryResult { + /** 是否已经成功恢复并拿到了有效输出。 */ readonly recovered: boolean + /** 恢复成功时的最终文本。 */ readonly text?: string + /** 恢复失败时应展示/继续处理的错误对象。 */ readonly sessionError?: CachedSessionError } /** - * 从全局配置读取默认模型(Config.model 字段),解析为 { providerID, modelID }。 - * 不在失败 provider 内搜索替代 — 只用用户明确配置的默认模型。 + * 从全局配置读取默认模型,并拆成 OpenCode 需要的结构。 + * + * 注意这里故意不在失败 provider 内做候选搜索, + * 只信任用户显式配置的默认模型。 */ async function getGlobalDefaultModel( client: OpencodeClient, @@ -44,8 +57,13 @@ async function getGlobalDefaultModel( } /** - * 从捕获的异常中提取会话错误信息。 - * 检查 SessionErrorDetected 和 SSE 缓存两个来源。 + * 从异常对象中提取当前会话真正的错误信息。 + * + * 错误来源有两类: + * 1. `SessionErrorDetected` 直接携带的结构化错误 + * 2. `event.ts` 提前缓存到 sessionErrors 里的 SSE 错误 + * + * 取到错误后会立即清理缓存,避免旧错误污染下一轮调用。 */ export function extractSessionError(err: unknown, sessionId: string): CachedSessionError | undefined { const result = err instanceof SessionErrorDetected @@ -55,6 +73,7 @@ export function extractSessionError(err: unknown, sessionId: string): CachedSess return result } +/** 由 chat.ts 注入的轮询函数签名,恢复逻辑复用同一套等待机制。 */ type PollFn = ( client: OpencodeClient, sessionId: string, @@ -68,8 +87,14 @@ type PollFn = ( ) => Promise /** - * 尝试模型错误恢复:检测模型不兼容错误,使用全局默认模型重试。 - * AbortError 会被重新抛出,由调用方处理中断清理。 + * 尝试做一次模型错误恢复。 + * + * 真正会进入重试的前提: + * - 错误字段被识别为模型类错误 + * - 尚未超过重试上限 + * - 能读到有效的全局默认模型 + * + * AbortError 不属于恢复失败,而是上层主动中断,因此必须继续向外抛。 */ export async function tryModelRecovery(params: { readonly sessionError: CachedSessionError @@ -98,6 +123,7 @@ export async function tryModelRecovery(params: { isModel: isModelError(sessionError.fields), }) + // 不是模型类错误时,不在这里兜底,交回上层按普通错误处理。 if (!isModelError(sessionError.fields)) { return { recovered: false, sessionError } } @@ -109,11 +135,12 @@ export async function tryModelRecovery(params: { } try { + // 单独保护读取配置这一步,避免配置查询失败把整个恢复逻辑直接打断。 let modelOverride: { providerID: string; modelID: string } | undefined try { modelOverride = await getGlobalDefaultModel(client, directory) } catch (configErr) { - log("warn", "读取全局模型配置失败", { + log("error", "读取全局模型配置失败", { sessionKey, error: configErr instanceof Error ? configErr.message : String(configErr), }) @@ -124,6 +151,7 @@ export async function tryModelRecovery(params: { return { recovered: false, sessionError } } + // 先记一次尝试次数,防止异常路径漏记。 setRetryAttempts(sessionKey, attempts + 1) log("info", "使用全局默认模型恢复", { sessionKey, @@ -131,6 +159,7 @@ export async function tryModelRecovery(params: { modelID: modelOverride.modelID, }) + // 清掉上一次调用残留的 SSE 错误,避免新一轮轮询读到旧状态。 clearSessionError(sessionId) await client.session.promptAsync({ path: { id: sessionId }, @@ -146,6 +175,7 @@ export async function tryModelRecovery(params: { sessionKey, sessionId, output: finalText || "(empty)", }) + // 只要恢复成功,就把累计重试次数清零。 clearRetryAttempts(sessionKey) log("info", "模型不兼容恢复成功", { @@ -163,9 +193,11 @@ export async function tryModelRecovery(params: { const errMsg = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr) let updatedError: CachedSessionError if (recoveryErr instanceof SessionErrorDetected) { + // 恢复期间如果直接收到了新的结构化 SSE 错误,优先使用它。 updatedError = recoveryErr.sessionError clearSessionError(sessionId) } else { + // 否则再回头看看 event.ts 是否已经缓存了更准确的 session.error。 const sseError = getSessionError(sessionId) if (sseError) { updatedError = sseError diff --git a/src/handler/event.ts b/src/handler/event.ts index ed77f17..ebf083c 100644 --- a/src/handler/event.ts +++ b/src/handler/event.ts @@ -1,5 +1,10 @@ /** - * OpenCode 事件处理:通过插件 event 钩子接收事件,更新飞书占位消息 + * OpenCode 事件处理层。 + * + * 这个模块站在插件 `event` hook 和下游 UI 之间,负责三件事: + * 1. 接收并归一化 OpenCode SSE 事件 + * 2. 维护若干与 session 绑定的短期状态缓存 + * 3. 把事件转成 action-bus 广播给流式卡片、交互卡片等消费者 */ import type { Event } from "@opencode-ai/sdk" @@ -9,22 +14,31 @@ import type * as Lark from "@larksuiteoapi/node-sdk" import { emit } from "./action-bus.js" import { TtlMap } from "../utils/ttl-map.js" +/** + * 当前正在“流式回复”的飞书消息上下文。 + * + * `chat.ts` 在创建占位消息或流式卡片后注册它, + * `message.part.updated` 事件到来时就靠这份上下文去更新对应飞书消息。 + */ export interface PendingReplyPayload { - chatId: string placeholderId: string feishuClient: InstanceType + /** 累积下来的完整文本缓冲区。 */ textBuffer: string /** 锁定的 assistant messageID,首个 SSE 事件设置,后续只接受匹配的事件 */ expectedMessageId?: string } +/** 事件处理层运行所需依赖。 */ export interface EventDeps { log: LogFn directory: string client: import("@opencode-ai/sdk").OpencodeClient + /** idle 催促配置,从解析后的 feishu.json 透传而来。 */ nudge: { enabled: boolean; message: string; intervalSeconds: number; maxIterations: number } } +/** sessionId → 当前飞书占位消息上下文。 */ const pendingBySession = new Map() /** 缓存的会话错误信息 */ @@ -33,32 +47,44 @@ export interface CachedSessionError { fields: string[] // 所有提取的错误文本字段(用于模式匹配) } +/** SSE 侧上报的会话错误,保留 30 秒供 chat.ts 轮询路径消费。 */ const sessionErrors = new TtlMap(30_000) /** 重试次数限制:防止模型不兼容时无限重试循环 */ +/** sessionKey → 已尝试的自动恢复次数,TTL 1 小时。 */ const retryAttempts = new TtlMap(3_600_000) export const MAX_RETRY_ATTEMPTS = 2 +/** 清空某个 sessionKey 的恢复次数统计。 */ export function clearRetryAttempts(sessionKey: string): void { retryAttempts.delete(sessionKey) } +/** 读取当前累计恢复次数;未记录时视为 0。 */ export function getRetryAttempts(sessionKey: string): number { return retryAttempts.get(sessionKey) ?? 0 } +/** 写入恢复次数并刷新 TTL。 */ export function setRetryAttempts(sessionKey: string, count: number): void { retryAttempts.set(sessionKey, count) } +/** 读取 session.error 缓存。 */ export function getSessionError(sessionId: string): CachedSessionError | undefined { return sessionErrors.get(sessionId) } +/** 清理 session.error 缓存,避免旧错误污染新一轮对话。 */ export function clearSessionError(sessionId: string): void { sessionErrors.delete(sessionId) } +/** + * 注册一个“待更新的飞书回复”。 + * + * 调用方只需要提供基础字段;文本缓冲和 expectedMessageId 由本模块初始化。 + */ export function registerPending( sessionId: string, payload: Omit, @@ -115,6 +141,31 @@ function collectStrings(obj: unknown, out: string[], maxDepth: number): void { * 1. 精确子串:覆盖已知的错误码和格式化字符串 * 2. 关键词组合:检测 "model" + 否定/不可用语义词,覆盖未知的自然语言变体 */ +/** 检测 session 历史数据中毒(每次 LLM 调用都会重复触发的错误) */ +/** + * session 历史中毒的高危关键词。 + * + * 这类错误通常意味着历史消息里存在当前模型无法接受的 part/schema, + * 单纯重试不会好,必须让上层主动丢弃旧 session。 + */ +const SESSION_POISON_PATTERNS = [ + "file part media type", + "tool choice type", +] + +/** + * 检测错误字段是否指向“session 历史已经中毒”。 + * + * 一旦命中,上层会直接 `invalidateSession()` 而不是再尝试模型恢复。 + */ +export function isSessionPoisoned(fields: string[]): boolean { + return fields.some(f => { + const l = f.toLowerCase() + if (SESSION_POISON_PATTERNS.some(p => l.includes(p))) return true + return /localshell.*schema|zoderror.*local.?shell/.test(l) + }) +} + export function isModelError(fields: string[]): boolean { const exactPatterns = [ "model not found", "modelnotfound", "model_not_found", @@ -132,7 +183,12 @@ export function isModelError(fields: string[]): boolean { } /** - * 处理 OpenCode 事件(由插件 event 钩子调用) + * 事件主分发入口。 + * + * 这里先按最关键的几个大类做路由: + * - `message.part.updated`:流式输出增量 + * - `session.error`:错误缓存 + * - 其他事件:统一丢给 `handleV2Event()` */ export async function handleEvent( event: Event, @@ -144,7 +200,7 @@ export async function handleEvent( if (!part?.sessionID) break const payload = pendingBySession.get(part.sessionID) if (!payload) break - await handleMessagePartUpdated(event, part, payload) + await handleMessagePartUpdated(event, part, payload, deps.log) break } case "session.error": @@ -157,22 +213,28 @@ export async function handleEvent( } /** - * 处理 message.part.updated 事件:更新飞书占位消息 + emit action-bus 事件 + * 处理 `message.part.updated`: + * - 更新飞书占位消息/流式卡片文本 + * - 广播 action-bus 事件给其他消费者 */ async function handleMessagePartUpdated( event: Event, part: { sessionID?: string; messageID?: unknown; type?: string; text?: string; [key: string]: unknown }, payload: PendingReplyPayload, + log: LogFn, ): Promise { // messageID 过滤:首个事件锁定 messageID,后续只接受匹配的事件 const messageId = part.messageID as string | undefined if (messageId) { if (!payload.expectedMessageId) { + // 首个事件到来时锁定 messageID,后续只接受同一 assistant message 的更新。 payload.expectedMessageId = messageId } else if (payload.expectedMessageId !== messageId) { + // 串行队列虽能大幅降低串扰,但这里仍做 messageID 守卫,确保只吃当前回复的事件。 return } } else if (payload.expectedMessageId) { + // 一旦已经锁定 messageID,就不再接受没有 messageID 的模糊事件。 return } @@ -194,7 +256,7 @@ async function handleMessagePartUpdated( callID, tool: toolName, state: toolState, - }) + }, log) } return } @@ -202,16 +264,31 @@ async function handleMessagePartUpdated( // delta 是增量文本,part.text 是全量文本 const delta = (event.properties as { delta?: string }).delta if (delta) { + // 增量事件:直接把 delta 追加进已有 buffer。 payload.textBuffer += delta } else { const fullText = extractPartText(part) if (fullText) { + // 快照事件:整段替换 buffer,避免重复拼接。 payload.textBuffer = fullText } } if (payload.textBuffer) { - await sender.updateMessage(payload.feishuClient, payload.placeholderId, payload.textBuffer.trim()) + // 传统占位消息路径会实时把 buffer 写回飞书消息。 + const res = await sender.updateMessage( + payload.feishuClient, + payload.placeholderId, + payload.textBuffer.trim(), + log, + ) + if (!res.ok) { + log("error", "更新飞书占位消息失败", { + sessionId: partSessionId, + placeholderId: payload.placeholderId, + error: res.error ?? "unknown", + }) + } } // Emit text-updated action to action-bus @@ -219,15 +296,17 @@ async function handleMessagePartUpdated( emit(partSessionId, { type: "text-updated", sessionId: partSessionId, - messageId: part.messageID as string | undefined, delta: delta ?? undefined, fullText: payload.textBuffer, - }) + }, log) } } /** - * 处理 session.error 事件:提取错误信息并缓存 + * 处理 `session.error`:提取可展示错误并写入短期缓存。 + * + * 注意这里故意不直接向用户发错,也不直接做恢复, + * 统一由 `chat.ts` 的 catch 路径消费这些信息,避免双重发送。 */ function handleSessionErrorEvent(event: Event, deps: EventDeps): void { const props = event.properties as Record @@ -260,7 +339,7 @@ function handleSessionErrorEvent(event: Event, deps: EventDeps): void { } /** - * 处理 v2 新增事件:permission.asked / question.asked / session.idle + * 处理 v2 新增事件:permission.asked / question.asked / session.idle。 */ function handleV2Event(event: Event, deps: EventDeps): void { const evtType = (event as { type: string }).type @@ -272,35 +351,45 @@ function handleV2Event(event: Event, deps: EventDeps): void { type: "permission-requested", sessionId: evtSessionId, request: evtProps as PermissionRequest, - }) + }, deps.log) deps.log("info", "permission.asked 事件已分发", { sessionId: evtSessionId }) } else if (evtType === "question.asked" && evtSessionId) { emit(evtSessionId, { type: "question-requested", sessionId: evtSessionId, request: evtProps as QuestionRequest, - }) + }, deps.log) deps.log("info", "question.asked 事件已分发", { sessionId: evtSessionId }) } else if (evtType === "session.idle" && evtSessionId) { emit(evtSessionId, { type: "session-idle", sessionId: evtSessionId, + }, deps.log) + // 按需催促:检查最后一条 AI 消息是否以工具调用结尾(AI 可能卡住了)。 + nudgeIfToolIdle(evtSessionId, deps).catch((err) => { + deps.log("error", "session.idle 催促任务异常退出", { + sessionId: evtSessionId, + error: err instanceof Error ? err.message : String(err), + }) }) - // 按需催促:检查最后一条 AI 消息是否以工具调用结尾(AI 可能卡住了) - nudgeIfToolIdle(evtSessionId, deps).catch(() => {}) } } /** 催促计数器:sessionId → { count, lastTime }(用户新消息时清理) */ const nudgeState = new Map() +/** 用户发新消息时清空该 session 的催促计数。 */ export function clearNudge(sessionId: string): void { nudgeState.delete(sessionId) } /** - * session.idle 时检查最后一条 AI 消息:如果以工具调用结尾,按配置催促。 - * 受 maxIterations 和 intervalSeconds 限制,用户新消息后重置。 + * session.idle 时检查最后一条 assistant 消息: + * 如果它以工具调用结尾,则按配置发送一条 synthetic prompt 催促继续。 + * + * 受两层限制: + * - `maxIterations`:总次数上限 + * - `intervalSeconds`:两次催促之间的最小间隔 */ async function nudgeIfToolIdle(sessionId: string, deps: EventDeps): Promise { if (!deps.nudge.enabled) return @@ -334,16 +423,24 @@ async function nudgeIfToolIdle(sessionId: string, deps: EventDeps): Promise + /** 项目统一日志函数。 */ log: LogFn + /** OpenCode v2 client;缺失时无法进行权限/问答回传。 */ v2Client?: OpencodeClient } /** 去重:同一 requestId 只发一张卡片(TTL 防止内存泄漏) */ const seenIds = new TtlMap(10 * 60 * 1_000) +interface PermissionReplyActionValue { + action: "permission_reply" + requestId: string + reply: "once" | "always" | "reject" +} + +interface QuestionReplyActionValue { + action: "question_reply" + requestId: string + answers: string[][] +} + +interface SendMessageActionValue { + action: "send_message" + text: string + chatId: string + /** + * chatType 由发卡侧写入;老卡片或外部构造 payload 可能缺失。 + * 这里保留“缺失态”,交给 gateway 结合回调上下文做最终判定。 + */ + chatType?: "p2p" | "group" +} + +export type ParsedCardActionValue = + | PermissionReplyActionValue + | QuestionReplyActionValue + | SendMessageActionValue + +/** + * 标记 requestId 是否首次出现。 + * + * 返回 `true` 表示这次应该继续发送卡片, + * 返回 `false` 表示此前已经处理过相同 requestId。 + */ function markSeen(requestId: string): boolean { if (seenIds.has(requestId)) return false seenIds.set(requestId, true) return true } -export function handlePermissionRequested( - request: PermissionRequest, - chatId: string, - deps: InteractiveDeps, - chatType: "p2p" | "group" = "p2p", -): void { +/** + * 发送失败时回滚已保留的 requestId,允许后续重试重新补发卡片。 + */ +function unmarkSeen(requestId: string): void { + seenIds.delete(requestId) +} + +/** + * 解析 card action payload,并只保留当前仓库真正处理的三类动作。 + * + * 这样 `interactive.ts` 和 `gateway.ts` 不必各自手写一套 JSON.parse + 字段校验。 + */ +export function parseCardActionValue( + actionValue: string | undefined, + log?: LogFn, +): ParsedCardActionValue | undefined { + if (!actionValue) return undefined + + let parsed: unknown + try { + parsed = JSON.parse(actionValue) + } catch (err) { + // 非法 actionValue 仍然按软失败处理,但会留下 error 日志便于排查卡片协议问题。 + log?.("error", "解析卡片 actionValue 失败", { + actionValue, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } + + if (!parsed || typeof parsed !== "object") return undefined + const value = parsed as Record + const requestId = typeof value.requestId === "string" ? value.requestId : "" + + switch (value.action) { + case "permission_reply": { + const reply = value.reply + if (!requestId || (reply !== "once" && reply !== "always" && reply !== "reject")) { + return undefined + } + return { action: "permission_reply", requestId, reply } + } + case "question_reply": { + const answers = value.answers + if ( + !requestId || + !Array.isArray(answers) || + answers.some( + (group) => !Array.isArray(group) || group.some((answer) => typeof answer !== "string"), + ) + ) { + return undefined + } + return { action: "question_reply", requestId, answers } + } + case "send_message": { + const text = typeof value.text === "string" ? value.text : "" + const chatId = typeof value.chatId === "string" ? value.chatId : "" + if (!text || !chatId) return undefined + return { + action: "send_message", + text, + chatId, + chatType: value.chatType === "group" || value.chatType === "p2p" + ? value.chatType + : undefined, + } + } + default: + return undefined + } +} + +/** + * 异步发送交互卡片,并补一层带 requestId/chatId 的业务日志。 + * + * sender 层会把飞书 SDK 异常折叠成 `{ ok: false }`, + * 这里负责检查结果并保留更完整的业务上下文。 + */ +function sendRequestCard(params: { + requestId: string + chatId: string + deps: InteractiveDeps + card: object + missingClientMessage: string + sendFailureMessage: string +}): void { + const { requestId, chatId, deps, card, missingClientMessage, sendFailureMessage } = params if (!deps.v2Client) { - deps.log("warn", "v2Client 未配置,跳过权限卡片发送", { requestId: String(request.id ?? "") }) + deps.log("warn", missingClientMessage, { requestId }) return } - const requestId = String(request.id ?? "") + // 先占住 requestId,避免同一条 SSE 在发送尚未完成时并发发出重复卡片。 if (!requestId || !markSeen(requestId)) return - const card = buildPermissionCardDSL(request, chatId, chatType) - sender.sendInteractiveCard(deps.feishuClient, chatId, card).catch((err) => { - deps.log("warn", "发送权限卡片失败", { + void (async () => { + const res = await sender.sendInteractiveCard(deps.feishuClient, chatId, card, deps.log) + if (!res.ok) { + // 发送失败要回滚占位,避免后续相同 requestId 永久失去重试机会。 + unmarkSeen(requestId) + deps.log("error", sendFailureMessage, { + requestId, + chatId, + error: res.error ?? "unknown", + }) + } + })().catch((err) => { + // 交互卡片是增强能力,失败后不应让主链路崩溃。 + unmarkSeen(requestId) + deps.log("error", sendFailureMessage, { requestId, + chatId, error: err instanceof Error ? err.message : String(err), }) }) } +/** + * 发送权限审批卡片。 + * + * 发送失败只记录日志,不阻断主对话流程。 + */ +export function handlePermissionRequested( + request: PermissionRequest, + chatId: string, + deps: InteractiveDeps, + chatType: "p2p" | "group" = "p2p", +): void { + const requestId = String(request.id ?? "") + sendRequestCard({ + requestId, + chatId, + deps, + card: buildPermissionCardDSL(request, chatId, chatType), + missingClientMessage: "v2Client 未配置,跳过权限卡片发送", + sendFailureMessage: "发送权限卡片失败", + }) +} + +/** + * 发送问答选择卡片。 + * + * 当前实现只渲染第一题,适合“单问题、按钮式确认”的场景。 + */ export function handleQuestionRequested( request: QuestionRequest, chatId: string, deps: InteractiveDeps, chatType: "p2p" | "group" = "p2p", ): void { - if (!deps.v2Client) { - deps.log("warn", "v2Client 未配置,跳过问答卡片发送", { requestId: String(request.id ?? "") }) - return - } const requestId = String(request.id ?? "") - if (!requestId || !markSeen(requestId)) return - - const card = buildQuestionCardDSL(request, chatId, chatType) - sender.sendInteractiveCard(deps.feishuClient, chatId, card).catch((err) => { - deps.log("warn", "发送问答卡片失败", { - requestId, - error: err instanceof Error ? err.message : String(err), - }) + sendRequestCard({ + requestId, + chatId, + deps, + card: buildQuestionCardDSL(request, chatId, chatType), + missingClientMessage: "v2Client 未配置,跳过问答卡片发送", + sendFailureMessage: "发送问答卡片失败", }) } +/** + * 飞书 `card.action.trigger` 回调里,本仓库真正关心的字段。 + * + * 保持宽松结构是为了兼容飞书 SDK 事件体的版本差异。 + */ export interface CardActionData { actionValue: string | undefined actionTag: string | undefined @@ -76,13 +237,17 @@ export interface CardActionData { } /** - * 处理卡片按钮点击回调(异步,不阻塞回调返回) + * 处理卡片点击后的异步回传。 + * + * 这里只处理真正需要调用 OpenCode v2 API 的 payload; + * 普通 `send_message` 按钮已经在 `gateway.ts` 中被转成合成消息事件。 */ export async function handleCardAction( action: CardActionData, deps: InteractiveDeps, ): Promise { - if (!action.actionValue) return + const value = parseCardActionValue(action.actionValue, deps.log) + if (!value || value.action === "send_message") return if (!deps.v2Client) { deps.log("warn", "v2Client 未配置,交互回调被忽略(按钮点击不会转发到 OpenCode)", { actionValue: action.actionValue, @@ -90,53 +255,36 @@ export async function handleCardAction( return } - type PermissionReplyValue = { action: "permission_reply"; requestId: string; reply: "once" | "always" | "reject" } - type QuestionReplyValue = { action: "question_reply"; requestId: string; answers: string[][] } - type ActionValue = PermissionReplyValue | QuestionReplyValue | { action?: string; requestId?: string } - - let value: ActionValue - try { - value = JSON.parse(action.actionValue) - } catch { - return - } - - const requestId = value.requestId - if (!requestId) return - try { - if (value.action === "permission_reply" && "reply" in value) { + if (value.action === "permission_reply") { await deps.v2Client.permission.reply({ - requestID: requestId, + requestID: value.requestId, reply: value.reply, }) - } else if (value.action === "question_reply" && "answers" in value) { + } else { await deps.v2Client.question.reply({ - requestID: requestId, + requestID: value.requestId, answers: value.answers, }) } } catch (err) { deps.log("error", "交互回调处理失败", { action: value.action, - requestId, + requestId: value.requestId, error: err instanceof Error ? err.message : String(err), }) } } /** - * 构建即时回调响应(3 秒内返回 toast) + * 构建飞书要求的即时回调响应。 + * + * 飞书要求 `card.action.trigger` 很快返回,因此这里只回 toast, + * 真正的业务处理在后台异步完成。 */ -export function buildCallbackResponse(action: CardActionData): object { - if (!action.actionValue) return {} - - let value: { action?: string; reply?: string } - try { - value = JSON.parse(action.actionValue) - } catch { - return {} - } +export function buildCallbackResponse(action: CardActionData, log?: LogFn): object { + const value = parseCardActionValue(action.actionValue, log) + if (!value) return {} if (value.action === "permission_reply") { const isReject = value.reply === "reject" @@ -164,7 +312,10 @@ export function buildCallbackResponse(action: CardActionData): object { } /** - * 使用统一 DSL 构建权限审批卡片 + * 把权限请求翻译成统一的 card DSL。 + * + * 这里的按钮通过 `actionPayload` 注入专用 JSON, + * 不走普通 `send_message` 分支。 */ function buildPermissionCardDSL(request: PermissionRequest, chatId: string, chatType: "p2p" | "group"): object { const permission = String(request.permission ?? "unknown") @@ -175,6 +326,7 @@ function buildPermissionCardDSL(request: PermissionRequest, chatId: string, chat ? patterns.map(p => `- \`${p}\``).join("\n") : "(无具体路径)" + // 三个按钮对应 OpenCode permission.reply 支持的三种答复。 const buttons: ButtonInput[] = [ { text: "✅ 允许一次", value: "", style: "primary", @@ -200,12 +352,15 @@ function buildPermissionCardDSL(request: PermissionRequest, chatId: string, chat } /** - * 使用统一 DSL 构建问答选择卡片 + * 把问答请求翻译成按钮卡片。 + * + * 当前每个选项都会映射成一个按钮,点击后回传 `answers: [[value]]`。 */ function buildQuestionCardDSL(request: QuestionRequest, chatId: string, chatType: "p2p" | "group"): object { const questions = request.questions ?? [] const requestId = String(request.id ?? "") + // 当前仅消费第一题;若未来支持多题,需要额外的表单状态设计。 const q = questions[0] const header = String(q?.header ?? "AI 提问") const questionText = String(q?.question ?? "请选择") diff --git a/src/handler/session-queue.ts b/src/handler/session-queue.ts index 41dd267..0456cdf 100644 --- a/src/handler/session-queue.ts +++ b/src/handler/session-queue.ts @@ -1,26 +1,39 @@ /** - * 会话消息队列调度器:按 sessionKey FIFO 串行处理 + * 会话消息队列:保证同一个逻辑聊天里的消息顺序稳定。 * - * - P2P 和群聊统一使用 FIFO 队列,消息按顺序处理不互相中断 - * - 静默转发完全绕过队列 + * 当前实现统一采用 FIFO 串行模型,这样最容易确保: + * - 同一 session 的上下文顺序正确 + * - 占位消息/流式卡片不会被并发覆盖 + * - 群聊中多个 @bot 请求不会互相踩状态 + * + * 说明: + * - 单聊不再做“新消息打断旧消息”的 CLI 式中断,避免 IM 场景里出现残留撤回或丢回复 + * - `session.idle` 之后的继续催促已经转移到 `event.ts` 的 nudge 流程,不再由队列维护第二阶段循环 */ import type { FeishuMessageContext } from "../types.js" import { handleChat, type ChatDeps } from "./chat.js" import { buildSessionKey } from "../session.js" +/** 单条待处理消息及其运行依赖。 */ interface QueuedMessage { readonly ctx: FeishuMessageContext readonly deps: ChatDeps } +/** 某个 sessionKey 当前的队列运行状态。 */ interface QueueState { + /** FIFO 消息数组。 */ queue: QueuedMessage[] + /** 是否已经有 drainLoop 在消费。 */ processing: boolean } /** 全局队列状态:sessionKey → QueueState */ const states = new Map() +/** + * 读取或初始化指定 sessionKey 的状态对象。 + */ function getOrCreateState(sessionKey: string): QueueState { const existing = states.get(sessionKey) if (existing) return existing @@ -29,6 +42,9 @@ function getOrCreateState(sessionKey: string): QueueState { return state } +/** + * 队列彻底空闲时回收状态对象,避免长时间运行后空壳条目积累。 + */ function cleanupStateIfIdle(sessionKey: string, state: QueueState): void { if (!state.processing && state.queue.length === 0) { states.delete(sessionKey) @@ -36,10 +52,14 @@ function cleanupStateIfIdle(sessionKey: string, state: QueueState): void { } /** - * 消息入队:统一入口,根据 shouldReply 和 chatType 分发策略 + * 统一入队入口。 + * + * 特殊规则: + * - `shouldReply=false` 的静默消息直接透传,不占用队列 + * - 需要回复的消息则按 sessionKey 归并到串行队列 */ export async function enqueueMessage(ctx: FeishuMessageContext, deps: ChatDeps): Promise { - // 静默消息完全绕过队列 + // 静默消息只做上下文同步,不需要排队等待 UI 回复链路。 if (!ctx.shouldReply) { await handleChat(ctx, deps) return @@ -50,13 +70,16 @@ export async function enqueueMessage(ctx: FeishuMessageContext, deps: ChatDeps): ctx.chatType === "p2p" ? ctx.senderId : ctx.chatId, ) - await handleGroupMessage(sessionKey, ctx, deps) + await handleQueuedMessage(sessionKey, ctx, deps) } /** - * FIFO 串行队列:P2P 和群聊统一使用 + * 把消息压入指定 session 的 FIFO 队列。 + * + * 如果当前已有消费者在跑,本次调用只负责入队; + * 如果当前没人消费,则由本次调用负责拉起 drainLoop。 */ -async function handleGroupMessage( +async function handleQueuedMessage( sessionKey: string, ctx: FeishuMessageContext, deps: ChatDeps, @@ -64,20 +87,24 @@ async function handleGroupMessage( const state = getOrCreateState(sessionKey) state.queue.push({ ctx, deps }) - // 已有 drainLoop 运行中,消息已入队,等它处理 + // 已有消费者运行中时,不重复启动第二个 drainLoop。 if (state.processing) return await drainLoop(sessionKey, state) } /** - * 串行消费队列中的所有消息 + * 串行消费同一 session 的所有待处理消息。 + * + * 这里故意在单条消息失败时继续往后处理, + * 避免一次异常把整个聊天队列永久堵死。 */ async function drainLoop(sessionKey: string, state: QueueState): Promise { state.processing = true try { while (state.queue.length > 0) { + // while 条件已经保证数组非空,因此这里的非空断言是安全的。 const item = state.queue.shift()! try { await handleChat(item.ctx, item.deps) @@ -94,4 +121,5 @@ async function drainLoop(sessionKey: string, state: QueueState): Promise { } } +/** 复导出,方便其他模块直接从队列层拿依赖类型。 */ export type { ChatDeps } from "./chat.js" diff --git a/src/index.ts b/src/index.ts index dd05b2a..d7170d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,46 +1,88 @@ /** - * OpenCode 飞书插件:通过飞书 WebSocket 长连接接入 OpenCode AI 对话 + * OpenCode 飞书插件入口模块 + * + * 本文件是 opencode-feishu 插件的主入口,负责: + * 1. 加载和验证飞书配置(feishu.json + Zod schema) + * 2. 初始化 Lark SDK 客户端(token 管理、HTTP 调用) + * 3. 获取 bot 自身 open_id(用于群聊 @提及检测) + * 4. 启动飞书 WebSocket 长连接网关 + * 5. 注册 OpenCode 事件钩子(SSE 事件处理、tool 注册、system prompt 注入) + * 6. 导出 FeishuPlugin 供 OpenCode 加载 + * + * 插件不是独立服务——由 OpenCode 管理其生命周期。 */ -import { readFileSync, existsSync } from "node:fs" -import { join } from "node:path" -import { fileURLToPath } from "node:url" -import { homedir } from "node:os" -import * as Lark from "@larksuiteoapi/node-sdk" -import type { Plugin, Hooks } from "@opencode-ai/plugin" -import { z } from "zod" -import { FeishuConfigSchema, type ResolvedConfig, type LogFn } from "./types.js" -import { CardKitClient } from "./feishu/cardkit.js" -import { startFeishuGateway, type FeishuGatewayResult } from "./feishu/gateway.js" -import { enqueueMessage } from "./handler/session-queue.js" -import { handleEvent } from "./handler/event.js" -import { handleCardAction, type InteractiveDeps } from "./handler/interactive.js" -import { ingestGroupHistory } from "./feishu/history.js" -import { initDedup } from "./feishu/dedup.js" -import { createSendCardTool } from "./tools/send-card.js" -import { getChatIdBySession } from "./feishu/session-chat-map.js" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +// ────────────────── Node.js 内置模块 ────────────────── +import { readFileSync, existsSync } from "node:fs" // 文件读取和存在性检查(同步版,仅启动阶段使用) +import { join } from "node:path" // 跨平台路径拼接 +import { fileURLToPath } from "node:url" // 将 import.meta.url 转为文件系统路径(用于定位 skills/ 目录) +import { homedir } from "node:os" // 获取用户主目录(~/.config/opencode/plugins/feishu.json) + +// ────────────────── 飞书 SDK ────────────────── +import * as Lark from "@larksuiteoapi/node-sdk" // Lark/飞书 SDK:Client(HTTP + token 管理)、WSClient(WebSocket 长连接) + +// ────────────────── OpenCode 插件接口 ────────────────── +import type { Plugin, Hooks } from "@opencode-ai/plugin" // Plugin 工厂函数类型 + Hooks 生命周期钩子类型 + +// ────────────────── Zod 配置校验 ────────────────── +import { z } from "zod" // 运行时 schema 验证,启动时捕获配置拼写/类型错误 + +// ────────────────── 内部模块 ────────────────── +import { FeishuConfigSchema, type ResolvedConfig, type LogFn } from "./types.js" // 配置 Zod schema + 解析后的配置类型 + 日志函数签名 +import { CardKitClient } from "./feishu/cardkit.js" // CardKit 2.0 SDK 薄封装(创建/更新/关闭流式卡片) +import { startFeishuGateway, type FeishuGatewayResult } from "./feishu/gateway.js" // 飞书 WebSocket 网关(消息接收、卡片回调、bot 入群事件) +import { enqueueMessage } from "./handler/session-queue.js" // 消息队列调度器入口(per-session 并发控制) +import { handleEvent } from "./handler/event.js" // SSE 事件处理器(message.part.updated / permission / question / idle / error) +import { handleCardAction, type InteractiveDeps } from "./handler/interactive.js" // 交互卡片按钮回调处理(权限审批 / 问答回复) +import { ingestGroupHistory } from "./feishu/history.js" // Bot 入群时批量摄入群聊历史消息 +import { initDedup } from "./feishu/dedup.js" // 消息去重缓存初始化(默认 10 分钟窗口) +import { createSendCardTool } from "./tools/send-card.js" // Agent 可调用的 feishu_send_card tool 工厂 +import { getChatIdBySession } from "./feishu/session-chat-map.js" // 会话 → 聊天 ID 映射查询(判断是否飞书会话) +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" // OpenCode v2 REST 客户端(用于权限/问答交互回复) + +/** 日志服务标识,所有 client.app.log() 调用都携带此名称 */ const SERVICE_NAME = "opencode-feishu" + +/** 日志消息前缀,便于在 OpenCode 日志中快速筛选飞书插件输出 */ const LOG_PREFIX = "[feishu]" + +/** 调试模式开关:设置 FEISHU_DEBUG=1 时同时输出结构化 JSON 到 stderr */ const isDebug = !!process.env.FEISHU_DEBUG -/** 从 skills/ 目录加载飞书交互决策指南(修改内容无需重新构建,重启即生效) */ +/** + * 从 skills/ 目录加载飞书交互决策指南(system prompt 片段) + * + * 该文件是 markdown 格式的 agent 指南,告知 AI 当前对话渠道为飞书, + * 并提供 feishu_send_card 工具的使用说明。 + * 内容在插件启动时读取一次,修改后重启即生效(无需重新构建)。 + * + * @returns 飞书 system prompt 字符串;skill 文件缺失时返回最小化 fallback 提示 + */ function loadFeishuSkill(): string { + // 基于当前模块路径回溯到项目根目录下的 skills/ 文件夹 const skillPath = join(fileURLToPath(import.meta.url), "../../skills/feishu-card-interaction.md") if (existsSync(skillPath)) { return readFileSync(skillPath, "utf-8") } - // fallback:skill 文件缺失时使用最小提示 + // fallback:skill 文件缺失时使用最小提示,确保 agent 至少知道飞书渠道和可用工具 return "当前用户通过飞书(Feishu/Lark)与你对话。你可以使用 feishu_send_card 工具发送格式化卡片消息(支持按钮交互)。" } +/** 缓存的飞书 system prompt,在模块加载时一次性读取 */ const feishuSystemPrompt = loadFeishuSkill() - +/** + * OpenCode 插件入口导出。 + * + * OpenCode 在加载插件时会调用这个工厂函数, + * 它完成初始化后返回本插件注册的 hooks 集合。 + */ export const FeishuPlugin: Plugin = async (ctx) => { const { client } = ctx + // `gateway` 用于在各个 hook 闭包里判断网关是否已经成功初始化。 let gateway: FeishuGatewayResult | null = null + // 统一日志桥接:始终写入 OpenCode 日志;调试模式额外输出 stderr JSON。 const log: LogFn = (level, message, extra) => { const prefixed = `${LOG_PREFIX} ${message}` if (isDebug) { @@ -59,6 +101,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { const configPath = join(homedir(), ".config", "opencode", "plugins", "feishu.json") let resolvedConfig: ResolvedConfig try { + // 启动期一次性完成配置读取、环境变量展开和 schema 校验。 resolvedConfig = loadAndValidateConfig(configPath, ctx.directory ?? "") } catch (e) { if (e instanceof z.ZodError) { @@ -86,6 +129,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { // 获取 bot open_id(用于群聊 @提及检测) const botOpenId = await fetchBotOpenId(larkClient, log) + // v2 client 主要用于权限审批/问答交互回调。 const v2Client = createOpencodeClient({ directory: resolvedConfig.directory || undefined }) const interactiveDeps: InteractiveDeps = { feishuClient: larkClient, log, v2Client } @@ -95,6 +139,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { larkClient, botOpenId, onMessage: async (msgCtx) => { + // 网关未完成初始化或消息为空时,不进入主处理链路。 if (!msgCtx.content.trim() || !gateway) return await enqueueMessage(msgCtx, { config: resolvedConfig, @@ -108,6 +153,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { }, onBotAdded: (chatId) => { if (!gateway) return + // Bot 刚入群时异步补录历史消息,帮助模型建立初始上下文。 ingestGroupHistory(larkClient, client, chatId, { maxMessages: resolvedConfig.maxHistoryMessages, log, @@ -121,6 +167,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { }, onCardAction: async (action) => { if (!gateway) return + // 交互按钮统一交给 interactive 层处理。 await handleCardAction(action, interactiveDeps) }, log, @@ -133,6 +180,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { const hooks: Hooks = { event: async ({ event }) => { + // 只有网关可用时才消费 OpenCode SSE 事件。 if (!gateway) return await handleEvent(event, { log, directory: resolvedConfig.directory, client, nudge: resolvedConfig.nudge }) }, @@ -152,6 +200,7 @@ export const FeishuPlugin: Plugin = async (ctx) => { } catch (err) { log("warn", "获取 config 失败", { error: err instanceof Error ? err.message : String(err) }) } + // 作为独立 system 段落注入,避免和 skill 文本粘连。 output.system.push(runtimeLines.join("\n")) }, } @@ -166,8 +215,10 @@ function loadAndValidateConfig(configPath: string, ctxDirectory: string): Resolv if (!existsSync(configPath)) { throw new Error(`缺少飞书配置文件:请创建 ${configPath},内容为 {"appId":"cli_xxx","appSecret":"xxx"}`) } + // 先 JSON.parse,再递归展开字符串里的环境变量占位符。 const raw = resolveEnvPlaceholders(JSON.parse(readFileSync(configPath, "utf-8"))) const parsed = FeishuConfigSchema.parse(raw) + // directory 在这里统一展开成最终运行时路径。 return { ...parsed, directory: expandDirectoryPath(parsed.directory ?? ctxDirectory ?? "") } } @@ -209,11 +260,13 @@ function resolveEnvPlaceholders(obj: unknown): unknown { }) } if (Array.isArray(obj)) { + // 数组元素递归替换,占位符规则与对象字段一致。 return obj.map(resolveEnvPlaceholders) } if (obj !== null && typeof obj === "object") { const result: Record = {} for (const [key, value] of Object.entries(obj as Record)) { + // 对象值逐个递归替换,键名保持原样。 result[key] = resolveEnvPlaceholders(value) } return result @@ -230,7 +283,7 @@ async function fetchBotOpenId( larkClient: InstanceType, log: LogFn, ): Promise { - // SDK 没有 /bot/v3/info 的语义方法,使用 client.request() 通用方法 + // SDK 没有 /bot/v3/info 的语义方法,因此走通用 request()。 const res = await larkClient.request<{ bot?: { open_id?: string } }>({ url: "https://open.feishu.cn/open-apis/bot/v3/info", method: "GET", diff --git a/src/session.ts b/src/session.ts index 7db404f..0aba21f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,62 +1,85 @@ /** - * 共享会话管理:查找或创建 OpenCode 会话 + * 共享会话管理:把飞书聊天稳定映射到 OpenCode session。 + * + * 目标是既保留上下文连续性,又避免每条消息都去远端扫描 session 列表。 */ import type { OpencodeClient } from "@opencode-ai/sdk" import { TtlMap } from "./utils/ttl-map.js" +/** 逻辑会话键的固定前缀,避免与其他渠道混淆。 */ const SESSION_KEY_PREFIX = "feishu" +/** OpenCode session 标题前缀;用于后续标题反查。 */ const TITLE_PREFIX = "Feishu" -const SESSION_CACHE_TTL = 60 * 60 * 1_000 // 1 hour +/** 本地缓存 TTL:1 小时。 */ +const SESSION_CACHE_TTL = 60 * 60 * 1_000 /** 内存会话缓存:sessionKey → { id, title },1 小时 TTL 自动清理 */ const sessionCache = new TtlMap<{ id: string; title?: string }>(SESSION_CACHE_TTL) +/** + * 写入本地 session 缓存并刷新 TTL。 + */ function setCachedSession(sessionKey: string, session: { id: string; title?: string }): void { sessionCache.set(sessionKey, session) } /** - * 构建会话键 + * 构建逻辑会话键。 + * + * - 单聊:`feishu-p2p-` + * - 群聊:`feishu-group-` */ export function buildSessionKey(chatType: "p2p" | "group", id: string): string { return `${SESSION_KEY_PREFIX}-${chatType}-${id}` } /** - * 生成带时间戳的会话标题 + * 生成带时间戳的 OpenCode session 标题。 + * + * 时间戳可以保证同一逻辑聊天在必要时仍能创建多条不同标题的 session。 */ function generateSessionTitle(sessionKey: string): string { return `${TITLE_PREFIX}-${sessionKey}-${Date.now()}` } /** - * 获取缓存中的会话(不创建新会话) + * 主动使指定逻辑会话失效。 + * + * 下次 `getOrCreateSession()` 将重新去 OpenCode 侧查找或创建。 */ -export function getCachedSession(sessionKey: string): { id: string; title?: string } | undefined { - return sessionCache.get(sessionKey) +export function invalidateSession(sessionKey: string): void { + sessionCache.delete(sessionKey) } /** - * 查找或创建 OpenCode 会话(按标题前缀匹配) + * 查找或创建 OpenCode 会话。 + * + * 查找顺序: + * 1. 先查本地 TTL 缓存 + * 2. 缓存未命中时按标题前缀从远端 session 列表中找最新的一条 + * 3. 仍找不到才真正创建新 session */ export async function getOrCreateSession( client: OpencodeClient, sessionKey: string, directory?: string, ): Promise<{ id: string; title?: string }> { - // 先查缓存 + // 第一层:本地缓存命中时直接返回,避免频繁 list session。 const cached = sessionCache.get(sessionKey) if (cached) return cached + // 第二层:按标题前缀在远端已有 session 中反查。 const titlePrefix = `${TITLE_PREFIX}-${sessionKey}-` const query = directory ? { directory } : undefined const { data: sessions } = await client.session.list({ query }) if (Array.isArray(sessions)) { + // 只保留属于当前逻辑聊天的候选 session。 const candidates = sessions.filter( (s) => s.title && s.title.startsWith(titlePrefix), ) if (candidates.length > 0) { + // 多个候选时,优先复用最近创建的一条,尽量延续最新上下文。 candidates.sort((a, b) => { const ca = a.time?.created ?? 0 const cb = b.time?.created ?? 0 @@ -71,6 +94,7 @@ export async function getOrCreateSession( } } + // 第三层:完全找不到时,创建新 session。 const title = generateSessionTitle(sessionKey) const createResp = await client.session.create({ query, body: { title } }) if (!createResp?.data?.id) { @@ -83,4 +107,3 @@ export async function getOrCreateSession( setCachedSession(sessionKey, session) return session } - diff --git a/src/tools/send-card.ts b/src/tools/send-card.ts index 09a6ecc..a419e3a 100644 --- a/src/tools/send-card.ts +++ b/src/tools/send-card.ts @@ -1,5 +1,9 @@ /** - * feishu_send_card Tool:agent 驱动的一次性结构化卡片 + * `feishu_send_card` Tool:允许 agent 主动往当前飞书会话发送结构化卡片。 + * + * 它和 StreamingCard 的定位不同: + * - StreamingCard 用于“当前这次 AI 回复”的流式展示 + * - feishu_send_card 用于 agent 主动发一条独立卡片消息 */ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { getChatIdBySession, getChatInfoBySession } from "../feishu/session-chat-map.js" @@ -8,15 +12,26 @@ import { sendInteractiveCard } from "../feishu/sender.js" import type * as Lark from "@larksuiteoapi/node-sdk" import type { LogFn } from "../types.js" +/** 复用插件工具系统自带的 schema 构造器。 */ const z = tool.schema +/** 飞书卡头支持的颜色模板。 */ const TEMPLATE_COLORS = ["blue", "green", "orange", "red", "purple", "grey"] as const +/** Tool 运行需要的最小依赖。 */ interface SendCardDeps { feishuClient: InstanceType log: LogFn } +/** + * 创建 `feishu_send_card` 工具定义。 + * + * 这个工具的本质是: + * 1. 从当前 sessionID 找到对应飞书聊天 + * 2. 把 DSL 翻译成 Card 2.0 JSON + * 3. 通过 sender 发一条 interactive 消息 + */ export function createSendCardTool(deps: SendCardDeps): ToolDefinition { return tool({ description: @@ -105,6 +120,7 @@ export function createSendCardTool(deps: SendCardDeps): ToolDefinition { .describe("卡片正文区块列表"), }, async execute(args, context) { + // Tool 执行发生在 OpenCode session 上下文里,需要先反查飞书 chatId。 const chatId = getChatIdBySession(context.sessionID) if (!chatId) { deps.log("warn", "Agent 卡片发送跳过:sessionID 无飞书聊天映射", { @@ -114,9 +130,11 @@ export function createSendCardTool(deps: SendCardDeps): ToolDefinition { return "错误:当前会话不关联飞书聊天,无法发送卡片" } + // 尽量保留聊天类型信息;DSL 里的按钮回调需要知道自己来自单聊还是群聊。 const chatInfo = getChatInfoBySession(context.sessionID) const card = buildCardFromDSL(args, chatId, chatInfo?.chatType ?? "p2p") - const result = await sendInteractiveCard(deps.feishuClient, chatId, card) + // Tool 主动发卡片也把 sender 层异常接到项目 error 日志,避免只有失败字符串没有日志上下文。 + const result = await sendInteractiveCard(deps.feishuClient, chatId, card, deps.log) if (result.ok) { deps.log("info", "Agent 卡片已发送", { @@ -128,7 +146,7 @@ export function createSendCardTool(deps: SendCardDeps): ToolDefinition { return `卡片已发送:「${args.title}」` } - deps.log("warn", "Agent 卡片发送失败", { + deps.log("error", "Agent 卡片发送失败", { sessionId: context.sessionID, chatId, title: args.title, @@ -139,6 +157,13 @@ export function createSendCardTool(deps: SendCardDeps): ToolDefinition { }) } +/** + * actions 区块里单个按钮的输入定义。 + * + * `actionPayload` 是内部增强字段: + * - agent 正常使用时不会看到它 + * - 权限/问答卡片会借它注入专用 JSON 回调值 + */ export type ButtonInput = { text: string value: string @@ -147,6 +172,11 @@ export type ButtonInput = { actionPayload?: object } +/** + * 通用 section 输入定义。 + * + * 一个 section 最终会被翻译成 1 个或多个 Card 2.0 元素。 + */ export type SectionInput = { type: | "markdown" | "divider" | "note" | "actions" @@ -177,6 +207,14 @@ export type SectionInput = { title?: string } +/** + * 把 DSL 翻译成 Card 2.0 JSON。 + * + * 设计原则: + * - 上层输入用统一 DSL,屏蔽 Card 2.0 的细碎字段差异 + * - 对飞书不存在的组件做“最相近组件”降级 + * - 缺必要数据时返回空数组,避免生成无效元素 + */ export function buildCardFromDSL( args: { title: string; template: string; sections: readonly SectionInput[] }, chatId: string, @@ -191,15 +229,16 @@ export function buildCardFromDSL( }, body: { elements: args.sections.flatMap((s) => { + // 每个 section 根据 type 翻译为对应的 Card 2.0 element。 switch (s.type) { case "divider": return { tag: "hr" } case "note": - // Card 2.0 无 note 组件,用 div + plain_text 替代 + // Card 2.0 无独立 note 组件,用 div + plain_text 近似替代。 return { tag: "div", text: { tag: "plain_text", content: s.content ?? "" } } case "actions": if (!s.buttons?.length) return [] - // Card 2.0 无 action 容器,用 column_set 横排按钮 + // Card 2.0 无 action 容器,用 column_set 横排按钮。 return { tag: "column_set", flex_mode: "none", @@ -212,6 +251,7 @@ export function buildCardFromDSL( tag: "button", text: { tag: "plain_text", content: btn.text }, type: btn.style, + // 未注入 actionPayload 时,默认把按钮点击转成一条 send_message 合成消息。 value: btn.actionPayload ?? { action: "send_message", chatId, @@ -243,6 +283,7 @@ export function buildCardFromDSL( if (!s.columns?.length) return [] return { tag: "table", + // 目前固定每页 10 条,避免超大表格在飞书里一次性展开过长。 page_size: 10, columns: s.columns.map(c => ({ name: c.name, data_type: c.dataType ?? "text" })), rows: s.rows ?? [], @@ -315,6 +356,7 @@ export function buildCardFromDSL( case "collapse": return { tag: "collapsible_panel", + // 默认折叠,避免卡片过长影响首屏可读性。 expanded: false, header: { title: { tag: "plain_text", content: s.title ?? "" } }, elements: [{ tag: "markdown", content: s.content ?? "" }], diff --git a/src/types.ts b/src/types.ts index fd64e98..ecdf8a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,61 +1,104 @@ import { z } from "zod" /** - * 飞书消息上下文(网关提取后传递给处理器) + * 飞书网关层整理出的“统一消息上下文”。 + * + * 后续 `session-queue.ts`、`chat.ts` 等模块都只依赖这个稳定结构, + * 不再直接接触飞书原始事件 payload。 */ export interface FeishuMessageContext { + /** 飞书 chat_id,用于发送和更新消息。 */ chatId: string + /** 当前消息自己的 message_id。 */ messageId: string + /** 飞书原始消息类型,例如 text / image / post / file。 */ messageType: string - /** 提取后的文本内容(text/post 类型),非文本类型可能为空 */ + /** 已提取的可读文本;非文本消息可能为空。 */ content: string - /** 原始 JSON content 字符串(用于资源下载和内容提取) */ + /** 飞书原始 JSON content 字符串,供资源解析器继续使用。 */ rawContent: string + /** 聊天类型:单聊或群聊。 */ chatType: "p2p" | "group" + /** 发送者 open_id。 */ senderId: string + /** 线程根消息 ID,可选。 */ rootId?: string + /** 被回复/引用的父消息 ID,可选。 */ parentId?: string - /** 消息创建时间(毫秒时间戳字符串,来自飞书 create_time 字段) */ + /** 飞书 create_time,通常为毫秒时间戳字符串。 */ createTime?: string - /** false = 静默监听:消息转发给 OpenCode 但不在飞书回复(群聊未被 @提及时) */ + /** + * 是否需要对用户可见地回复。 + * `false` 表示只把消息同步给 OpenCode 作为上下文,不在飞书侧发送消息。 + */ shouldReply: boolean } +/** + * `session.idle` 兜底催促配置。 + * + * 当模型以工具调用收尾却没有继续输出时,插件可按该配置再发一条 synthetic prompt。 + */ const NudgeSchema = z.object({ + /** 是否启用 idle 催促能力。 */ enabled: z.boolean().default(false), + /** 真正送入 OpenCode 的催促文本。 */ message: z.string().min(1).default("上一步操作已完成。请继续执行下一步,同步当前进度。如果全部完成,给出完整结果和结论。"), + /** 两次催促之间的最小间隔(秒)。 */ intervalSeconds: z.number().int().positive().max(300).default(30), + /** 同一会话内最多催促多少次。 */ maxIterations: z.number().int().positive().max(100).default(3), }) +/** + * 飞书插件配置 schema。 + * + * 这里同时承担: + * 1. 运行时 JSON 校验 + * 2. 默认值补齐 + * 3. TypeScript 类型推导源 + */ export const FeishuConfigSchema = z.object({ + /** 飞书自建应用 appId。 */ appId: z.string().min(1, "appId 不能为空"), + /** 飞书自建应用 appSecret。 */ appSecret: z.string().min(1, "appSecret 不能为空"), + /** 对话轮询总超时。 */ timeout: z.number().int().positive().optional(), + /** 延迟多久显示“正在思考…”占位。 */ thinkingDelay: z.number().int().nonnegative().default(2_500), + /** 飞书 SDK 的内部日志等级。 */ logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), + /** 入群后最多摄入多少条历史消息。 */ maxHistoryMessages: z.number().int().positive().max(500).default(200), + /** 轮询 session messages 的间隔。 */ pollInterval: z.number().int().positive().default(1_000), + /** 连续多少次轮询无变化视为稳定。 */ stablePolls: z.number().int().positive().default(3), + /** 飞书消息去重窗口。 */ dedupTtl: z.number().int().positive().default(10 * 60 * 1_000), + /** 资源下载大小上限。 */ maxResourceSize: z.number().int().positive().max(500 * 1024 * 1024).default(500 * 1024 * 1024), + /** idle 催促子配置。 */ nudge: NudgeSchema.default(() => NudgeSchema.parse({})), + /** OpenCode 工作目录,可在启动阶段进一步展开。 */ directory: z.string().optional(), }) /** - * feishu.json 输入类型(所有字段可选,Zod 填充默认值) - * 用于文档和外部类型引用,自动与 schema 同步 + * `feishu.json` 的“输入态”类型。 + * + * 适合描述外部配置文件,因为此时默认值还没被补齐。 */ export type FeishuPluginConfig = z.input /** - * 合并默认值后的完整配置(由 FeishuConfigSchema 推导) + * 经过 Zod 补齐默认值后的“运行态”配置。 */ export type ResolvedConfig = z.infer & { directory: string } /** - * 插件日志函数签名 + * 项目内部统一日志函数签名。 */ export type LogFn = ( level: "info" | "warn" | "error", @@ -63,17 +106,33 @@ export type LogFn = ( extra?: Record, ) => void +/** + * 权限请求事件里,本仓库真正用到的字段。 + * + * 字段保持宽松是为了兼容上游事件结构的小变动。 + */ export interface PermissionRequest { + /** 请求唯一 ID,用于按钮回传。 */ id?: string | number + /** 权限名称。 */ permission?: string + /** 路径模式列表。 */ patterns?: string[] } +/** + * 问答请求事件里,本仓库真正用到的字段。 + */ export interface QuestionRequest { + /** 请求唯一 ID。 */ id?: string | number + /** 问题数组;当前卡片实现只消费第一题。 */ questions?: Array<{ + /** 问题正文。 */ question?: string + /** 卡片标题。 */ header?: string + /** 用户可选选项。 */ options?: Array<{ label?: string; value?: string }> }> } diff --git a/src/utils/ttl-map.ts b/src/utils/ttl-map.ts index fbadae0..1d385ee 100644 --- a/src/utils/ttl-map.ts +++ b/src/utils/ttl-map.ts @@ -1,32 +1,67 @@ /** - * TTL Map — 带自动过期的 Map,替代手动 setTimeout/sweep 模式 + * TTL Map:带自动过期能力的轻量缓存容器。 + * + * 本项目里的缓存规模普遍不大,因此采用“每个 key 一个 timer”的模型, + * 比统一 sweep 更直观,也足够稳定。 + * + * @template V 存储值类型 */ export class TtlMap { + /** 真正存值的 Map。 */ private readonly data = new Map() + /** 每个 key 对应一个过期定时器,便于刷新 TTL 或手动删除时清理。 */ private readonly timers = new Map>() + /** + * @param defaultTtlMs 默认 TTL;`set()` 未传 ttlMs 时使用它 + */ constructor(private readonly defaultTtlMs: number) {} + /** + * 读取缓存值。 + * + * 这里不需要额外判断“是否过期”,因为到期条目会被 timer 主动清走。 + */ get(key: string): V | undefined { return this.data.get(key) } + /** + * 检查 key 当前是否还存在于缓存中。 + */ has(key: string): boolean { return this.data.has(key) } + /** + * 设置缓存并启动/刷新过期定时器。 + * + * 如果 key 已存在,先删除旧条目(含清理旧定时器),再重新设置。 + * 这意味着重复 `set()` 同一个 key 会刷新其 TTL。 + * + * @param key 键名 + * @param value 缓存值 + * @param ttlMs 可选自定义 TTL + */ set(key: string, value: V, ttlMs?: number): void { + // 先清旧值,保证一个 key 不会挂着两个 timer。 this.delete(key) this.data.set(key, value) + // 到期后同步清理数据和 timer 引用,保持两个 Map 一致。 const timer = setTimeout(() => { this.data.delete(key) this.timers.delete(key) }, ttlMs ?? this.defaultTtlMs) + // 不让缓存 timer 阻止 Node 进程退出。 timer.unref() this.timers.set(key, timer) } + /** + * 手动删除 key,并一并清理其定时器。 + */ delete(key: string): void { + // 如果不清 timer,会留下悬挂定时器。 const timer = this.timers.get(key) if (timer) { clearTimeout(timer)