# 技术文档 `dsh-feishu-bridge` 是一个 DeepSeek Harness(DSH)的 Cordis 插件。它把飞书机器人 收到的消息交给 Harness 的 Agent 执行,再把 Agent 的回答和执行过程回传到飞书。 ## 1. 总体架构 ``` 飞书用户 ──消息──▶ 飞书开放平台 ──WebSocket 长连接──▶ @larksuiteoapi/node-sdk (WSClient) │ im.message.receive_v1 ▼ FeishuChannel(lib/feishu.js) 解析/过滤,规整成 InboundMessage │ ▼ HarnessBridge(lib/bridge.js) 会话映射 → Agent 创建/复用 agent.followup() 提交消息 session/event 订阅 → 进度回传 │ ┌─────────────────────────┴─────────────────────────┐ ▼ ▼ 进度提示(工具调用等) 最终回答(assistant 文本) │ │ └──────────────▶ FeishuChannel.sendText / replyText ◀──┘ │ ▼ 飞书 ``` 三个关键对象: - **`FeishuChannel`**(`lib/feishu.js`):飞书侧的“传输层”。用官方 SDK 建长连接、 收事件、发消息/回复。它不理解 Harness。 - **`HarnessBridge`**(`lib/bridge.js`):Harness 侧的“业务层”。维护飞书会话 → Agent 的映射、驱动 Agent turn、把执行过程事件转成进度回调。它不理解飞书。 - **插件入口**(`lib/index.js`):把两者接起来——解析入站消息、订阅 `session/event`、 编排“进度回传”和“最终回答回传”,并注册卸载清理。 ## 2. 与 DSH 的集成 ### 2.1 插件是如何被加载的 DSH 用 Cordis 的 Loader 管理插件树。`package.json` 里声明: ```json "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } ``` 安装后(`dsh plugin --profile web add `),插件会被写进 Profile 的 `dsh.profile.bundles`,它的 `cordis.patch.yml` 作为一个 patch 层叠加到插件树上: ```yaml - insert: - id: feishu-bridge name: 'dsh-feishu-bridge' # 模块说明符,指向本包的 lib/index.js disabled: true # 默认禁用,等用户在 profile patch 里启用 config: { ... } ``` Loader 会 `import('dsh-feishu-bridge')`,读取模块导出的 `{ name, inject, Config, apply }`, 先解析 `inject` 里的服务,再把 `config`(经过 `Config` Schema 校验)传给 `apply(ctx, config)`。 ### 2.2 依赖的 Harness 服务 | 服务 | 用途 | | --- | --- | | `ctx.agents` | `AgentRegistry`:`create()` 创建 Agent,返回 `{agent, dispose}` | | `ctx.sessions` | `SessionStore`:`flush()` 触发持久化检查点 | | `ctx.agentDefaultModel` | 读取默认模型 `currentSelection()`,作为飞书渠道的模型兜底 | | `ctx.agentPresets` | `resolve(id?)` / `mount(agentCtx, id?)`:给 Agent 挂载 Preset(工具/系统提示组合) | | `ctx.workspaceRegistry` | 取工作目录并 `attachSession()`,让会话出现在对应工作区 | | `ctx.credentials` | DSH 的凭据服务:`resolve(ref)` 解析 App Secret(值在 `.credentials.yaml`) | | `ctx.llm`(可选) | 枚举模型 `listModels()`、解析推理强度 `resolveModelInfo()`,供 `/model` `/effort` 使用 | | `ctx.permissionPresets`(可选) | 切换权限预设 `set(session, name)`,供 `/mode` 使用 | | `ctx.commands`(可选) | 透传 DSH 原生命令 `execute(agent, line)`,供 `/feedback` `/goal` `/plan` 使用 | | `ctx.sessionPersistence`(可选) | `list()` / `prepare()`,供 `/session` 枚举与恢复持久化会话 | | `ctx.sessionQuery`(可选) | `listSessions()` / `readTitleSnapshots()`,供 `/session` 列出会话与读取标题 | | `ctx.compaction`(可选) | `compactNow(agent)`,供 `/compact` 手动压缩上下文 | | `ctx.logger` | 日志(告警 / 错误)输出 | 这些服务都由 **Web Profile**(`dsh-base` + `dsh-web-app`)提供,所以本插件应挂到 `web` Profile。 ### 2.2.1 凭据(App Secret)的解析 插件不把 App Secret 写进配置文件,而是走 DSH 的凭据服务: - 配置里只声明**引用名** `appSecretEnv`(默认 `FEISHU_APP_SECRET`); - `apply()` 里注入 `ctx.credentials`,调用 `credentials.resolve(credentialRef(appSecretEnv))` 拿到真正的值; - 值存在 DSH 的凭据文件 `~/.dsh/.credentials.yaml`(键名 = 引用名)。 DSH 的凭据服务内置了分层解析(环境变量 > `.credentials.yaml` > 项目 `.env` > 用户 `.env`)、 热更新和写保护,插件因此复用平台能力,不再自己读环境变量 / 文件。 ### 2.3 Agent 的创建 每条飞书会话首次收到消息时,通过 `ctx.agents.create()` 创建一个 Agent: ```js const selection = this.channelModel; // 渠道级模型(provider/model/reasoningEffort) const preset = await agentPresets.resolve(presetId); // 默认 Preset const handle = await agents.create({ sessionId, // 由会话 key 派生 meta: { cwd, agentPreset: preset.id }, agentOptions: selection, setup: async (agentCtx) => { installModelSelection(agentCtx, { current: selection, assembled: undefined }); await agentPresets.mount(agentCtx, preset.id); // 挂载 Preset(拿到工具/系统提示) }, }); ``` `channelModel` 是**共享的可变对象**:`installModelSelection` 每次请求都会重新读它,因此 `/model`、`/effort` 原地改它之后,已存在 Agent 的**下一轮请求即生效**,无需重置。 关键点:`setup(agentCtx)` 是“组合期”回调,在 Agent 发布前运行;在这里挂载 Preset 和模型选择,能保证工具/提示在第一个请求前就绪。之后对同一条会话复用该 handle, 不再重复创建。 ### 2.4 提交消息与等待完成 ```js await agent.whenIdle(); // 等上一个 turn 结束(串行化兜底) const firstSeq = agent.session.seq; // 记下本轮开始前的事件序号 agent.followup(createUserMessage({ // 投递一条用户消息,唤醒 driver content: [{ type: "text", text }], source: { kind: "user" }, })); await agent.whenIdle(); // 等本轮 turn 完成 await sessions.flush(agent.session); // 触发持久化 ``` 然后从 `agent.session.events` 里、只统计 `seq >= firstSeq` 的事件,取最后一个非空的 `assistant/message` 文本作为最终回答。 ### 2.5 进度回传(执行过程可见) `HarnessBridge.onSessionEvent()` 订阅全局 `session/event` 火线(`ctx.on("session/event", ...)`)。 每当某个 Session 追加事件,就检查它是否属于“正在进行中的 turn”(按 session id + `seq >= startSeq` 过滤),是的话转发两类过程事件: ```js if (event.type === "tool/call") { onProgress({ kind: "tool", name: event.data.name, args: truncate(event.data.arguments, 200) }); } if (event.type === "assistant/message" && 带工具调用的中间回复) { onProgress({ kind: "assistant", text: truncate(text, 2000) }); // 💬 中间回复 } ``` - **`tool/call`** → `🔧 调用工具 `:在“模型决定调用工具、工具真正执行前”就落盘, 是最早、最稳定的进度信号。 - **`assistant/message` 且含 tool-call** → `💬 <文本>`:模型“说一句、再继续调工具”的中间回复。 最终回答(无工具调用)不走这里,由 `_process` 通过回复通道发送,避免重复。 上层 `lib/index.js` 把这两类过程消息放进**全局节流队列**(串行 + 每条 ≥1 秒间隔)发送: 飞书对机器人发消息有频控,多工具并行调用时桥会在几十毫秒内连发多条,直接发会被飞书拒绝 而静默丢失;节流后不会丢,且发送顺序天然稳定。最终回答前会 `await` 排空队列,保证 “中间过程 → 最终回答”顺序不乱。`streamProgress` 是总开关,`maxProgressMessages` (默认 0 = 不限制,设正数才限条数)供需要限流的部署自行收紧。 > 之所以选 `tool/call` 作为进度信号:它在“模型决定调用工具、工具真正执行前”就落盘, > 能最早、最稳定地反映“Agent 正在干什么”。`assistant/chunk`(流式 token)也可用于 > 更细粒度的流式回显,但 MVP 出于消息条数控制暂未启用。 ### 2.6 自主轮次的结果推送(长任务场景) 桥只会转发“飞书消息直接触发的那一轮”的最终回答。为了支持长任务(目标轮询、定时任务、 后台任务唤醒 Agent 后再产出结果),`onSessionEvent()` 额外监听 `turn/end`: - `sessionChats` 维护「session id → 飞书聊天」的映射(`_process` / `switchMode` / `runNativeCommand` / `_adopt` 都会登记;`/reset`、`/session` 切换时会清除旧映射); - 当 `turn/end` 到达时,若该会话**没有**桥登记的进行中 turn(`activeTurns` 无条目), 说明这是 DSH 自主唤醒的轮次——`forwardAutonomousTurn()` 取最后一个 `turn/start` 为界, 汇总出最终文本,通过 `deps.onAutonomousResult` 回调推给飞书; - 只推“正常完成”的轮次,出错 / 中止不推,避免噪音。 桥发起轮次的 `turn/end` 在事件派发时 `activeTurns` 仍有登记(`session.append` 同步通知 观察者,先于 `whenIdle()` 返回),因此不会与 `_process` 的汇总重复。 ### 2.7 选项问答(`ask_user_question`)在飞书可交互 原版 `ask_user_question` 工具走 `ctx.userQuestions.ask()` → 网页端 UI 提供方;飞书聊天没有 浏览器卡片可点,答案无法命中。桥的解法是**在 agent 作用域影子注册同名工具**(`mountAskTool()`, 经 `agentCtx.tools.register`,agent 层覆盖 preset 层): - 模型调用时,桥把问题格式化成 `❓` 列表(选项编号 + 提示语)发到飞书(走节流队列), 并挂起一个 `pendingQuestions: sessionId → { questions, settle }` 的 promise; - 用户在飞书回复后,`handleMessage` 先走 `answerQuestion()`:非命令消息解析为答案并喂回工具。 解析规则(`parseQuestionAnswer`):**纯数字回复 = 按编号选择**——编号全部在范围内且(多选或恰好 一个)才命中;单选给了多个、编号越界等一律判定**无效并提示重答**(问题保持挂起,不给模型喂 垃圾答案);文字回复先精确匹配选项标签,匹配不到则整段作为自定义文本(`custom`); - turn 被取消(`/stop` / 超时)时,`exec.signal` 的 abort 回调拒绝挂起的 promise,避免永久挂起; - 多问题(一次问多条)时按第一条解析,其余留空——模型通常会重新追问,属已知简化。 命令(`/reset`、`/stop` 等)即使在等待回答时也优先按命令处理,不会误当成答案。 ## 3. 与飞书的集成 ### 3.1 长连接(WebSocket) 用官方 SDK 的 `WSClient`: ```js const wsClient = new WSClient({ appId, appSecret, domain, autoReconnect: true, ... }); await wsClient.start({ eventDispatcher: new EventDispatcher({}).register({ "im.message.receive_v1": (data) => onEvent(data), }), }); ``` 长连接模式:建连时鉴权一次,之后事件明文推送,SDK 内部处理多帧合并与去重、断线重连。 ### 3.2 3 秒 ack 约束(重要) SDK 在 `WSClient.handleEventData` 里**先 await 事件处理器的返回值,再向飞书发送 ack**。 飞书要求事件在 **3 秒内 ack**,否则会超时重推。因此: - 事件处理器 `onEvent(data)` 必须**同步快速返回**(只做解析 + 触发异步任务); - Agent 的整个 turn(可能数分钟)放在 `handleMessage()` 里**后台异步执行**,绝不被事件处理器 await。 ### 3.3 消息事件结构与解析 `im.message.receive_v1` 的 `data` 关键字段: ```js { sender: { sender_id: { open_id, user_id?, union_id? }, sender_type }, message: { message_id, chat_id, thread_id?, chat_type /* 'p2p' | 'group' */, message_type /* 'text' | ... */, content /* JSON 字符串 */, mentions: [{ key, id: { open_id, ... }, name }], }, } ``` `parseInbound()` 做三件事: 1. 只接受 `message_type === 'text'`,并 `JSON.parse(content).text` 取正文; 2. 单聊按 `dmMode` 放行/白名单/关闭; 3. 群聊按 `groupAllowlist` + `requireMention`(精确匹配 `botOpenId` 或退化为 “mentions 非空”)过滤。 ### 3.4 发送与回复 - 回复(关联原消息,话题内可 `reply_in_thread`): `client.im.message.reply({ path: { message_id }, data: { msg_type: 'text', content: JSON.stringify({ text }), reply_in_thread } })` - 主动发到会话(用于进度提示): `client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) } })` 两者返回 `{ code, msg, data }`,`code !== 0` 视为失败。 ## 4. 会话映射 - 会话 key:普通聊天 `chat:`;话题 `thread::`(话题各自独立)。 - Session ID:`feishu-` + `SHA-256(domain + "\0" + key)` 前 24 位 + 12 位随机 nonce。 - 摘要前缀让 ID 仍可读(能看出属于哪个聊天),又不泄露原始 `chat_id` / `thread_id`; - 随机段保证**每次“新建会话”(首次 or 重置后)都得到全新 ID**,不会和已持久化的旧会话撞号。 - **会话复用**:同一聊天在进程内通过 `handles`(chatKey → Agent)复用同一个 Session; 但用户发 `/reset`(或 `/new`、`重置`)时,机器人**换到一个全新 Session**,旧 Session 保留。 ### 4.1 重置会话(`/reset`)——保留历史、只换新 `lib/index.js` 在把消息提交给模型**之前**先判断它是不是重置指令(配置项 `resetCommand`, 默认 `/reset`,另有 `/new`、`/clear`、`重置` 等别名)。命中则: 1. `bridge.reset(inbound)`:走同一条 per-key 处理链(保证不跟进行中的消息并发), 只从 `handles` 里删掉当前会话的引用,**不 dispose 旧 Agent**。旧 Agent / 旧 Session 继续留在 DSH 的在线列表里(侧边栏照常显示、可查历史),和网页端「新建对话」一致; 旧 Agent 由 DSH 进程生命周期统一回收; 2. 回复一条「已开启新会话」,不把这个词发给模型; 3. 下一条消息 `getOrCreate` 找不到句柄,就用新的随机 nonce 建一个全新 Session。 ### 4.1.1 压缩上下文(`/compact`) 另有配置项 `compactCommand`(默认 `/compact`,别名 `/压缩`):命中则调用 DSH 的 `ctx.compaction.compactNow(agent)`,把较早历史总结成摘要、替换掉被压缩的表面节点, 降低后续请求的 token 占用。适合长会话快撞到模型速率/配额上限时手动瘦身。该能力依赖 进程加载了 compaction 服务(dsh-base 自带);未加载时回退为友好提示。 ### 4.1.2 渠道控制命令 在 `lib/index.js` 里,消息在提交给模型前先做一次「渠道控制命令」匹配(`matchChannelCommand`), 命中即走 `runChannelCommand` 并直接回复,不喂给模型。命令如下: - **`/workspace `**:`workspaceRegistry.create(path)`(创建或复用,路径做 realpath 归一化),写入 `bridge.channelWorkspace` 并 `reset`。项目目录固定在 Session 头(cwd)里, 因此**切目录 = 开新对话**——和用户的直觉一致。 - **`/mode `**:走 `bridge.switchMode(key, name)` → `ctx.permissionPresets.set(session, name)` (记录 `permission/preset` + `sandbox/mode` + `approval/policy` 事件),**仅本会话生效**, `/reset` 后回到默认预设。短名 `read` / `write` / `full` 分别对应 `read-only` / `workspace-write` / `danger-full-access`,全名也接受。切换无二次确认——网页端选 Full access 的「确认弹窗」是纯前端的 UX,服务端 `permissionPresets.set` 本身没有闸门,飞书侧同样直接切。 - **`/model `**:通过 `ctx.llm.listProviders()` + `listModels()` 枚举模型,命中后 `bridge.setChannelModel(...)` **原地更新**共享的 `channelModel`(`installModelSelection` 每次 请求都读它),因此**下一轮即生效、不打断对话**。 - **`/effort `**:通过 `ctx.llm.resolveModelInfo(provider, model)` 读取该模型的 `reasoning.efforts`,**只允许选择模型实际支持的档位**(不支持的给出明确提示并拒绝, 而非静默无效——这是相对“任何模型都能设、不支持就没用”的更稳设计)。同样原地更新、下一轮生效。 - **`/stop`**:`bridge.stop(message)` 直接 `agent.cancel({ kind: "user-stop" })`(**不走 per-key 处理链**,否则会排到任务结束之后)。被取消的 turn 以 `aborted` 结束,`_process` 识别 `reason.reason.kind === "user-stop"` 后抛 `STOPPED`,上层静默(`/stop` 命令本身已回复过), 避免重复回复。 - **`/feedback` `/goal` `/plan`(透传)**:`bridge.runNativeCommand(key, line)` 走 per-key 链, 调 `ctx.commands.execute(agent, line, signal)` 复用 DSH 原生命令实现,把命令返回的 `text` 原样回给飞书。`/permission` 在匹配层归一化为 `/mode`;`/export` 是网页端功能(浏览器下载 ZIP),飞书文字通道只能给出提示、无法下发文件。 - **`/session`(会话切换)**:不带参数时 `bridge.listSessions()` 走 `ctx.sessionQuery.listSessions()` 与 `readTitleSnapshots()`(与网页端同一数据源),排除子代理 / 已归档 / 空会话并带标题列出;带 参数走 `bridge.switchSession()`:live 会话直接复用 `agents.get(id)`,持久化会话用 `agents.resume()` 恢复,成功后覆盖当前句柄(不 dispose 旧会话)。**已归档的会话拒绝接入**(`isArchived`);若当前 聊天接的会话在 DSH 里被归档,下一条消息会经 `detachArchived()` 自动断开并开全新会话。`/session` 会缓存编号 10 分钟,支持 `/session <编号>` 快捷切换。 模型 / 推理强度是**渠道级内存状态**(`bridge.channelModel`,共享可变对象,切换后已存在会话的 下一轮请求即生效);项目目录(`channelWorkspace`)固定在 Session 头里,因此切换目录必须 `reset`。 初始值来自配置(含 `reasoningEffort`,此前会被丢弃的 bug 已修复);DSH 重启后回到配置默认。 ### 4.2 群聊重置的权限控制 - 单聊(`p2p`):发 `/reset` 直接生效; - 群聊(`group`):默认(`requireAdminForGroupReset !== false`)发 `/reset` 前,先调 `client.im.chat.get`(`user_id_type: open_id`)取群的 `owner_id` 与 `user_manager_id_list`, 判断发送者是否**群主/管理员**;非管理员直接回复“只有群主或群管理员才能重置会话”。 该判定需要 `im:chat:readonly` 权限,调用失败时 `isChatAdmin` 不抛异常而是返回 `false` (并记录告警),从而给出明确的权限提示而不是笼统的错误。 这样群里的语义是:**管理员开任务(重置),群成员在共享会话里跟 Agent 协作**,普通成员 不能随手把共享任务清掉。若不需要此限制,配置 `requireAdminForGroupReset: false` 即可。 ## 5. 并发与串行化 同一飞书会话的消息可能快速连续到达。`HarnessBridge.reply()` 用 per-key 的 promise 链 串行化,保证同一会话内“提交 → 等待 idle → 汇总 → 回复”按序执行,不会出现两个 turn 同时改同一个 Session。不同会话(不同聊天/话题)并行,互不干扰。 `getOrCreate()` 用单飞(single-flight)避免同一会话并发创建两个 Agent。 **单轮超时**:`_process` 用 `Promise.race(agent.whenIdle(), 定时器)` 限制单轮时长 (`turnTimeoutMs`,默认 15 分钟);超时会 `agent.cancel({ kind: "turn-timeout" })` 并抛 `TIMEOUT`,上层回“任务执行超过时限,已自动停止”。防止一条卡死的任务永久堵住整个聊天。 **排队反馈**:`reply()` 维护 per-key 的排队计数,上层在收到消息时先回“正在处理 / 排队中”的即时回执(`processingNotice`),避免长任务期间飞书端长时间沉默。 ## 6. 生命周期与卸载 - `apply()` 里 `await channel.connect()`:长连接失败会让插件激活失败(fail loud), 便于第一时间发现凭据/网络问题。 - `ctx.effect(() => async () => { channel.disconnect(); await bridge.dispose(); })`: Profile 卸载 / 进程退出时,先断飞书连接,再销毁所有 Agent 句柄。 ## 7. 错误处理策略 - 内部异常(模型错误、工具失败等)不会把堆栈发给飞书用户,只回统一的 `config.errorMessage`,真实错误写到 `ctx.logger.error`。 - 特例:`STOPPED`(被 `/stop` 取消)静默;`TIMEOUT`(单轮超时)回明确提示。 - 进度回传失败是“尽力而为”,不影响 Agent 执行。 - `onProgress` 回调内异常被 bridge 吞掉,防止同步事件派发被打断。 ## 8. 已知限制(MVP) - 只处理**文本**消息;图片、富文本(post)、文件、卡片等未支持。 - 进度回传覆盖“工具调用开始 + 工具之间的中间回复”,不做 token 级流式回显、不展示工具结果。 - 回答为**一次性发送**,非流式(streaming)输出。 - 没有持久化的“机器人 ↔ 会话”状态恢复:进程重启后,已有的飞书聊天会重新创建 新的 Session(旧的 Session 仍在磁盘,但不再被复用)。若要跨重启复用,需额外按 `chatId → sessionId` 做持久映射。 - 消息事件按 `message_id` 做了业务层幂等去重(10 分钟窗口),防止平台 3 秒超时重推导致 同一消息被重复执行。 - `thread_id` 在 `parseInbound` 里被规范化:飞书对非话题消息可能返回 `null` / 空字符串, 统一归一成 `undefined`,避免把普通消息误判成“话题内消息”(会话 key 与 `reply_in_thread` 都依赖这个判断)。 - 模型 / 推理强度 / 项目目录的运行时切换是**内存态**,DSH 重启后回到配置默认。 - `/export`(导出 ZIP)是 DSH 网页端能力(浏览器下载),飞书文字通道无法下发文件,只能提示到网页端操作。 - 自主轮次结果、工具进度提示通过 `im.message.create` 发送,**话题(thread)内会话的这些消息会发到群聊根消息**(飞书 API 限制,`reply` 才能留在话题内)。 - 单轮执行超过 `turnTimeoutMs`(默认 15 分钟)会被自动取消——真正需要超长单轮的任务建议拆成后台任务 + 自主轮次。 - 单飞书应用只应跑一个长连接实例(平台是集群分发,多实例会丢消息)。 ## 9. 关于“TOKEN 超上限 / 消息没反应”的说明 `deepseek-v4-pro` 等推理模型在 `reasoningEffort: high` 下会消耗大量 token;DeepSeek 官方 API 存在速率(TPM/RPM/并发)与配额上限。当网页端正在跑长任务、或飞书侧频繁/连续发消息时, 新请求可能被 API 限流(表现为回复慢、统一错误提示、或 DSH 轨迹里出现 token/rate 相关错误)。 这不是桥接插件的并发锁(插件内部对同一会话只串行、不同会话并行,无全局互斥),而是外部 API 限制。缓解方式:`/reset` 开新会话、`/compact` 压缩历史、降低 reasoningEffort、或错峰使用。 ## 10. 扩展方向 - 富文本/卡片回复、图片/文件处理。 - token 级流式回显(利用 `client.im.message.reply` 返回的 `message_id` 做 `patch` 更新)。 - 工具结果摘要回传、`todo/write` 任务清单回显。 - 持久化 `chatId → sessionId` 映射,支持跨重启复用会话。 - `message_id` 幂等去重、更完善的群聊 @机器人精确匹配(自动拉取 bot open_id)。 - 安全增强:每用户/每群配额、敏感词过滤、命令白名单。