diff --git a/docs/agent-runtime-developer-guide/09-platform-adapter-development-guide.md b/docs/agent-runtime-developer-guide/09-platform-adapter-development-guide.md index e8bc0e1e..d8dc55c5 100644 --- a/docs/agent-runtime-developer-guide/09-platform-adapter-development-guide.md +++ b/docs/agent-runtime-developer-guide/09-platform-adapter-development-guide.md @@ -11,6 +11,7 @@ - Nine1Bot 产品层负责平台启用状态、配置、secret、status、action、Web 设置页和 runtime 注册。 - Browser extension 只在用户发送消息时采集页面事实,不做后台监听,也不承担复杂平台业务解释。 - 平台贡献 context / resources / agents / skills 必须经过 Platform Adapter Manager 和 runtime registry,不能绕过 Manager。 +- 平台长连接、webhook consumer、轮询器等长期运行任务必须通过通用 Platform Background Service lifecycle 管理,不要放进 `PlatformRuntimeAdapter.createAdapter()`,也不要在 launcher 中新增平台专属启动分支。 - 平台禁用是 hard gate:新 session 不使用该平台 template/resource/source,旧 session 后续 turn 也不能继续获得该平台能力。 - 平台资源默认应使用 `declared-only` 或 `recommendable`,避免污染普通 Web 对话和 default-user-template。 @@ -29,10 +30,14 @@ flowchart TD Manager --> PlatformRegistry["RuntimePlatformAdapterRegistry"] Manager --> SourceRegistry["RuntimeSourceRegistry"] + Manager --> BackgroundHost["Platform Background Service Host"] PlatformRegistry --> TemplateResolver["ControllerTemplateResolver"] PlatformRegistry --> ContextEvents["RuntimeContextEvents"] PlatformRegistry --> ResourceResolver["RuntimeResourceResolver"] + BackgroundHost --> BackgroundHandles["PlatformBackgroundServiceHandle[]"] + Launcher["Nine1Bot launcher"] --> BackgroundHost + BackgroundHost --> PlatformAPI SourceRegistry --> AgentRegistry["Agent Registry"] SourceRegistry --> SkillRegistry["Skill Registry"] @@ -50,18 +55,29 @@ flowchart TD - `PlatformAdapterContribution` 是平台包对外提供能力的统一入口。 - `RuntimePlatformAdapterRegistry` 只接收通用 adapter,不 import 具体平台包。 - `RuntimeSourceRegistry` 只保存 agent / skill source 元数据,文件扫描由 Agent / Skill registry 完成。 +- `Platform Background Service Host` 只管理通用后台服务生命周期,不理解具体平台的 websocket、event stream、polling 或 SDK。 - `profileSnapshot` 是 session 创建时冻结的平台能力声明;后续 turn 只在该声明内做 live gate。 +当前通用架构状态: + +- 配置面:`platforms.` 已承载 enabled、features、settings、secret ref、descriptor-driven settings form。 +- Runtime 面:平台 adapter 已接入 template inference、page context blocks、resource contribution、recommended agent。 +- Source 面:平台 agent / skill sources 已能跟随平台 enabled 状态注册和注销,并通过 declared-only / recommendable 控制默认可见性。 +- 后台面:平台 background services 已能通过 Manager 统一 start / stop / restart,并向平台 status cards 暴露运行状态。 +- Web 面:多平台页面已能通过通用 Platform API 展示 descriptor、status、actions、settings、runtime sources;复杂平台可以后续补 custom section。 + ## 3. 当前实现地图 核心文件: - `packages/platform-protocol/src/index.ts`:平台 descriptor、contribution、config、secret、action、runtime adapter、runtime sources 的共享类型。 - `packages/platform-gitlab/`:当前 GitLab 样板平台包。 +- `packages/platform-feishu/`:当前 Feishu 样板平台包,覆盖 page context、外部 CLI metadata enrichment、platform skills 和 Feishu IM background service。 - `packages/nine1bot/src/platform/manager.ts`:Platform Adapter Manager。 - `packages/nine1bot/src/platform/builtin.ts`:内置平台 contribution 清单。 - `packages/nine1bot/src/platform/config-store.ts`:只更新 `nine1bot.config` 中 `platforms` 字段的持久化 helper。 - `packages/nine1bot/src/platform/secrets.ts`:本地平台 secret store。 +- `packages/nine1bot/src/launcher/orchestrator.ts`:启动本地 server 后创建通用 `PlatformControllerBridge`,并启动 / 停止内置平台 background services。 - `opencode/packages/opencode/src/runtime/platform/adapter.ts`:Runtime 平台 adapter registry。 - `opencode/packages/opencode/src/runtime/source/registry.ts`:Runtime agent / skill source registry。 - `opencode/packages/opencode/src/agent/agent.ts`:内置 / 用户 / 平台 agent 扫描和可见性控制。 @@ -90,6 +106,11 @@ packages/platform-/ runtime.ts shared.ts types.ts + node.ts + im/ + runtime.ts + gateway.ts + ... agents/ .agent.md skills/ @@ -104,6 +125,8 @@ packages/platform-/ - `shared.ts`:URL 解析、页面类型识别、payload normalize、稳定 `objectKey` 生成。保持纯函数,便于 browser / runtime / test 复用。 - `browser.ts`:浏览器安全入口,供 browser extension 或 Web 使用。不要依赖 Node-only API。 - `runtime.ts`:平台 descriptor、runtime adapter、contribution、status/action handler。 +- `node.ts`:Node-only 聚合入口,可选。用于导出 CLI、SDK、background service 等不能进入 browser bundle 的能力。 +- `im/`:平台 IM / event consumer / webhook consumer 等长期运行能力,可选。只能通过 background service 暴露到 Nine1Bot 产品层。 - `types.ts`:平台内部类型,可以复用或 alias `@nine1bot/platform-protocol` 类型。 - `agents/`:平台自带 agent source,可选。文件格式是 `*.agent.md`。 - `skills/`:平台自带 skills source,可选。每个 skill 目录包含 `SKILL.md`。 @@ -310,7 +333,7 @@ export function createExamplePlatformAdapter(): PlatformRuntimeAdapter { ## 7. Contribution -平台包通过 `PlatformAdapterContribution` 把 descriptor、runtime adapter、runtime sources、status、config validation 和 actions 交给 Platform Manager。 +平台包通过 `PlatformAdapterContribution` 把 descriptor、runtime adapter、runtime sources、background services、status、config validation 和 actions 交给 Platform Manager。 ```ts import type { PlatformAdapterContribution } from '@nine1bot/platform-protocol' @@ -340,6 +363,24 @@ export const examplePlatformContribution = { ], }, }, + backgroundServices(ctx) { + if (!isExampleRealtimeEnabled(ctx.settings)) return [] + return [ + { + id: 'example-realtime', + async start(serviceCtx) { + return startExampleRealtimeService({ + localUrl: serviceCtx.localUrl, + authHeader: serviceCtx.authHeader, + controller: serviceCtx.controller, + settings: serviceCtx.settings, + secrets: serviceCtx.secrets, + legacySettings: serviceCtx.legacySettings, + }) + }, + }, + ] + }, async validateConfig(settings, ctx) { return { ok: true } }, @@ -375,14 +416,81 @@ export const examplePlatformContribution = { - `secrets` - `audit` +`PlatformBackgroundServiceContext` 在 `PlatformAdapterContext` 基础上额外提供: + +- `localUrl` +- `authHeader` +- `controller` +- `legacySettings` +- `projectId` +- `projectDirectory` + 注意: - `settings` 中的 secret 字段应是 `PlatformSecretRef`,不要假设有明文。 - `handleAction` 返回 `openUrl` 时只允许 `http:` / `https:`。 - `danger: true` 的 action 需要请求体带 `confirm: true`。 - status/action/validation 失败要返回结构化结果或抛错,Manager 会转成 `error/degraded` 状态。 +- `backgroundServices` 只声明服务,不应立即启动连接;真正启动由 Platform Manager 的 background service host 完成。 -## 8. 注册到 Nine1Bot +## 8. Background Services + +`PlatformBackgroundService` 用于平台需要长期运行的能力,例如 IM websocket、event stream consumer、webhook subscriber、平台轮询器、外部同步任务等。它解决的是平台 adapter 生命周期和长期连接生命周期不同步的问题:adapter 注册/注销可能因为配置保存、status refresh 或 runtime registry 重建而发生;长期连接如果放进 `createAdapter()`,很容易重复连接或泄漏 handle。 + +当前通用协议: + +```ts +export type PlatformBackgroundServiceContext = PlatformAdapterContext & { + localUrl: string + authHeader?: string + controller?: PlatformControllerBridge + legacySettings?: Record +} + +export type PlatformBackgroundServiceHandle = { + stop(): Promise + getStatus?(): PlatformRuntimeStatus +} + +export type PlatformBackgroundService = { + id: string + start(ctx: PlatformBackgroundServiceContext): Promise +} +``` + +生命周期: + +1. Nine1Bot launcher 启动本地 server。 +2. launcher 计算 `localUrl`、`authHeader`,创建通用 `PlatformControllerBridge`。 +3. launcher 调用 `startBuiltinPlatformBackgroundServices()`,只接入通用 lifecycle。 +4. Platform Manager 先停止旧 background handles,再遍历 enabled platform 的 `backgroundServices(ctx)`。 +5. 每个 service `start()` 返回 handle;如果 handle 提供 `getStatus()`,Manager 会把它合并到平台 runtime status。 +6. 配置重载、平台禁用、Manager reconfigure、应用 shutdown 时,Manager 调用所有 active handle 的 `stop()`。 +7. `stop()` 失败只写 audit warning,不阻塞其它服务停止。 + +实现规则: + +- `PlatformRuntimeAdapter.createAdapter()` 只负责 page context、template、resource contribution、recommended agent 等纯 runtime adapter 能力。 +- background service 可以调用 `ctx.controller.requestJson()` 访问 Nine1Bot 公开 Controller API,但不能 import `packages/nine1bot` 内部模块或 opencode server 内部实现。 +- background service 可以读取 `ctx.legacySettings` 做旧配置兼容,但新配置入口仍必须走 `platforms..settings`。 +- background service 必须支持幂等停止;重复 start 之前 Manager 会先 stop 旧 handle,但 service 自身也应避免全局单例泄漏。 +- 如果平台处于 staged/degraded 状态,例如旧实现仍占用 websocket,service 可以返回 degraded status,不需要抛错。 +- Node-only SDK、websocket client、文件 watcher 等依赖必须留在平台包 Node 入口或后台服务模块,不要从 `./browser` 导出。 +- 长期运行状态应通过 `getStatus()`、`getStatus(ctx)` 或平台 telemetry 体现在 Web 多平台详情页的 status cards / recent events 中。 + +适用场景: + +| 场景 | 放置位置 | +| --- | --- | +| URL parser、context block、template 推断 | `PlatformRuntimeAdapter` | +| 平台 agent / skill 目录注册 | `runtime.sources` | +| 连接测试、登录引导、一次性诊断 | `handleAction()` | +| IM websocket、event consumer、watcher、poller | `backgroundServices()` | +| 旧顶层配置兼容 | config normalization + `legacySettings` | + +当前样板是 Feishu IM:`platform-feishu` 通过 `backgroundServices` 声明 `feishu-im` 服务,服务读取多平台 Feishu IM 配置并暴露 staged/degraded status;真实 websocket cutover 前不会抢占旧 `feishu.enabled` 链路。 + +## 9. 注册到 Nine1Bot 新增平台后,只在 Nine1Bot 产品层内置清单中注册 contribution。 @@ -406,7 +514,9 @@ export const builtinPlatformContributions = [ 启动时 Nine1Bot 会根据 `fullConfig.platforms` 调用 Manager 注册启用的平台。禁用的平台会被登记到 `RuntimePlatformAdapterRegistry` 的 disabled marker,用于后续 template/page live gate。 -## 9. 配置与 Secret +本地 server 启动完成后,launcher 会调用 `startBuiltinPlatformBackgroundServices()` 启动 enabled 平台声明的 background services。launcher 只传入 `localUrl`、`authHeader`、通用 `PlatformControllerBridge` 和 legacy settings map,不写具体平台分支。 + +## 10. 配置与 Secret 用户配置位于 `nine1bot.config` 的 `platforms` 字段: @@ -435,10 +545,14 @@ export const builtinPlatformContributions = [ 规则: - `platforms` 是 Nine1Bot-only 配置,不会进入生成给 Runtime 的 opencode config。 +- 配置读写、secret 写入、Web UI 保存、脱敏返回、配置刷新、平台启停都由 Platform Adapter Manager 统一负责。 +- 平台适配包只声明 `PlatformDescriptor.config`、实现 `validateConfig()`,并在运行时把 `ctx.settings` normalize 成自己的业务结构。 +- `PlatformRuntimeAdapter.createAdapter(ctx)`、`backgroundServices(ctx)`、`getStatus(ctx)`、`handleAction(ctx)` 都只能消费 Manager 传入的 `ctx.settings/features/secrets`,不能直接读写 `nine1bot.config` 或实现自己的 Web 保存逻辑。 +- 平台 action 如需修改配置,应返回 `updatedSettings`,由 Manager 走同一套校验、secret 处理、持久化和 reconfigure 流程。 - secret 真值写入本地 `platform-secrets.json` 或通过 `env/external` 引用,不进入 `nine1bot.config`。 - Platform API 响应中 secret 字段只返回 `{ redacted: true, hasValue, provider }`。 - `PATCH /nine1bot/platforms/:id` 中 secret 字段传空字符串表示保留旧值,传 `null` 表示删除。 -- `enabled: false` 会立即注销 adapter 和 runtime sources。 +- `enabled: false` 会立即注销 adapter、runtime sources,并停止该 Manager 当前持有的 background service handles。 认证差异应通过平台 action 和 settings 表达,不要新增固定的 `/auth/start` / `/auth/complete` 路由。例如: @@ -446,7 +560,7 @@ export const builtinPlatformContributions = [ - OAuth:`auth.open` action 返回 `openUrl` - 外部 CLI:`cli.status`、`cli.login.openGuide`、`cli.refresh` -## 10. Browser / Web 页面上下文 +## 11. Browser / Web 页面上下文 Browser extension 的职责是请求式采集页面事实: @@ -457,6 +571,10 @@ Browser extension 的职责是请求式采集页面事实: 5. Web 把 payload 放入 Controller message 的 `context.page`。 6. Runtime 通过当前启用的平台 adapter normalize / 写 context event。 +前端只需要传递入口事实和页面事实,不需要在创建 session 前先完成平台 template 推断。创建 session / 发送消息的主路径可以直接携带 `entry.source`、`entry.mode`、`entry.platform` 和 `page`,由后端 `ControllerTemplateResolver.resolve()` 调用已注册的平台 adapter 推断最终 template,并根据 Manager 的 enabled 状态做 live gate。 + +`POST /nine1bot/agent/templates/resolve` 用于 UI preview、诊断和用户选择前确认,例如展示最终 `templateIds`、context preview、resources preview、recommended agent 和 skipped audit。它不是创建 session 的前置必需步骤。Web client 可以内置入口级语义,例如 `web-chat`、`browser-sidepanel`、`scheduled-run`,但平台专属 template id 映射应放在平台 runtime adapter 的 `inferTemplateIds()` 中,不应在 Web client 里维护另一套平台 template 表。 + 创建 session 时可以携带 page,用于推导 session template: ```json @@ -464,8 +582,7 @@ Browser extension 的职责是请求式采集页面事实: "entry": { "source": "browser-extension", "platform": "example", - "mode": "browser-sidepanel", - "templateIds": ["web-chat", "browser-generic"] + "mode": "browser-sidepanel" }, "page": { "platform": "example", @@ -503,7 +620,7 @@ Browser extension 的职责是请求式采集页面事实: 如果平台被禁用,后端会按 disabled marker 跳过平台 adapter,并在 audit/debug 中写 `platform-disabled-by-current-config`。 -## 11. 平台 Resources +## 12. 平台 Resources 平台资源贡献发生在 session 创建阶段: @@ -546,7 +663,7 @@ resourceContributions(input) { } ``` -## 12. 平台 Skills +## 13. 平台 Skills 平台可以自带 skills,但默认不进入普通会话。 @@ -595,7 +712,7 @@ platform.. 这样可以避免覆盖用户或内置技能。 -## 13. 平台 Agents +## 14. 平台 Agents 平台可以自带 agents,用于平台特定工作流。agent 会影响系统提示词和权限边界,因此只能在 session 创建时选择并冻结。 @@ -656,7 +773,7 @@ runtime: { - 每轮 `body.agent` override 继续忽略,只 audit,不改变 `profileSnapshot.agent`。 - 平台禁用后,旧 session 如果 profile 中冻结的是该平台 agent,本轮会 fail closed,并发出 `runtime.agent.unavailable`。 -## 14. Web 多平台设置页 +## 15. Web 多平台设置页 Web 通过 `/nine1bot/platforms` API 消费 descriptor: @@ -676,6 +793,7 @@ Web 通过 `/nine1bot/platforms` API 消费 descriptor: - action list - recent events - runtime sources 摘要 +- background service 暴露的 status cards / recent events 如果平台需要复杂 UI,可以在 descriptor 中声明 custom section: @@ -694,12 +812,13 @@ detailPage: { 自定义组件仍应通过统一 Platform API 保存配置、执行 action、刷新状态。 -## 15. Live Gate 与禁用语义 +## 16. Live Gate 与禁用语义 平台禁用后: - Manager 注销 runtime adapter。 - Manager 注销 runtime sources。 +- Manager 停止 background services。 - Manager 在 `RuntimePlatformAdapterRegistry` 里登记 disabled marker。 - 新 session 不会使用该平台 template/context/resource contribution。 - 旧 session 历史 context event 不删除。 @@ -721,7 +840,7 @@ platform-disabled-by-current-config - runtime debug - 相关 runtime event -## 16. 新增平台开发步骤 +## 17. 新增平台开发步骤 建议按这个顺序做: @@ -729,15 +848,16 @@ platform-disabled-by-current-config 2. 实现 `shared.ts`:URL parser、pageType、objectKey、normalize。 3. 实现 `browser.ts`:浏览器安全 payload builder。 4. 实现 `runtime.ts`:descriptor、adapter、contribution。 -5. 按需增加 `agents/` 和 `skills/`,并在 contribution 中声明 runtime sources。 -6. 在 `packages/nine1bot/src/platform/builtin.ts` 注册 contribution。 -7. 补平台包 parser / adapter 测试。 -8. 补 Manager enable / disable / status / source 测试。 -9. 补 Controller template / page context / resource live gate 测试。 -10. 补 Web API / browser extension 请求路径测试。 -11. 跑完整回归。 +5. 如果平台需要长连接、event consumer 或轮询器,在平台包 Node-only 模块中实现 background service,并由 contribution 声明。 +6. 按需增加 `agents/` 和 `skills/`,并在 contribution 中声明 runtime sources。 +7. 在 `packages/nine1bot/src/platform/builtin.ts` 注册 contribution。 +8. 补平台包 parser / adapter / background service 测试。 +9. 补 Manager enable / disable / status / source / background lifecycle 测试。 +10. 补 Controller template / page context / resource live gate 测试。 +11. 补 Web API / browser extension 请求路径测试。 +12. 跑完整回归。 -## 17. 测试清单 +## 18. 测试清单 ### 平台包测试 @@ -756,6 +876,7 @@ packages/platform-/test/-platform.test.ts - resource contribution。 - runtime page context blocks。 - runtime source descriptor 路径与可见性。 +- background service staged / degraded / stopped status。 ### Platform Manager 测试 @@ -773,6 +894,9 @@ packages/nine1bot/src/platform/manager.test.ts - settings / features / secrets 会传给 contribution context。 - status / action 失败不会影响其它平台。 - runtime sources 随平台 enabled / disabled 注册和注销。 +- background services 只为 enabled 平台启动。 +- 重复启动、配置刷新、平台禁用和 shutdown 会停止旧 handles,且 stop 幂等。 +- background service start 失败会写平台 error/degraded status,不影响其它平台。 ### Runtime 测试 @@ -810,10 +934,12 @@ packages/browser-extension/test/-page-context.test.ts - 平台设置走 `/nine1bot/platforms` API。 - 普通 Web 对话不携带 page context。 - browser extension 环境发送消息时携带 `context.page`。 +- 创建 session 主路径只传 `entry + page` 也能由后端 resolve 出平台 template,不要求前端预填平台专属 `templateIds`。 +- `POST /nine1bot/agent/templates/resolve` 可用于 UI preview / debug,但不是创建 session 前的必需调用。 - disabled 平台不会影响普通 Web 对话。 - runtime event 能表达 resource / agent unavailable。 -## 18. 推荐回归命令 +## 19. 推荐回归命令 平台适配改动建议至少跑: @@ -834,7 +960,7 @@ git diff --check 如果平台改动涉及 session profile、权限、MCP、artifact、interaction 或 legacy API,还要补跑对应的 session / server 测试。 -## 19. PR Checklist +## 20. PR Checklist 新增平台 PR 合入前确认: @@ -842,24 +968,32 @@ git diff --check - 平台包导出 `./browser` 和 `./runtime`。 - descriptor 中 `id`、`capabilities.templates`、`config`、`detailPage`、`actions` 清晰。 - contribution 不反向依赖 Nine1Bot 产品层。 +- background service 如需 Node-only SDK 或长连接,必须留在平台包 Node/runtime 入口,不从 `./browser` 导出。 - Nine1Bot 只在 `builtinPlatformContributions` 注册 contribution。 - runtime core 没有 import 具体平台包。 - Browser extension 只请求式采集页面,不后台轮询。 - Web 普通对话不受平台适配影响。 - 禁用平台后不会贡献 template/resource/context/source。 +- 禁用平台或配置刷新后不会遗留 background service handle。 - 禁用后重新启用能恢复 adapter 和 sources 注册。 - 平台 skills 默认不进入普通会话。 - 平台 agents 不进入 `defaultAgent()`。 - Secret 字段不进入 config 明文、profileSnapshot、turn snapshot、runtime event、debug payload。 - 测试覆盖 parser、template、context、resource、manager、web/extension 请求路径。 -## 20. 常见错误 +## 21. 常见错误 - 把 GitLab / Jira / GitHub 等平台逻辑写进 runtime core。 - 在平台包里直接注册 runtime adapter。 +- 在 `PlatformRuntimeAdapter.createAdapter()` 中启动 websocket、watcher、event consumer 或轮询器。 +- 在 Nine1Bot launcher / supervisor 中加入 Feishu、GitLab、Jira 等平台专属启动分支;应改用通用 background service lifecycle。 +- 在平台包里直接读取、修改或保存 `nine1bot.config`,或者为某个平台单独实现 Web UI 保存接口;平台包只声明配置 schema、校验和 normalize。 +- 在 `PlatformRuntimeAdapter` 里承担配置管理职责。adapter 应消费 `ctx.settings`,配置生命周期由 Platform Adapter Manager 管理。 +- 在 Web client 中维护平台专属 template id 映射,导致前端和 runtime adapter 出现两套平台解释。 - 把平台 skill 放进全局 built-in skills,导致普通会话默认继承。 - 用平台 template 静默切换用户模型。 - 每轮消息用 `body.agent` 切换平台 agent。 - 平台禁用后只隐藏 Web 设置页,没有注销 adapter/source。 - 将 token 明文写进 config、audit、debug 或 runtime event。 - browser extension 做后台页面同步,造成 context 污染和 busy 语义不一致。 +- background service 直接 import `packages/nine1bot` 产品层或 opencode server 内部模块,而不是通过公开 Controller API / bridge 调用。 diff --git a/docs/agent-runtime-developer-guide/README.md b/docs/agent-runtime-developer-guide/README.md index 4c53504e..2d86289e 100644 --- a/docs/agent-runtime-developer-guide/README.md +++ b/docs/agent-runtime-developer-guide/README.md @@ -30,6 +30,7 @@ - 权限、交互、文件、图片、预览、资源失败等能力通过 runtime event envelope 表达。 - 第三方平台深度适配应放在 `packages/platform-*`,runtime core 只保留通用 registry / protocol / pipeline,不直接写 GitLab、Jira、GitHub 等平台语义。 - 平台适配的启用、禁用、状态展示和平台自有配置页由 Nine1Bot 产品层 Platform Adapter Manager 负责,Web 配置页应提供“多平台适配 > 具体平台”的可扩展入口。 +- 平台长连接、IM、event consumer、轮询器等长期运行任务应通过通用 Platform Background Service lifecycle 管理,不放进 runtime adapter,也不在 launcher 中写平台专属启动逻辑。 ## 不包含的内容 diff --git a/packages/nine1bot/src/feishu/service.ts b/packages/nine1bot/src/feishu/service.ts deleted file mode 100644 index 2eabd007..00000000 --- a/packages/nine1bot/src/feishu/service.ts +++ /dev/null @@ -1,1415 +0,0 @@ -import { stat } from 'fs/promises' -import { extname, isAbsolute, resolve } from 'path' -import { Readable } from 'stream' -import { AppType, Client, Domain, EventDispatcher, LoggerLevel, WSClient } from '@larksuiteoapi/node-sdk' -import type { AuthConfig, FeishuConfig } from '../config/schema' -import { FeishuBindingStore } from './store' -import { getFeishuBindingStorePath } from './store' -import type { FeishuConversationBinding, FeishuEventDedupEntry, FeishuMessageLockState } from './types' - -const BUSY_TEXT = '当前会话正在处理中,请稍后再发,或发送 /new 新开会话。' -const UNSUPPORTED_TEXT = '目前飞书私聊只支持文本、图片和文件消息。' -const UNKNOWN_COMMAND_TEXT = [ - '当前支持的命令:', - '/new 新建对话', - '/cwd 查看当前工作目录', - '/cwd 切换工作目录并新建会话', -].join('\n') -const WEB_CONTINUE_TEXT = '这个操作需要在 web 端继续处理。' -const WELCOME_TEXT = [ - '欢迎使用 Nine1Bot 飞书私聊版。', - '你可以直接给我发消息开始对话,也可以发送图片或文件。', - '', - '常用命令:', - '/new 新建对话', - '/cwd 查看当前工作目录', - '/cwd 切换工作目录并新建会话', -].join('\n') -const PROJECT_COMMAND_TEXT = [ - '/project 查看当前项目', - '/project list 查看可切换的项目', - '/project 切换项目并新建会话', -].join('\n') -const IMAGE_LIMIT_BYTES = 20 * 1024 * 1024 -const FILE_LIMIT_BYTES = 10 * 1024 * 1024 -const DEDUP_TTL_MS = 10 * 60 * 1000 -const PROGRESS_FLUSH_MS = 2500 -const EVENT_RETRY_DELAY_MS = 1000 -const PROJECT_LIST_LIMIT = 12 -const SESSION_COMPLETION_TIMEOUT_MS = 10 * 60 * 1000 -const FEISHU_CONTROLLER_CAPABILITIES = { - interactions: false, - permissionRequests: false, - questionRequests: false, - artifacts: false, - filePreview: false, - resourceFailures: true, - continueInWeb: true, -} - -type FeishuMessageEvent = { - event_id?: string - uuid?: string - sender: { - sender_id?: { - open_id?: string - } - sender_type: string - } - message: { - message_id: string - chat_id: string - chat_type: string - message_type: string - content: string - } -} - -type FeishuP2PChatCreateEvent = { - event_id?: string - uuid?: string - chat_id: string - user?: { - open_id?: string - } -} - -type IncomingAttachment = { - filename: string - mime: string - url: string -} - -type NormalizedMessage = { - openId: string - chatId: string - messageId: string - text: string - attachments: IncomingAttachment[] -} - -type ActiveSessionContext = FeishuMessageLockState & { - chatId: string - directory: string - progressBuffer: string - progressTimer?: ReturnType - assistantMessageIds: Set - partLengths: Map - needsWebContinuation: boolean - completion: Promise - resolveCompletion: () => void - rejectCompletion: (error: Error) => void - completed: boolean - turnSnapshotId?: string -} - -type LocalSessionInfo = { - id: string - projectID?: string - directory: string -} - -type ControllerSessionCreateResponse = { - sessionId: string - session: LocalSessionInfo - profileSnapshotId?: string - agent?: string - currentModel?: { - providerID: string - modelID: string - } -} - -type LocalProjectInfo = { - id: string - name?: string - worktree?: string - rootDirectory?: string - time?: { - updated: number - } -} - -type AssistantResult = { - parts: Array<{ - type: string - text?: string - synthetic?: boolean - }> -} - -type LocalMessage = AssistantResult & { - info: { - id: string - role: 'user' | 'assistant' - sessionID: string - } -} - -type ControllerMessageResponse = { - accepted: boolean - sessionId: string - turnSnapshotId?: string - busy?: boolean -} - -type SessionPartPayload = { - id: string - type: string - messageID: string - sessionID: string - synthetic?: boolean - text?: string -} - -type GlobalEventEnvelope = { - directory?: string - payload: { - type: string - properties: Record - } -} - -type PendingPermission = { - id: string - sessionID: string -} - -type PendingQuestion = { - id: string - sessionID: string -} - -type FeishuLogger = { - error: (...msg: any[]) => void | Promise - warn: (...msg: any[]) => void | Promise - info: (...msg: any[]) => void | Promise - debug: (...msg: any[]) => void | Promise - trace: (...msg: any[]) => void | Promise -} - -export interface FeishuServiceHandle { - stop(): Promise -} - -function toBindingKey(openId: string): string { - return `feishu:dm:${openId}` -} - -function trimWrappedQuotes(input: string): string { - if (input.length >= 2) { - const first = input[0] - const last = input[input.length - 1] - if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { - return input.slice(1, -1) - } - } - return input -} - -function headerValue(headers: any, key: string): string | undefined { - if (!headers) { - return undefined - } - - const direct = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()] - if (Array.isArray(direct)) { - return direct[0] - } - if (typeof direct === 'string') { - return direct - } - if (typeof headers.get === 'function') { - return headers.get(key) ?? headers.get(key.toLowerCase()) ?? undefined - } - return undefined -} - -function sanitizeChatText(text: string): string { - return text.replace(/\r\n/g, '\n').trim() -} - -function chunkText(text: string, maxLength = 4000): string[] { - const normalized = sanitizeChatText(text) - if (!normalized) { - return [] - } - - if (normalized.length <= maxLength) { - return [normalized] - } - - const chunks: string[] = [] - let remaining = normalized - while (remaining.length > maxLength) { - let splitAt = remaining.lastIndexOf('\n', maxLength) - if (splitAt < Math.floor(maxLength / 2)) { - splitAt = remaining.lastIndexOf(' ', maxLength) - } - if (splitAt < Math.floor(maxLength / 2)) { - splitAt = maxLength - } - chunks.push(remaining.slice(0, splitAt).trim()) - remaining = remaining.slice(splitAt).trim() - } - if (remaining) { - chunks.push(remaining) - } - return chunks -} - -function getProjectDisplayName(project: Pick): string { - return project.name || project.rootDirectory || project.worktree || project.id -} - -function getProjectDirectory(project: Pick): string | undefined { - return project.rootDirectory || project.worktree -} - -function inferMimeFromFilename(filename: string, fallback = 'application/octet-stream'): string { - const ext = extname(filename).toLowerCase() - const byExt: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.svg': 'image/svg+xml', - '.pdf': 'application/pdf', - '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.xls': 'application/vnd.ms-excel', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - '.ppt': 'application/vnd.ms-powerpoint', - '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - '.txt': 'text/plain', - '.md': 'text/markdown', - '.json': 'application/json', - '.csv': 'text/csv', - '.zip': 'application/zip', - } - return byExt[ext] || fallback -} - -function getBufferMime(headers: any, fallbackFilename: string, fallback = 'application/octet-stream'): string { - const contentType = headerValue(headers, 'content-type') - if (contentType) { - return contentType.split(';')[0].trim() - } - return inferMimeFromFilename(fallbackFilename, fallback) -} - -function extractFilenameFromDisposition(value?: string): string | undefined { - if (!value) { - return undefined - } - const match = value.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i) - return decodeURIComponent(match?.[1] || match?.[2] || '') -} - -async function readableToBuffer(stream: Readable, limitBytes: number): Promise { - const chunks: Buffer[] = [] - let total = 0 - - for await (const chunk of stream) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - total += buffer.length - if (total > limitBytes) { - throw new Error(`Attachment exceeds limit of ${limitBytes} bytes`) - } - chunks.push(buffer) - } - - return Buffer.concat(chunks) -} - -function getFinalAssistantText(result: AssistantResult): string { - return sanitizeChatText( - result.parts - .filter((part) => part.type === 'text' && !part.synthetic) - .map((part) => part.text || '') - .join('\n\n'), - ) -} - -async function ensureDirectoryExists(directory: string): Promise { - const stats = await stat(directory) - if (!stats.isDirectory()) { - throw new Error(`Not a directory: ${directory}`) - } - return directory -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -export class FeishuService implements FeishuServiceHandle { - private readonly config: Required> & FeishuConfig - private readonly defaultDirectory: string - private readonly store = new FeishuBindingStore() - private readonly client: Client - private readonly wsClient: WSClient - private readonly localUrl: string - private readonly authHeader?: string - private readonly activeByOpenId = new Map() - private readonly activeBySessionId = new Map() - private readonly recentEvents = new Map() - private stopped = false - private eventStreamAbort?: AbortController - private eventStreamTask?: Promise - - constructor(config: FeishuConfig, options: { localUrl: string; auth?: AuthConfig }) { - if (!config.appId || !config.appSecret) { - throw new Error('Feishu is enabled but appId/appSecret is missing') - } - - this.config = { - ...config, - appId: config.appId, - appSecret: config.appSecret, - mode: config.mode || 'websocket', - } - const baseDirectory = process.env.NINE1BOT_PROJECT_DIR || process.cwd() - this.defaultDirectory = config.defaultDirectory - ? (isAbsolute(config.defaultDirectory) - ? resolve(config.defaultDirectory) - : resolve(baseDirectory, config.defaultDirectory)) - : resolve(baseDirectory) - this.localUrl = options.localUrl - this.authHeader = - options.auth?.enabled && options.auth.password - ? `Basic ${Buffer.from(`nine1bot:${options.auth.password}`).toString('base64')}` - : undefined - - const logger = this.createSdkLogger() - this.client = new Client({ - appId: this.config.appId, - appSecret: this.config.appSecret, - appType: AppType.SelfBuild, - domain: Domain.Feishu, - logger, - loggerLevel: LoggerLevel.info, - }) - - this.wsClient = new WSClient({ - appId: this.config.appId, - appSecret: this.config.appSecret, - domain: Domain.Feishu, - logger, - loggerLevel: LoggerLevel.info, - autoReconnect: true, - }) - } - - async start(): Promise { - await ensureDirectoryExists(this.defaultDirectory) - this.eventStreamAbort = new AbortController() - this.eventStreamTask = this.runGlobalEventStream(this.eventStreamAbort.signal) - - try { - await this.wsClient.start({ - eventDispatcher: new EventDispatcher({}).register({ - p2p_chat_create: async (event: FeishuP2PChatCreateEvent) => { - await this.handleP2PChatCreate(event) - }, - 'im.message.receive_v1': async (event: FeishuMessageEvent) => { - await this.handleEvent(event) - }, - }), - }) - console.log(`[Nine1Bot] 飞书长连接已连接,绑定存储:${getFeishuBindingStorePath()}`) - } catch (error: any) { - this.eventStreamAbort.abort() - console.error(`[Nine1Bot] 飞书长连接失败: ${error?.message || error}`) - throw error - } - } - - async stop(): Promise { - if (this.stopped) { - return - } - this.stopped = true - - this.eventStreamAbort?.abort() - await this.eventStreamTask?.catch(() => undefined) - this.wsClient.close({ force: true }) - - for (const context of this.activeByOpenId.values()) { - if (context.progressTimer) { - clearTimeout(context.progressTimer) - } - } - - this.activeByOpenId.clear() - this.activeBySessionId.clear() - } - - async resolveBinding(openId: string): Promise { - const bindingKey = toBindingKey(openId) - const existing = await this.store.get(bindingKey) - if (existing) { - const valid = await this.requestJson(`/session/${existing.sessionId}`, { - directory: existing.directory, - timeoutMs: 10000, - }).then(() => true).catch(() => false) - - if (valid) { - return existing - } - } - - return this.createAndBindSession(openId, this.defaultDirectory) - } - - async sendSessionPrompt( - sessionId: string, - parts: Array<{ type: 'text'; text: string } | { type: 'file'; filename: string; mime: string; url: string }>, - directory: string, - system?: string, - ): Promise { - return this.requestJson(`/nine1bot/agent/sessions/${encodeURIComponent(sessionId)}/messages`, { - method: 'POST', - directory, - body: { - parts, - ...(system ? { system } : {}), - entry: { - source: 'feishu', - platform: 'feishu', - mode: 'feishu-private-chat', - templateIds: ['default-user-template', 'feishu-chat'], - }, - clientCapabilities: FEISHU_CONTROLLER_CAPABILITIES, - }, - timeoutMs: 30000, - }) - } - - async autoHandlePendingRequests(sessionId: string, directory: string): Promise { - const permissions = await this.requestJson('/permission', { - directory, - timeoutMs: 10000, - }).catch(() => []) - - for (const permission of permissions) { - if (permission.sessionID !== sessionId) { - continue - } - await this.requestJson(`/nine1bot/agent/interactions/${encodeURIComponent(permission.id)}/answer`, { - method: 'POST', - directory, - body: { - kind: 'permission', - answer: 'deny', - message: WEB_CONTINUE_TEXT, - }, - timeoutMs: 10000, - }).catch(() => false) - } - - const questions = await this.requestJson('/question', { - directory, - timeoutMs: 10000, - }).catch(() => []) - - for (const question of questions) { - if (question.sessionID !== sessionId) { - continue - } - await this.requestJson(`/nine1bot/agent/interactions/${encodeURIComponent(question.id)}/answer`, { - method: 'POST', - directory, - body: { - kind: 'question', - answer: 'deny', - }, - timeoutMs: 10000, - }).catch(() => false) - } - } - - async handleEvent(event: FeishuMessageEvent): Promise { - if (this.stopped) { - return - } - - const dedupKey = event.event_id || event.uuid || event.message?.message_id - if (!dedupKey || this.isDuplicateEvent(dedupKey)) { - return - } - - const normalized = await this.normalizeEvent(event) - if (!normalized) { - return - } - - if (this.activeByOpenId.has(normalized.openId)) { - await this.replyText(normalized.chatId, BUSY_TEXT) - return - } - - if (normalized.text.startsWith('/')) { - await this.handleCommand(normalized) - return - } - - await this.handleConversation(normalized) - } - - async handleP2PChatCreate(event: FeishuP2PChatCreateEvent): Promise { - if (this.stopped) { - return - } - - const dedupKey = event.event_id || event.uuid || `p2p:${event.chat_id}:${event.user?.open_id || 'unknown'}` - if (this.isDuplicateEvent(dedupKey)) { - return - } - - const openId = event.user?.open_id - const chatId = event.chat_id - if (!openId || !chatId) { - return - } - - try { - const binding = await this.resolveBinding(openId) - const projectName = await this.getProjectLabel(binding) - await this.replyText(chatId, [ - WELCOME_TEXT, - PROJECT_COMMAND_TEXT, - '', - `当前目录:${binding.directory}`, - `项目:${projectName}`, - `Session:${binding.sessionId}`, - ].join('\n')) - } catch (error: any) { - console.warn(`[Nine1Bot][Feishu] failed to handle p2p_chat_create: ${error?.message || error}`) - await this.replyText(chatId, [WELCOME_TEXT, PROJECT_COMMAND_TEXT].join('\n')).catch(() => undefined) - } - } - - private createSdkLogger(): FeishuLogger { - return { - error: (...msg: any[]) => { - console.error('[Nine1Bot][Feishu]', ...msg) - }, - warn: (...msg: any[]) => { - const rendered = msg.map((item) => String(item)).join(' ') - if (rendered.toLowerCase().includes('reconnect')) { - console.log('[Nine1Bot] 飞书长连接重连中') - return - } - console.warn('[Nine1Bot][Feishu]', ...msg) - }, - info: (...msg: any[]) => { - const rendered = msg.map((item) => String(item)).join(' ') - if (rendered.toLowerCase().includes('reconnect')) { - console.log('[Nine1Bot] 飞书长连接重连中') - } - }, - debug: () => {}, - trace: () => {}, - } - } - - private isDuplicateEvent(eventId: string): boolean { - const now = Date.now() - for (const [key, value] of this.recentEvents) { - if (now - value.createdAt > DEDUP_TTL_MS) { - this.recentEvents.delete(key) - } - } - - if (this.recentEvents.has(eventId)) { - return true - } - - this.recentEvents.set(eventId, { - eventId, - createdAt: now, - }) - return false - } - - private async normalizeEvent(event: FeishuMessageEvent): Promise { - if (event.sender?.sender_type !== 'user') { - return null - } - - if (event.message?.chat_type !== 'p2p') { - return null - } - - const openId = event.sender.sender_id?.open_id - if (!openId) { - return null - } - - const chatId = event.message.chat_id - const messageId = event.message.message_id - const messageType = event.message.message_type - const content = this.parseJson(event.message.content) - - if (messageType === 'text') { - return { - openId, - chatId, - messageId, - text: sanitizeChatText(typeof content?.text === 'string' ? content.text : ''), - attachments: [], - } - } - - if (messageType === 'image') { - const imageKey = typeof content?.image_key === 'string' ? content.image_key : '' - if (!imageKey) { - return null - } - const attachment = await this.downloadMessageAttachment({ - messageId, - resourceKey: imageKey, - resourceType: 'image', - fallbackFilename: `image-${messageId}.png`, - limitBytes: IMAGE_LIMIT_BYTES, - }) - return { - openId, - chatId, - messageId, - text: '', - attachments: [attachment], - } - } - - if (messageType === 'file') { - const fileKey = typeof content?.file_key === 'string' ? content.file_key : '' - if (!fileKey) { - return null - } - const attachment = await this.downloadMessageAttachment({ - messageId, - resourceKey: fileKey, - resourceType: 'file', - fallbackFilename: content?.file_name || `file-${messageId}`, - limitBytes: FILE_LIMIT_BYTES, - }) - return { - openId, - chatId, - messageId, - text: '', - attachments: [attachment], - } - } - - await this.replyText(chatId, UNSUPPORTED_TEXT) - return null - } - - private async handleCommand(message: NormalizedMessage): Promise { - const text = message.text.trim() - - if (text === '/new') { - const binding = await this.resolveBinding(message.openId) - const nextBinding = await this.createAndBindSession(message.openId, binding.directory) - await this.replyText(message.chatId, [ - '已新建对话。', - `目录:${nextBinding.directory}`, - `Session:${nextBinding.sessionId}`, - ].join('\n')) - return - } - - if (text === '/cwd') { - const binding = await this.resolveBinding(message.openId) - const projectName = await this.getProjectLabel(binding) - await this.replyText(message.chatId, [ - `当前目录:${binding.directory}`, - `项目:${projectName}`, - `Session:${binding.sessionId}`, - ].join('\n')) - return - } - - if (text.startsWith('/cwd ')) { - const binding = await this.resolveBinding(message.openId) - const rawInput = trimWrappedQuotes(text.slice(5).trim()) - if (!rawInput) { - await this.replyText(message.chatId, '请提供要切换到的目录。') - return - } - - try { - const targetDirectory = await this.resolveDirectoryInput(binding.directory, rawInput) - const nextBinding = await this.createAndBindSession(message.openId, targetDirectory) - const projectName = await this.getProjectLabel(nextBinding) - await this.replyText(message.chatId, [ - '已切换工作目录,并为你新建了会话。', - `当前目录:${nextBinding.directory}`, - `项目:${projectName}`, - `Session:${nextBinding.sessionId}`, - ].join('\n')) - } catch (error: any) { - await this.replyText(message.chatId, `切换目录失败:${error?.message || error}`) - } - return - } - - if (text === '/project') { - const binding = await this.resolveBinding(message.openId) - const project = await this.getProjectInfo(binding.projectId) - const projectName = project ? getProjectDisplayName(project) : binding.projectId - const projectDirectory = project ? getProjectDirectory(project) || binding.directory : binding.directory - await this.replyText(message.chatId, [ - `当前项目:${projectName}`, - `Project ID:${binding.projectId}`, - `目录:${projectDirectory}`, - `Session:${binding.sessionId}`, - ].join('\n')) - return - } - - if (text === '/project list') { - const projects = await this.listProjects() - if (projects.length === 0) { - await this.replyText(message.chatId, '当前没有可切换的项目。') - return - } - - const lines = ['可切换的项目:'] - for (const [index, project] of projects.slice(0, PROJECT_LIST_LIMIT).entries()) { - lines.push(`${index + 1}. ${getProjectDisplayName(project)}`) - lines.push(`ID:${project.id}`) - lines.push(`目录:${getProjectDirectory(project) || '未提供目录信息'}`) - } - if (projects.length > PROJECT_LIST_LIMIT) { - lines.push(`还有 ${projects.length - PROJECT_LIST_LIMIT} 个项目未显示,请使用更具体的 ID 或名称。`) - } - await this.replyText(message.chatId, lines.join('\n')) - return - } - - if (text.startsWith('/project ')) { - const rawInput = trimWrappedQuotes(text.slice(9).trim()) - if (!rawInput) { - await this.replyText(message.chatId, '请提供要切换的项目 ID 或名称。') - return - } - - const projects = await this.listProjects() - const exactById = projects.find((project) => project.id === rawInput) - if (exactById) { - const projectDirectory = getProjectDirectory(exactById) - if (!projectDirectory) { - await this.replyText(message.chatId, `项目存在但缺少可用目录:${exactById.id}`) - return - } - const nextBinding = await this.createAndBindSession(message.openId, projectDirectory) - await this.replyText(message.chatId, [ - '已切换到新项目,并为你新建了会话。', - `当前项目:${getProjectDisplayName(exactById)}`, - `Project ID:${exactById.id}`, - `目录:${nextBinding.directory}`, - `Session:${nextBinding.sessionId}`, - ].join('\n')) - return - } - - const exactByName = projects.filter((project) => getProjectDisplayName(project) === rawInput) - if (exactByName.length === 1) { - const project = exactByName[0] - const projectDirectory = getProjectDirectory(project) - if (!projectDirectory) { - await this.replyText(message.chatId, `项目存在但缺少可用目录:${project.id}`) - return - } - const nextBinding = await this.createAndBindSession(message.openId, projectDirectory) - await this.replyText(message.chatId, [ - '已切换到新项目,并为你新建了会话。', - `当前项目:${getProjectDisplayName(project)}`, - `Project ID:${project.id}`, - `目录:${nextBinding.directory}`, - `Session:${nextBinding.sessionId}`, - ].join('\n')) - return - } - - if (exactByName.length > 1) { - const lines = ['匹配到多个同名项目,请改用 projectId:'] - for (const project of exactByName) { - lines.push(`${getProjectDisplayName(project)} | ${project.id}`) - } - await this.replyText(message.chatId, lines.join('\n')) - return - } - - await this.replyText(message.chatId, `未找到匹配的项目:${rawInput}`) - return - } - - await this.replyText(message.chatId, [UNKNOWN_COMMAND_TEXT, PROJECT_COMMAND_TEXT].join('\n')) - } - - private async handleConversation(message: NormalizedMessage): Promise { - let binding = await this.resolveBinding(message.openId) - let resolveCompletion!: () => void - let rejectCompletion!: (error: Error) => void - const completion = new Promise((resolve, reject) => { - resolveCompletion = resolve - rejectCompletion = reject - }) - - const context: ActiveSessionContext = { - openId: message.openId, - sessionId: binding.sessionId, - messageId: message.messageId, - startedAt: Date.now(), - chatId: message.chatId, - directory: binding.directory, - progressBuffer: '', - assistantMessageIds: new Set(), - partLengths: new Map(), - needsWebContinuation: false, - completion, - resolveCompletion, - rejectCompletion, - completed: false, - } - - this.activeByOpenId.set(message.openId, context) - this.activeBySessionId.set(binding.sessionId, context) - - try { - await this.autoHandlePendingRequests(binding.sessionId, binding.directory) - - const parts: Array<{ type: 'text'; text: string } | { type: 'file'; filename: string; mime: string; url: string }> = [] - if (message.text) { - parts.push({ type: 'text', text: message.text }) - } - for (const attachment of message.attachments) { - parts.push({ - type: 'file', - filename: attachment.filename, - mime: attachment.mime, - url: attachment.url, - }) - } - - if (parts.length === 0) { - await this.replyText(message.chatId, '没有识别到可处理的消息内容。') - return - } - - const accepted = await this.sendSessionPrompt(binding.sessionId, parts, binding.directory) - context.turnSnapshotId = accepted.turnSnapshotId - if (!accepted.accepted || accepted.busy) { - throw new Error('当前会话正在处理中,请稍后再试') - } - - await this.waitForSessionCompletion(context) - await this.flushProgress(context, true) - - let finalText = await this.getLatestAssistantText(binding.sessionId, binding.directory, context.assistantMessageIds) - if (!finalText) { - finalText = '处理完成,但这次没有生成可发送到飞书的文本结果。' - } - if (context.needsWebContinuation) { - finalText = `${finalText}\n\n${WEB_CONTINUE_TEXT}` - } - await this.replyText(message.chatId, finalText) - - binding = { - ...binding, - updatedAt: Date.now(), - } - await this.store.set(binding) - } catch (error: any) { - await this.flushProgress(context, true) - const messageText = context.needsWebContinuation - ? WEB_CONTINUE_TEXT - : `处理失败:${error?.message || error}` - await this.replyText(message.chatId, messageText) - } finally { - if (context.progressTimer) { - clearTimeout(context.progressTimer) - } - this.activeByOpenId.delete(message.openId) - this.activeBySessionId.delete(binding.sessionId) - } - } - - private async waitForSessionCompletion(context: ActiveSessionContext): Promise { - await Promise.race([ - context.completion, - sleep(SESSION_COMPLETION_TIMEOUT_MS).then(() => { - throw new Error('等待 agent 完成超时,请稍后到 web 端查看结果') - }), - ]) - } - - private async getLatestAssistantText( - sessionId: string, - directory: string, - assistantMessageIds: Set, - ): Promise { - const messages = await this.requestJson(`/session/${encodeURIComponent(sessionId)}/message`, { - directory, - timeoutMs: 30000, - }).catch(() => []) - - const assistantMessages = messages.filter((message) => { - if (message.info.role !== 'assistant') { - return false - } - return assistantMessageIds.size === 0 || assistantMessageIds.has(message.info.id) - }) - const latest = assistantMessages.at(-1) - return latest ? getFinalAssistantText(latest) : '' - } - - private async createAndBindSession(openId: string, directory: string): Promise { - const normalizedDirectory = await ensureDirectoryExists(resolve(directory)) - const created = await this.requestJson('/nine1bot/agent/sessions', { - method: 'POST', - directory: normalizedDirectory, - body: { - directory: normalizedDirectory, - entry: { - source: 'feishu', - platform: 'feishu', - mode: 'feishu-private-chat', - templateIds: ['default-user-template', 'feishu-chat'], - }, - clientCapabilities: FEISHU_CONTROLLER_CAPABILITIES, - }, - timeoutMs: 30000, - }) - const session = created.session - - const binding: FeishuConversationBinding = { - bindingKey: toBindingKey(openId), - openId, - sessionId: session.id, - directory: normalizedDirectory, - projectId: session.projectID || '', - updatedAt: Date.now(), - } - - await this.store.set(binding) - return binding - } - - private async getProjectLabel(binding: FeishuConversationBinding): Promise { - const project = await this.getProjectInfo(binding.projectId) - return project ? getProjectDisplayName(project) : binding.projectId - } - - private async getProjectInfo(projectId: string): Promise { - return this.requestJson(`/project/${encodeURIComponent(projectId)}`, { - timeoutMs: 10000, - }).catch(() => undefined) - } - - private async listProjects(): Promise { - const projects = await this.requestJson('/project', { - timeoutMs: 10000, - }).catch(() => []) - return [...projects].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)) - } - - private async resolveDirectoryInput(baseDirectory: string, input: string): Promise { - const target = isAbsolute(input) ? resolve(input) : resolve(baseDirectory, input) - return ensureDirectoryExists(target) - } - - private async replyText(chatId: string, text: string): Promise { - const chunks = chunkText(text) - if (chunks.length === 0) { - return - } - - for (const chunk of chunks) { - await this.client.im.v1.message.create({ - params: { - receive_id_type: 'chat_id', - }, - data: { - receive_id: chatId, - msg_type: 'text', - content: JSON.stringify({ text: chunk }), - }, - }) - } - } - - private async runGlobalEventStream(signal: AbortSignal): Promise { - while (!this.stopped && !signal.aborted) { - try { - const response = await this.request('/global/event', { - signal, - timeoutMs: 0, - acceptSse: true, - }) - - if (!response.body) { - throw new Error('Global event stream has no body') - } - - await this.consumeSse(response.body, async (line) => { - const parsed = this.parseJson(line) as GlobalEventEnvelope - if (parsed?.payload?.type) { - await this.handleServerEvent(parsed) - } - }, signal) - } catch (error: any) { - if (this.stopped || signal.aborted) { - return - } - console.warn(`[Nine1Bot] Global event stream disconnected: ${error?.message || error}`) - await sleep(EVENT_RETRY_DELAY_MS) - } - } - } - - private async consumeSse( - body: ReadableStream, - onData: (line: string) => Promise, - signal: AbortSignal, - ): Promise { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (!signal.aborted) { - const { done, value } = await reader.read() - if (done) { - break - } - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (!line.startsWith('data: ')) { - continue - } - await onData(line.slice(6)) - } - } - } finally { - reader.releaseLock() - } - } - - private async handleServerEvent(event: GlobalEventEnvelope): Promise { - const sessionId = - event.payload?.properties?.sessionID || - event.payload?.properties?.part?.sessionID || - event.payload?.properties?.info?.sessionID || - event.payload?.properties?.status?.sessionID || - event.payload?.properties?.error?.sessionID - - if (!sessionId) { - return - } - - const context = this.activeBySessionId.get(sessionId) - if (!context) { - return - } - - if (event.directory) { - context.directory = event.directory - } - - if (event.payload.type === 'session.idle') { - this.resolveContextCompletion(context) - return - } - - if (event.payload.type === 'session.status') { - const statusType = event.payload.properties?.status?.type - if (statusType === 'idle') { - this.resolveContextCompletion(context) - } - return - } - - if (event.payload.type === 'session.error') { - const message = event.payload.properties?.error?.data?.message || - event.payload.properties?.error?.message || - 'Agent failed' - this.rejectContextCompletion(context, new Error(message)) - return - } - - if (event.payload.type === 'runtime.resource.failed') { - context.needsWebContinuation = true - return - } - - if (event.payload.type === 'message.created' || event.payload.type === 'message.updated') { - if (event.payload.properties?.info?.role === 'assistant') { - context.assistantMessageIds.add(event.payload.properties.info.id) - } - return - } - - if (event.payload.type === 'message.part.updated') { - await this.handlePartUpdated(context, event.payload.properties as { part: SessionPartPayload; delta?: string }) - return - } - - if (event.payload.type === 'permission.asked') { - await this.handlePermissionAsked(context, event.payload.properties as { id: string }) - return - } - - if (event.payload.type === 'question.asked') { - await this.handleQuestionAsked(context, event.payload.properties as { id: string }) - } - } - - private resolveContextCompletion(context: ActiveSessionContext): void { - if (context.completed) { - return - } - context.completed = true - context.resolveCompletion() - } - - private rejectContextCompletion(context: ActiveSessionContext, error: Error): void { - if (context.completed) { - return - } - context.completed = true - context.rejectCompletion(error) - } - - private async handlePartUpdated( - context: ActiveSessionContext, - properties: { - part: SessionPartPayload - delta?: string - }, - ): Promise { - const part = properties.part - if (part.type !== 'text' || part.synthetic) { - return - } - - if (!context.assistantMessageIds.has(part.messageID)) { - return - } - - let delta = properties.delta - if (!delta) { - const previousLength = context.partLengths.get(part.id) || 0 - const currentLength = part.text?.length || 0 - delta = (part.text || '').slice(previousLength) - context.partLengths.set(part.id, currentLength) - } else { - const currentLength = (context.partLengths.get(part.id) || 0) + delta.length - context.partLengths.set(part.id, currentLength) - } - - const normalized = delta?.replace(/\r\n/g, '\n') || '' - if (!normalized.trim()) { - return - } - - context.progressBuffer += normalized - if (!context.progressTimer) { - context.progressTimer = setTimeout(() => { - void this.flushProgress(context) - }, PROGRESS_FLUSH_MS) - } - } - - private async flushProgress(context: ActiveSessionContext, force = false): Promise { - if (context.progressTimer) { - clearTimeout(context.progressTimer) - context.progressTimer = undefined - } - - const content = force ? context.progressBuffer : context.progressBuffer.trim() - if (!content) { - return - } - - context.progressBuffer = '' - await this.replyText(context.chatId, sanitizeChatText(content)) - } - - private async handlePermissionAsked( - context: ActiveSessionContext, - properties: { - id: string - }, - ): Promise { - context.needsWebContinuation = true - await this.requestJson(`/nine1bot/agent/interactions/${encodeURIComponent(properties.id)}/answer`, { - method: 'POST', - directory: context.directory, - body: { - kind: 'permission', - answer: 'deny', - message: WEB_CONTINUE_TEXT, - }, - timeoutMs: 10000, - }).catch(() => false) - } - - private async handleQuestionAsked( - context: ActiveSessionContext, - properties: { - id: string - }, - ): Promise { - context.needsWebContinuation = true - await this.requestJson(`/nine1bot/agent/interactions/${encodeURIComponent(properties.id)}/answer`, { - method: 'POST', - directory: context.directory, - body: { - kind: 'question', - answer: 'deny', - }, - timeoutMs: 10000, - }).catch(() => false) - } - - private async downloadMessageAttachment(input: { - messageId: string - resourceKey: string - resourceType: 'image' | 'file' - fallbackFilename: string - limitBytes: number - }): Promise { - const response = await this.client.im.v1.messageResource.get({ - params: { - type: input.resourceType, - }, - path: { - message_id: input.messageId, - file_key: input.resourceKey, - }, - }) - - const buffer = await readableToBuffer(response.getReadableStream(), input.limitBytes) - const disposition = headerValue(response.headers, 'content-disposition') - const filename = extractFilenameFromDisposition(disposition) || input.fallbackFilename - const mime = getBufferMime(response.headers, filename, input.resourceType === 'image' ? 'image/png' : 'application/octet-stream') - - return { - filename, - mime, - url: `data:${mime};base64,${buffer.toString('base64')}`, - } - } - - private parseJson(content: string): Record { - try { - return JSON.parse(content) - } catch { - return {} - } - } - - private async requestJson( - path: string, - options: { - method?: string - directory?: string - body?: unknown - timeoutMs?: number - signal?: AbortSignal - } = {}, - ): Promise { - const response = await this.request(path, options) - const text = await response.text() - if (!text) { - return true as T - } - return JSON.parse(text) as T - } - - private async request( - path: string, - options: { - method?: string - directory?: string - body?: unknown - timeoutMs?: number - signal?: AbortSignal - acceptSse?: boolean - } = {}, - ): Promise { - const url = new URL(path, this.localUrl) - const headers = new Headers() - - if (this.authHeader) { - headers.set('authorization', this.authHeader) - } - if (options.directory) { - headers.set('x-opencode-directory', options.directory) - if (!url.searchParams.has('directory')) { - url.searchParams.set('directory', options.directory) - } - } - if (options.acceptSse) { - headers.set('accept', 'text/event-stream') - } else if (options.body !== undefined) { - headers.set('content-type', 'application/json') - } - - const timeoutMs = options.timeoutMs ?? 30000 - const controller = new AbortController() - const timeoutId = - timeoutMs > 0 - ? setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs) - : undefined - - const linkedAbort = () => controller.abort() - options.signal?.addEventListener('abort', linkedAbort, { once: true }) - - try { - const response = await fetch(url.toString(), { - method: options.method || 'GET', - headers, - body: options.body !== undefined ? JSON.stringify(options.body) : undefined, - signal: controller.signal, - }) - - if (!response.ok) { - const text = await response.text().catch(() => '') - throw new Error(text || `Request failed: ${response.status} ${response.statusText}`) - } - - return response - } finally { - if (timeoutId) { - clearTimeout(timeoutId) - } - options.signal?.removeEventListener('abort', linkedAbort) - } - } -} - -export async function startFeishuService( - config: FeishuConfig, - options: { localUrl: string; auth?: AuthConfig }, -): Promise { - if (!config.enabled) { - return undefined - } - - const service = new FeishuService(config, options) - await service.start() - return service -} diff --git a/packages/nine1bot/src/feishu/store.ts b/packages/nine1bot/src/feishu/store.ts deleted file mode 100644 index 1a8eb896..00000000 --- a/packages/nine1bot/src/feishu/store.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { access, mkdir, readFile, writeFile } from 'fs/promises' -import { dirname, join } from 'path' -import { getGlobalConfigDir } from '../config/loader' -import type { FeishuBindingsFile, FeishuConversationBinding } from './types' - -const STORE_FILENAME = 'feishu-conversations.json' - -function createEmptyFile(): FeishuBindingsFile { - return { - version: 1, - bindings: [], - } -} - -async function fileExists(filepath: string): Promise { - try { - await access(filepath) - return true - } catch { - return false - } -} - -export class FeishuBindingStore { - private loaded = false - private bindings = new Map() - - get filepath(): string { - return join(getGlobalConfigDir(), STORE_FILENAME) - } - - private async load(): Promise { - if (this.loaded) { - return - } - - if (!(await fileExists(this.filepath))) { - this.loaded = true - return - } - - try { - const content = await readFile(this.filepath, 'utf-8') - const parsed = JSON.parse(content) as Partial - const bindings = Array.isArray(parsed.bindings) ? parsed.bindings : [] - this.bindings = new Map( - bindings - .filter((binding): binding is FeishuConversationBinding => { - return !!binding?.bindingKey && !!binding.openId && !!binding.sessionId && !!binding.directory && !!binding.projectId - }) - .map((binding) => [binding.bindingKey, binding]), - ) - } catch (error) { - console.warn('[Nine1Bot] Failed to load Feishu bindings:', error) - this.bindings.clear() - } - - this.loaded = true - } - - private async save(): Promise { - await mkdir(dirname(this.filepath), { recursive: true }) - const data: FeishuBindingsFile = { - version: 1, - bindings: [...this.bindings.values()], - } - await writeFile(this.filepath, JSON.stringify(data, null, 2), 'utf-8') - } - - async get(bindingKey: string): Promise { - await this.load() - return this.bindings.get(bindingKey) - } - - async set(binding: FeishuConversationBinding): Promise { - await this.load() - this.bindings.set(binding.bindingKey, binding) - await this.save() - } - - async delete(bindingKey: string): Promise { - await this.load() - if (!this.bindings.delete(bindingKey)) { - return - } - await this.save() - } - - async list(): Promise { - await this.load() - return [...this.bindings.values()] - } - - async clear(): Promise { - await this.load() - this.bindings.clear() - await this.save() - } -} - -export function getFeishuBindingStorePath(): string { - return join(getGlobalConfigDir(), STORE_FILENAME) -} diff --git a/packages/nine1bot/src/feishu/types.ts b/packages/nine1bot/src/feishu/types.ts deleted file mode 100644 index f8672855..00000000 --- a/packages/nine1bot/src/feishu/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface FeishuConversationBinding { - bindingKey: string - openId: string - sessionId: string - directory: string - projectId: string - updatedAt: number -} - -export interface FeishuMessageLockState { - openId: string - sessionId: string - messageId: string - startedAt: number -} - -export interface FeishuEventDedupEntry { - eventId: string - createdAt: number -} - -export interface FeishuBindingsFile { - version: 1 - bindings: FeishuConversationBinding[] -} diff --git a/packages/nine1bot/src/launcher/orchestrator.ts b/packages/nine1bot/src/launcher/orchestrator.ts index 214ab75b..c923aca2 100644 --- a/packages/nine1bot/src/launcher/orchestrator.ts +++ b/packages/nine1bot/src/launcher/orchestrator.ts @@ -5,7 +5,11 @@ import type { Nine1BotConfig } from '../config/schema' import { loadConfig, findConfigPath, getDefaultConfigPath } from '../config/loader' import { startServer, type ServerInstance } from './server' import { createTunnel, type TunnelManager } from '../tunnel' -import { startFeishuService, type FeishuServiceHandle } from '../feishu/service' +import { + startBuiltinPlatformBackgroundServices, + stopBuiltinPlatformBackgroundServices, +} from '../platform/builtin' +import type { PlatformControllerBridge } from '@nine1bot/platform-protocol' const execFileAsync = promisify(execFile) @@ -20,7 +24,6 @@ export interface LaunchOptions { export interface LaunchResult { server: ServerInstance tunnel?: TunnelManager - feishu?: FeishuServiceHandle localUrl: string publicUrl?: string configPath: string @@ -58,14 +61,16 @@ export async function launch(options: LaunchOptions = {}): Promise const localUrl = server.url || `http://${serverConfig.hostname}:${serverConfig.port}` process.env.NINE1BOT_LOCAL_URL = localUrl + const authHeader = createAuthHeader(config.auth) - let feishu: FeishuServiceHandle | undefined - if (config.feishu?.enabled) { - feishu = await startFeishuService(config.feishu, { - localUrl, - auth: config.auth, - }) - } + await startBuiltinPlatformBackgroundServices({ + localUrl, + authHeader, + controller: createPlatformControllerBridge(localUrl, authHeader), + legacySettings: { + feishu: config.feishu, + }, + }) // 2. 创建隧道(如果启用) let tunnel: TunnelManager | undefined @@ -112,7 +117,6 @@ export async function launch(options: LaunchOptions = {}): Promise return { server, tunnel, - feishu, localUrl, publicUrl, configPath, @@ -132,12 +136,10 @@ export async function shutdown(result: LaunchResult): Promise { } } - if (result.feishu) { - try { - await result.feishu.stop() - } catch { - // 忽略停止飞书服务时的错误 - } + try { + await stopBuiltinPlatformBackgroundServices() + } catch { + // 忽略停止平台后台服务时的错误 } // 停止服务器 @@ -150,6 +152,40 @@ export async function shutdown(result: LaunchResult): Promise { } } +function createAuthHeader(auth: Nine1BotConfig['auth']): string | undefined { + return auth?.enabled && auth.password + ? `Basic ${Buffer.from(`nine1bot:${auth.password}`).toString('base64')}` + : undefined +} + +function createPlatformControllerBridge(localUrl: string, authHeader?: string): PlatformControllerBridge { + return { + localUrl, + authHeader, + async requestJson(path, init = {}) { + const url = new URL(path, localUrl) + const headers = new Headers(init.headers) + if (authHeader) headers.set('authorization', authHeader) + if (init.body !== undefined && !headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + const response = await fetch(url, { + method: init.method ?? 'GET', + headers, + body: init.body === undefined + ? undefined + : typeof init.body === 'string' + ? init.body + : JSON.stringify(init.body), + }) + if (!response.ok) { + throw new Error(`Controller request failed: ${response.status} ${response.statusText}`) + } + return await response.json() + }, + } +} + /** * 优雅退出处理 */ diff --git a/packages/nine1bot/src/platform/builtin.ts b/packages/nine1bot/src/platform/builtin.ts index c4906468..bc2fbf0f 100644 --- a/packages/nine1bot/src/platform/builtin.ts +++ b/packages/nine1bot/src/platform/builtin.ts @@ -1,7 +1,11 @@ import { feishuPlatformContribution } from '@nine1bot/platform-feishu/runtime' import { gitlabPlatformContribution } from '@nine1bot/platform-gitlab/runtime' import type { PlatformSecretAccess } from '@nine1bot/platform-protocol' -import { PlatformAdapterManager, type PlatformManagerConfig } from './manager' +import { + PlatformAdapterManager, + type PlatformBackgroundServicesStartOptions, + type PlatformManagerConfig, +} from './manager' export const builtinPlatformContributions = [ gitlabPlatformContribution, @@ -26,16 +30,6 @@ export function getBuiltinPlatformManager(options: BuiltinPlatformManagerOptions }) return builtinPlatformManager } - if (options.secrets || options.env) { - unregisterBuiltinPlatformAdapters() - builtinPlatformManager = new PlatformAdapterManager({ - contributions: builtinPlatformContributions, - config: options.config, - secrets: options.secrets, - env: options.env, - }) - return builtinPlatformManager - } if (options.config) { builtinPlatformManager.configure(options.config) } @@ -46,11 +40,25 @@ export function registerBuiltinPlatformAdapters(options: BuiltinPlatformManagerO return getBuiltinPlatformManager(options).registerRuntimeAdapters() } +export async function startBuiltinPlatformBackgroundServices(options: PlatformBackgroundServicesStartOptions & BuiltinPlatformManagerOptions) { + const manager = builtinPlatformManager ?? getBuiltinPlatformManager({ + config: options.config, + secrets: options.secrets, + env: options.env, + }) + return manager.startBackgroundServices(options) +} + +export async function stopBuiltinPlatformBackgroundServices() { + await builtinPlatformManager?.stopBackgroundServices() +} + export function unregisterBuiltinPlatformAdapters() { return builtinPlatformManager?.unregisterRuntimeAdapters() ?? [] } export function resetBuiltinPlatformManagerForTesting() { unregisterBuiltinPlatformAdapters() + void stopBuiltinPlatformBackgroundServices() builtinPlatformManager = undefined } diff --git a/packages/nine1bot/src/platform/manager.test.ts b/packages/nine1bot/src/platform/manager.test.ts index 8d8e8a7c..e8c8029d 100644 --- a/packages/nine1bot/src/platform/manager.test.ts +++ b/packages/nine1bot/src/platform/manager.test.ts @@ -3,6 +3,7 @@ import { win32 as win32Path } from 'node:path' import type { PlatformAdapterContext, PlatformAdapterContribution, + PlatformBackgroundService, PlatformRuntimeSourcesDescriptor, PlatformRuntimeSourcesProvider, PlatformSecretAccess, @@ -10,7 +11,7 @@ import type { import { RuntimePlatformAdapterRegistry } from '../../../../opencode/packages/opencode/src/runtime/platform/adapter' import { RuntimeSourceRegistry } from '../../../../opencode/packages/opencode/src/runtime/source/registry' import { PlatformAdapterManager } from './manager' -import { registerBuiltinPlatformAdapters, resetBuiltinPlatformManagerForTesting } from './builtin' +import { getBuiltinPlatformManager, registerBuiltinPlatformAdapters, resetBuiltinPlatformManagerForTesting } from './builtin' import { registerGitLabPlatformAdapter } from './gitlab' function resetPlatformState() { @@ -92,6 +93,59 @@ function runtimeSources(): PlatformRuntimeSourcesDescriptor { } } +function backgroundContribution( + id: string, + options: { + defaultEnabled?: boolean + getStatus?: (startNumber: number) => { status: 'available' | 'degraded' | 'error' | 'disabled'; message?: string } + handleAction?: PlatformAdapterContribution['handleAction'] + config?: PlatformAdapterContribution['descriptor']['config'] + actions?: PlatformAdapterContribution['descriptor']['actions'] + } = {}, +) { + let starts = 0 + let stops = 0 + const service: PlatformBackgroundService = { + id: `${id}-background`, + async start() { + starts += 1 + const startNumber = starts + return { + async stop() { + stops += 1 + }, + getStatus() { + return options.getStatus?.(startNumber) ?? { + status: 'available', + message: `background run ${startNumber}`, + } + }, + } + }, + } + + return { + contribution: { + ...contribution(id, { defaultEnabled: options.defaultEnabled }), + descriptor: { + ...contribution(id, { defaultEnabled: options.defaultEnabled }).descriptor, + config: options.config, + actions: options.actions, + }, + backgroundServices: () => [service], + handleAction: options.handleAction, + } satisfies PlatformAdapterContribution, + counts: { + get starts() { + return starts + }, + get stops() { + return stops + }, + }, + } +} + describe('PlatformAdapterManager', () => { it('registers default-enabled contributions', () => { const manager = new PlatformAdapterManager({ @@ -449,6 +503,152 @@ describe('PlatformAdapterManager', () => { expect(RuntimeSourceRegistry.listOwner('demo').agents).toEqual([]) }) + it('starts and stops background services for enabled platforms', async () => { + let starts = 0 + let stops = 0 + const service: PlatformBackgroundService = { + id: 'demo-background', + async start(ctx) { + starts++ + expect(ctx.localUrl).toBe('http://127.0.0.1:4096') + expect(ctx.authHeader).toBe('Basic test') + return { + async stop() { + stops++ + }, + getStatus() { + return { + status: 'degraded', + message: 'background staged', + } + }, + } + }, + } + const manager = new PlatformAdapterManager({ + contributions: [{ + ...contribution('demo', { defaultEnabled: true }), + backgroundServices: () => [service], + }], + }) + + await manager.startBackgroundServices({ + localUrl: 'http://127.0.0.1:4096', + authHeader: 'Basic test', + }) + + expect(starts).toBe(1) + expect(manager.get('demo')).toMatchObject({ + lifecycleStatus: 'degraded', + runtimeStatus: { + status: 'degraded', + message: 'background staged', + }, + }) + + await manager.stopBackgroundServices() + expect(stops).toBe(1) + }) + + it('does not start background services for disabled platforms', async () => { + let starts = 0 + const manager = new PlatformAdapterManager({ + contributions: [{ + ...contribution('demo', { defaultEnabled: true }), + backgroundServices: () => [{ + id: 'demo-background', + async start() { + starts++ + return { async stop() {} } + }, + }], + }], + config: { + demo: { + enabled: false, + }, + }, + }) + + await manager.startBackgroundServices({ + localUrl: 'http://127.0.0.1:4096', + }) + + expect(starts).toBe(0) + }) + + it('stops previous background services before restart or reconfigure', async () => { + let starts = 0 + let stops = 0 + const manager = new PlatformAdapterManager({ + contributions: [{ + ...contribution('demo', { defaultEnabled: true }), + backgroundServices: () => [{ + id: 'demo-background', + async start() { + starts++ + return { + async stop() { + stops++ + }, + } + }, + }], + }], + }) + + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + + expect(starts).toBe(2) + expect(stops).toBe(1) + + manager.configure({ + demo: { + enabled: false, + }, + }) + + expect(stops).toBe(2) + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + expect(starts).toBe(2) + }) + + it('waits for configure-triggered background service stops before starting new ones', async () => { + const events: string[] = [] + let releaseStop: (() => void) | undefined + const manager = new PlatformAdapterManager({ + contributions: [{ + ...contribution('demo', { defaultEnabled: true }), + backgroundServices: () => [{ + id: 'demo-background', + async start() { + events.push('start') + return { + async stop() { + events.push('stop-begin') + await new Promise((resolve) => { + releaseStop = resolve + }) + events.push('stop-end') + }, + } + }, + }], + }], + }) + + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + manager.configure({ demo: { enabled: true } }) + const restart = manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + await Promise.resolve() + + expect(events).toEqual(['start', 'stop-begin']) + releaseStop?.() + await restart + expect(events).toEqual(['start', 'stop-begin', 'stop-end', 'start']) + }) + it('does not leave runtime sources behind when adapter creation fails', async () => { const manager = new PlatformAdapterManager({ contributions: [ @@ -630,6 +830,113 @@ describe('PlatformAdapterManager', () => { }) }) + it('restarts previously started background services after re-enabling a platform', async () => { + const background = backgroundContribution('demo', { defaultEnabled: true }) + const manager = new PlatformAdapterManager({ + contributions: [background.contribution], + }) + manager.registerRuntimeAdapters() + + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + await manager.updateConfig('demo', { enabled: false }) + + expect(background.counts.starts).toBe(1) + expect(background.counts.stops).toBe(1) + expect(manager.get('demo')).toMatchObject({ + enabled: false, + runtimeStatus: { + status: 'disabled', + }, + }) + + await manager.updateConfig('demo', { enabled: true }) + + expect(background.counts.starts).toBe(2) + expect(background.counts.stops).toBe(1) + expect(manager.get('demo')).toMatchObject({ + enabled: true, + runtimeStatus: { + status: 'available', + message: 'background run 2', + }, + }) + }) + + it('restarts started background services after settings-only updates', async () => { + const background = backgroundContribution('demo', { + defaultEnabled: true, + config: { + sections: [{ + id: 'settings', + title: 'Settings', + fields: [{ + key: 'mode', + label: 'Mode', + type: 'string', + }], + }], + }, + }) + const manager = new PlatformAdapterManager({ + contributions: [background.contribution], + }) + manager.registerRuntimeAdapters() + + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + await manager.updateConfig('demo', { + settings: { + mode: 'next', + }, + }) + + expect(background.counts.starts).toBe(2) + expect(background.counts.stops).toBe(1) + expect(manager.configSnapshot().demo?.settings).toEqual({ + mode: 'next', + }) + expect(manager.get('demo')).toMatchObject({ + runtimeStatus: { + status: 'available', + message: 'background run 2', + }, + }) + }) + + it('does not auto-start background services on config updates before the first launch', async () => { + const background = backgroundContribution('demo', { + defaultEnabled: true, + config: { + sections: [{ + id: 'settings', + title: 'Settings', + fields: [{ + key: 'mode', + label: 'Mode', + type: 'string', + }], + }], + }, + }) + const manager = new PlatformAdapterManager({ + contributions: [background.contribution], + }) + manager.registerRuntimeAdapters() + + await manager.updateConfig('demo', { + settings: { + mode: 'draft', + }, + }) + + expect(background.counts.starts).toBe(0) + expect(background.counts.stops).toBe(0) + expect(manager.get('demo')).toMatchObject({ + runtimeStatus: { + status: 'available', + }, + }) + }) + it('rejects invalid config without changing manager config', async () => { const manager = new PlatformAdapterManager({ contributions: [{ @@ -803,6 +1110,108 @@ describe('PlatformAdapterManager', () => { }) }) + it('restarts started background services when actions update settings', async () => { + const background = backgroundContribution('demo', { + defaultEnabled: true, + config: { + sections: [{ + id: 'settings', + title: 'Settings', + fields: [{ + key: 'mode', + label: 'Mode', + type: 'string', + }], + }], + }, + actions: [{ + id: 'settings.apply', + label: 'Apply settings', + kind: 'button', + }], + handleAction: async () => ({ + status: 'ok', + updatedSettings: { + mode: 'action', + }, + }), + }) + const manager = new PlatformAdapterManager({ + contributions: [background.contribution], + }) + manager.registerRuntimeAdapters() + + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + await manager.executeAction('demo', 'settings.apply') + + expect(background.counts.starts).toBe(2) + expect(background.counts.stops).toBe(1) + expect(manager.configSnapshot().demo?.settings).toEqual({ + mode: 'action', + }) + expect(manager.get('demo')).toMatchObject({ + runtimeStatus: { + status: 'available', + message: 'background run 2', + }, + }) + }) + + it('keeps restarted background-service status instead of stale action updatedStatus', async () => { + const background = backgroundContribution('demo', { + defaultEnabled: true, + config: { + sections: [{ + id: 'settings', + title: 'Settings', + fields: [{ + key: 'mode', + label: 'Mode', + type: 'string', + }], + }], + }, + actions: [{ + id: 'settings.refresh', + label: 'Refresh settings', + kind: 'button', + }], + handleAction: async () => ({ + status: 'ok', + updatedSettings: { + mode: 'fresh', + }, + updatedStatus: { + status: 'disabled', + message: 'stale status', + }, + }), + }) + const manager = new PlatformAdapterManager({ + contributions: [background.contribution], + }) + manager.registerRuntimeAdapters() + + await manager.startBackgroundServices({ localUrl: 'http://127.0.0.1:4096' }) + const result = await manager.executeAction('demo', 'settings.refresh') + + expect(result).toMatchObject({ + status: 'ok', + updatedStatus: { + status: 'disabled', + message: 'stale status', + }, + }) + expect(background.counts.starts).toBe(2) + expect(background.counts.stops).toBe(1) + expect(manager.get('demo')).toMatchObject({ + runtimeStatus: { + status: 'available', + message: 'background run 2', + }, + }) + }) + it('guards platform actions by descriptor and confirmation', async () => { const manager = new PlatformAdapterManager({ contributions: [{ @@ -863,4 +1272,32 @@ describe('PlatformAdapterManager', () => { }, }) }) + + it('reuses the built-in manager instance across config syncs with secrets', () => { + const firstSecrets = memorySecrets().access + const secondSecrets = memorySecrets().access + + registerBuiltinPlatformAdapters({ + config: { + feishu: { + enabled: true, + }, + }, + secrets: firstSecrets, + }) + const firstManager = getBuiltinPlatformManager() + + registerBuiltinPlatformAdapters({ + config: { + feishu: { + enabled: false, + }, + }, + secrets: secondSecrets, + }) + const secondManager = getBuiltinPlatformManager() + + expect(secondManager).toBe(firstManager) + expect(RuntimePlatformAdapterRegistry.list().map((adapter) => adapter.id)).not.toContain('feishu') + }) }) diff --git a/packages/nine1bot/src/platform/manager.ts b/packages/nine1bot/src/platform/manager.ts index 9b0d1fd6..f9dcab53 100644 --- a/packages/nine1bot/src/platform/manager.ts +++ b/packages/nine1bot/src/platform/manager.ts @@ -5,8 +5,10 @@ import type { PlatformAdapterContribution, PlatformAuditEntry, PlatformAuditWriter, + PlatformBackgroundServiceHandle, PlatformConfigDescriptor, PlatformConfigField, + PlatformControllerBridge, PlatformDescriptor, PlatformRuntimeSourcesDescriptor, PlatformRuntimeSourcesProvider, @@ -123,6 +125,21 @@ export type PlatformAdapterManagerOptions = { env?: Record } +export type PlatformBackgroundServicesStartOptions = { + localUrl: string + authHeader?: string + controller?: PlatformControllerBridge + legacySettings?: Record + projectId?: string + projectDirectory?: string +} + +type ActivePlatformBackgroundService = { + platformId: string + serviceId: string + handle: PlatformBackgroundServiceHandle +} + export class PlatformNotFoundError extends Error { constructor(readonly platformId: string) { super(`Platform not found: ${platformId}`) @@ -178,9 +195,12 @@ const noopAudit: PlatformAuditWriter = { export class PlatformAdapterManager { private readonly contributions = new Map() private readonly records = new Map() + private readonly backgroundServices = new Map() private readonly secrets: PlatformSecretAccess private readonly audit: PlatformAuditWriter private readonly env: Record + private backgroundStopPromise: Promise | undefined + private lastBackgroundServicesStartOptions: PlatformBackgroundServicesStartOptions | undefined private config: PlatformManagerConfig constructor(options: PlatformAdapterManagerOptions) { @@ -196,6 +216,7 @@ export class PlatformAdapterManager { } configure(config: PlatformManagerConfig) { + void this.stopBackgroundServices() this.unregisterRuntimeAdapters() this.config = normalizeConfig(config) this.rebuildRecords() @@ -292,7 +313,100 @@ export class PlatformAdapterManager { return this.list() } + async startBackgroundServices(options: PlatformBackgroundServicesStartOptions): Promise { + this.lastBackgroundServicesStartOptions = { + ...options, + } + await this.stopBackgroundServices() + + for (const contribution of this.contributions.values()) { + const record = this.records.get(contribution.descriptor.id) + if (!record?.installed || !record.enabled) continue + + const baseContext = this.createContext(record) + const services = contribution.backgroundServices?.(baseContext) ?? [] + for (const service of services) { + const serviceKey = `${record.id}:${service.id}` + try { + const handle = await service.start({ + ...baseContext, + projectId: options.projectId, + projectDirectory: options.projectDirectory, + localUrl: options.localUrl, + authHeader: options.authHeader, + controller: options.controller, + legacySettings: options.legacySettings, + }) + this.backgroundServices.set(serviceKey, { + platformId: record.id, + serviceId: service.id, + handle, + }) + const status = handle.getStatus?.() + if (status) { + this.applyRuntimeStatus(record.id, status) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const status: PlatformRuntimeStatus = { + status: 'error', + message, + } + this.applyRuntimeStatus(record.id, status) + this.writeAudit({ + platformId: record.id, + level: 'error', + stage: 'background-service-start', + message, + reason: 'background-service-failed', + data: { + serviceId: service.id, + }, + }) + } + } + } + + return this.list() + } + + async stopBackgroundServices(): Promise { + const active = Array.from(this.backgroundServices.values()) + this.backgroundServices.clear() + const stopActive = async () => { + await Promise.all(active.map(async (service) => { + try { + await service.handle.stop() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.writeAudit({ + platformId: service.platformId, + level: 'warn', + stage: 'background-service-stop', + message, + reason: 'background-service-stop-failed', + data: { + serviceId: service.serviceId, + }, + }) + } + })) + } + const previousStop = this.backgroundStopPromise + const stopPromise = previousStop + ? previousStop.then(stopActive, stopActive) + : stopActive() + const trackedPromise = stopPromise.finally(() => { + if (this.backgroundStopPromise === trackedPromise) { + this.backgroundStopPromise = undefined + } + }) + this.backgroundStopPromise = trackedPromise + await this.backgroundStopPromise + } + unregisterRuntimeAdapters(): PlatformManagerRecord[] { + void this.stopBackgroundServices() for (const record of this.records.values()) { if (record.registered) { RuntimePlatformAdapterRegistry.unregister(record.id) @@ -330,8 +444,7 @@ export class PlatformAdapterManager { ...this.config, [id]: nextEntry, } - this.configure(nextConfig) - this.registerRuntimeAdapters() + await this.reconfigureAndRestartBackgroundServices(nextConfig) const updated = this.records.get(id) if (!updated) throw new PlatformNotFoundError(id) return cloneRecord(updated) @@ -438,6 +551,7 @@ export class PlatformAdapterManager { } } let currentRecord = record + let restartedBackgroundServices = false if (result.updatedSettings !== undefined) { const previousEntry = this.config[id] ?? {} const nextEntry = await this.prepareConfigEntry(record, previousEntry, { @@ -447,14 +561,13 @@ export class PlatformAdapterManager { if (!validation.ok) { throw new PlatformValidationError(validation.message ?? 'Invalid platform config', validation.fieldErrors ?? {}) } - this.configure({ + restartedBackgroundServices = await this.reconfigureAndRestartBackgroundServices({ ...this.config, [id]: nextEntry, }) - this.registerRuntimeAdapters() currentRecord = this.records.get(id) ?? currentRecord } - if (result.updatedStatus) { + if (result.updatedStatus && !restartedBackgroundServices) { this.records.set(id, { ...currentRecord, lifecycleStatus: lifecycleStatusFromRuntime(result.updatedStatus.status), @@ -554,6 +667,18 @@ export class PlatformAdapterManager { }) } + private applyRuntimeStatus(id: string, runtimeStatus: PlatformRuntimeStatus) { + const record = this.records.get(id) + if (!record) return + this.records.set(id, { + ...record, + lifecycleStatus: lifecycleStatusFromRuntime(runtimeStatus.status), + runtimeStatus, + error: runtimeStatus.status === 'error' ? runtimeStatus.message : undefined, + errorAt: runtimeStatus.status === 'error' ? new Date().toISOString() : undefined, + }) + } + private createContext(record: PlatformManagerRecord): PlatformAdapterContext { return { platformId: record.id, @@ -762,6 +887,32 @@ export class PlatformAdapterManager { // Audit is best-effort for the platform manager. } } + + private async reconfigureAndRestartBackgroundServices(nextConfig: PlatformManagerConfig): Promise { + await this.stopBackgroundServices() + this.configure(nextConfig) + this.registerRuntimeAdapters() + return this.restartBackgroundServicesIfNeeded() + } + + private async restartBackgroundServicesIfNeeded(): Promise { + if (!this.lastBackgroundServicesStartOptions || !this.hasConfiguredBackgroundServices()) { + return false + } + await this.startBackgroundServices(this.lastBackgroundServicesStartOptions) + return true + } + + private hasConfiguredBackgroundServices(): boolean { + for (const record of this.records.values()) { + if (!record.installed || !record.enabled) continue + const contribution = this.contributions.get(record.id) + if (!contribution?.backgroundServices) continue + const services = contribution.backgroundServices(this.createContext(record)) + if (services.length > 0) return true + } + return false + } } function normalizeConfig(config: PlatformManagerConfig): PlatformManagerConfig { diff --git a/packages/platform-feishu/package.json b/packages/platform-feishu/package.json index de1a2233..1f6ae74b 100644 --- a/packages/platform-feishu/package.json +++ b/packages/platform-feishu/package.json @@ -6,7 +6,7 @@ "exports": { ".": "./src/index.ts", "./browser": "./src/browser.ts", - "./runtime": "./src/runtime.ts", + "./runtime": "./src/platform-runtime.ts", "./node": "./src/node.ts" }, "scripts": { @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@larksuiteoapi/node-sdk": "^1.59.0", "@nine1bot/platform-protocol": "workspace:*" }, "devDependencies": { diff --git a/packages/platform-feishu/src/enrichment.ts b/packages/platform-feishu/src/enrichment.ts index 8cded3c6..b250fd26 100644 --- a/packages/platform-feishu/src/enrichment.ts +++ b/packages/platform-feishu/src/enrichment.ts @@ -69,7 +69,7 @@ type FeishuSettings = { metadataTimeoutMs: number } -const DEFAULT_METADATA_TIMEOUT_MS = 2_000 +export const FEISHU_DEFAULT_METADATA_TIMEOUT_MS = 2_000 export async function enrichFeishuPageContext( input: FeishuPageContextEnrichmentInput, @@ -483,7 +483,7 @@ function readSettings(settings: unknown): FeishuSettings { return { cliPath: stringValue(record?.cliPath), contextEnrichment, - metadataTimeoutMs: clampNumber(numberValue(record?.metadataTimeoutMs) ?? DEFAULT_METADATA_TIMEOUT_MS, 500, 15_000), + metadataTimeoutMs: clampNumber(numberValue(record?.metadataTimeoutMs) ?? FEISHU_DEFAULT_METADATA_TIMEOUT_MS, 500, 15_000), } } diff --git a/packages/platform-feishu/src/im/abort.ts b/packages/platform-feishu/src/im/abort.ts new file mode 100644 index 00000000..a9f2066a --- /dev/null +++ b/packages/platform-feishu/src/im/abort.ts @@ -0,0 +1,34 @@ +import type { FeishuIMIncomingMessage } from './types' + +export const FEISHU_IM_ABORT_TEXTS = [ + '停止', + '取消', + '中止', + 'abort', + 'cancel', + 'stop', + '/abort', + '/cancel', + '/stop', +] as const + +const ABORT_TEXTS = new Set(FEISHU_IM_ABORT_TEXTS) + +export function isFeishuIMAbortMessage(message: FeishuIMIncomingMessage): boolean { + return isFeishuIMAbortText(message.text) +} + +export function isFeishuIMAbortText(input: string | undefined): boolean { + const normalized = normalizeAbortText(input) + return Boolean(normalized && ABORT_TEXTS.has(normalized)) +} + +export function normalizeAbortText(input: string | undefined): string | undefined { + if (!input) return undefined + let text = input.trim() + while (/^@\S+\s+/.test(text)) { + text = text.replace(/^@\S+\s+/, '').trim() + } + text = text.replace(/\s+/g, ' ').toLowerCase() + return text || undefined +} diff --git a/packages/platform-feishu/src/im/background-runtime.ts b/packages/platform-feishu/src/im/background-runtime.ts new file mode 100644 index 00000000..de063d38 --- /dev/null +++ b/packages/platform-feishu/src/im/background-runtime.ts @@ -0,0 +1,930 @@ +import type { + PlatformAdapterContext, + PlatformBackgroundService, + PlatformBackgroundServiceContext, + PlatformBackgroundServiceHandle, + PlatformRecentEvent, + PlatformRuntimeStatus, + PlatformStatusCard, +} from '@nine1bot/platform-protocol' +import { stat } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' +import type { + FeishuIMGatewayConnectionStateEvent, + FeishuIMGatewayHandle, +} from './gateway-interface' +import { normalizeFeishuIMConfig } from './config' +import { + getFeishuIMReplyRuntimeRecentEvents, + getFeishuIMReplyRuntimeSummary, + resetFeishuIMReplyRuntimeSummary, + subscribeFeishuIMReplyRuntimeSummary, +} from './reply-telemetry' +import type { FeishuNodeIMGatewayOptions } from './node/ws-gateway' +import type { + FeishuIMAccount, + FeishuIMNormalizedConfig, + FeishuIMRuntimeSnapshot, +} from './types' + +const FEISHU_IM_SERVICE_ID = 'feishu-im' +const FEISHU_IM_RESTART_BACKOFF_MS = [1_000, 3_000, 10_000, 30_000, 60_000] +const FEISHU_IM_STABILITY_WINDOW_MS = 60_000 +const FEISHU_IM_RECENT_EVENT_LIMIT = 20 + +type FeishuIMRuntimeManagerHandle = { + stop(): void +} + +type FeishuIMAccountRuntimeState = + | 'starting' + | 'connected' + | 'reconnecting' + | 'restarting' + | 'error' + | 'stopped' + +type FeishuIMGatewayFactory = ( + options: FeishuNodeIMGatewayOptions, +) => FeishuIMGatewayHandle + +type FeishuIMRuntimeScheduler = { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void +} + +type FeishuIMRuntimeTestHooks = { + createGateway?: FeishuIMGatewayFactory + scheduler?: FeishuIMRuntimeScheduler + retryBackoffMs?: number[] + stabilityWindowMs?: number +} + +type FeishuIMAccountRuntime = { + account: FeishuIMAccount + manager?: FeishuIMRuntimeManagerHandle + createGateway?: ( + callbacks: Pick, + ) => FeishuIMGatewayHandle + gateway?: FeishuIMGatewayHandle + restartTimer?: unknown + stabilityTimer?: unknown + restartAttempt: number + generation: number + connectionState: FeishuIMAccountRuntimeState + lastConnectionError?: string + stopping: boolean + restartable: boolean +} + +const defaultRuntimeScheduler: FeishuIMRuntimeScheduler = { + setTimeout(callback, delayMs) { + return setTimeout(callback, delayMs) + }, + clearTimeout(handle) { + clearTimeout(handle as ReturnType) + }, +} + +let latestSnapshot: FeishuIMRuntimeSnapshot | undefined +let runtimeTestHooks: FeishuIMRuntimeTestHooks | undefined +let recentEventCounter = 0 + +export function createFeishuIMBackgroundServices(ctx: PlatformAdapterContext): PlatformBackgroundService[] { + const config = normalizeFeishuIMConfig(ctx.settings) + if (!config.enabled) return [] + return [createFeishuIMBackgroundService()] +} + +export function getFeishuIMRuntimeStatus( + ctx: PlatformAdapterContext, + options: { + legacyConfig?: unknown + } = {}, +): PlatformRuntimeStatus { + const config = normalizeFeishuIMConfig(ctx.settings, options) + const snapshot = latestSnapshot + if (snapshot && snapshot.updatedAt) { + return snapshot.status + } + return statusFromConfig(config) +} + +export function clearFeishuIMRuntimeSnapshotForTesting() { + latestSnapshot = undefined + runtimeTestHooks = undefined + recentEventCounter = 0 +} + +export function setFeishuIMRuntimeTestHooksForTesting(hooks?: FeishuIMRuntimeTestHooks) { + runtimeTestHooks = hooks +} + +function createFeishuIMBackgroundService(): PlatformBackgroundService { + return { + id: FEISHU_IM_SERVICE_ID, + async start(ctx) { + const handle = new FeishuIMBackgroundHandle(ctx) + await handle.start() + return handle + }, + } +} + +class FeishuIMBackgroundHandle implements PlatformBackgroundServiceHandle { + private status: PlatformRuntimeStatus + private readonly runtimes = new Map() + private recentEvents: PlatformRecentEvent[] = [] + private readonly scheduler: FeishuIMRuntimeScheduler + private readonly retryBackoffMs: number[] + private readonly stabilityWindowMs: number + private readonly gatewayFactoryOverride?: FeishuIMGatewayFactory + private unsubscribeReplyTelemetry?: () => void + + constructor(private readonly ctx: PlatformBackgroundServiceContext) { + const hooks = runtimeTestHooks + this.scheduler = hooks?.scheduler ?? defaultRuntimeScheduler + this.retryBackoffMs = hooks?.retryBackoffMs?.length + ? hooks.retryBackoffMs + : FEISHU_IM_RESTART_BACKOFF_MS + this.stabilityWindowMs = hooks?.stabilityWindowMs ?? FEISHU_IM_STABILITY_WINDOW_MS + this.gatewayFactoryOverride = hooks?.createGateway + this.status = statusFromConfig(this.config()) + } + + async start(): Promise { + const config = this.config() + this.runtimes.clear() + this.recentEvents = [] + this.unsubscribeReplyTelemetry?.() + this.unsubscribeReplyTelemetry = undefined + resetFeishuIMReplyRuntimeSummary() + + if (!config.enabled || config.accounts.length === 0) { + this.status = statusFromConfig(config) + latestSnapshot = snapshotFrom(config, this.status) + return + } + + if (process.platform === 'win32' && config.legacy.enabled) { + this.status = { + status: 'degraded', + message: 'Feishu IM is staged because both legacy feishu.enabled and platform IM are enabled on Windows.', + cards: cardsFromConfig(config, 'staged'), + recentEvents: [ + event( + 'warn', + 'runtime', + 'Platform Feishu IM websocket was not started while legacy Feishu config is still enabled on Windows.', + { event: 'staged-on-windows' }, + ), + ], + } + latestSnapshot = snapshotFrom(config, this.status, 'staged') + return + } + + this.unsubscribeReplyTelemetry = subscribeFeishuIMReplyRuntimeSummary(() => { + this.refreshStatus() + }) + + const [ + sdk, + { FeishuIMSessionManager }, + { + createFeishuIMCardActionHandler, + createFeishuIMImmediateReplyHandler, + createFeishuIMReplySinkFactory, + }, + { FeishuFileIMBindingStore }, + { createHttpFeishuControllerBridge }, + { createFeishuNodeReplyClient }, + { createFeishuNodeIMGateway }, + ] = await Promise.all([ + import('@larksuiteoapi/node-sdk'), + import('./session-manager'), + import('./reply-coordinator'), + import('./node/binding-store'), + import('./node/http-controller-bridge'), + import('./node/reply-client'), + import('./node/ws-gateway'), + ]) + + const gatewayFactory = this.gatewayFactoryOverride ?? createFeishuNodeIMGateway + const controller = createHttpFeishuControllerBridge({ + localUrl: this.ctx.localUrl, + authHeader: this.ctx.authHeader, + platformController: this.ctx.controller, + }) + const store = new FeishuFileIMBindingStore({ env: this.ctx.env }) + + for (const account of config.accounts) { + const runtime: FeishuIMAccountRuntime = { + account, + restartAttempt: 0, + generation: 0, + connectionState: 'starting', + stopping: false, + restartable: false, + } + this.runtimes.set(account.id, runtime) + try { + const appSecret = await this.ctx.secrets.get(account.appSecretRef) + if (!appSecret) { + throw new Error(`Secret ref is missing for account "${account.id}"`) + } + const client = createClient(sdk, account, appSecret) + const replyClient = createFeishuNodeReplyClient({ client: client as any }) + const manager = new FeishuIMSessionManager({ + account, + config, + controller, + store, + defaultDirectory: defaultDirectoryFor(this.ctx, account), + resolveDirectory, + replySinkFactory: createFeishuIMReplySinkFactory({ + account, + config, + controller, + client: replyClient, + continueUrlForSession: (sessionId) => continueUrl(this.ctx.localUrl, sessionId), + }), + onImmediateReply: createFeishuIMImmediateReplyHandler({ + account, + config, + client: replyClient, + continueUrlForSession: (sessionId) => continueUrl(this.ctx.localUrl, sessionId), + }), + }) + runtime.manager = manager + runtime.restartable = true + runtime.createGateway = ({ onConnectionStateChange, onOperationalError }) => createGatewayForAccount({ + gatewayFactory, + account, + appSecret, + manager, + controller, + onConnectionStateChange, + onOperationalError, + continueUrlForSession: (sessionId) => continueUrl(this.ctx.localUrl, sessionId), + createCardActionHandler: createFeishuIMCardActionHandler, + }) + await this.startGateway(runtime, 'initial-start') + } catch (error) { + runtime.connectionState = 'error' + runtime.lastConnectionError = errorMessage(error) + runtime.restartable = false + this.recordRecentEvent( + 'error', + 'runtime', + `Feishu IM account "${account.id}" failed to initialize: ${runtime.lastConnectionError}`, + { + accountId: account.id, + event: 'account-init-failed', + }, + ) + } + } + + this.refreshStatus() + } + + async stop(): Promise { + const config = this.config() + const runtimes = [...this.runtimes.values()] + for (const runtime of runtimes) { + runtime.stopping = true + runtime.generation += 1 + this.clearRestartTimer(runtime) + this.clearStabilityTimer(runtime) + } + for (const runtime of runtimes) { + const gateway = runtime.gateway + runtime.gateway = undefined + await gateway?.stop().catch(() => undefined) + } + for (const runtime of runtimes) { + runtime.manager?.stop() + runtime.manager = undefined + runtime.connectionState = 'stopped' + } + this.unsubscribeReplyTelemetry?.() + this.unsubscribeReplyTelemetry = undefined + resetFeishuIMReplyRuntimeSummary() + this.runtimes.clear() + this.recentEvents = [] + this.status = { + status: 'disabled', + message: 'Feishu IM background service is stopped.', + cards: cardsFromConfig(config, 'stopped'), + } + latestSnapshot = snapshotFrom(config, this.status, 'stopped') + } + + getStatus(): PlatformRuntimeStatus { + return this.status + } + + private config(): FeishuIMNormalizedConfig { + return normalizeFeishuIMConfig(this.ctx.settings, { + legacyConfig: this.ctx.legacySettings?.feishu, + }) + } + + private async startGateway(runtime: FeishuIMAccountRuntime, reason: 'initial-start' | 'restart') { + if (!runtime.createGateway || runtime.stopping) return + + const generation = runtime.generation + 1 + runtime.generation = generation + runtime.connectionState = runtime.restartAttempt > 0 || reason === 'restart' ? 'restarting' : 'starting' + this.refreshStatus() + + const gateway = runtime.createGateway({ + onConnectionStateChange: async (event) => { + await this.handleConnectionStateChange(runtime.account.id, generation, event) + }, + onOperationalError: async (error) => { + await this.handleOperationalError(runtime.account.id, error) + }, + }) + runtime.gateway = gateway + + try { + await gateway.start() + } catch (error) { + if (runtime.stopping || runtime.generation !== generation) return + runtime.gateway = undefined + await gateway.stop().catch(() => undefined) + this.handleGatewayStartFailure(runtime, error, reason) + } + } + + private handleGatewayStartFailure( + runtime: FeishuIMAccountRuntime, + error: unknown, + reason: 'initial-start' | 'restart', + ) { + runtime.connectionState = 'error' + runtime.lastConnectionError = errorMessage(error) + this.recordRecentEvent( + 'error', + 'im-gateway', + reason === 'restart' + ? `Feishu IM account "${runtime.account.id}" failed to restart gateway: ${runtime.lastConnectionError}` + : `Feishu IM account "${runtime.account.id}" failed to start gateway: ${runtime.lastConnectionError}`, + { + accountId: runtime.account.id, + event: reason === 'restart' ? 'restart-failed' : 'start-failed', + }, + ) + this.scheduleRestart(runtime, runtime.lastConnectionError) + } + + private async handleConnectionStateChange( + accountId: string, + generation: number, + change: FeishuIMGatewayConnectionStateEvent, + ) { + const runtime = this.runtimes.get(accountId) + if (!runtime || runtime.stopping || runtime.generation !== generation) return + + if (change.state === 'connected') { + this.clearRestartTimer(runtime) + runtime.connectionState = 'connected' + runtime.lastConnectionError = undefined + if (runtime.restartAttempt > 0) { + this.recordRecentEvent( + 'info', + 'im-gateway', + `Feishu IM account "${accountId}" gateway connected after restart attempt ${runtime.restartAttempt}.`, + { + accountId, + event: 'connected', + }, + ) + } + this.startStabilityTimer(runtime, generation) + this.refreshStatus() + return + } + + if (change.state === 'reconnecting') { + this.clearStabilityTimer(runtime) + runtime.lastConnectionError = change.message ?? runtime.lastConnectionError + if (!runtime.restartTimer && runtime.connectionState !== 'restarting') { + runtime.connectionState = 'reconnecting' + } + this.recordRecentEvent( + 'warn', + 'im-gateway', + `Feishu IM account "${accountId}" gateway is reconnecting${change.message ? `: ${change.message}` : '.'}`, + { + accountId, + event: 'reconnecting', + }, + ) + this.refreshStatus() + return + } + + if (change.state === 'connection-error') { + this.clearStabilityTimer(runtime) + runtime.connectionState = 'error' + runtime.lastConnectionError = change.message ?? 'Unknown connection error' + this.recordRecentEvent( + 'error', + 'im-gateway', + `Feishu IM account "${accountId}" connection error: ${runtime.lastConnectionError}`, + { + accountId, + event: 'connection-error', + }, + ) + this.scheduleRestart(runtime, runtime.lastConnectionError) + return + } + + this.clearStabilityTimer(runtime) + if (!runtime.restartTimer && runtime.connectionState !== 'restarting') { + runtime.connectionState = 'stopped' + this.refreshStatus() + } + } + + private async handleOperationalError(accountId: string, error: Error) { + await this.ctx.audit.write({ + platformId: this.ctx.platformId, + level: 'warn', + stage: 'im-gateway-operational', + message: error.message, + data: { accountId }, + }) + } + + private scheduleRestart(runtime: FeishuIMAccountRuntime, message?: string) { + if (!runtime.restartable || runtime.stopping || runtime.restartTimer) { + this.refreshStatus() + return + } + + this.clearStabilityTimer(runtime) + runtime.connectionState = 'error' + runtime.restartAttempt += 1 + const backoffMs = this.backoffForAttempt(runtime.restartAttempt) + const scheduledGeneration = runtime.generation + this.recordRecentEvent( + 'warn', + 'im-gateway', + `Feishu IM account "${runtime.account.id}" scheduled a gateway restart in ${backoffMs}ms${message ? `: ${message}` : '.'}`, + { + accountId: runtime.account.id, + event: 'restart-scheduled', + backoffMs, + restartAttempt: runtime.restartAttempt, + error: message, + }, + ) + const timer = this.scheduler.setTimeout(() => { + if (runtime.stopping || runtime.restartTimer !== timer || runtime.generation !== scheduledGeneration) return + runtime.restartTimer = undefined + void this.restartGateway(runtime) + }, backoffMs) + runtime.restartTimer = timer + this.refreshStatus() + } + + private async restartGateway(runtime: FeishuIMAccountRuntime) { + if (runtime.stopping || runtime.connectionState === 'restarting') return + + runtime.connectionState = 'restarting' + const previousGateway = runtime.gateway + runtime.generation += 1 + runtime.gateway = undefined + this.refreshStatus() + + await previousGateway?.stop().catch(() => undefined) + if (runtime.stopping) return + await this.startGateway(runtime, 'restart') + } + + private startStabilityTimer(runtime: FeishuIMAccountRuntime, generation: number) { + this.clearStabilityTimer(runtime) + if (runtime.restartAttempt === 0 || this.stabilityWindowMs <= 0) return + + const timer = this.scheduler.setTimeout(() => { + if ( + runtime.stopping + || runtime.stabilityTimer !== timer + || runtime.generation !== generation + || runtime.connectionState !== 'connected' + ) { + return + } + runtime.stabilityTimer = undefined + runtime.restartAttempt = 0 + this.refreshStatus() + }, this.stabilityWindowMs) + runtime.stabilityTimer = timer + } + + private clearRestartTimer(runtime: FeishuIMAccountRuntime) { + if (!runtime.restartTimer) return + this.scheduler.clearTimeout(runtime.restartTimer) + runtime.restartTimer = undefined + } + + private clearStabilityTimer(runtime: FeishuIMAccountRuntime) { + if (!runtime.stabilityTimer) return + this.scheduler.clearTimeout(runtime.stabilityTimer) + runtime.stabilityTimer = undefined + } + + private backoffForAttempt(attempt: number): number { + const index = Math.max(0, Math.min(attempt - 1, this.retryBackoffMs.length - 1)) + return this.retryBackoffMs[index] ?? FEISHU_IM_RESTART_BACKOFF_MS[FEISHU_IM_RESTART_BACKOFF_MS.length - 1]! + } + + private recordRecentEvent( + level: PlatformRecentEvent['level'], + stage: string, + message: string, + data?: Record, + ) { + const entry = event(level, stage, message, data) + this.recentEvents = [entry, ...this.recentEvents].slice(0, FEISHU_IM_RECENT_EVENT_LIMIT) + if (level === 'warn' || level === 'error') { + void this.ctx.audit.write({ + platformId: this.ctx.platformId, + level, + stage, + message, + data, + }) + } + } + + private refreshStatus() { + const config = this.config() + this.status = statusFromRuntime(config, [...this.runtimes.values()], this.recentEvents) + latestSnapshot = snapshotFrom(config, this.status) + } +} + +function statusFromConfig(config: FeishuIMNormalizedConfig): PlatformRuntimeStatus { + if (!config.enabled) { + return { + status: 'disabled', + message: 'Feishu IM is disabled in platform settings.', + cards: cardsFromConfig(config, 'disabled'), + } + } + + if (config.accounts.length === 0) { + return { + status: 'error', + message: 'Feishu IM is enabled but no valid IM account is configured.', + cards: cardsFromConfig(config, 'error'), + recentEvents: [event('error', 'config', 'Feishu IM enabled without a valid account', { + event: 'no-valid-account', + })], + } + } + + return { + status: 'degraded', + message: 'Feishu IM background service is configured and waiting to start.', + cards: cardsFromConfig(config, 'staged'), + recentEvents: legacyEvents(config), + } +} + +function statusFromRuntime( + config: FeishuIMNormalizedConfig, + runtimes: FeishuIMAccountRuntime[], + runtimeEvents: PlatformRecentEvent[], +): PlatformRuntimeStatus { + if (runtimes.length === 0) { + return statusFromConfig(config) + } + + const healthy = runtimes.filter((runtime) => runtime.connectionState === 'connected') + const reconnecting = runtimes.filter((runtime) => runtime.connectionState === 'reconnecting') + const restarting = runtimes.filter((runtime) => runtime.connectionState === 'restarting') + const starting = runtimes.filter((runtime) => runtime.connectionState === 'starting') + const errors = runtimes.filter((runtime) => runtime.connectionState === 'error') + const unhealthyCount = runtimes.length - healthy.length + const recentEvents = mergedRecentEvents(config, runtimeEvents) + + if (healthy.length === runtimes.length) { + return { + status: 'available', + message: `Feishu IM websocket is running for ${healthy.length} account(s).`, + cards: cardsFromConfig(config, 'running', runtimes), + recentEvents, + } + } + + if (healthy.length > 0) { + const unhealthyKinds = [ + reconnecting.length ? `${reconnecting.length} reconnecting` : undefined, + restarting.length ? `${restarting.length} restarting` : undefined, + starting.length ? `${starting.length} starting` : undefined, + errors.length ? `${errors.length} failing` : undefined, + ].filter(Boolean).join(', ') + + return { + status: 'degraded', + message: `Feishu IM is running for ${healthy.length} account(s) while ${unhealthyCount} account(s) are ${unhealthyKinds || 'recovering'}.`, + cards: cardsFromConfig(config, 'running', runtimes), + recentEvents, + } + } + + const lastError = runtimes.find((runtime) => runtime.lastConnectionError)?.lastConnectionError + return { + status: 'error', + message: lastError + ? `Feishu IM is not currently healthy for any account. Last error: ${lastError}` + : 'Feishu IM is not currently healthy for any account.', + cards: cardsFromConfig(config, 'error', runtimes), + recentEvents, + } +} + +function cardsFromConfig( + config: FeishuIMNormalizedConfig, + phase: string, + runtimes: FeishuIMAccountRuntime[] = [], +): PlatformStatusCard[] { + const gateway = summarizeGatewayState(phase, runtimes) + const restartAttempts = summarizeRestartAttempts(runtimes) + const cards: PlatformStatusCard[] = [ + { + id: 'im-runtime', + label: 'IM runtime', + value: phase, + tone: phase === 'error' ? 'danger' : phase === 'staged' ? 'warning' : phase === 'running' ? 'success' : 'neutral', + }, + { + id: 'im-gateway-state', + label: 'Gateway state', + value: gateway.value, + tone: gateway.tone, + }, + { + id: 'im-restart-attempts', + label: 'Restart attempts', + value: restartAttempts.value, + tone: restartAttempts.tone, + }, + { + id: 'im-accounts', + label: 'IM accounts', + value: String(config.accounts.length), + tone: config.accounts.length > 0 ? 'success' : config.enabled ? 'danger' : 'neutral', + }, + ] + + if (config.legacy.enabled) { + cards.push({ + id: 'im-legacy', + label: 'Legacy IM', + value: 'active', + tone: 'warning', + }) + } + + return cards +} + +function summarizeGatewayState( + phase: string, + runtimes: FeishuIMAccountRuntime[], +): Pick { + if (runtimes.length === 0) { + return { + value: phase === 'running' ? 'running' : phase, + tone: phase === 'error' ? 'danger' : phase === 'staged' ? 'warning' : 'neutral', + } + } + + if (runtimes.length === 1) { + const runtime = runtimes[0]! + return { + value: displayGatewayState(runtime.connectionState), + tone: toneForGatewayState([runtime]), + } + } + + const counts = new Map() + for (const runtime of runtimes) { + const key = displayGatewayState(runtime.connectionState) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + + return { + value: [...counts.entries()] + .map(([state, count]) => `${count} ${state}`) + .join(' / '), + tone: toneForGatewayState(runtimes), + } +} + +function summarizeRestartAttempts( + runtimes: FeishuIMAccountRuntime[], +): Pick { + const attempts = runtimes.filter((runtime) => runtime.restartAttempt > 0) + if (attempts.length === 0) { + return { + value: '0', + tone: 'neutral', + } + } + + if (attempts.length === 1 && runtimes.length === 1) { + return { + value: String(attempts[0]!.restartAttempt), + tone: 'warning', + } + } + + return { + value: attempts + .map((runtime) => `${runtime.account.id}:${runtime.restartAttempt}`) + .join(', '), + tone: 'warning', + } +} + +function displayGatewayState(state: FeishuIMAccountRuntimeState): string { + return state === 'connected' ? 'running' : state +} + +function toneForGatewayState(runtimes: FeishuIMAccountRuntime[]): PlatformStatusCard['tone'] { + if (runtimes.every((runtime) => runtime.connectionState === 'connected')) { + return 'success' + } + if (runtimes.some((runtime) => runtime.connectionState === 'error')) { + return runtimes.some((runtime) => runtime.connectionState === 'connected') ? 'warning' : 'danger' + } + if (runtimes.some((runtime) => runtime.connectionState === 'reconnecting' || runtime.connectionState === 'restarting' || runtime.connectionState === 'starting')) { + return 'warning' + } + return 'neutral' +} + +function snapshotFrom( + config: FeishuIMNormalizedConfig, + status: PlatformRuntimeStatus, + phase: FeishuIMRuntimeSnapshot['phase'] = status.status === 'error' + ? 'error' + : status.status === 'disabled' + ? 'disabled' + : status.status === 'available' || status.status === 'degraded' + ? 'running' + : 'staged', +): FeishuIMRuntimeSnapshot { + const reply = getFeishuIMReplyRuntimeSummary() + return { + phase, + status, + accountCount: config.accounts.length, + legacyActive: config.legacy.enabled, + activeReplySinks: reply.activeSinks, + pendingInteractions: reply.pendingInteractions, + activeStreamingCards: reply.activeStreamingCards, + cardUpdateFailures: reply.cardUpdateFailures, + streamingFallbacks: reply.streamingFallbacks, + lastReplyError: reply.lastReplyError, + lastCardAction: reply.lastCardAction, + lastCardUpdateError: reply.lastCardUpdateError, + lastStreamingTransport: reply.lastStreamingTransport, + lastStreamingFallbackReason: reply.lastStreamingFallbackReason, + updatedAt: new Date().toISOString(), + } +} + +function event( + level: PlatformRecentEvent['level'], + stage: string, + message: string, + data?: Record, +): PlatformRecentEvent { + recentEventCounter += 1 + return { + id: `feishu-im-${stage}-${Date.now()}-${recentEventCounter}`, + at: new Date().toISOString(), + level, + stage, + message, + data, + } +} + +function legacyEvents(config: FeishuIMNormalizedConfig): PlatformRecentEvent[] { + return config.legacy.enabled + ? [event( + 'warn', + 'config', + 'Legacy feishu config is present but the legacy Feishu service is disabled; platform-feishu IM owns the websocket lifecycle.', + { event: 'legacy-config-present' }, + )] + : [] +} + +function mergedRecentEvents( + config: FeishuIMNormalizedConfig, + runtimeEvents: PlatformRecentEvent[], +): PlatformRecentEvent[] { + return [ + ...legacyEvents(config), + ...runtimeEvents, + ...getFeishuIMReplyRuntimeRecentEvents(), + ] + .sort((left, right) => right.at.localeCompare(left.at) || right.id.localeCompare(left.id)) + .slice(0, FEISHU_IM_RECENT_EVENT_LIMIT) +} + +function createGatewayForAccount(options: { + gatewayFactory: FeishuIMGatewayFactory + account: FeishuIMAccount + appSecret: string + manager: FeishuIMRuntimeManagerHandle & { + handleIncomingMessage(message: unknown): Promise + } + controller: unknown + onConnectionStateChange?: FeishuNodeIMGatewayOptions['onConnectionStateChange'] + onOperationalError?: FeishuNodeIMGatewayOptions['onOperationalError'] + continueUrlForSession: (sessionId: string) => string + createCardActionHandler: (...args: any[]) => any +}): FeishuIMGatewayHandle { + const { + gatewayFactory, + account, + appSecret, + manager, + controller, + onConnectionStateChange, + onOperationalError, + continueUrlForSession, + createCardActionHandler, + } = options + return gatewayFactory({ + account, + appSecret, + onMessage: async (event) => { + await manager.handleIncomingMessage(event.message) + }, + onCardAction: createCardActionHandler({ + account, + controller, + manager, + continueUrlForSession, + }), + onConnectionStateChange, + onOperationalError, + }) +} + +function createClient( + sdk: typeof import('@larksuiteoapi/node-sdk'), + account: FeishuIMAccount, + appSecret: string, +) { + const { AppType, Client, Domain, LoggerLevel } = sdk + return new Client({ + appId: account.appId, + appSecret, + appType: AppType.SelfBuild, + domain: Domain.Feishu, + logger: { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + trace: () => {}, + }, + loggerLevel: LoggerLevel.info, + }) +} + +function defaultDirectoryFor(ctx: PlatformBackgroundServiceContext, account: FeishuIMAccount): string { + return account.defaultDirectory || ctx.projectDirectory || ctx.env.NINE1BOT_PROJECT_DIR || process.cwd() +} + +async function resolveDirectory(baseDirectory: string | undefined, input: string): Promise { + const target = isAbsolute(input) ? resolve(input) : resolve(baseDirectory || process.cwd(), input) + const stats = await stat(target) + if (!stats.isDirectory()) throw new Error(`Not a directory: ${target}`) + return target +} + +function continueUrl(localUrl: string, sessionId: string): string { + const url = new URL(localUrl) + url.searchParams.set('session', sessionId) + return url.toString() +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/platform-feishu/src/im/buffer/message-buffer.ts b/packages/platform-feishu/src/im/buffer/message-buffer.ts new file mode 100644 index 00000000..376957fd --- /dev/null +++ b/packages/platform-feishu/src/im/buffer/message-buffer.ts @@ -0,0 +1,130 @@ +import type { FeishuIMIncomingMessage } from '../types' +import type { FeishuIMRouteKey } from '../route' + +export type FeishuIMBufferedBatch = { + routeKey: FeishuIMRouteKey + routeKeyString: string + messages: FeishuIMIncomingMessage[] +} + +export type FeishuIMBufferSnapshotEntry = { + routeKey: FeishuIMRouteKey + routeKeyString: string + messageCount: number + firstMessageId?: string + lastMessageId?: string +} + +type BufferEntry = FeishuIMBufferedBatch & { + flushTimer?: ReturnType + maxTimer?: ReturnType +} + +export class FeishuIMMessageBuffer { + private readonly entries = new Map() + + constructor( + private readonly options: { + messageBufferMs: number + maxBufferMs: number + onDue?: (routeKeyString: string) => void | Promise + }, + ) {} + + enqueue(input: { + routeKey: FeishuIMRouteKey + routeKeyString: string + message: FeishuIMIncomingMessage + }): { status: 'ready' | 'buffered'; messageCount: number } { + if (this.options.messageBufferMs <= 0) { + this.entries.set(input.routeKeyString, { + routeKey: input.routeKey, + routeKeyString: input.routeKeyString, + messages: [input.message], + }) + return { status: 'ready', messageCount: 1 } + } + + const entry = this.entries.get(input.routeKeyString) ?? { + routeKey: input.routeKey, + routeKeyString: input.routeKeyString, + messages: [], + } + entry.messages.push(input.message) + this.resetFlushTimer(entry) + if (!entry.maxTimer) { + entry.maxTimer = setTimeout(() => { + void this.options.onDue?.(input.routeKeyString) + }, this.options.maxBufferMs) + entry.maxTimer.unref?.() + } + this.entries.set(input.routeKeyString, entry) + return { + status: 'buffered', + messageCount: entry.messages.length, + } + } + + drain(routeKeyString: string): FeishuIMBufferedBatch | undefined { + const entry = this.entries.get(routeKeyString) + if (!entry) return undefined + this.entries.delete(routeKeyString) + if (entry.flushTimer) clearTimeout(entry.flushTimer) + if (entry.maxTimer) clearTimeout(entry.maxTimer) + return { + routeKey: entry.routeKey, + routeKeyString: entry.routeKeyString, + messages: [...entry.messages], + } + } + + discard(routeKeyString: string): FeishuIMBufferedBatch | undefined { + return this.drain(routeKeyString) + } + + get(routeKeyString: string): FeishuIMBufferedBatch | undefined { + const entry = this.entries.get(routeKeyString) + if (!entry) return undefined + return { + routeKey: entry.routeKey, + routeKeyString: entry.routeKeyString, + messages: [...entry.messages], + } + } + + routeCount(): number { + return this.entries.size + } + + messageCount(): number { + let count = 0 + for (const entry of this.entries.values()) { + count += entry.messages.length + } + return count + } + + snapshot(): FeishuIMBufferSnapshotEntry[] { + return [...this.entries.values()].map((entry) => ({ + routeKey: entry.routeKey, + routeKeyString: entry.routeKeyString, + messageCount: entry.messages.length, + firstMessageId: entry.messages[0]?.messageId, + lastMessageId: entry.messages.at(-1)?.messageId, + })) + } + + clear() { + for (const key of this.entries.keys()) { + this.drain(key) + } + } + + private resetFlushTimer(entry: BufferEntry) { + if (entry.flushTimer) clearTimeout(entry.flushTimer) + entry.flushTimer = setTimeout(() => { + void this.options.onDue?.(entry.routeKeyString) + }, this.options.messageBufferMs) + entry.flushTimer.unref?.() + } +} diff --git a/packages/platform-feishu/src/im/cards.ts b/packages/platform-feishu/src/im/cards.ts new file mode 100644 index 00000000..9552a75e --- /dev/null +++ b/packages/platform-feishu/src/im/cards.ts @@ -0,0 +1,528 @@ +import { + createFeishuCardActionPayload, + type FeishuCardActionContext, + type FeishuCardActionPayload, + type FeishuCardActionType, +} from './interactions' +import { serializeFeishuRouteKey, type FeishuIMRouteKey } from './route' +import type { FeishuIMCard } from './reply-client' +import type { FeishuIMControlResult } from './types' + +export type FeishuTurnCardStatus = 'running' | 'final' | 'error' | 'timeout' + +export const FEISHU_STREAMING_CARD_CONTENT_ELEMENT_ID = 'nine1bot_streaming_content' +export const FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID = 'nine1bot_streaming_tool_status' + +export type FeishuStreamingToolStatus = { + id: string + name: string + status: 'pending' | 'running' | 'completed' | 'failed' + detail?: string + durationMs?: number + error?: string +} + +export type FeishuTurnCardInput = { + status: FeishuTurnCardStatus + title?: string + content?: string + routeKey: FeishuIMRouteKey + sessionId?: string + turnSnapshotId?: string + continueUrl?: string + error?: string + resourceFailure?: string +} + +export type FeishuStreamingTurnCardInput = FeishuTurnCardInput & { + accountId: string + maxChars: number + tools?: FeishuStreamingToolStatus[] + transport?: 'cardkit' | 'patch' | 'text' + fallbackReason?: string +} + +export type FeishuInteractionCardInput = { + accountId: string + routeKey: FeishuIMRouteKey + sessionId?: string + turnSnapshotId?: string + requestId: string + continueUrl?: string + data: Record +} + +export function renderFeishuTurnCard(input: FeishuTurnCardInput): FeishuIMCard { + const statusText = { + running: '处理中', + final: '已完成', + error: '失败', + timeout: '超时', + }[input.status] + return card({ + title: input.title ?? 'Nine1Bot', + template: input.status === 'final' ? 'green' : input.status === 'running' ? 'blue' : 'red', + elements: [ + markdown([ + `**状态**:${statusText}`, + input.content ? `\n${input.content}` : undefined, + input.error ? `\n**错误**:${input.error}` : undefined, + input.resourceFailure ? `\n**资源提示**:${input.resourceFailure}` : undefined, + ].filter(Boolean).join('\n')), + ...(input.continueUrl ? [actions([linkButton('Web 打开', input.continueUrl)])] : []), + ], + }) +} + +export function renderFeishuStreamingTurnCard(input: FeishuStreamingTurnCardInput): FeishuIMCard { + const statusText = { + running: '生成中', + final: '已完成', + error: '失败', + timeout: '超时', + }[input.status] + const content = trimStreamingContent(input.content, input.maxChars) + const context = cardContext(input.accountId, input.routeKey, { + sessionId: input.sessionId, + turnSnapshotId: input.turnSnapshotId, + }) + const actionItems = [ + input.status === 'running' && input.sessionId + ? actionButton('停止', 'turn.abort', context, { type: 'danger' }) + : undefined, + input.continueUrl ? linkButton(input.status === 'running' ? 'Web 继续' : 'Web 打开', input.continueUrl) : undefined, + ].filter(Boolean) + return card({ + title: input.title ?? 'Nine1Bot 正在回复', + template: input.status === 'final' ? 'green' : input.status === 'running' ? 'blue' : 'red', + elements: [ + markdown(`**状态**:${statusText}`), + markdown(content.text || '正在等待 Agent 输出...'), + content.truncated + ? markdown('内容较长,已在飞书卡片中截断。可以在 Web 端查看完整输出。') + : undefined, + input.status === 'running' && input.tools?.length ? markdown(renderToolStatusLines(input.tools)) : undefined, + input.error ? markdown(`**错误**:${input.error}`) : undefined, + input.resourceFailure ? markdown(`**资源提示**:${input.resourceFailure}`) : undefined, + actionItems.length > 0 ? actions(actionItems) : undefined, + ].filter(Boolean), + }) +} + +export function renderFeishuStreamingCardKitInitialCard(input: FeishuStreamingTurnCardInput): FeishuIMCard { + const context = cardContext(input.accountId, input.routeKey, { + sessionId: input.sessionId, + turnSnapshotId: input.turnSnapshotId, + }) + const elements: unknown[] = [ + { + tag: 'markdown', + element_id: FEISHU_STREAMING_CARD_CONTENT_ELEMENT_ID, + content: '正在等待 Agent 输出...', + text_align: 'left', + text_size: 'normal_v2', + }, + { + tag: 'markdown', + element_id: FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID, + content: '', + text_size: 'notation', + }, + ] + if (input.status === 'running' && input.sessionId) { + elements.push(actionButton2('停止', 'turn.abort', context, { type: 'danger' })) + } + if (input.continueUrl) { + elements.push(linkButton2(input.status === 'running' ? 'Web 继续' : 'Web 打开', input.continueUrl)) + } + return { + schema: '2.0', + config: { + streaming_mode: true, + update_multi: true, + width_mode: 'fill', + summary: { + content: 'Nine1Bot 正在回复', + }, + }, + header: { + template: 'blue', + title: { + tag: 'plain_text', + content: input.title ?? 'Nine1Bot 正在回复', + }, + }, + body: { + elements, + }, + } +} + +export function renderFeishuStreamingCardKitFinalCard(input: FeishuStreamingTurnCardInput): FeishuIMCard { + const statusText = { + running: '生成中', + final: '已完成', + error: '失败', + timeout: '超时', + }[input.status] + const content = trimStreamingContent(input.content, input.maxChars) + const elements: unknown[] = [ + { + tag: 'markdown', + element_id: FEISHU_STREAMING_CARD_CONTENT_ELEMENT_ID, + content: content.text || '已完成。', + text_align: 'left', + text_size: 'normal_v2', + }, + content.truncated + ? { + tag: 'markdown', + content: '内容较长,已在飞书卡片中截断。可以在 Web 端查看完整输出。', + } + : undefined, + { + tag: 'markdown', + content: `状态:${statusText}`, + text_size: 'notation', + }, + input.error ? { tag: 'markdown', content: `**错误**:${input.error}` } : undefined, + input.resourceFailure ? { tag: 'markdown', content: `**资源提示**:${input.resourceFailure}` } : undefined, + input.continueUrl ? linkButton2('Web 打开', input.continueUrl) : undefined, + ].filter(Boolean) + return { + schema: '2.0', + config: { + streaming_mode: false, + update_multi: true, + width_mode: 'fill', + summary: { + content: statusText, + }, + }, + header: { + template: input.status === 'final' ? 'green' : input.status === 'running' ? 'blue' : 'red', + title: { + tag: 'plain_text', + content: input.title ?? 'Nine1Bot', + }, + }, + body: { + elements, + }, + } +} + +export function renderFeishuControlCard(input: { + accountId: string + routeKey: FeishuIMRouteKey + result: FeishuIMControlResult + sessionId?: string + continueUrl?: string +}): FeishuIMCard { + const context = cardContext(input.accountId, input.routeKey, { + sessionId: input.sessionId, + }) + return card({ + title: 'Nine1Bot 控制面', + template: input.result.type === 'failed' ? 'red' : 'blue', + elements: [ + markdown(renderControlSummary(input.result, input.routeKey)), + actions([ + actionButton('新对话', 'control.newSession', context), + actionButton('项目列表', 'control.projectList', context), + actionButton('查看目录', 'control.showCwd', context), + ...(input.continueUrl ? [linkButton('Web 打开', input.continueUrl)] : []), + actionButton('帮助', 'control.help', context), + ]), + ], + }) +} + +export function renderFeishuPermissionCard(input: FeishuInteractionCardInput): FeishuIMCard { + const context = cardContext(input.accountId, input.routeKey, { + sessionId: input.sessionId, + turnSnapshotId: input.turnSnapshotId, + requestId: input.requestId, + }) + const permission = stringValue(input.data.permission) ?? 'unknown' + const patterns = arrayString(input.data.patterns) + return card({ + title: '需要权限确认', + template: 'yellow', + elements: [ + markdown([ + `**权限**:${permission}`, + patterns.length ? `**范围**:${patterns.join(', ')}` : undefined, + ].filter(Boolean).join('\n')), + actions([ + actionButton('允许一次', 'permission.allowOnce', context), + actionButton('允许本对话', 'permission.allowSession', context), + actionButton('拒绝', 'permission.deny', context, { type: 'danger' }), + ...(input.continueUrl ? [linkButton('Web 继续', input.continueUrl)] : []), + ]), + ], + }) +} + +export function renderFeishuQuestionCard(input: FeishuInteractionCardInput): FeishuIMCard { + const context = cardContext(input.accountId, input.routeKey, { + sessionId: input.sessionId, + turnSnapshotId: input.turnSnapshotId, + requestId: input.requestId, + }) + const questions = Array.isArray(input.data.questions) ? input.data.questions : [] + const first = questions[0] && typeof questions[0] === 'object' + ? questions[0] as Record + : undefined + const question = stringValue(first?.question) ?? '需要你回答一个问题。' + const options = Array.isArray(first?.options) + ? first.options + .filter((option): option is Record => Boolean(option && typeof option === 'object')) + .map((option) => stringValue(option.label)) + .filter((option): option is string => Boolean(option)) + : [] + const simple = questions.length === 1 && options.length > 0 && options.length <= 4 && first?.multiple !== true + return card({ + title: '需要补充信息', + template: 'blue', + elements: [ + markdown(question), + simple + ? actions([ + ...options.map((option) => actionButton(option, 'question.answer', context, { value: { answer: option } })), + actionButton('拒绝', 'question.deny', context, { type: 'danger' }), + ...(input.continueUrl ? [linkButton('Web 继续', input.continueUrl)] : []), + ]) + : { + tag: 'input', + name: 'answer', + placeholder: { + tag: 'plain_text', + content: '输入回答', + }, + }, + simple + ? undefined + : actions([ + actionButton('提交', 'question.answer', context), + actionButton('拒绝', 'question.deny', context, { type: 'danger' }), + ...(input.continueUrl ? [linkButton('Web 继续', input.continueUrl)] : []), + ]), + ].filter(Boolean), + }) +} + +export function renderFeishuInteractionAnsweredCard(input: { + title?: string + message: string +}): FeishuIMCard { + return card({ + title: input.title ?? '已处理', + template: 'green', + elements: [ + markdown(input.message), + ], + }) +} + +export function renderControlText(result: FeishuIMControlResult): string { + return renderControlSummary(result) +} + +function renderControlSummary(result: FeishuIMControlResult, routeKey?: FeishuIMRouteKey): string { + const route = routeKey ? `\n**Route**:${serializeFeishuRouteKey(routeKey)}` : '' + switch (result.type) { + case 'control-panel': + return [ + '**当前控制面**', + `**Session**:${result.sessionId}`, + result.projectName || result.projectId ? `**项目**:${result.projectName ?? result.projectId}` : undefined, + result.directory ? `**目录**:${result.directory}` : undefined, + route.trim() ? route.trim() : undefined, + ].filter(Boolean).join('\n') + case 'new-session': + return `已开启新对话。\n**Session**:${result.sessionId}${result.directory ? `\n**目录**:${result.directory}` : ''}` + case 'cwd-current': + return `当前目录:${result.directory ?? '未设置'}\nSession:${result.sessionId}` + case 'cwd-switched': + return `已切换目录:${result.directory}\nSession:${result.sessionId}` + case 'project-current': + return `当前项目:${result.projectName ?? result.projectId ?? '未绑定'}\n目录:${result.directory ?? '未设置'}\nSession:${result.sessionId}` + case 'project-list': + return [ + '**可用项目**', + ...result.projects.map((project, index) => + `${index + 1}. ${project.name ?? project.id} (${project.id})${project.directory ? ` · ${project.directory}` : ''}` + ), + ].join('\n') + case 'project-switched': + return `已切换项目:${result.projectName ?? result.projectId}\n目录:${result.directory}\nSession:${result.sessionId}` + case 'unknown-command': + return `未知命令:${result.command}` + case 'failed': + return `命令失败:${result.message}` + case 'help': + return ['可用命令:', ...result.commands.map((command) => `- ${command}`)].join('\n') + case 'turn-aborted': + return result.message + } +} + +function card(input: { + title: string + template: 'blue' | 'green' | 'yellow' | 'red' + elements: unknown[] +}): FeishuIMCard { + return { + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + template: input.template, + title: { + tag: 'plain_text', + content: input.title, + }, + }, + elements: input.elements, + } +} + +function markdown(content: string) { + return { + tag: 'markdown', + content, + } +} + +function actions(actions: unknown[]) { + return { + tag: 'action', + actions, + } +} + +function actionButton( + text: string, + action: FeishuCardActionType, + context: FeishuCardActionContext, + options: { + type?: 'default' | 'primary' | 'danger' + value?: Record + } = {}, +) { + const payload = createFeishuCardActionPayload(action, context) + return { + tag: 'button', + text: { + tag: 'plain_text', + content: text, + }, + type: options.type ?? 'default', + value: { + nine1bot: payload, + ...options.value, + }, + } +} + +function linkButton(text: string, url: string) { + return { + tag: 'button', + text: { + tag: 'plain_text', + content: text, + }, + type: 'default', + url, + } +} + +function actionButton2( + text: string, + action: FeishuCardActionType, + context: FeishuCardActionContext, + options: { + type?: 'default' | 'primary' | 'danger' + } = {}, +) { + const payload = createFeishuCardActionPayload(action, context) + return { + tag: 'button', + text: { + tag: 'plain_text', + content: text, + }, + type: options.type ?? 'default', + value: { + nine1bot: payload, + }, + } +} + +function linkButton2(text: string, url: string) { + return { + tag: 'button', + text: { + tag: 'plain_text', + content: text, + }, + type: 'default', + url, + } +} + +function cardContext( + accountId: string, + routeKey: FeishuIMRouteKey, + extra: Partial>, +): FeishuCardActionContext { + return { + accountId, + routeKey, + sessionId: extra.sessionId, + turnSnapshotId: extra.turnSnapshotId, + requestId: extra.requestId, + } +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function arrayString(input: unknown): string[] { + return Array.isArray(input) + ? input.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : [] +} + +function trimStreamingContent(input: string | undefined, maxChars: number): { text: string; truncated: boolean } { + const text = input?.trim() ?? '' + if (!text || text.length <= maxChars) return { text, truncated: false } + return { + text: `${text.slice(0, maxChars).trimEnd()}\n\n...`, + truncated: true, + } +} + +function renderToolStatusLines(tools: FeishuStreamingToolStatus[]): string { + const statusText = { + pending: '等待', + running: '运行中', + completed: '已完成', + failed: '失败', + } + return [ + '**工具状态**', + ...tools.slice(-5).map((tool) => { + const duration = tool.durationMs === undefined + ? '' + : tool.durationMs < 1000 + ? ` · ${tool.durationMs}ms` + : ` · ${(tool.durationMs / 1000).toFixed(1)}s` + const detail = tool.error ?? tool.detail + return `- ${statusText[tool.status]} ${tool.name}${duration}${detail ? `:${detail}` : ''}` + }), + ].join('\n') +} diff --git a/packages/platform-feishu/src/im/config.ts b/packages/platform-feishu/src/im/config.ts new file mode 100644 index 00000000..d49a3adf --- /dev/null +++ b/packages/platform-feishu/src/im/config.ts @@ -0,0 +1,264 @@ +import type { + PlatformSecretRef, + PlatformValidationResult, +} from '@nine1bot/platform-protocol' +import { asRecord } from '../shared' +import type { + FeishuIMAccount, + FeishuIMConnectionMode, + FeishuIMLegacyState, + FeishuIMNormalizedConfig, + FeishuIMPolicy, +} from './types' + +export const FEISHU_IM_DEFAULT_BUFFER_MS = 3_000 +export const FEISHU_IM_DEFAULT_MAX_BUFFER_MS = 8_000 +export const FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS = 600_000 +export const FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS = 1_000 +export const FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS = 6_000 +export const FEISHU_IM_DEFAULT_BUSY_TEXT = '当前会话正在处理中,请稍后再试。' + +export function normalizeFeishuIMConfig( + settings: unknown, + options: { + legacyConfig?: unknown + } = {}, +): FeishuIMNormalizedConfig { + const record = asRecord(settings) ?? {} + const legacy = readLegacyState(options.legacyConfig) + const explicitEnabled = booleanValue(record.imEnabled) + const enabled = explicitEnabled === true + const connectionMode = connectionModeValue(record.imConnectionMode) ?? 'websocket' + const warnings: string[] = [] + const accounts = [ + ...readDefaultAccount(record, connectionMode), + ...readAccounts(record.imAccounts, connectionMode, warnings), + ] + + return { + enabled, + connectionMode, + accounts: dedupeAccounts(accounts).filter((account) => account.enabled), + policy: readPolicy(record), + legacy, + warnings, + } +} + +export function validateFeishuIMConfig(settings: unknown): PlatformValidationResult { + const record = asRecord(settings) ?? {} + const fieldErrors: Record = {} + const connectionMode = connectionModeValue(record.imConnectionMode) + + if (record.imConnectionMode !== undefined && !connectionMode) { + fieldErrors.imConnectionMode = 'Only websocket mode is supported in Phase 1' + } + + const accountsResult = parseAccounts(record.imAccounts, 'websocket') + if (!accountsResult.ok) { + fieldErrors.imAccounts = accountsResult.message + } + + if (record.imMessageBufferMs !== undefined && !validNumber(record.imMessageBufferMs, 0)) { + fieldErrors.imMessageBufferMs = 'Must be a non-negative number' + } + if (record.imMaxBufferMs !== undefined && !validNumber(record.imMaxBufferMs, 0)) { + fieldErrors.imMaxBufferMs = 'Must be a non-negative number' + } + if (record.imReplyTimeoutMs !== undefined && !validNumber(record.imReplyTimeoutMs, 1)) { + fieldErrors.imReplyTimeoutMs = 'Must be a positive number' + } + if (record.imStreamingCardUpdateMs !== undefined && !validNumber(record.imStreamingCardUpdateMs, 1)) { + fieldErrors.imStreamingCardUpdateMs = 'Must be a positive number' + } + if (record.imStreamingCardMaxChars !== undefined && !validNumber(record.imStreamingCardMaxChars, 1)) { + fieldErrors.imStreamingCardMaxChars = 'Must be a positive number' + } + if (record.imReplyPresentation !== undefined && !replyPresentationValue(record.imReplyPresentation)) { + fieldErrors.imReplyPresentation = 'Must be one of auto, text, card, or streaming-card' + } + + const bufferMs = numberValue(record.imMessageBufferMs, FEISHU_IM_DEFAULT_BUFFER_MS) + const maxBufferMs = numberValue(record.imMaxBufferMs, FEISHU_IM_DEFAULT_MAX_BUFFER_MS) + if (bufferMs > maxBufferMs) { + fieldErrors.imMaxBufferMs = 'Must be greater than or equal to message buffer' + } + + const enabled = booleanValue(record.imEnabled) === true + if (enabled) { + const normalized = normalizeFeishuIMConfig(settings) + if (normalized.accounts.length === 0) { + fieldErrors.imAccounts = fieldErrors.imAccounts ?? 'At least one IM account or default app secret is required when IM is enabled' + } + } + + return Object.keys(fieldErrors).length + ? { + ok: false, + message: 'Invalid Feishu IM config', + fieldErrors, + } + : { ok: true } +} + +export function isPlatformSecretRef(input: unknown): input is PlatformSecretRef { + const record = asRecord(input) + return Boolean( + record && + (record.provider === 'nine1bot-local' || record.provider === 'env' || record.provider === 'external') && + typeof record.key === 'string' && + record.key.trim(), + ) +} + +function readDefaultAccount( + settings: Record, + connectionMode: FeishuIMConnectionMode, +): FeishuIMAccount[] { + const appId = stringValue(settings.imDefaultAppId) + const appSecretRef = isPlatformSecretRef(settings.imDefaultAppSecret) + ? settings.imDefaultAppSecret + : undefined + if (!appId || !appSecretRef) return [] + return [{ + id: 'default', + name: 'Default app', + enabled: true, + appId, + appSecretRef, + defaultDirectory: stringValue(settings.imDefaultDirectory), + connectionMode, + }] +} + +function readAccounts( + input: unknown, + fallbackMode: FeishuIMConnectionMode, + warnings: string[], +): FeishuIMAccount[] { + const result = parseAccounts(input, fallbackMode) + if (!result.ok) { + warnings.push(result.message) + return [] + } + return result.accounts +} + +function parseAccounts( + input: unknown, + fallbackMode: FeishuIMConnectionMode, +): { ok: true; accounts: FeishuIMAccount[] } | { ok: false; message: string } { + if (input === undefined || input === null || input === '') return { ok: true, accounts: [] } + let value = input + if (typeof input === 'string') { + try { + value = JSON.parse(input) + } catch { + return { ok: false, message: 'Must be valid JSON' } + } + } + if (!Array.isArray(value)) { + return { ok: false, message: 'Must be an array of account objects' } + } + + const accounts: FeishuIMAccount[] = [] + for (let index = 0; index < value.length; index++) { + const account = asRecord(value[index]) + if (!account) return { ok: false, message: `Account ${index + 1} must be an object` } + if (typeof account.appSecret === 'string' && account.appSecret.trim()) { + return { ok: false, message: 'Accounts must use appSecretRef; plaintext appSecret is not allowed' } + } + const appId = stringValue(account.appId) + if (!appId) return { ok: false, message: `Account ${index + 1} is missing appId` } + if (!isPlatformSecretRef(account.appSecretRef)) { + return { ok: false, message: `Account ${index + 1} is missing appSecretRef` } + } + const connectionMode = connectionModeValue(account.connectionMode) + if (account.connectionMode !== undefined && !connectionMode) { + return { ok: false, message: `Account ${index + 1} has unsupported connectionMode` } + } + accounts.push({ + id: stringValue(account.id) ?? `account-${index + 1}`, + name: stringValue(account.name), + enabled: account.enabled === undefined ? true : account.enabled === true, + appId, + appSecretRef: account.appSecretRef, + defaultDirectory: stringValue(account.defaultDirectory), + connectionMode: connectionMode ?? fallbackMode, + }) + } + + return { ok: true, accounts } +} + +function dedupeAccounts(accounts: FeishuIMAccount[]): FeishuIMAccount[] { + const seen = new Set() + const output: FeishuIMAccount[] = [] + for (const account of accounts) { + if (seen.has(account.id)) continue + seen.add(account.id) + output.push(account) + } + return output +} + +function readPolicy(settings: Record): FeishuIMPolicy { + return { + dmPolicy: settings.imDmPolicy === 'deny' ? 'deny' : 'allow', + groupPolicy: settings.imGroupPolicy === 'allow' || settings.imGroupPolicy === 'deny' + ? settings.imGroupPolicy + : 'mention-only', + allowFrom: stringListValue(settings.imAllowFrom), + replyMode: settings.imReplyMode === 'thread' ? 'thread' : 'message', + replyPresentation: replyPresentationValue(settings.imReplyPresentation) ?? 'auto', + replyTimeoutMs: numberValue(settings.imReplyTimeoutMs, FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS), + streamingCardUpdateMs: numberValue(settings.imStreamingCardUpdateMs, FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS), + streamingCardMaxChars: numberValue(settings.imStreamingCardMaxChars, FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS), + messageBufferMs: numberValue(settings.imMessageBufferMs, FEISHU_IM_DEFAULT_BUFFER_MS), + maxBufferMs: numberValue(settings.imMaxBufferMs, FEISHU_IM_DEFAULT_MAX_BUFFER_MS), + busyRejectText: stringValue(settings.imBusyRejectText) ?? FEISHU_IM_DEFAULT_BUSY_TEXT, + } +} + +function readLegacyState(input: unknown): FeishuIMLegacyState { + const record = asRecord(input) + return { + enabled: record?.enabled === true, + mode: stringValue(record?.mode), + appId: stringValue(record?.appId), + hasAppSecret: Boolean(stringValue(record?.appSecret)), + defaultDirectory: stringValue(record?.defaultDirectory), + } +} + +function connectionModeValue(input: unknown): FeishuIMConnectionMode | undefined { + return input === undefined || input === null || input === '' || input === 'websocket' ? 'websocket' : undefined +} + +function replyPresentationValue(input: unknown): FeishuIMPolicy['replyPresentation'] | undefined { + if (input === undefined || input === null || input === '') return undefined + if (input === 'auto' || input === 'text' || input === 'card' || input === 'streaming-card') return input + return undefined +} + +function booleanValue(input: unknown): boolean | undefined { + return typeof input === 'boolean' ? input : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function stringListValue(input: unknown): string[] { + if (!Array.isArray(input)) return [] + return input.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()) +} + +function numberValue(input: unknown, fallback: number): number { + return typeof input === 'number' && Number.isFinite(input) ? input : fallback +} + +function validNumber(input: unknown, min: number): boolean { + return typeof input === 'number' && Number.isFinite(input) && input >= min +} diff --git a/packages/platform-feishu/src/im/controller-bridge.ts b/packages/platform-feishu/src/im/controller-bridge.ts new file mode 100644 index 00000000..dd3cb142 --- /dev/null +++ b/packages/platform-feishu/src/im/controller-bridge.ts @@ -0,0 +1,177 @@ +import type { + FeishuIMControlResult, + FeishuIMControllerMessagePart, +} from './types' + +export const FEISHU_CONTROLLER_CAPABILITIES = { + interactions: true, + permissionRequests: true, + questionRequests: true, + artifacts: true, + resourceFailures: true, + turnSnapshots: true, + continueInWeb: true, +} as const + +export type FeishuControllerEntry = { + source: 'feishu' + platform: 'feishu' + mode: 'feishu-im' + templateIds: string[] + traceId?: string +} + +export type FeishuControllerContextBlock = { + id: string + layer: 'platform' | 'user' | 'turn' + source: string + enabled: boolean + priority: number + lifecycle: 'turn' + visibility: 'system-required' | 'developer-toggle' + mergeKey?: string + content: string +} + +export type FeishuControllerProject = { + id: string + name?: string + worktree?: string + rootDirectory?: string + time?: { + updated: number + } +} + +export type FeishuControllerSession = { + id: string + projectID?: string + directory: string + title?: string +} + +export type FeishuControllerCreateSessionInput = { + title?: string + directory?: string + entry?: FeishuControllerEntry + contextBlocks?: FeishuControllerContextBlock[] +} + +export type FeishuControllerCreateSessionResult = { + sessionId: string + session: FeishuControllerSession + agent?: string + currentModel?: { + providerID: string + modelID: string + } +} + +export type FeishuControllerSendMessageInput = { + sessionId: string + directory?: string + messageId?: string + parts: FeishuIMControllerMessagePart[] + contextBlocks?: FeishuControllerContextBlock[] + system?: string + entry?: FeishuControllerEntry +} + +export type FeishuControllerMessageResult = { + accepted: boolean + sessionId: string + turnSnapshotId?: string + busy?: boolean + status?: number + fallbackAction?: { + type: 'continue-in-web' + label: string + } +} + +export type FeishuControllerTurnResult = { + completed: boolean + failed?: boolean + text?: string + error?: string +} + +export type FeishuControllerAbortSessionInput = { + sessionId: string + directory?: string + reason?: string +} + +export type FeishuInteractionAnswerInput = { + requestId: string + kind?: 'question' | 'permission' + answer: + | 'allow-once' + | 'allow-session' + | 'deny' + | { + answers: string[][] + } + message?: string +} + +export type FeishuRuntimeEventEnvelope = { + id?: string + version?: string + sessionId?: string + turnSnapshotId?: string + createdAt?: number + type: string + at?: number + data?: unknown + properties?: Record + legacy?: { + type: string + properties?: unknown + } +} + +export type FeishuRuntimeEventSubscription = { + ready?: Promise + stop(): void +} + +export type FeishuControllerBridge = { + createSession(input: FeishuControllerCreateSessionInput): Promise + getSession(input: { sessionId: string; directory?: string }): Promise + sendMessage(input: FeishuControllerSendMessageInput): Promise + getLatestTurnResult?(input: { sessionId: string; directory?: string }): Promise + abortSession(input: FeishuControllerAbortSessionInput): Promise + answerInteraction(input: FeishuInteractionAnswerInput): Promise + listProjects(): Promise + getProject(projectId: string): Promise + subscribeEvents(input: { + sessionId: string + onEvent: (event: FeishuRuntimeEventEnvelope) => void | Promise + onError?: (error: Error) => void | Promise + }): FeishuRuntimeEventSubscription +} + +export function feishuControllerEntry(traceId?: string): FeishuControllerEntry { + return { + source: 'feishu', + platform: 'feishu', + mode: 'feishu-im', + templateIds: ['default-user-template', 'feishu-chat'], + traceId, + } +} + +export function projectDisplayName(project: Pick): string { + return project.name || project.rootDirectory || project.worktree || project.id +} + +export function projectDirectory(project: Pick): string | undefined { + return project.rootDirectory || project.worktree +} + +export function controlResultLabel(result: FeishuIMControlResult): string { + if (result.type === 'failed') return result.message + if (result.type === 'unknown-command') return `Unknown command: ${result.command}` + return result.type +} diff --git a/packages/platform-feishu/src/im/dedup.ts b/packages/platform-feishu/src/im/dedup.ts new file mode 100644 index 00000000..c727d7ab --- /dev/null +++ b/packages/platform-feishu/src/im/dedup.ts @@ -0,0 +1,31 @@ +export class FeishuEventDeduplicator { + private readonly seen = new Map() + + constructor( + private readonly ttlMs = 5 * 60_000, + private readonly maxEntries = 2_000, + ) {} + + accept(key: string | undefined, now = Date.now()): boolean { + if (!key) return true + this.prune(now) + if (this.seen.has(key)) return false + this.seen.set(key, now) + if (this.seen.size > this.maxEntries) { + const oldest = this.seen.keys().next().value + if (oldest) this.seen.delete(oldest) + } + return true + } + + clear() { + this.seen.clear() + } + + private prune(now: number) { + for (const [key, at] of this.seen) { + if (now - at <= this.ttlMs) continue + this.seen.delete(key) + } + } +} diff --git a/packages/platform-feishu/src/im/gateway-interface.ts b/packages/platform-feishu/src/im/gateway-interface.ts new file mode 100644 index 00000000..69ddb3a6 --- /dev/null +++ b/packages/platform-feishu/src/im/gateway-interface.ts @@ -0,0 +1,158 @@ +import { parseFeishuCardAction, type FeishuCardActionPayload, type FeishuCardActionValue } from './interactions' +import type { FeishuIMCard } from './reply-client' +import type { FeishuIMAccount, FeishuIMIncomingMessage } from './types' + +export type FeishuIMGatewayEvent = { + accountId: string + message: FeishuIMIncomingMessage +} + +export type FeishuIMGatewayCardActionEvent = { + accountId: string + payload: FeishuCardActionPayload + value: FeishuCardActionValue + raw: unknown +} + +export type FeishuIMGatewayCardActionResponse = FeishuIMCard | { + toast: { + type: 'success' | 'info' | 'warning' | 'error' + content: string + } + card: { + type: 'raw' + data: FeishuIMCard + } +} + +export type FeishuIMGatewayConnectionState = 'connected' | 'reconnecting' | 'connection-error' | 'stopped' + +export type FeishuIMGatewayConnectionStateEvent = { + accountId: string + state: FeishuIMGatewayConnectionState + at: string + message?: string +} + +export type FeishuIMGatewayOptions = { + account: FeishuIMAccount + onMessage: (event: FeishuIMGatewayEvent) => void | Promise + onCardAction?: (event: FeishuIMGatewayCardActionEvent) => FeishuIMCard | undefined | Promise + onConnectionStateChange?: (event: FeishuIMGatewayConnectionStateEvent) => void | Promise + onOperationalError?: (error: Error) => void | Promise +} + +export type FeishuIMGatewayHandle = { + start(): Promise + stop(): Promise + injectMessage(message: FeishuIMIncomingMessage): Promise + injectCardAction(input: unknown): Promise + isStarted(): boolean +} + +export function createFeishuIMGateway(options: FeishuIMGatewayOptions): FeishuIMGatewayHandle { + let started = false + + return { + async start() { + if (started) return + started = true + await emitConnectionState(options, 'connected') + }, + async stop() { + if (!started) return + started = false + await emitConnectionState(options, 'stopped') + }, + async injectMessage(message) { + if (!started) return + try { + await options.onMessage({ + accountId: options.account.id, + message, + }) + } catch (error) { + await emitOperationalError(options, error) + } + }, + async injectCardAction(input) { + if (!started || !options.onCardAction) return undefined + const parsed = parseFeishuCardAction(input) + if (!parsed.ok) { + await emitOperationalError(options, new Error(`Invalid Feishu card action: ${parsed.reason}`)) + return undefined + } + try { + const card = await options.onCardAction({ + accountId: options.account.id, + payload: parsed.payload, + value: parsed.value, + raw: input, + }) + return formatFeishuCardActionResponse(input, card) + } catch (error) { + await emitOperationalError(options, error) + return undefined + } + }, + isStarted() { + return started + }, + } +} + +export function formatFeishuCardActionResponse( + raw: unknown, + card: FeishuIMCard | undefined, +): FeishuIMGatewayCardActionResponse | undefined { + if (!card) return undefined + if (cardActionEventType(raw) === 'card.action.trigger') { + return { + toast: { + type: 'success', + content: '操作已处理', + }, + card: { + type: 'raw', + data: card, + }, + } + } + return card +} + +function cardActionEventType(raw: unknown): string | undefined { + const record = asRecord(raw) + return stringValue(record?.event_type) + ?? stringValue(asRecord(record?.header)?.event_type) + ?? stringValue(record?.type) +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +async function emitConnectionState( + options: Pick, + state: FeishuIMGatewayConnectionState, + message?: string, +) { + await options.onConnectionStateChange?.({ + accountId: options.account.id, + state, + at: new Date().toISOString(), + message, + }) +} + +async function emitOperationalError( + options: Pick, + error: unknown, +) { + const normalized = error instanceof Error ? error : new Error(String(error)) + await options.onOperationalError?.(normalized) +} diff --git a/packages/platform-feishu/src/im/history.ts b/packages/platform-feishu/src/im/history.ts new file mode 100644 index 00000000..b10f8792 --- /dev/null +++ b/packages/platform-feishu/src/im/history.ts @@ -0,0 +1,56 @@ +import type { FeishuIMIncomingMessage } from './types' + +export type FeishuIMHistoryEntry = { + message: FeishuIMIncomingMessage + recordedAt: number +} + +export class FeishuIMHistoryStore { + private readonly entries = new Map() + + constructor( + private readonly options: { + limit?: number + ttlMs?: number + now?: () => number + } = {}, + ) {} + + record(routeKeyString: string, message: FeishuIMIncomingMessage) { + const now = this.now() + const existing = this.entries.get(routeKeyString) ?? [] + const next = [...existing, { message, recordedAt: now }] + .filter((entry) => now - entry.recordedAt <= this.ttlMs()) + .slice(-this.limit()) + this.entries.set(routeKeyString, next) + } + + list(routeKeyString: string): FeishuIMIncomingMessage[] { + const now = this.now() + const next = (this.entries.get(routeKeyString) ?? []) + .filter((entry) => now - entry.recordedAt <= this.ttlMs()) + .slice(-this.limit()) + this.entries.set(routeKeyString, next) + return next.map((entry) => entry.message) + } + + clear(routeKeyString?: string) { + if (routeKeyString) { + this.entries.delete(routeKeyString) + return + } + this.entries.clear() + } + + private now() { + return this.options.now?.() ?? Date.now() + } + + private limit() { + return this.options.limit ?? 20 + } + + private ttlMs() { + return this.options.ttlMs ?? 10 * 60_000 + } +} diff --git a/packages/platform-feishu/src/im/inbound/gate.ts b/packages/platform-feishu/src/im/inbound/gate.ts new file mode 100644 index 00000000..4b2482fa --- /dev/null +++ b/packages/platform-feishu/src/im/inbound/gate.ts @@ -0,0 +1,65 @@ +import type { + FeishuIMGateDecision, + FeishuIMIncomingMessage, + FeishuIMNormalizedConfig, +} from '../types' + +export function evaluateFeishuIMGate( + message: FeishuIMIncomingMessage, + config: FeishuIMNormalizedConfig, + options: { + botOpenId?: string + botUserId?: string + } = {}, +): FeishuIMGateDecision { + if (!config.enabled) return { action: 'drop', allowed: false, reason: 'not-allowlisted' } + + if (config.policy.allowFrom.length > 0 && !matchesAllowList(message, config.policy.allowFrom)) { + return { action: 'drop', allowed: false, reason: 'not-allowlisted' } + } + + if (message.chatType === 'p2p') { + return config.policy.dmPolicy === 'deny' + ? { action: 'drop', allowed: false, reason: 'dm-denied' } + : { action: 'dispatch', allowed: true } + } + + if (message.chatType === 'group') { + if (config.policy.groupPolicy === 'deny') { + return { action: 'drop', allowed: false, reason: 'group-denied' } + } + if (config.policy.groupPolicy === 'allow') { + return { action: 'dispatch', allowed: true } + } + if (mentionsBot(message, options)) { + return { action: 'dispatch', allowed: true } + } + return { action: 'history', allowed: false, reason: 'mention-required' } + } + + return { action: 'drop', allowed: false, reason: 'not-allowlisted' } +} + +function matchesAllowList(message: FeishuIMIncomingMessage, allowFrom: string[]): boolean { + const candidates = new Set([ + message.chatId, + message.sender.openId, + message.sender.userId, + message.sender.unionId, + ].filter((item): item is string => Boolean(item))) + return allowFrom.some((item) => candidates.has(item)) +} + +function mentionsBot( + message: FeishuIMIncomingMessage, + options: { + botOpenId?: string + botUserId?: string + }, +): boolean { + if (!options.botOpenId && !options.botUserId) return message.mentions.length > 0 + return message.mentions.some((mention) => ( + (options.botOpenId && mention.openId === options.botOpenId) || + (options.botUserId && mention.userId === options.botUserId) + )) +} diff --git a/packages/platform-feishu/src/im/inbound/parse.ts b/packages/platform-feishu/src/im/inbound/parse.ts new file mode 100644 index 00000000..e04495cd --- /dev/null +++ b/packages/platform-feishu/src/im/inbound/parse.ts @@ -0,0 +1,93 @@ +import { asRecord } from '../../shared' +import type { + FeishuIMChatType, + FeishuIMIncomingMessage, + FeishuIMMention, + FeishuIMSender, +} from '../types' + +export function parseFeishuIMEvent(input: unknown): FeishuIMIncomingMessage | undefined { + const envelope = asRecord(input) + const event = asRecord(envelope?.event) ?? envelope + const message = asRecord(event?.message) + if (!event || !message) return undefined + + const messageId = stringValue(message.message_id) + const chatId = stringValue(message.chat_id) + if (!messageId || !chatId) return undefined + + return { + eventId: stringValue(asRecord(envelope?.header)?.event_id) ?? stringValue(envelope?.event_id), + messageId, + rootId: stringValue(message.root_id) ?? stringValue(message.thread_id), + parentId: stringValue(message.parent_id), + chatId, + chatType: chatTypeValue(message.chat_type), + messageType: stringValue(message.message_type) ?? 'unknown', + text: textFromContent(message.content), + sender: senderFrom(event.sender), + mentions: mentionsFrom(message.mentions), + createTime: numberFromString(message.create_time), + raw: input, + } +} + +export function describeIncomingMessageSource(message: FeishuIMIncomingMessage): string { + const sender = message.sender.name || message.sender.openId || message.sender.userId || 'unknown sender' + const chat = message.chatType === 'p2p' ? 'private chat' : message.chatType === 'group' ? 'group chat' : 'chat' + return `${sender} in ${chat} ${message.chatId}` +} + +function senderFrom(input: unknown): FeishuIMSender { + const sender = asRecord(input) + const senderId = asRecord(sender?.sender_id) + return { + openId: stringValue(senderId?.open_id), + userId: stringValue(senderId?.user_id), + unionId: stringValue(senderId?.union_id), + tenantKey: stringValue(sender?.tenant_key), + name: stringValue(sender?.name) ?? stringValue(sender?.display_name), + } +} + +function mentionsFrom(input: unknown): FeishuIMMention[] { + if (!Array.isArray(input)) return [] + return input.map((item) => { + const mention = asRecord(item) + const id = asRecord(mention?.id) + return { + key: stringValue(mention?.key), + name: stringValue(mention?.name), + openId: stringValue(id?.open_id), + userId: stringValue(id?.user_id), + unionId: stringValue(id?.union_id), + } + }) +} + +function textFromContent(input: unknown): string | undefined { + if (typeof input !== 'string' || !input.trim()) return undefined + try { + const parsed = JSON.parse(input) + const record = asRecord(parsed) + return stringValue(record?.text) ?? stringValue(record?.content) ?? input + } catch { + return input + } +} + +function chatTypeValue(input: unknown): FeishuIMChatType { + if (input === 'p2p' || input === 'group') return input + return 'unknown' +} + +function numberFromString(input: unknown): number | undefined { + if (typeof input === 'number' && Number.isFinite(input)) return input + if (typeof input !== 'string') return undefined + const value = Number(input) + return Number.isFinite(value) ? value : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} diff --git a/packages/platform-feishu/src/im/index.ts b/packages/platform-feishu/src/im/index.ts new file mode 100644 index 00000000..28b67dbd --- /dev/null +++ b/packages/platform-feishu/src/im/index.ts @@ -0,0 +1,158 @@ +export { + FEISHU_IM_DEFAULT_BUFFER_MS, + FEISHU_IM_DEFAULT_BUSY_TEXT, + FEISHU_IM_DEFAULT_MAX_BUFFER_MS, + FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS, + FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS, + FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS, + isPlatformSecretRef, + normalizeFeishuIMConfig, + validateFeishuIMConfig, +} from './config' +export { FeishuEventDeduplicator } from './dedup' +export { + FEISHU_IM_ABORT_TEXTS, + isFeishuIMAbortMessage, + isFeishuIMAbortText, + normalizeAbortText, +} from './abort' +export { + createFeishuIMGateway, + formatFeishuCardActionResponse, + type FeishuIMGatewayCardActionEvent, + type FeishuIMGatewayCardActionResponse, + type FeishuIMGatewayConnectionState, + type FeishuIMGatewayConnectionStateEvent, + type FeishuIMGatewayEvent, + type FeishuIMGatewayHandle, + type FeishuIMGatewayOptions, +} from './gateway-interface' +export { + clearFeishuIMRuntimeSnapshotForTesting, + createFeishuIMBackgroundServices, + getFeishuIMRuntimeStatus, +} from './background-runtime' +export { + FEISHU_CONTROLLER_CAPABILITIES, + controlResultLabel, + feishuControllerEntry, + projectDirectory, + projectDisplayName, + type FeishuControllerAbortSessionInput, + type FeishuControllerBridge, + type FeishuControllerContextBlock, + type FeishuControllerCreateSessionInput, + type FeishuControllerCreateSessionResult, + type FeishuControllerEntry, + type FeishuControllerMessageResult, + type FeishuControllerProject, + type FeishuControllerSendMessageInput, + type FeishuControllerSession, + type FeishuControllerTurnResult, + type FeishuInteractionAnswerInput, + type FeishuRuntimeEventEnvelope, + type FeishuRuntimeEventSubscription, +} from './controller-bridge' +export { FeishuIMHistoryStore, type FeishuIMHistoryEntry } from './history' +export { + FeishuIMMessageBuffer, + type FeishuIMBufferedBatch, + type FeishuIMBufferSnapshotEntry, +} from './buffer/message-buffer' +export { + FeishuIMSessionManager, + type FeishuIMActiveTurnSnapshot, + type FeishuIMImmediateReplyInput, + type FeishuIMReplySinkFactoryInput, + type FeishuIMReplySinkHandle as FeishuIMSessionReplySinkHandle, + type FeishuIMSessionManagerOptions, +} from './session-manager' +export { + parseFeishuRouteKey, + routeKeyForFeishuMessage, + serializeFeishuRouteKey, + type FeishuIMRouteKey, +} from './route' +export { + FEISHU_STREAMING_CARD_CONTENT_ELEMENT_ID, + FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID, + renderControlText, + renderFeishuControlCard, + renderFeishuInteractionAnsweredCard, + renderFeishuPermissionCard, + renderFeishuQuestionCard, + renderFeishuStreamingCardKitFinalCard, + renderFeishuStreamingCardKitInitialCard, + renderFeishuStreamingTurnCard, + renderFeishuTurnCard, + type FeishuInteractionCardInput, + type FeishuStreamingToolStatus, + type FeishuStreamingTurnCardInput, + type FeishuTurnCardInput, + type FeishuTurnCardStatus, +} from './cards' +export { + answerFeishuCardInteraction, + createFeishuCardActionPayload, + parseFeishuCardAction, + routeFromFeishuCardAction, + validateFeishuCardActionPayload, + type FeishuCardActionContext, + type FeishuCardActionParseResult, + type FeishuCardActionPayload, + type FeishuCardActionType, + type FeishuCardActionValue, + type FeishuCardInteractionResult, +} from './interactions' +export { + MemoryFeishuIMReplyClient, + type FeishuIMCard, + type FeishuIMCardEntity, + type FeishuIMReplyClient, + type FeishuIMReplyClientTelemetry, + type FeishuIMReplyDelivery, + type FeishuIMReplyTarget, + type FeishuIMResolvedPresentation, + type FeishuIMSentMessage, +} from './reply-client' +export { + FeishuStreamingCardController, + type FeishuStreamingCardControllerOptions, +} from './streaming-card-controller' +export { + FeishuReplySink, + normalizedEventType, + type FeishuReplySinkDoneResult, + type FeishuReplySinkHandle, + type FeishuReplySinkOptions, +} from './reply-sink' +export { + createFeishuIMCardActionHandler, + createFeishuIMImmediateReplyHandler, + createFeishuIMReplySinkFactory, + type FeishuIMReplyCoordinatorOptions, +} from './reply-coordinator' +export { + clearFeishuIMReplyRuntimeSummaryForTesting, + decrementFeishuIMActiveStreamingCards, + getFeishuIMReplyRuntimeRecentEvents, + getFeishuIMReplyRuntimeSummary, + incrementFeishuIMActiveStreamingCards, + recordFeishuIMCardAction, + recordFeishuIMCardUpdateFailure, + recordFeishuIMReplyError, + recordFeishuIMSessionManagerSnapshot, + recordFeishuIMStreamingFallback, + recordFeishuIMStreamingTransport, + resetFeishuIMReplyRuntimeSummary, + subscribeFeishuIMReplyRuntimeSummary, + type FeishuIMReplyRuntimeSummary, +} from './reply-telemetry' +export { + MemoryFeishuIMBindingStore, + type FeishuIMBindingStore, + type FeishuIMSessionBinding, +} from './store/binding-store' +export { evaluateFeishuIMGate } from './inbound/gate' +export { describeIncomingMessageSource, parseFeishuIMEvent } from './inbound/parse' +export type * from './types' diff --git a/packages/platform-feishu/src/im/interactions.ts b/packages/platform-feishu/src/im/interactions.ts new file mode 100644 index 00000000..46792e43 --- /dev/null +++ b/packages/platform-feishu/src/im/interactions.ts @@ -0,0 +1,286 @@ +import { parseFeishuRouteKey, serializeFeishuRouteKey, type FeishuIMRouteKey } from './route' +import type { FeishuControllerBridge, FeishuInteractionAnswerInput } from './controller-bridge' + +export type FeishuCardActionType = + | 'permission.allowOnce' + | 'permission.allowSession' + | 'permission.deny' + | 'question.answer' + | 'question.deny' + | 'control.newSession' + | 'control.projectList' + | 'control.switchProject' + | 'control.showCwd' + | 'control.openWeb' + | 'control.help' + | 'turn.abort' + +export type FeishuCardActionPayload = { + v: 1 + accountId: string + routeKey: string + sessionId?: string + turnSnapshotId?: string + requestId?: string + action: FeishuCardActionType + nonce: string + issuedAt: string +} + +export type FeishuCardActionContext = { + accountId: string + routeKey: FeishuIMRouteKey + sessionId?: string + turnSnapshotId?: string + requestId?: string +} + +export type FeishuCardActionValue = { + answer?: string | string[] | string[][] + projectId?: string + value?: string +} + +export type FeishuCardActionParseResult = + | { + ok: true + payload: FeishuCardActionPayload + value: FeishuCardActionValue + } + | { + ok: false + reason: string + } + +export type FeishuCardInteractionResult = + | { + status: 'answered' + requestId: string + action: FeishuCardActionType + } + | { + status: 'ignored' + reason: string + } + | { + status: 'failed' + reason: string + } + +export function createFeishuCardActionPayload( + action: FeishuCardActionType, + context: FeishuCardActionContext, +): FeishuCardActionPayload { + return { + v: 1, + accountId: context.accountId, + routeKey: serializeFeishuRouteKey(context.routeKey), + sessionId: context.sessionId, + turnSnapshotId: context.turnSnapshotId, + requestId: context.requestId, + action, + nonce: randomNonce(), + issuedAt: new Date().toISOString(), + } +} + +export function parseFeishuCardAction(input: unknown): FeishuCardActionParseResult { + const value = actionValueRecord(input) + const rawPayload = value?.nine1bot ?? value?.payload ?? value + const payload = payloadFrom(rawPayload) + if (!payload) return { ok: false, reason: 'missing-action-payload' } + if (!parseFeishuRouteKey(payload.routeKey)) return { ok: false, reason: 'invalid-route-key' } + return { + ok: true, + payload, + value: { + answer: answerValue(value), + projectId: stringValue(value?.projectId), + value: stringValue(value?.value), + }, + } +} + +export function validateFeishuCardActionPayload( + payload: FeishuCardActionPayload, + expected: { + accountId?: string + routeKey?: string + sessionId?: string + turnSnapshotId?: string + maxAgeMs?: number + now?: number + } = {}, +): { ok: true } | { ok: false; reason: string } { + if (expected.accountId && payload.accountId !== expected.accountId) { + return { ok: false, reason: 'account-mismatch' } + } + if (expected.routeKey && payload.routeKey !== expected.routeKey) { + return { ok: false, reason: 'route-mismatch' } + } + if (expected.sessionId && payload.sessionId && payload.sessionId !== expected.sessionId) { + return { ok: false, reason: 'session-mismatch' } + } + if (expected.turnSnapshotId && payload.turnSnapshotId && payload.turnSnapshotId !== expected.turnSnapshotId) { + return { ok: false, reason: 'turn-mismatch' } + } + if (expected.maxAgeMs !== undefined) { + const issuedAt = Date.parse(payload.issuedAt) + const now = expected.now ?? Date.now() + if (!Number.isFinite(issuedAt) || now - issuedAt > expected.maxAgeMs) { + return { ok: false, reason: 'expired' } + } + } + return { ok: true } +} + +export async function answerFeishuCardInteraction(input: { + controller: FeishuControllerBridge + payload: FeishuCardActionPayload + value?: FeishuCardActionValue + expected?: Parameters[1] +}): Promise { + const validation = validateFeishuCardActionPayload(input.payload, input.expected) + if (!validation.ok) return { status: 'ignored', reason: validation.reason } + + const answer = interactionAnswerFor(input.payload, input.value) + if (!answer) return { status: 'ignored', reason: 'not-an-interaction-action' } + + try { + const accepted = await input.controller.answerInteraction(answer) + return accepted + ? { status: 'answered', requestId: answer.requestId, action: input.payload.action } + : { status: 'failed', reason: 'controller-rejected' } + } catch (error) { + return { status: 'failed', reason: error instanceof Error ? error.message : String(error) } + } +} + +export function routeFromFeishuCardAction(payload: FeishuCardActionPayload): FeishuIMRouteKey | undefined { + return parseFeishuRouteKey(payload.routeKey) +} + +function interactionAnswerFor( + payload: FeishuCardActionPayload, + value?: FeishuCardActionValue, +): FeishuInteractionAnswerInput | undefined { + if (!payload.requestId) return undefined + if (payload.action === 'permission.allowOnce') { + return { + requestId: payload.requestId, + kind: 'permission', + answer: 'allow-once', + } + } + if (payload.action === 'permission.allowSession') { + return { + requestId: payload.requestId, + kind: 'permission', + answer: 'allow-session', + } + } + if (payload.action === 'permission.deny') { + return { + requestId: payload.requestId, + kind: 'permission', + answer: 'deny', + } + } + if (payload.action === 'question.deny') { + return { + requestId: payload.requestId, + kind: 'question', + answer: 'deny', + } + } + if (payload.action === 'question.answer') { + return { + requestId: payload.requestId, + kind: 'question', + answer: { + answers: normalizeQuestionAnswers(value?.answer ?? value?.value), + }, + } + } + return undefined +} + +function payloadFrom(input: unknown): FeishuCardActionPayload | undefined { + const record = asRecord(input) + if (!record) return undefined + if (record.v !== 1) return undefined + const accountId = stringValue(record.accountId) + const routeKey = stringValue(record.routeKey) + const action = actionType(record.action) + const nonce = stringValue(record.nonce) + const issuedAt = stringValue(record.issuedAt) + if (!accountId || !routeKey || !action || !nonce || !issuedAt) return undefined + return { + v: 1, + accountId, + routeKey, + sessionId: stringValue(record.sessionId), + turnSnapshotId: stringValue(record.turnSnapshotId), + requestId: stringValue(record.requestId), + action, + nonce, + issuedAt, + } +} + +function actionValueRecord(input: unknown): Record | undefined { + const record = asRecord(input) + if (!record) return undefined + const action = asRecord(record.action) + const value = asRecord(action?.value) ?? asRecord(record.value) + return value ?? record +} + +function actionType(input: unknown): FeishuCardActionType | undefined { + return typeof input === 'string' && [ + 'permission.allowOnce', + 'permission.allowSession', + 'permission.deny', + 'question.answer', + 'question.deny', + 'control.newSession', + 'control.projectList', + 'control.switchProject', + 'control.showCwd', + 'control.openWeb', + 'control.help', + 'turn.abort', + ].includes(input) + ? input as FeishuCardActionType + : undefined +} + +function normalizeQuestionAnswers(input: unknown): string[][] { + if (Array.isArray(input)) { + if (input.every((item) => Array.isArray(item))) { + return input.map((item) => item.filter((value): value is string => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())) + } + return [input.filter((value): value is string => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())] + } + const value = typeof input === 'string' && input.trim() ? input.trim() : 'deny' + return [[value]] +} + +function answerValue(record: Record | undefined): FeishuCardActionValue['answer'] { + if (!record) return undefined + if (record.answer !== undefined) return record.answer as FeishuCardActionValue['answer'] + if (record.answers !== undefined) return record.answers as FeishuCardActionValue['answer'] + return undefined +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function randomNonce(): string { + return Math.random().toString(36).slice(2, 10) +} diff --git a/packages/platform-feishu/src/im/node/binding-store.ts b/packages/platform-feishu/src/im/node/binding-store.ts new file mode 100644 index 00000000..4285db1d --- /dev/null +++ b/packages/platform-feishu/src/im/node/binding-store.ts @@ -0,0 +1,111 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import { serializeFeishuRouteKey } from '../route' +import type { FeishuIMBindingStore, FeishuIMSessionBinding } from '../store/binding-store' + +type FeishuIMBindingsFile = { + version: 2 + bindings: FeishuIMSessionBinding[] +} + +export type FeishuFileIMBindingStoreOptions = { + filepath?: string + env?: Record +} + +export class FeishuFileIMBindingStore implements FeishuIMBindingStore { + private loaded = false + private readonly bindings = new Map() + readonly filepath: string + + constructor(options: FeishuFileIMBindingStoreOptions = {}) { + this.filepath = options.filepath ?? defaultFeishuIMBindingStorePath(options.env) + } + + async get(routeKey: string): Promise { + await this.load() + const binding = this.bindings.get(routeKey) + return binding ? cloneBinding(binding) : undefined + } + + async set(routeKey: string, binding: FeishuIMSessionBinding): Promise { + await this.load() + this.bindings.set(routeKey, cloneBinding(binding)) + await this.save() + } + + async delete(routeKey: string): Promise { + await this.load() + if (!this.bindings.delete(routeKey)) return + await this.save() + } + + private async load(): Promise { + if (this.loaded) return + if (!(await fileExists(this.filepath))) { + this.loaded = true + return + } + + try { + const parsed = JSON.parse(await readFile(this.filepath, 'utf8')) as Partial + const bindings = Array.isArray(parsed.bindings) ? parsed.bindings : [] + for (const binding of bindings) { + if (!isBinding(binding)) continue + this.bindings.set(serializeFeishuRouteKey(binding.routeKey), cloneBinding(binding)) + } + } catch { + this.bindings.clear() + } + this.loaded = true + } + + private async save(): Promise { + await mkdir(dirname(this.filepath), { recursive: true }) + const payload: FeishuIMBindingsFile = { + version: 2, + bindings: [...this.bindings.values()], + } + await writeFile(this.filepath, JSON.stringify(payload, null, 2), 'utf8') + } +} + +export function defaultFeishuIMBindingStorePath(env: Record = process.env): string { + const explicit = env.NINE1BOT_DATA_DIR + const base = explicit && explicit.trim() + ? explicit.trim() + : process.platform === 'win32' + ? env.LOCALAPPDATA + ? join(env.LOCALAPPDATA, 'nine1bot') + : join(homedir(), 'AppData', 'Local', 'nine1bot') + : env.XDG_DATA_HOME + ? join(env.XDG_DATA_HOME, 'nine1bot') + : join(homedir(), '.local', 'share', 'nine1bot') + return join(base, 'feishu-im-bindings-v2.json') +} + +async function fileExists(filepath: string): Promise { + try { + await access(filepath) + return true + } catch { + return false + } +} + +function isBinding(input: unknown): input is FeishuIMSessionBinding { + const record = input && typeof input === 'object' ? input as Partial : undefined + return Boolean( + record?.routeKey && + typeof record.sessionId === 'string' && + typeof record.updatedAt === 'string', + ) +} + +function cloneBinding(binding: FeishuIMSessionBinding): FeishuIMSessionBinding { + return { + ...binding, + routeKey: { ...binding.routeKey }, + } +} diff --git a/packages/platform-feishu/src/im/node/http-controller-bridge.ts b/packages/platform-feishu/src/im/node/http-controller-bridge.ts new file mode 100644 index 00000000..d7f327db --- /dev/null +++ b/packages/platform-feishu/src/im/node/http-controller-bridge.ts @@ -0,0 +1,314 @@ +import type { PlatformControllerBridge } from '@nine1bot/platform-protocol' +import { + FEISHU_CONTROLLER_CAPABILITIES, + feishuControllerEntry, + type FeishuControllerBridge, + type FeishuControllerAbortSessionInput, + type FeishuControllerCreateSessionInput, + type FeishuControllerCreateSessionResult, + type FeishuControllerMessageResult, + type FeishuControllerProject, + type FeishuControllerSendMessageInput, + type FeishuControllerSession, + type FeishuControllerTurnResult, + type FeishuInteractionAnswerInput, + type FeishuRuntimeEventEnvelope, + type FeishuRuntimeEventSubscription, +} from '../controller-bridge' + +export type FeishuHttpControllerBridgeOptions = { + localUrl: string + authHeader?: string + requestTimeoutMs?: number + platformController?: PlatformControllerBridge +} + +export function createHttpFeishuControllerBridge(options: FeishuHttpControllerBridgeOptions): FeishuControllerBridge { + const request = async (path: string, init: { + method?: string + directory?: string + body?: unknown + timeoutMs?: number + signal?: AbortSignal + acceptSse?: boolean + allowStatus?: number[] + } = {}): Promise<{ status: number; ok: boolean; body: T; response: Response }> => { + const url = new URL(path, options.localUrl) + const headers = new Headers() + if (options.authHeader) headers.set('authorization', options.authHeader) + if (init.directory) { + headers.set('x-opencode-directory', init.directory) + if (!url.searchParams.has('directory')) url.searchParams.set('directory', init.directory) + } + if (init.acceptSse) { + headers.set('accept', 'text/event-stream') + } else if (init.body !== undefined) { + headers.set('content-type', 'application/json') + } + + if (options.platformController?.requestJson && !init.acceptSse && !init.allowStatus?.length) { + const body = await options.platformController.requestJson(`${url.pathname}${url.search}`, { + method: init.method ?? 'GET', + headers: headersToRecord(headers), + body: init.body, + }) + return { + status: 200, + ok: true, + body, + response: new Response(JSON.stringify(body), { status: 200 }), + } + } + + const timeoutMs = init.timeoutMs ?? options.requestTimeoutMs ?? 30_000 + const controller = new AbortController() + const timeout = timeoutMs > 0 + ? setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs) + : undefined + const linkedAbort = () => controller.abort() + init.signal?.addEventListener('abort', linkedAbort, { once: true }) + + try { + const response = await fetch(url.toString(), { + method: init.method ?? 'GET', + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: controller.signal, + }) + const text = await response.text() + const body = text ? JSON.parse(text) as T : true as T + if (!response.ok && !init.allowStatus?.includes(response.status)) { + throw new Error(text || `Request failed: ${response.status} ${response.statusText}`) + } + return { + status: response.status, + ok: response.ok, + body, + response, + } + } finally { + if (timeout) clearTimeout(timeout) + init.signal?.removeEventListener('abort', linkedAbort) + } + } + + return { + async createSession(input: FeishuControllerCreateSessionInput) { + const result = await request('/nine1bot/agent/sessions', { + method: 'POST', + directory: input.directory, + body: { + title: input.title, + directory: input.directory, + entry: input.entry ?? feishuControllerEntry(), + context: input.contextBlocks?.length ? { blocks: input.contextBlocks } : undefined, + clientCapabilities: FEISHU_CONTROLLER_CAPABILITIES, + }, + }) + return result.body + }, + async getSession(input) { + const result = await request(`/session/${encodeURIComponent(input.sessionId)}`, { + directory: input.directory, + allowStatus: [404], + }).catch(() => undefined) + return result?.ok ? result.body : undefined + }, + async sendMessage(input: FeishuControllerSendMessageInput): Promise { + const result = await request( + `/nine1bot/agent/sessions/${encodeURIComponent(input.sessionId)}/messages`, + { + method: 'POST', + directory: input.directory, + allowStatus: [409], + body: { + parts: input.parts, + system: input.system, + context: input.contextBlocks?.length ? { blocks: input.contextBlocks } : undefined, + entry: input.entry ?? feishuControllerEntry(input.messageId), + clientCapabilities: FEISHU_CONTROLLER_CAPABILITIES, + }, + }, + ) + return { + ...result.body, + status: result.status, + } + }, + async getLatestTurnResult(input): Promise { + const result = await request(`/session/${encodeURIComponent(input.sessionId)}/message?limit=8`, { + directory: input.directory, + allowStatus: [404], + }).catch(() => undefined) + if (!result?.ok || !Array.isArray(result.body)) return undefined + return latestTurnResultFromMessages(result.body) + }, + async abortSession(input: FeishuControllerAbortSessionInput): Promise { + const result = await request(`/session/${encodeURIComponent(input.sessionId)}/abort`, { + method: 'POST', + directory: input.directory, + }) + return Boolean(result.body) + }, + async answerInteraction(input: FeishuInteractionAnswerInput) { + const result = await request(`/nine1bot/agent/interactions/${encodeURIComponent(input.requestId)}/answer`, { + method: 'POST', + body: { + kind: input.kind, + answer: input.answer, + message: input.message, + }, + }) + return Boolean(result.body) + }, + async listProjects() { + const result = await request('/project') + return [...result.body].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)) + }, + async getProject(projectId) { + const result = await request(`/project/${encodeURIComponent(projectId)}`, { + allowStatus: [404], + }).catch(() => undefined) + return result?.ok ? result.body : undefined + }, + subscribeEvents(input): FeishuRuntimeEventSubscription { + const abort = new AbortController() + let resolveReady!: () => void + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + consumeSse({ + localUrl: options.localUrl, + authHeader: options.authHeader, + sessionId: input.sessionId, + signal: abort.signal, + onOpen: resolveReady, + onEvent: input.onEvent, + onError: input.onError, + }).catch((error) => { + if (!abort.signal.aborted) { + void input.onError?.(error instanceof Error ? error : new Error(String(error))) + } + }) + return { + ready, + stop() { + abort.abort() + }, + } + }, + } +} + +function latestTurnResultFromMessages(messages: unknown[]): FeishuControllerTurnResult | undefined { + const latestAssistant = [...messages] + .reverse() + .map(asRecord) + .find((message) => asRecord(message?.info)?.role === 'assistant') + if (!latestAssistant) return undefined + + const info = asRecord(latestAssistant.info) + const error = asRecord(info?.error) + const completed = Boolean(asRecord(info?.time)?.completed) || Boolean(info?.finish) || Boolean(info?.error) + return { + completed, + failed: Boolean(info?.error), + text: textFromMessageParts(Array.isArray(latestAssistant.parts) ? latestAssistant.parts : []), + error: stringValue(error?.message) ?? stringValue(error?.name), + } +} + +function textFromMessageParts(parts: unknown[]): string | undefined { + const text = parts + .map(asRecord) + .filter((part) => part?.type === 'text' && part.ignored !== true && part.synthetic !== true) + .filter((part) => { + const metadata = asRecord(part?.metadata) + const kind = stringValue(metadata?.kind) ?? stringValue(metadata?.type) + return kind !== 'reasoning' && kind !== 'thinking' + }) + .map((part) => stringValue(part?.text)) + .filter((part): part is string => Boolean(part)) + .filter((part) => !part.trimStart().startsWith(' { + const record: Record = {} + headers.forEach((value, key) => { + record[key] = value + }) + return record +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +async function consumeSse(input: { + localUrl: string + authHeader?: string + sessionId: string + signal: AbortSignal + onOpen?: () => void + onEvent: (event: FeishuRuntimeEventEnvelope) => void | Promise + onError?: (error: Error) => void | Promise +}) { + const url = new URL(`/nine1bot/agent/sessions/${encodeURIComponent(input.sessionId)}/events`, input.localUrl) + const headers = new Headers({ accept: 'text/event-stream' }) + if (input.authHeader) headers.set('authorization', input.authHeader) + const response = await fetch(url.toString(), { + headers, + signal: input.signal, + }) + if (!response.ok || !response.body) { + throw new Error(`Event subscription failed: ${response.status} ${response.statusText}`) + } + input.onOpen?.() + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let dataLines: string[] = [] + const dispatch = async () => { + if (dataLines.length === 0) return + const payload = dataLines.join('\n').trimEnd() + dataLines = [] + if (!payload) return + const parsed = JSON.parse(payload) as FeishuRuntimeEventEnvelope + await input.onEvent(parsed) + } + const processLine = async (rawLine: string) => { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (line === '') { + await dispatch() + return + } + if (!line.startsWith('data:')) return + const value = line.slice(5) + dataLines.push(value.startsWith(' ') ? value.slice(1) : value) + } + try { + while (!input.signal.aborted) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + for (const line of lines) { + await processLine(line) + } + } + buffer += decoder.decode() + if (buffer) await processLine(buffer) + await dispatch() + } finally { + reader.releaseLock() + } +} diff --git a/packages/platform-feishu/src/im/node/reply-client.ts b/packages/platform-feishu/src/im/node/reply-client.ts new file mode 100644 index 00000000..6da294ad --- /dev/null +++ b/packages/platform-feishu/src/im/node/reply-client.ts @@ -0,0 +1,227 @@ +import type { + FeishuIMCard, + FeishuIMReplyClient, + FeishuIMReplyDelivery, + FeishuIMSentMessage, +} from '../reply-client' + +export type FeishuNodeReplyClientOptions = { + client: { + im?: { + message?: FeishuNodeMessageApi + } + cardkit?: { + v1?: { + card?: FeishuNodeCardKitCardApi + cardElement?: FeishuNodeCardKitElementApi + } + } + } + receiveIdType?: 'chat_id' | 'open_id' | 'user_id' | 'union_id' +} + +type FeishuNodeMessageApi = { + create?: (input: unknown) => Promise + reply?: (input: unknown) => Promise + update?: (input: unknown) => Promise + patch?: (input: unknown) => Promise +} + +type FeishuNodeCardKitCardApi = { + create?: (input: unknown) => Promise + update?: (input: unknown) => Promise + settings?: (input: unknown) => Promise +} + +type FeishuNodeCardKitElementApi = { + content?: (input: unknown) => Promise +} + +type FeishuMessageIdentity = Pick + +export function createFeishuNodeReplyClient(options: FeishuNodeReplyClientOptions): FeishuIMReplyClient { + const receiveIdType = options.receiveIdType ?? 'chat_id' + const messageApi = options.client.im?.message + const cardApi = options.client.cardkit?.v1?.card + const cardElementApi = options.client.cardkit?.v1?.cardElement + return { + async sendText(input) { + const response = await sendMessage(messageApi, input, 'text', { text: input.text }, receiveIdType) + return normalizeSentMessage(response) + }, + async sendCard(input) { + const response = await sendMessage(messageApi, input, 'interactive', input.card, receiveIdType) + return normalizeSentMessage(response) + }, + async updateCard(input) { + if (!messageApi?.update && !messageApi?.patch) { + throw new Error('Feishu message update API is unavailable') + } + if (!input.messageId) { + throw new Error('Feishu message update requires message_id') + } + const method = messageApi.patch ?? messageApi.update! + const response = await method({ + path: { + message_id: input.messageId, + }, + data: { + content: JSON.stringify(input.card), + }, + }) + assertFeishuOk(response, 'im.message.patch') + return normalizeSentMessage(response, { + messageId: input.messageId, + cardId: input.cardId, + }) + }, + async createCardEntity(input) { + if (!cardApi?.create) throw new Error('Feishu CardKit create API is unavailable') + const response = await cardApi.create({ + data: { + type: 'card_json', + data: JSON.stringify(input.card), + }, + }) + assertFeishuOk(response, 'cardkit.card.create') + const cardId = stringValue(asRecord(asRecord(response)?.data)?.card_id) + ?? stringValue(asRecord(response)?.card_id) + if (!cardId) throw new Error('Feishu CardKit create response did not include card_id') + return { cardId, raw: response } + }, + async sendCardEntity(input) { + const response = await sendMessage( + messageApi, + input, + 'interactive', + { type: 'card', data: { card_id: input.cardId } }, + receiveIdType, + ) + return normalizeSentMessage(response, { cardId: input.cardId }) + }, + async streamCardContent(input) { + if (!cardElementApi?.content) throw new Error('Feishu CardKit content API is unavailable') + const response = await cardElementApi.content({ + path: { + card_id: input.cardId, + element_id: input.elementId, + }, + data: { + content: input.content, + sequence: input.sequence, + }, + }) + assertFeishuOk(response, 'cardkit.cardElement.content') + }, + async updateCardEntity(input) { + if (!cardApi?.update) throw new Error('Feishu CardKit update API is unavailable') + const response = await cardApi.update({ + path: { + card_id: input.cardId, + }, + data: { + card: { + type: 'card_json', + data: JSON.stringify(input.card), + }, + sequence: input.sequence, + }, + }) + assertFeishuOk(response, 'cardkit.card.update') + }, + async setCardStreamingMode(input) { + if (!cardApi?.settings) throw new Error('Feishu CardKit settings API is unavailable') + const response = await cardApi.settings({ + path: { + card_id: input.cardId, + }, + data: { + settings: JSON.stringify({ + config: { + streaming_mode: input.streaming, + }, + }), + sequence: input.sequence, + }, + }) + assertFeishuOk(response, 'cardkit.card.settings') + }, + } +} + +async function sendMessage( + api: FeishuNodeMessageApi | undefined, + input: FeishuIMReplyDelivery, + msgType: 'text' | 'interactive', + content: { text: string } | FeishuIMCard, + receiveIdType: string, +): Promise { + if (!api?.create && !api?.reply) { + throw new Error('Feishu message send API is unavailable') + } + const data = { + receive_id: input.chatId, + msg_type: msgType, + content: JSON.stringify(content), + } + if (input.rootMessageId && api.reply) { + const response = await api.reply({ + path: { + message_id: input.rootMessageId, + }, + data: { + msg_type: msgType, + content: JSON.stringify(content), + reply_in_thread: input.replyTarget === 'thread', + }, + }) + assertFeishuOk(response, 'im.message.reply') + return response + } + const response = await api.create!({ + params: { + receive_id_type: receiveIdType, + }, + data, + }) + assertFeishuOk(response, 'im.message.create') + return response +} + +function normalizeSentMessage(input: unknown, fallback: FeishuMessageIdentity = {}): FeishuIMSentMessage { + const record = asRecord(input) + const data = asRecord(record?.data) ?? record + const messageId = stringValue(data?.message_id) + ?? stringValue(data?.messageId) + ?? stringValue(asRecord(data?.message)?.message_id) + return { + messageId: messageId ?? fallback.messageId, + cardId: stringValue(data?.card_id) ?? stringValue(data?.cardId) ?? fallback.cardId, + raw: input, + } +} + +function assertFeishuOk(input: unknown, api: string): void { + const record = asRecord(input) + const code = typeof record?.code === 'number' ? record.code : 0 + if (code && code !== 0) { + const error = asRecord(record?.error) + const details = [ + `code=${code}`, + stringValue(record?.msg) ? `msg=${stringValue(record?.msg)}` : undefined, + stringValue(record?.log_id) ? `log_id=${stringValue(record?.log_id)}` : undefined, + stringValue(error?.log_id) ? `log_id=${stringValue(error?.log_id)}` : undefined, + stringValue(record?.troubleshooter) ? `troubleshooter=${stringValue(record?.troubleshooter)}` : undefined, + stringValue(error?.troubleshooter) ? `troubleshooter=${stringValue(error?.troubleshooter)}` : undefined, + ].filter(Boolean).join(', ') + throw new Error(`${api} failed: ${details}`) + } +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} diff --git a/packages/platform-feishu/src/im/node/ws-gateway.ts b/packages/platform-feishu/src/im/node/ws-gateway.ts new file mode 100644 index 00000000..05657be5 --- /dev/null +++ b/packages/platform-feishu/src/im/node/ws-gateway.ts @@ -0,0 +1,236 @@ +import { FeishuEventDeduplicator } from '../dedup' +import { parseFeishuCardAction } from '../interactions' +import { parseFeishuIMEvent } from '../inbound/parse' +import { + formatFeishuCardActionResponse, + type FeishuIMGatewayCardActionEvent, + type FeishuIMGatewayCardActionResponse, + type FeishuIMGatewayConnectionState, + type FeishuIMGatewayEvent, + type FeishuIMGatewayHandle, +} from '../gateway-interface' +import type { FeishuIMCard } from '../reply-client' +import type { FeishuIMAccount } from '../types' + +export type FeishuNodeIMGatewayOptions = { + account: FeishuIMAccount + appSecret: string + onMessage: (event: FeishuIMGatewayEvent) => void | Promise + onCardAction?: (event: FeishuIMGatewayCardActionEvent) => FeishuIMCard | undefined | Promise + onConnectionStateChange?: (event: { + accountId: string + state: FeishuIMGatewayConnectionState + at: string + message?: string + }) => void | Promise + onOperationalError?: (error: Error) => void | Promise +} + +type FeishuSdkDomain = { + Feishu: unknown +} + +type FeishuSdkLoggerLevel = { + info: unknown +} + +type FeishuSdkEventDispatcherInstance = { + register(events: Record unknown>): unknown +} + +type FeishuSdkEventDispatcher = new (input: Record) => FeishuSdkEventDispatcherInstance + +type FeishuSdkWsClientInstance = { + start(input: unknown): Promise + close(input: { force: boolean }): void +} + +type FeishuSdkWsClient = new (input: { + appId: string + appSecret: string + domain: unknown + logger: ReturnType + loggerLevel: unknown + autoReconnect: boolean +}) => FeishuSdkWsClientInstance + +type FeishuNodeGatewaySdkModule = { + Domain: FeishuSdkDomain + EventDispatcher: FeishuSdkEventDispatcher + LoggerLevel: FeishuSdkLoggerLevel + WSClient: FeishuSdkWsClient +} + +let sdkLoader: (() => Promise) | undefined + +export function setFeishuNodeGatewaySdkLoaderForTesting( + loader?: () => Promise, +) { + sdkLoader = loader +} + +export function createFeishuNodeIMGateway(options: FeishuNodeIMGatewayOptions): FeishuIMGatewayHandle { + const dedup = new FeishuEventDeduplicator() + let wsClient: FeishuSdkWsClientInstance | undefined + let started = false + + const handleRawMessage = async (raw: unknown) => { + if (senderType(raw) && senderType(raw) !== 'user') return + const message = parseFeishuIMEvent(raw) + if (!message) return + if (!dedup.accept(message.eventId)) return + if (!dedup.accept(`message:${message.messageId}`)) return + try { + await options.onMessage({ + accountId: options.account.id, + message, + }) + } catch (error) { + await emitOperationalError(options, error) + } + } + + const handleRawCardAction = async (raw: unknown): Promise => { + if (!options.onCardAction) return undefined + const parsed = parseFeishuCardAction(raw) + if (!parsed.ok) { + await emitOperationalError(options, new Error(`Invalid Feishu card action: ${parsed.reason}`)) + return undefined + } + if (!dedup.accept(`card-action:${parsed.payload.nonce}`)) return undefined + try { + const card = await options.onCardAction({ + accountId: options.account.id, + payload: parsed.payload, + value: parsed.value, + raw, + }) + return formatFeishuCardActionResponse(raw, card) + } catch (error) { + await emitOperationalError(options, error) + return undefined + } + } + + return { + async start() { + if (started) return + const { Domain, EventDispatcher, LoggerLevel, WSClient } = await loadSdk() + wsClient = new WSClient({ + appId: options.account.appId, + appSecret: options.appSecret, + domain: Domain.Feishu, + logger: createLogger(options), + loggerLevel: LoggerLevel.info, + autoReconnect: true, + }) + await wsClient.start({ + eventDispatcher: new EventDispatcher({}).register({ + 'im.message.receive_v1': handleRawMessage, + 'im.message.message_read_v1': async () => {}, + 'card.action.trigger': handleRawCardAction, + 'card.action.trigger_v1': handleRawCardAction, + }), + }) + started = true + await emitConnectionState(options, 'connected') + }, + async stop() { + if (!started) return + started = false + dedup.clear() + wsClient?.close({ force: true }) + wsClient = undefined + await emitConnectionState(options, 'stopped') + }, + async injectMessage(message) { + if (!started) return + if (!dedup.accept(message.eventId)) return + if (!dedup.accept(`message:${message.messageId}`)) return + try { + await options.onMessage({ + accountId: options.account.id, + message, + }) + } catch (error) { + await emitOperationalError(options, error) + } + }, + async injectCardAction(input) { + if (!started) return undefined + return await handleRawCardAction(input) + }, + isStarted() { + return started + }, + } +} + +function senderType(raw: unknown): string | undefined { + const envelope = asRecord(raw) + const event = asRecord(envelope?.event) ?? envelope + const sender = asRecord(event?.sender) + return stringValue(sender?.sender_type) +} + +function createLogger(options: Pick) { + return { + error: (...msg: unknown[]) => { + const rendered = msg.map(String).join(' ') + void emitConnectionState(options, 'connection-error', rendered) + }, + warn: (...msg: unknown[]) => { + const rendered = msg.map(String).join(' ') + if (isReconnectLikeWarning(rendered)) { + void emitConnectionState(options, 'reconnecting', rendered) + return + } + void emitOperationalError(options, new Error(rendered)) + }, + info: () => {}, + debug: () => {}, + trace: () => {}, + } +} + +async function loadSdk(): Promise { + if (sdkLoader) return await sdkLoader() + return await import('@larksuiteoapi/node-sdk') as unknown as FeishuNodeGatewaySdkModule +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function isReconnectLikeWarning(message: string): boolean { + const lowered = message.toLowerCase() + return lowered.includes('reconnect') + || lowered.includes('disconnect') + || lowered.includes('close') + || lowered.includes('socket') +} + +async function emitConnectionState( + options: Pick, + state: FeishuIMGatewayConnectionState, + message?: string, +) { + await options.onConnectionStateChange?.({ + accountId: options.account.id, + state, + at: new Date().toISOString(), + message, + }) +} + +async function emitOperationalError( + options: Pick, + error: unknown, +) { + const normalized = error instanceof Error ? error : new Error(String(error)) + await options.onOperationalError?.(normalized) +} diff --git a/packages/platform-feishu/src/im/reply-client.ts b/packages/platform-feishu/src/im/reply-client.ts new file mode 100644 index 00000000..3e458b6a --- /dev/null +++ b/packages/platform-feishu/src/im/reply-client.ts @@ -0,0 +1,105 @@ +import type { FeishuIMReplyPresentation } from './types' + +export type FeishuIMReplyTarget = 'message' | 'thread' + +export type FeishuIMResolvedPresentation = Exclude + +export type FeishuIMCard = Record + +export type FeishuIMReplyDelivery = { + chatId: string + rootMessageId?: string + replyTarget: FeishuIMReplyTarget +} + +export type FeishuIMSentMessage = { + messageId?: string + cardId?: string + raw?: unknown +} + +export type FeishuIMCardEntity = { + cardId: string + raw?: unknown +} + +export type FeishuIMReplyClient = { + sendText(input: FeishuIMReplyDelivery & { + text: string + }): Promise + sendCard(input: FeishuIMReplyDelivery & { + card: FeishuIMCard + }): Promise + updateCard(input: { + messageId?: string + cardId?: string + card: FeishuIMCard + }): Promise + createCardEntity?(input: { + card: FeishuIMCard + }): Promise + sendCardEntity?(input: FeishuIMReplyDelivery & { + cardId: string + }): Promise + streamCardContent?(input: { + cardId: string + elementId: string + content: string + sequence: number + }): Promise + updateCardEntity?(input: { + cardId: string + card: FeishuIMCard + sequence: number + }): Promise + setCardStreamingMode?(input: { + cardId: string + streaming: boolean + sequence: number + }): Promise +} + +export type FeishuIMReplyClientTelemetry = { + sentText: number + sentCards: number + updatedCards: number +} + +export class MemoryFeishuIMReplyClient implements FeishuIMReplyClient { + readonly texts: Array = [] + readonly cards: Array = [] + readonly updates: Array<{ messageId?: string; cardId?: string; card: FeishuIMCard }> = [] + + async sendText(input: FeishuIMReplyDelivery & { text: string }): Promise { + this.texts.push(clone(input)) + return { messageId: `text_${this.texts.length}` } + } + + async sendCard(input: FeishuIMReplyDelivery & { card: FeishuIMCard }): Promise { + this.cards.push(clone(input)) + return { + messageId: `card_message_${this.cards.length}`, + cardId: `card_${this.cards.length}`, + } + } + + async updateCard(input: { messageId?: string; cardId?: string; card: FeishuIMCard }): Promise { + this.updates.push(clone(input)) + return { + messageId: input.messageId, + cardId: input.cardId, + } + } + + telemetry(): FeishuIMReplyClientTelemetry { + return { + sentText: this.texts.length, + sentCards: this.cards.length, + updatedCards: this.updates.length, + } + } +} + +function clone(input: T): T { + return JSON.parse(JSON.stringify(input)) as T +} diff --git a/packages/platform-feishu/src/im/reply-coordinator.ts b/packages/platform-feishu/src/im/reply-coordinator.ts new file mode 100644 index 00000000..3df3760b --- /dev/null +++ b/packages/platform-feishu/src/im/reply-coordinator.ts @@ -0,0 +1,152 @@ +import { renderControlText, renderFeishuControlCard, renderFeishuInteractionAnsweredCard } from './cards' +import type { FeishuControllerBridge } from './controller-bridge' +import type { FeishuIMGatewayCardActionEvent } from './gateway-interface' +import { answerFeishuCardInteraction, routeFromFeishuCardAction } from './interactions' +import { FeishuReplySink } from './reply-sink' +import type { FeishuIMReplyClient } from './reply-client' +import type { + FeishuIMAccount, + FeishuIMControlResult, + FeishuIMHandleMessageResult, + FeishuIMNormalizedConfig, +} from './types' +import type { + FeishuIMImmediateReplyInput, + FeishuIMReplySinkFactoryInput, + FeishuIMReplySinkHandle, + FeishuIMSessionManager, +} from './session-manager' + +export type FeishuIMReplyCoordinatorOptions = { + account: FeishuIMAccount + config: FeishuIMNormalizedConfig + controller: FeishuControllerBridge + client: FeishuIMReplyClient + continueUrlForSession?: (sessionId: string) => string | undefined +} + +export function createFeishuIMReplySinkFactory( + options: FeishuIMReplyCoordinatorOptions, +): (input: FeishuIMReplySinkFactoryInput) => FeishuIMReplySinkHandle { + return (input) => new FeishuReplySink({ + accountId: options.account.id, + routeKey: input.routeKey, + sessionId: input.binding.sessionId, + directory: input.binding.directory, + controller: options.controller, + client: options.client, + replyMode: options.config.policy.replyMode, + presentation: options.config.policy.replyPresentation, + timeoutMs: options.config.policy.replyTimeoutMs, + streamingCardUpdateMs: options.config.policy.streamingCardUpdateMs, + streamingCardMaxChars: options.config.policy.streamingCardMaxChars, + rootMessageId: input.rootMessageId, + continueUrl: options.continueUrlForSession?.(input.binding.sessionId), + }) +} + +export function createFeishuIMImmediateReplyHandler( + options: Pick, +): (input: FeishuIMImmediateReplyInput) => Promise { + return async (input) => { + const routeKey = input.routeKey + if (!routeKey) return + const delivery = { + chatId: routeKey.chatId, + replyTarget: options.config.policy.replyMode, + } as const + if ( + input.result.status === 'busy' || + input.result.status === 'failed' || + input.result.status === 'aborted' || + input.result.status === 'abort-noop' || + input.result.status === 'buffer-cancelled' + ) { + await options.client.sendText({ + ...delivery, + text: textForImmediate(input.result), + }) + return + } + if (input.result.status === 'control') { + const sessionId = sessionIdFromControl(input.result) + await options.client.sendCard({ + ...delivery, + card: renderFeishuControlCard({ + accountId: options.account.id, + routeKey, + result: input.result.control, + sessionId, + continueUrl: sessionId ? options.continueUrlForSession?.(sessionId) : undefined, + }), + }) + } + } +} + +export function createFeishuIMCardActionHandler( + options: Pick & { + manager: FeishuIMSessionManager + }, +): (input: FeishuIMGatewayCardActionEvent) => Promise | undefined> { + return async (input) => { + const payload = input.payload + if (isInteractionAction(payload.action)) { + const result = await answerFeishuCardInteraction({ + controller: options.controller, + payload, + value: input.value, + expected: { + accountId: options.account.id, + maxAgeMs: 24 * 60 * 60 * 1000, + }, + }) + return renderFeishuInteractionAnsweredCard({ + title: result.status === 'answered' ? '已处理' : '操作失败', + message: result.status === 'answered' + ? '操作已提交。' + : `操作未处理:${result.reason}`, + }) + } + + const routeKey = routeFromFeishuCardAction(payload) + if (!routeKey) { + return renderFeishuInteractionAnsweredCard({ + title: '操作失败', + message: '卡片路由已失效,请重新发送 /control 打开控制面。', + }) + } + + const result = await options.manager.handleCardAction(payload, input.value) + const sessionId = sessionIdFromControlResult(result) ?? payload.sessionId + return renderFeishuControlCard({ + accountId: options.account.id, + routeKey, + result, + sessionId, + continueUrl: sessionId ? options.continueUrlForSession?.(sessionId) : undefined, + }) + } +} + +function textForImmediate(result: FeishuIMHandleMessageResult): string { + if (result.status === 'busy') return result.message + if (result.status === 'failed') return result.message + if (result.status === 'aborted') return result.message + if (result.status === 'abort-noop') return result.message + if (result.status === 'buffer-cancelled') return result.message + if (result.status === 'control') return renderControlText(result.control) + return '' +} + +function sessionIdFromControl(result: Extract): string | undefined { + return sessionIdFromControlResult(result.control) +} + +function sessionIdFromControlResult(control: FeishuIMControlResult): string | undefined { + return 'sessionId' in control ? control.sessionId : undefined +} + +function isInteractionAction(action: string): boolean { + return action.startsWith('permission.') || action.startsWith('question.') +} diff --git a/packages/platform-feishu/src/im/reply-sink.ts b/packages/platform-feishu/src/im/reply-sink.ts new file mode 100644 index 00000000..6a15873d --- /dev/null +++ b/packages/platform-feishu/src/im/reply-sink.ts @@ -0,0 +1,725 @@ +import type { + FeishuControllerBridge, + FeishuRuntimeEventEnvelope, + FeishuRuntimeEventSubscription, +} from './controller-bridge' +import { + renderFeishuInteractionAnsweredCard, + renderFeishuPermissionCard, + renderFeishuQuestionCard, + renderFeishuTurnCard, +} from './cards' +import type { FeishuIMRouteKey } from './route' +import { + type FeishuIMReplyClient, + type FeishuIMReplyDelivery, + type FeishuIMResolvedPresentation, + type FeishuIMSentMessage, +} from './reply-client' +import { FeishuStreamingCardController } from './streaming-card-controller' +import type { FeishuIMReplyPresentation } from './types' +import { + decrementFeishuIMActiveReplySinks, + decrementFeishuIMActiveStreamingCards, + decrementFeishuIMPendingInteractions, + incrementFeishuIMActiveReplySinks, + incrementFeishuIMActiveStreamingCards, + incrementFeishuIMPendingInteractions, + recordFeishuIMReplyError, +} from './reply-telemetry' + +const POST_TOOL_COMPLETION_GRACE_MS = 1_500 +const TURN_RESULT_POLL_INITIAL_DELAY_MS = 1_500 +const TURN_RESULT_POLL_INTERVAL_MS = 1_500 +const SUBSCRIPTION_READY_TIMEOUT_MS = 1_000 +const TERMINAL_DELIVERY_TIMEOUT_MS = 5_000 + +export type FeishuReplySinkOptions = { + accountId: string + routeKey: FeishuIMRouteKey + sessionId: string + directory?: string + turnSnapshotId?: string + controller: FeishuControllerBridge + client: FeishuIMReplyClient + replyMode: FeishuIMReplyDelivery['replyTarget'] + presentation: FeishuIMReplyPresentation + timeoutMs: number + streamingCardUpdateMs?: number + streamingCardMaxChars?: number + rootMessageId?: string + continueUrl?: string + onDone?: (result: FeishuReplySinkDoneResult) => void | Promise + onError?: (error: Error) => void | Promise +} + +export type FeishuReplySinkDoneResult = { + status: 'final' | 'error' | 'timeout' | 'stopped' + message?: string +} + +export type FeishuReplySinkHandle = { + done: Promise + start(): Promise + bindTurnSnapshotId(turnSnapshotId?: string): Promise + handleEvent(event: FeishuRuntimeEventEnvelope): Promise + stop(): void +} + +export class FeishuReplySink implements FeishuReplySinkHandle { + readonly done: Promise + + private subscription?: FeishuRuntimeEventSubscription + private timeout?: ReturnType + private resolveDone!: (result: FeishuReplySinkDoneResult) => void + private bound = false + private stopped = false + private completed = false + private pendingEvents: FeishuRuntimeEventEnvelope[] = [] + private sentCard?: FeishuIMSentMessage + private textBuffer = '' + private streamingTelemetryActive = false + private streamingController?: FeishuStreamingCardController + private resourceFailure?: string + private errorMessage?: string + private toolActivitySeen = false + private postToolTextSeen = false + private completionPending = false + private deferredCompletionTimer?: ReturnType + private turnResultPollTimer?: ReturnType + private turnResultPollInProgress = false + private readonly partTextLengths = new Map() + private readonly pendingInteractions = new Set() + + constructor(private readonly options: FeishuReplySinkOptions) { + this.done = new Promise((resolve) => { + this.resolveDone = resolve + }) + } + + async start(): Promise { + if (this.subscription || this.stopped) return + this.subscription = this.options.controller.subscribeEvents({ + sessionId: this.options.sessionId, + onEvent: (event) => this.handleEvent(event), + onError: async (error) => { + recordFeishuIMReplyError(error) + await this.options.onError?.(error) + }, + }) + incrementFeishuIMActiveReplySinks() + if (this.presentation() === 'streaming-card') { + this.activateStreamingTelemetry() + } + await this.waitForSubscriptionReady() + if (this.options.turnSnapshotId !== undefined) { + await this.bindTurnSnapshotId(this.options.turnSnapshotId) + } + } + + async bindTurnSnapshotId(turnSnapshotId?: string): Promise { + if (this.stopped) return + this.options.turnSnapshotId = turnSnapshotId + this.bound = true + this.startTimeout() + if (this.presentation() === 'card') { + await this.upsertTurnCard('running') + } else if (this.presentation() === 'streaming-card') { + await this.streaming().start(turnSnapshotId) + } + this.startTurnResultPoll() + const pending = this.pendingEvents + this.pendingEvents = [] + for (const event of pending) { + await this.handleEvent(event) + } + } + + async handleEvent(event: FeishuRuntimeEventEnvelope): Promise { + if (this.stopped || this.completed) return + if (!this.isRelevantEvent(event)) return + if (!this.bound && shouldBufferUntilTurn(event)) { + this.pendingEvents.push(event) + return + } + if (!this.eventMatchesTurn(event)) return + + try { + const type = normalizedEventType(event) + if (type === 'runtime.message.part.updated') { + await this.handlePartUpdated(event) + return + } + if (isToolEvent(type)) { + this.noteToolActivity() + if (this.presentation() === 'streaming-card') { + await this.streaming().handleRuntimeEvent(event) + } + return + } + if (type === 'runtime.interaction.requested') { + await this.handleInteractionRequested(event) + return + } + if (type === 'runtime.interaction.answered') { + await this.handleInteractionAnswered(event) + return + } + if (type === 'runtime.resource.failed') { + await this.handleResourceFailed(event) + return + } + if (type === 'runtime.turn.completed') { + if (this.shouldDeferTurnCompletion()) { + await this.deferTurnCompletion() + return + } + await this.finish('final') + return + } + if (type === 'runtime.turn.failed') { + await this.finish('error', messageFromEvent(event) ?? 'Agent turn failed.') + } + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)) + recordFeishuIMReplyError(normalized) + await this.options.onError?.(normalized) + } + } + + stop(): void { + if (this.stopped) return + this.stopped = true + this.subscription?.stop() + this.subscription = undefined + if (this.timeout) clearTimeout(this.timeout) + this.timeout = undefined + this.clearDeferredFinal() + this.clearTurnResultPoll() + this.streamingController?.stop() + for (const _id of this.pendingInteractions) { + decrementFeishuIMPendingInteractions() + } + this.pendingInteractions.clear() + decrementFeishuIMActiveReplySinks() + this.deactivateStreamingTelemetry() + this.resolveDoneOnce({ status: this.completed ? 'final' : 'stopped' }) + } + + private async handlePartUpdated(event: FeishuRuntimeEventEnvelope): Promise { + const toolPartUpdate = isToolPartUpdate(event) + if (toolPartUpdate) { + this.noteToolActivity() + } + if (this.presentation() === 'streaming-card') { + await this.streaming().handleRuntimeEvent(event) + } + if (toolPartUpdate) return + const text = textDeltaFromEvent(event, this.partTextLengths) + if (!text) return + if (this.toolActivitySeen) { + this.postToolTextSeen = true + } + this.textBuffer += text + if (this.presentation() === 'text') { + await this.options.client.sendText({ + ...this.delivery(), + text, + }) + } else if (this.presentation() === 'streaming-card') { + await this.streaming().appendText(text) + } else { + await this.upsertTurnCard('running') + } + if (this.completionPending && this.postToolTextSeen) { + await this.finish('final') + } + } + + private async handleInteractionRequested(event: FeishuRuntimeEventEnvelope): Promise { + const data = eventData(event) + const kind = stringValue(data.kind) + const requestId = stringValue(data.requestId) ?? stringValue(data.id) + if (!requestId) return + if (!this.pendingInteractions.has(requestId)) { + this.pendingInteractions.add(requestId) + incrementFeishuIMPendingInteractions() + } + if (kind === 'permission') { + await this.options.client.sendCard({ + ...this.delivery(), + card: renderFeishuPermissionCard({ + accountId: this.options.accountId, + routeKey: this.options.routeKey, + sessionId: this.options.sessionId, + turnSnapshotId: this.options.turnSnapshotId, + requestId, + continueUrl: this.options.continueUrl, + data, + }), + }) + return + } + if (kind === 'question') { + await this.options.client.sendCard({ + ...this.delivery(), + card: renderFeishuQuestionCard({ + accountId: this.options.accountId, + routeKey: this.options.routeKey, + sessionId: this.options.sessionId, + turnSnapshotId: this.options.turnSnapshotId, + requestId, + continueUrl: this.options.continueUrl, + data, + }), + }) + } + } + + private async handleInteractionAnswered(event: FeishuRuntimeEventEnvelope): Promise { + const data = eventData(event) + const requestId = stringValue(data.requestId) ?? stringValue(data.requestID) + if (requestId && this.pendingInteractions.delete(requestId)) { + decrementFeishuIMPendingInteractions() + } + await this.options.client.sendCard({ + ...this.delivery(), + card: renderFeishuInteractionAnsweredCard({ + message: '飞书卡片操作已提交。', + }), + }) + } + + private async handleResourceFailed(event: FeishuRuntimeEventEnvelope): Promise { + const message = messageFromEvent(event) ?? '部分资源加载失败,可以在 Web 端继续查看。' + if (this.presentation() === 'text') { + await this.options.client.sendText({ + ...this.delivery(), + text: message, + }) + return + } + this.resourceFailure = message + if (this.presentation() === 'streaming-card') { + await this.streaming().setResourceFailure(message) + return + } + await this.upsertTurnCard('running', { resourceFailure: message }) + } + + private async finish(status: FeishuReplySinkDoneResult['status'], message?: string): Promise { + if (this.completed) return + this.completed = true + this.completionPending = false + if (this.timeout) clearTimeout(this.timeout) + this.timeout = undefined + this.clearDeferredFinal() + this.clearTurnResultPoll() + + let resultMessage = message + try { + await this.deliverTerminalState(status, message) + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)) + recordFeishuIMReplyError(normalized) + try { + await this.options.onError?.(normalized) + } catch (onErrorFailure) { + recordFeishuIMReplyError(onErrorFailure instanceof Error ? onErrorFailure : new Error(String(onErrorFailure))) + } + resultMessage ??= normalized.message + await this.sendTerminalFallback(status, resultMessage) + } + + for (const _id of this.pendingInteractions) { + decrementFeishuIMPendingInteractions() + } + this.pendingInteractions.clear() + this.subscription?.stop() + this.subscription = undefined + decrementFeishuIMActiveReplySinks() + this.deactivateStreamingTelemetry() + const result = { status, message: resultMessage } satisfies FeishuReplySinkDoneResult + try { + await this.options.onDone?.(result) + } catch (error) { + recordFeishuIMReplyError(error instanceof Error ? error : new Error(String(error))) + } + this.resolveDoneOnce(result) + this.stopped = true + } + + private async deliverTerminalState(status: FeishuReplySinkDoneResult['status'], message?: string): Promise { + const delivery = (async () => { + if (status === 'final') { + if (this.presentation() === 'card') { + await this.upsertTurnCard('final') + } else if (this.presentation() === 'streaming-card') { + await this.streaming().finish('final') + } else if (!this.textBuffer.trim()) { + await this.options.client.sendText({ + ...this.delivery(), + text: '已完成。', + }) + } + return + } + + if (status === 'error' || status === 'timeout') { + const text = message ?? (status === 'timeout' ? '飞书回复等待超时,请在 Web 端继续。' : '处理失败。') + this.errorMessage = text + if (this.presentation() === 'card') { + await this.upsertTurnCard(status, { error: text }) + } else if (this.presentation() === 'streaming-card') { + await this.streaming().finish(status, text) + } else { + await this.options.client.sendText({ + ...this.delivery(), + text, + }) + } + } + })() + delivery.catch((error) => { + recordFeishuIMReplyError(error instanceof Error ? error : new Error(String(error))) + }) + await withTimeout(delivery, TERMINAL_DELIVERY_TIMEOUT_MS, 'Feishu terminal reply delivery timed out') + } + + private async sendTerminalFallback(status: FeishuReplySinkDoneResult['status'], message?: string): Promise { + const text = status === 'final' + ? this.textBuffer.trim() || '已完成,可在 Web 端继续查看。' + : message ?? (status === 'timeout' ? '飞书回复等待超时,请在 Web 端继续。' : '处理失败。') + await this.options.client.sendText({ + ...this.delivery(), + text, + }).catch((error) => { + recordFeishuIMReplyError(error instanceof Error ? error : new Error(String(error))) + }) + } + + private async upsertTurnCard( + status: FeishuTurnCardStatus, + extra: { error?: string; resourceFailure?: string } = {}, + ) { + const card = renderFeishuTurnCard({ + status, + routeKey: this.options.routeKey, + sessionId: this.options.sessionId, + turnSnapshotId: this.options.turnSnapshotId, + continueUrl: this.options.continueUrl, + content: this.textBuffer.trim() || undefined, + error: extra.error, + resourceFailure: extra.resourceFailure, + }) + if (!this.sentCard?.messageId && !this.sentCard?.cardId) { + this.sentCard = await this.options.client.sendCard({ + ...this.delivery(), + card, + }) + return + } + this.sentCard = await this.options.client.updateCard({ + messageId: this.sentCard.messageId, + cardId: this.sentCard.cardId, + card, + }) + } + + private startTimeout() { + if (this.timeout || this.options.timeoutMs <= 0) return + this.timeout = setTimeout(() => { + this.finish('timeout', '飞书回复等待超时,请在 Web 端继续。').catch((error) => + recordFeishuIMReplyError(error), + ) + }, this.options.timeoutMs) + } + + private startTurnResultPoll(): void { + if (!this.options.controller.getLatestTurnResult || this.turnResultPollTimer || this.completed || this.stopped) return + this.turnResultPollTimer = setTimeout(() => { + this.turnResultPollTimer = undefined + this.pollTurnResult().catch((error) => { + recordFeishuIMReplyError(error instanceof Error ? error : new Error(String(error))) + this.scheduleNextTurnResultPoll() + }) + }, TURN_RESULT_POLL_INITIAL_DELAY_MS) + this.turnResultPollTimer.unref?.() + } + + private scheduleNextTurnResultPoll(): void { + if (!this.options.controller.getLatestTurnResult || this.turnResultPollTimer || this.completed || this.stopped) return + this.turnResultPollTimer = setTimeout(() => { + this.turnResultPollTimer = undefined + this.pollTurnResult().catch((error) => { + recordFeishuIMReplyError(error instanceof Error ? error : new Error(String(error))) + this.scheduleNextTurnResultPoll() + }) + }, TURN_RESULT_POLL_INTERVAL_MS) + this.turnResultPollTimer.unref?.() + } + + private clearTurnResultPoll(): void { + if (this.turnResultPollTimer) clearTimeout(this.turnResultPollTimer) + this.turnResultPollTimer = undefined + } + + private async pollTurnResult(): Promise { + if (this.completed || this.stopped || this.turnResultPollInProgress || !this.options.controller.getLatestTurnResult) return + this.turnResultPollInProgress = true + try { + if (!await this.tryFinishFromLatestTurnResult()) { + this.scheduleNextTurnResultPoll() + } + } finally { + this.turnResultPollInProgress = false + } + } + + private async finishDeferredTurnCompletion(): Promise { + if (this.completed || this.stopped) return + if (await this.tryFinishFromLatestTurnResult()) return + await this.finish('final') + } + + private async tryFinishFromLatestTurnResult(): Promise { + if (!this.options.controller.getLatestTurnResult) return false + const result = await this.options.controller.getLatestTurnResult({ + sessionId: this.options.sessionId, + directory: this.options.directory, + }) + if (!result?.completed) return false + if (result.failed) { + await this.finish('error', result.error ?? 'Agent turn failed.') + return true + } + if (result.text) { + await this.replaceVisibleText(result.text) + } + await this.finish('final') + return true + } + + private async replaceVisibleText(text: string): Promise { + const normalized = text.trim() + if (!normalized) return + if (this.textBuffer.trim() === normalized) return + this.textBuffer = normalized + if (this.presentation() === 'streaming-card') { + this.streaming().replaceText(normalized) + } + } + + private async waitForSubscriptionReady(): Promise { + const ready = this.subscription?.ready + if (!ready) return + await withTimeout(ready, SUBSCRIPTION_READY_TIMEOUT_MS, 'Feishu event subscription ready timed out').catch((error) => { + recordFeishuIMReplyError(error instanceof Error ? error : new Error(String(error))) + }) + } + + private noteToolActivity(): void { + if (this.toolActivitySeen) return + this.toolActivitySeen = true + this.textBuffer = '' + this.streamingController?.clearText() + } + + private shouldDeferTurnCompletion(): boolean { + return this.toolActivitySeen && !this.postToolTextSeen + } + + private async deferTurnCompletion(): Promise { + this.completionPending = true + if (await this.tryFinishFromLatestTurnResult()) return + this.scheduleDeferredFinal() + } + + private scheduleDeferredFinal(): void { + if (this.deferredCompletionTimer) return + this.deferredCompletionTimer = setTimeout(() => { + this.deferredCompletionTimer = undefined + this.finishDeferredTurnCompletion().catch((error) => recordFeishuIMReplyError(error)) + }, POST_TOOL_COMPLETION_GRACE_MS) + } + + private clearDeferredFinal(): void { + if (this.deferredCompletionTimer) clearTimeout(this.deferredCompletionTimer) + this.deferredCompletionTimer = undefined + } + + private presentation(): FeishuIMResolvedPresentation { + if (this.options.presentation === 'text' || this.options.presentation === 'card' || this.options.presentation === 'streaming-card') return this.options.presentation + return this.options.routeKey.kind === 'dm' ? 'text' : 'streaming-card' + } + + private streamingUpdateMs(): number { + return Math.max(1, this.options.streamingCardUpdateMs ?? 1_000) + } + + private streaming(): FeishuStreamingCardController { + if (!this.streamingController) { + this.streamingController = new FeishuStreamingCardController({ + accountId: this.options.accountId, + routeKey: this.options.routeKey, + sessionId: this.options.sessionId, + turnSnapshotId: this.options.turnSnapshotId, + client: this.options.client, + delivery: this.delivery(), + updateMs: this.streamingUpdateMs(), + maxChars: this.options.streamingCardMaxChars, + continueUrl: this.options.continueUrl, + onError: this.options.onError, + }) + } + return this.streamingController + } + + private delivery(): FeishuIMReplyDelivery { + return { + chatId: this.options.routeKey.chatId, + rootMessageId: this.options.rootMessageId, + replyTarget: this.options.replyMode, + } + } + + private isRelevantEvent(event: FeishuRuntimeEventEnvelope): boolean { + const type = normalizedEventType(event) + return type === 'runtime.message.part.updated' + || type === 'runtime.tool.started' + || type === 'runtime.tool.completed' + || type === 'runtime.tool.failed' + || type === 'runtime.interaction.requested' + || type === 'runtime.interaction.answered' + || type === 'runtime.resource.failed' + || type === 'runtime.turn.completed' + || type === 'runtime.turn.failed' + } + + private eventMatchesTurn(event: FeishuRuntimeEventEnvelope): boolean { + if (!this.options.turnSnapshotId) return true + const turnSnapshotId = event.turnSnapshotId + ?? stringValue(eventData(event).turnSnapshotId) + ?? stringValue(eventData(event).turnSnapshotID) + return !turnSnapshotId || turnSnapshotId === this.options.turnSnapshotId + } + + private resolveDoneOnce(result: FeishuReplySinkDoneResult) { + const resolve = this.resolveDone + this.resolveDone = () => undefined + resolve(result) + } + + private activateStreamingTelemetry() { + if (this.streamingTelemetryActive) return + this.streamingTelemetryActive = true + incrementFeishuIMActiveStreamingCards() + } + + private deactivateStreamingTelemetry() { + if (!this.streamingTelemetryActive) return + this.streamingTelemetryActive = false + decrementFeishuIMActiveStreamingCards() + } +} + +type FeishuTurnCardStatus = Parameters[0]['status'] + +export function normalizedEventType(event: FeishuRuntimeEventEnvelope): string { + if (event.type === 'message.part.updated') return 'runtime.message.part.updated' + if (event.type === 'permission.asked' || event.type === 'question.asked') return 'runtime.interaction.requested' + if (event.type === 'permission.replied' || event.type === 'question.replied' || event.type === 'question.rejected') { + return 'runtime.interaction.answered' + } + if (event.type === 'session.idle') return 'runtime.turn.completed' + if (event.type === 'session.error') return 'runtime.turn.failed' + return event.type +} + +function shouldBufferUntilTurn(event: FeishuRuntimeEventEnvelope): boolean { + const type = normalizedEventType(event) + return type === 'runtime.message.part.updated' + || type === 'runtime.tool.started' + || type === 'runtime.tool.completed' + || type === 'runtime.tool.failed' + || type === 'runtime.interaction.requested' + || type === 'runtime.interaction.answered' + || type === 'runtime.resource.failed' + || type === 'runtime.turn.completed' + || type === 'runtime.turn.failed' +} + +function isToolEvent(type: string): boolean { + return type === 'runtime.tool.started' + || type === 'runtime.tool.completed' + || type === 'runtime.tool.failed' +} + +function isToolPartUpdate(event: FeishuRuntimeEventEnvelope): boolean { + const type = normalizedEventType(event) + if (type !== 'runtime.message.part.updated') return false + const part = asRecord(eventData(event).part) ?? asRecord(asRecord(event.properties)?.part) + return part?.type === 'tool' +} + +function eventData(event: FeishuRuntimeEventEnvelope): Record { + if (event.data && typeof event.data === 'object') return event.data as Record + if (event.properties) return event.properties + return {} +} + +function textDeltaFromEvent(event: FeishuRuntimeEventEnvelope, lengths: Map): string | undefined { + const data = eventData(event) + const part = asRecord(data.part) + if (part && !isVisibleTextPart(part)) return undefined + const delta = data.delta + const deltaRecord = asRecord(delta) + const deltaText = typeof delta === 'string' + ? delta + : stringValue(deltaRecord?.text) + if (deltaText) return deltaText + + if (!part || part.type !== 'text') return undefined + const partText = stringValue(part.text) + if (!partText) return undefined + const partId = stringValue(part.id) ?? stringValue(data.partId) ?? 'default' + const previousLength = lengths.get(partId) ?? 0 + lengths.set(partId, partText.length) + return partText.length > previousLength ? partText.slice(previousLength) : undefined +} + +function isVisibleTextPart(part: Record): boolean { + if (part.type !== 'text') return false + if (part.ignored === true || part.synthetic === true) return false + const metadata = asRecord(part.metadata) + const kind = stringValue(metadata?.kind) ?? stringValue(metadata?.type) + return kind !== 'reasoning' && kind !== 'thinking' +} + +function messageFromEvent(event: FeishuRuntimeEventEnvelope): string | undefined { + const data = eventData(event) + const direct = stringValue(data.message) ?? stringValue(data.error) + if (direct) return direct + const error = asRecord(data.error) + return stringValue(error?.message) ?? stringValue(error?.name) +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (timeoutMs <= 0) return promise + let timeout: ReturnType | undefined + const timer = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs) + timeout.unref?.() + }) + return Promise.race([promise, timer]).finally(() => { + if (timeout) clearTimeout(timeout) + }) +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} diff --git a/packages/platform-feishu/src/im/reply-telemetry.ts b/packages/platform-feishu/src/im/reply-telemetry.ts new file mode 100644 index 00000000..fb153f6e --- /dev/null +++ b/packages/platform-feishu/src/im/reply-telemetry.ts @@ -0,0 +1,188 @@ +import type { PlatformRecentEvent } from '@nine1bot/platform-protocol' + +export type FeishuIMReplyRuntimeSummary = { + activeSinks: number + pendingInteractions: number + activeTurns: number + pendingBuffers: number + bufferedMessages: number + activeStreamingCards: number + cardUpdateFailures: number + streamingFallbacks: number + lastReplyError?: string + lastCardAction?: string + lastCardUpdateError?: string + lastStreamingTransport?: 'cardkit' | 'patch' | 'text' + lastStreamingFallbackReason?: string +} + +type FeishuIMReplyTelemetryListener = () => void + +const FEISHU_IM_REPLY_RECENT_EVENT_LIMIT = 20 + +const summary: FeishuIMReplyRuntimeSummary = { + activeSinks: 0, + pendingInteractions: 0, + activeTurns: 0, + pendingBuffers: 0, + bufferedMessages: 0, + activeStreamingCards: 0, + cardUpdateFailures: 0, + streamingFallbacks: 0, +} + +const recentEvents: PlatformRecentEvent[] = [] +const listeners = new Set() +let recentEventCounter = 0 + +export function getFeishuIMReplyRuntimeSummary(): FeishuIMReplyRuntimeSummary { + return { ...summary } +} + +export function getFeishuIMReplyRuntimeRecentEvents(): PlatformRecentEvent[] { + return [...recentEvents] +} + +export function subscribeFeishuIMReplyRuntimeSummary(listener: FeishuIMReplyTelemetryListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function resetFeishuIMReplyRuntimeSummary() { + summary.activeSinks = 0 + summary.pendingInteractions = 0 + summary.activeTurns = 0 + summary.pendingBuffers = 0 + summary.bufferedMessages = 0 + summary.activeStreamingCards = 0 + summary.cardUpdateFailures = 0 + summary.streamingFallbacks = 0 + summary.lastReplyError = undefined + summary.lastCardAction = undefined + summary.lastCardUpdateError = undefined + summary.lastStreamingTransport = undefined + summary.lastStreamingFallbackReason = undefined + recentEvents.length = 0 + recentEventCounter = 0 + notifyListeners() +} + +export function clearFeishuIMReplyRuntimeSummaryForTesting() { + resetFeishuIMReplyRuntimeSummary() +} + +export function incrementFeishuIMActiveReplySinks() { + summary.activeSinks += 1 + notifyListeners() +} + +export function decrementFeishuIMActiveReplySinks() { + summary.activeSinks = Math.max(0, summary.activeSinks - 1) + notifyListeners() +} + +export function incrementFeishuIMPendingInteractions() { + summary.pendingInteractions += 1 + notifyListeners() +} + +export function decrementFeishuIMPendingInteractions() { + summary.pendingInteractions = Math.max(0, summary.pendingInteractions - 1) + notifyListeners() +} + +export function incrementFeishuIMActiveStreamingCards() { + summary.activeStreamingCards += 1 + notifyListeners() +} + +export function decrementFeishuIMActiveStreamingCards() { + summary.activeStreamingCards = Math.max(0, summary.activeStreamingCards - 1) + notifyListeners() +} + +export function recordFeishuIMReplyError(error: unknown) { + const message = errorMessage(error) + summary.lastReplyError = message + appendRecentEvent('error', 'im-reply', `Feishu IM reply delivery failed: ${message}`, { + event: 'reply-error', + error: message, + }) + notifyListeners() +} + +export function recordFeishuIMCardUpdateFailure(error: unknown) { + const message = errorMessage(error) + summary.cardUpdateFailures += 1 + summary.lastCardUpdateError = message + appendRecentEvent('warn', 'im-reply', `Feishu IM card update failed: ${message}`, { + event: 'card-update-failed', + error: message, + }) + notifyListeners() +} + +export function recordFeishuIMStreamingTransport(transport: 'cardkit' | 'patch' | 'text') { + summary.lastStreamingTransport = transport + notifyListeners() +} + +export function recordFeishuIMStreamingFallback( + reason: string, + transport: 'patch' | 'text', +) { + summary.streamingFallbacks += 1 + summary.lastStreamingFallbackReason = reason + summary.lastStreamingTransport = transport + appendRecentEvent('warn', 'im-reply', `Feishu IM streaming reply fell back to ${transport}: ${reason}`, { + event: 'streaming-fallback', + reason, + transport, + }) + notifyListeners() +} + +export function recordFeishuIMCardAction(action: string) { + summary.lastCardAction = action + notifyListeners() +} + +export function recordFeishuIMSessionManagerSnapshot(input: { + activeTurns: number + pendingBuffers: number + bufferedMessages: number +}) { + summary.activeTurns = input.activeTurns + summary.pendingBuffers = input.pendingBuffers + summary.bufferedMessages = input.bufferedMessages +} + +function appendRecentEvent( + level: PlatformRecentEvent['level'], + stage: string, + message: string, + data?: Record, +) { + recentEventCounter += 1 + recentEvents.unshift({ + id: `feishu-im-reply-${Date.now()}-${recentEventCounter}`, + at: new Date().toISOString(), + level, + stage, + message, + data, + }) + recentEvents.splice(FEISHU_IM_REPLY_RECENT_EVENT_LIMIT) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function notifyListeners() { + for (const listener of listeners) { + listener() + } +} diff --git a/packages/platform-feishu/src/im/route.ts b/packages/platform-feishu/src/im/route.ts new file mode 100644 index 00000000..d7dd623b --- /dev/null +++ b/packages/platform-feishu/src/im/route.ts @@ -0,0 +1,89 @@ +import type { FeishuIMIncomingMessage } from './types' + +export type FeishuIMRouteKey = { + platform: 'feishu' + accountId: string + kind: 'dm' | 'group' | 'thread' + chatId: string + openId?: string + threadId?: string +} + +export function routeKeyForFeishuMessage( + message: FeishuIMIncomingMessage, + options: { + accountId?: string + } = {}, +): FeishuIMRouteKey { + const accountId = options.accountId || 'default' + const threadId = message.rootId || message.parentId + if (message.chatType === 'p2p') { + return { + platform: 'feishu', + accountId, + kind: 'dm', + chatId: message.chatId, + openId: message.sender.openId || message.sender.userId || message.sender.unionId || message.chatId, + } + } + + return { + platform: 'feishu', + accountId, + kind: threadId ? 'thread' : 'group', + chatId: message.chatId, + threadId, + } +} + +export function serializeFeishuRouteKey(key: FeishuIMRouteKey): string { + if (key.kind === 'dm') { + return [key.platform, key.accountId, 'dm', key.openId || key.chatId].join(':') + } + if (key.kind === 'thread') { + return [key.platform, key.accountId, 'thread', key.chatId, key.threadId || 'root'].join(':') + } + return [key.platform, key.accountId, 'group', key.chatId].join(':') +} + +export function parseFeishuRouteKey(input: string): FeishuIMRouteKey | undefined { + const parts = input.split(':') + if (parts[0] !== 'feishu') return undefined + const accountId = parts[1] + const kind = parts[2] + if (!accountId) return undefined + if (kind === 'dm') { + const openId = parts[3] + if (!openId) return undefined + return { + platform: 'feishu', + accountId, + kind, + chatId: openId, + openId, + } + } + if (kind === 'group') { + const chatId = parts[3] + if (!chatId) return undefined + return { + platform: 'feishu', + accountId, + kind, + chatId, + } + } + if (kind === 'thread') { + const chatId = parts[3] + const threadId = parts[4] + if (!chatId || !threadId) return undefined + return { + platform: 'feishu', + accountId, + kind, + chatId, + threadId, + } + } + return undefined +} diff --git a/packages/platform-feishu/src/im/session-manager.ts b/packages/platform-feishu/src/im/session-manager.ts new file mode 100644 index 00000000..49d6c133 --- /dev/null +++ b/packages/platform-feishu/src/im/session-manager.ts @@ -0,0 +1,763 @@ +import { + FEISHU_CONTROLLER_CAPABILITIES, + feishuControllerEntry, + projectDirectory, + projectDisplayName, + type FeishuControllerBridge, + type FeishuControllerContextBlock, + type FeishuControllerProject, +} from './controller-bridge' +import { evaluateFeishuIMGate } from './inbound/gate' +import { isFeishuIMAbortMessage } from './abort' +import { parseFeishuRouteKey, routeKeyForFeishuMessage, serializeFeishuRouteKey, type FeishuIMRouteKey } from './route' +import type { FeishuIMBindingStore, FeishuIMSessionBinding } from './store/binding-store' +import { + type FeishuIMAccount, + type FeishuIMControlResult, + type FeishuIMControllerMessagePart, + type FeishuIMHandleMessageResult, + type FeishuIMIncomingMessage, + type FeishuIMNormalizedConfig, +} from './types' +import { + FeishuIMMessageBuffer, + type FeishuIMBufferedBatch, + type FeishuIMBufferSnapshotEntry, +} from './buffer/message-buffer' +import { FeishuIMHistoryStore } from './history' +import type { FeishuCardActionPayload, FeishuCardActionValue } from './interactions' +import { recordFeishuIMCardAction, recordFeishuIMSessionManagerSnapshot } from './reply-telemetry' + +export type FeishuIMReplySinkFactoryInput = { + account: FeishuIMAccount + config: FeishuIMNormalizedConfig + routeKey: FeishuIMRouteKey + routeKeyString: string + binding: FeishuIMSessionBinding + batch: FeishuIMBufferedBatch + rootMessageId?: string +} + +export type FeishuIMReplySinkHandle = { + done?: Promise + start?: () => void | Promise + bindTurnSnapshotId?: (turnSnapshotId?: string) => void | Promise + stop: () => void | Promise +} + +export type FeishuIMImmediateReplyInput = { + result: FeishuIMHandleMessageResult + routeKey?: FeishuIMRouteKey + routeKeyString?: string + binding?: FeishuIMSessionBinding +} + +export type FeishuIMActiveTurnSnapshot = { + routeKey: FeishuIMRouteKey + routeKeyString: string + sessionId?: string + turnSnapshotId?: string + startedAt: string +} + +type FeishuIMActiveTurn = FeishuIMActiveTurnSnapshot & { + binding?: FeishuIMSessionBinding + sink?: FeishuIMReplySinkHandle +} + +export type FeishuIMSessionManagerOptions = { + account: FeishuIMAccount + config: FeishuIMNormalizedConfig + controller: FeishuControllerBridge + store: FeishuIMBindingStore + defaultDirectory?: string + botOpenId?: string + botUserId?: string + resolveDirectory?: (baseDirectory: string | undefined, input: string) => Promise + history?: FeishuIMHistoryStore + onFlushResult?: (result: FeishuIMHandleMessageResult) => void | Promise + replySinkFactory?: (input: FeishuIMReplySinkFactoryInput) => FeishuIMReplySinkHandle | Promise + onImmediateReply?: (input: FeishuIMImmediateReplyInput) => void | Promise +} + +export class FeishuIMSessionManager { + private readonly buffer: FeishuIMMessageBuffer + private readonly history: FeishuIMHistoryStore + private readonly activeTurns = new Map() + private readonly replySinks = new Set() + + constructor(private readonly options: FeishuIMSessionManagerOptions) { + this.history = options.history ?? new FeishuIMHistoryStore() + this.buffer = new FeishuIMMessageBuffer({ + messageBufferMs: options.config.policy.messageBufferMs, + maxBufferMs: options.config.policy.maxBufferMs, + onDue: async (routeKeyString) => { + const result = await this.flushRoute(routeKeyString) + if (result) await this.options.onFlushResult?.(result) + }, + }) + } + + async resolveOrCreateSession(routeKey: FeishuIMRouteKey, directory?: string): Promise { + const routeKeyString = serializeFeishuRouteKey(routeKey) + const existing = await this.options.store.get(routeKeyString) + if (existing) { + const session = await this.options.controller.getSession({ + sessionId: existing.sessionId, + directory: existing.directory, + }) + if (session) return existing + } + return this.createAndBindSession(routeKey, directory ?? this.defaultDirectory()) + } + + async handleIncomingMessage(message: FeishuIMIncomingMessage): Promise { + const routeKey = routeKeyForFeishuMessage(message, { accountId: this.options.account.id }) + const routeKeyString = serializeFeishuRouteKey(routeKey) + const gate = evaluateFeishuIMGate(message, this.options.config, { + botOpenId: this.options.botOpenId, + botUserId: this.options.botUserId, + }) + const bypassMentionGate = gate.action === 'history' && shouldBypassMentionGateForControl(message) + + if (gate.action === 'drop') { + return { status: 'ignored', reason: gate.reason } + } + + if (gate.action === 'history' && !bypassMentionGate) { + this.history.record(routeKeyString, message) + return { status: 'history-recorded', routeKey: routeKeyString } + } + + if (isFeishuIMAbortMessage(message)) { + return this.handleAbort(routeKey, routeKeyString) + } + + const control = await this.handleControlCommand(routeKey, message) + if (control) { + const result = { status: 'control', routeKey: routeKeyString, control } satisfies FeishuIMHandleMessageResult + await this.options.onImmediateReply?.({ result, routeKey, routeKeyString }) + return result + } + + if (this.activeTurns.has(routeKeyString)) { + const result = { status: 'busy', routeKey: routeKeyString, message: this.options.config.policy.busyRejectText } satisfies FeishuIMHandleMessageResult + await this.options.onImmediateReply?.({ result, routeKey, routeKeyString }) + return result + } + + const enqueued = this.buffer.enqueue({ + routeKey, + routeKeyString, + message, + }) + this.recordSnapshot() + if (enqueued.status === 'ready') { + return await this.flushRoute(routeKeyString) ?? { + status: 'failed', + routeKey: routeKeyString, + message: 'No buffered messages to flush', + } + } + + return { + status: 'buffered', + routeKey: routeKeyString, + messageCount: enqueued.messageCount, + } + } + + async flushRoute(routeKeyString: string): Promise { + const batch = this.buffer.drain(routeKeyString) + if (!batch) return undefined + this.recordSnapshot() + if (this.activeTurns.has(routeKeyString)) { + const result = { status: 'busy', routeKey: routeKeyString, message: this.options.config.policy.busyRejectText } satisfies FeishuIMHandleMessageResult + await this.options.onImmediateReply?.({ result, routeKey: batch.routeKey, routeKeyString }) + return result + } + this.activeTurns.set(routeKeyString, { + routeKey: batch.routeKey, + routeKeyString, + startedAt: new Date().toISOString(), + }) + this.recordSnapshot() + let releaseOnFinally = true + let sink: FeishuIMReplySinkHandle | undefined + try { + const binding = await this.resolveOrCreateSession(batch.routeKey) + this.updateActiveTurn(routeKeyString, { binding, sessionId: binding.sessionId }) + sink = await this.options.replySinkFactory?.({ + account: this.options.account, + config: this.options.config, + routeKey: batch.routeKey, + routeKeyString, + binding, + batch, + rootMessageId: batch.messages.at(-1)?.messageId, + }) + if (sink) { + this.replySinks.add(sink) + this.updateActiveTurn(routeKeyString, { sink }) + await sink.start?.() + } + const response = await this.options.controller.sendMessage({ + sessionId: binding.sessionId, + directory: binding.directory, + messageId: batch.messages.at(-1)?.messageId, + parts: partsFromBatch(batch), + contextBlocks: this.contextBlocksForBatch(batch), + entry: feishuControllerEntry(batch.messages.at(-1)?.eventId), + }) + + if (!response.accepted || response.busy) { + await this.stopReplySink(sink) + const result = { status: 'busy', routeKey: routeKeyString, message: this.options.config.policy.busyRejectText } satisfies FeishuIMHandleMessageResult + await this.options.onImmediateReply?.({ result, routeKey: batch.routeKey, routeKeyString, binding }) + return result + } + + await this.options.store.set(routeKeyString, { + ...binding, + updatedAt: new Date().toISOString(), + }) + this.updateActiveTurn(routeKeyString, { turnSnapshotId: response.turnSnapshotId }) + await sink?.bindTurnSnapshotId?.(response.turnSnapshotId) + if (sink?.done) { + const activeSink = sink + releaseOnFinally = false + void activeSink.done!.finally(() => { + this.replySinks.delete(activeSink) + this.activeTurns.delete(routeKeyString) + this.recordSnapshot() + }) + } + return { + status: 'accepted', + routeKey: routeKeyString, + sessionId: binding.sessionId, + turnSnapshotId: response.turnSnapshotId, + } + } catch (error) { + await this.stopReplySink(sink) + const result = { + status: 'failed', + routeKey: routeKeyString, + message: error instanceof Error ? error.message : String(error), + } satisfies FeishuIMHandleMessageResult + await this.options.onImmediateReply?.({ result, routeKey: batch.routeKey, routeKeyString }) + return result + } finally { + if (releaseOnFinally) { + this.activeTurns.delete(routeKeyString) + this.recordSnapshot() + } + } + } + + async handleAbort( + routeKey: FeishuIMRouteKey, + routeKeyString = serializeFeishuRouteKey(routeKey), + options: { notify?: boolean } = {}, + ): Promise { + const notify = options.notify ?? true + const pending = this.buffer.discard(routeKeyString) + if (pending) { + this.recordSnapshot() + const result = { + status: 'buffer-cancelled', + routeKey: routeKeyString, + messageCount: pending.messages.length, + message: '已取消尚未发送到 Agent 的飞书消息。', + } satisfies FeishuIMHandleMessageResult + if (notify) await this.options.onImmediateReply?.({ result, routeKey, routeKeyString }) + return result + } + + const active = this.activeTurns.get(routeKeyString) + if (!active?.sessionId) { + const result = { + status: 'abort-noop', + routeKey: routeKeyString, + message: '当前飞书会话没有正在运行的 Agent turn。', + } satisfies FeishuIMHandleMessageResult + if (notify) await this.options.onImmediateReply?.({ result, routeKey, routeKeyString }) + return result + } + + try { + const aborted = await this.options.controller.abortSession({ + sessionId: active.sessionId, + directory: active.binding?.directory, + reason: 'feishu-im-abort', + }) + if (!aborted) { + const result = { + status: 'failed', + routeKey: routeKeyString, + message: 'Controller rejected abort request', + } satisfies FeishuIMHandleMessageResult + if (notify) await this.options.onImmediateReply?.({ result, routeKey, routeKeyString, binding: active.binding }) + return result + } + await this.stopReplySink(active.sink) + this.activeTurns.delete(routeKeyString) + this.recordSnapshot() + const result = { + status: 'aborted', + routeKey: routeKeyString, + sessionId: active.sessionId, + turnSnapshotId: active.turnSnapshotId, + message: '已取消当前飞书会话的 Agent turn。', + } satisfies FeishuIMHandleMessageResult + if (notify) await this.options.onImmediateReply?.({ result, routeKey, routeKeyString, binding: active.binding }) + return result + } catch (error) { + const result = { + status: 'failed', + routeKey: routeKeyString, + message: error instanceof Error ? error.message : String(error), + } satisfies FeishuIMHandleMessageResult + if (notify) await this.options.onImmediateReply?.({ result, routeKey, routeKeyString, binding: active.binding }) + return result + } + } + + async resetRoute(routeKey: FeishuIMRouteKey): Promise { + const current = await this.options.store.get(serializeFeishuRouteKey(routeKey)) + return this.createAndBindSession(routeKey, current?.directory ?? this.defaultDirectory()) + } + + async switchDirectory(routeKey: FeishuIMRouteKey, input: string): Promise { + const current = await this.options.store.get(serializeFeishuRouteKey(routeKey)) + const directory = await this.resolveDirectory(current?.directory ?? this.defaultDirectory(), input) + return this.createAndBindSession(routeKey, directory) + } + + async switchProject(routeKey: FeishuIMRouteKey, input: string): Promise { + const projects = await this.sortedProjects() + const project = matchProject(projects, input) + if (!project) throw new Error(`Project not found: ${input}`) + const directory = projectDirectory(project) + if (!directory) throw new Error(`Project has no usable directory: ${project.id}`) + const binding = await this.createAndBindSession(routeKey, directory) + return { ...binding, project } + } + + async handleCardAction(payload: FeishuCardActionPayload, value: FeishuCardActionValue = {}): Promise { + recordFeishuIMCardAction(payload.action) + if (payload.accountId !== this.options.account.id) { + return { type: 'failed', command: payload.action, message: 'Card action account does not match this IM account' } + } + const routeKey = parseFeishuRouteKey(payload.routeKey) + if (!routeKey) { + return { type: 'failed', command: payload.action, message: 'Card action route is invalid' } + } + const current = await this.options.store.get(payload.routeKey) + if (payload.sessionId && current?.sessionId && payload.sessionId !== current.sessionId && payload.action !== 'control.newSession') { + return { type: 'failed', command: payload.action, message: 'Card action session is no longer current' } + } + + try { + if (payload.action === 'turn.abort') { + const active = this.activeTurns.get(payload.routeKey) + if (!active?.sessionId) { + return { type: 'failed', command: payload.action, message: 'Card action route has no active turn' } + } + if (payload.sessionId && payload.sessionId !== active.sessionId) { + return { type: 'failed', command: payload.action, message: 'Card action session is no longer active' } + } + if (!payload.turnSnapshotId || payload.turnSnapshotId !== active.turnSnapshotId) { + return { type: 'failed', command: payload.action, message: 'Card action turn is no longer active' } + } + const result = await this.handleAbort(routeKey, payload.routeKey, { notify: false }) + if (result.status === 'aborted') { + return { + type: 'turn-aborted', + sessionId: result.sessionId, + turnSnapshotId: result.turnSnapshotId, + message: result.message, + } + } + return { + type: 'failed', + command: payload.action, + message: 'message' in result ? result.message : 'Abort did not complete', + } + } + if (payload.action === 'control.newSession') { + const binding = await this.resetRoute(routeKey) + return { + type: 'new-session', + sessionId: binding.sessionId, + directory: binding.directory, + projectId: binding.projectId, + } + } + if (payload.action === 'control.projectList') { + const projects = await this.sortedProjects() + return { + type: 'project-list', + projects: projects.map((project) => ({ + id: project.id, + name: projectDisplayName(project), + directory: projectDirectory(project), + })), + } + } + if (payload.action === 'control.switchProject') { + const projectId = value.projectId ?? value.value + if (!projectId) return { type: 'failed', command: payload.action, message: 'Project id is required' } + const binding = await this.switchProject(routeKey, projectId) + return { + type: 'project-switched', + sessionId: binding.sessionId, + projectId: binding.project.id, + projectName: projectDisplayName(binding.project), + directory: binding.directory ?? projectDirectory(binding.project) ?? '', + } + } + if (payload.action === 'control.showCwd') { + const binding = await this.resolveOrCreateSession(routeKey) + return { + type: 'cwd-current', + sessionId: binding.sessionId, + directory: binding.directory, + projectId: binding.projectId, + } + } + if (payload.action === 'control.help' || payload.action === 'control.openWeb') { + return { type: 'help', commands: CONTROL_COMMANDS } + } + } catch (error) { + return { type: 'failed', command: payload.action, message: error instanceof Error ? error.message : String(error) } + } + + return { type: 'failed', command: payload.action, message: 'Unsupported control card action' } + } + + stop() { + this.buffer.clear() + for (const sink of this.replySinks) { + void this.stopReplySink(sink) + } + this.replySinks.clear() + this.activeTurns.clear() + this.recordSnapshot() + } + + activeTurnSnapshot(): FeishuIMActiveTurnSnapshot[] { + this.recordSnapshot() + return [...this.activeTurns.values()].map((turn) => ({ + routeKey: { ...turn.routeKey }, + routeKeyString: turn.routeKeyString, + sessionId: turn.sessionId, + turnSnapshotId: turn.turnSnapshotId, + startedAt: turn.startedAt, + })) + } + + bufferSnapshot(): FeishuIMBufferSnapshotEntry[] { + this.recordSnapshot() + return this.buffer.snapshot() + } + + private async handleControlCommand( + routeKey: FeishuIMRouteKey, + message: FeishuIMIncomingMessage, + ): Promise { + const text = message.text?.trim() + if (!text?.startsWith('/')) return undefined + + if (text === '/control') { + const binding = await this.resolveOrCreateSession(routeKey) + const project = binding.projectId ? await this.options.controller.getProject(binding.projectId) : undefined + return { + type: 'control-panel', + sessionId: binding.sessionId, + routeKey: serializeFeishuRouteKey(routeKey), + projectId: binding.projectId, + projectName: project ? projectDisplayName(project) : undefined, + directory: project ? projectDirectory(project) ?? binding.directory : binding.directory, + } + } + + if (text === '/help') { + return { type: 'help', commands: CONTROL_COMMANDS } + } + + if (text === '/new') { + const binding = await this.resetRoute(routeKey) + return { + type: 'new-session', + sessionId: binding.sessionId, + directory: binding.directory, + projectId: binding.projectId, + } + } + + if (text === '/cwd') { + const binding = await this.resolveOrCreateSession(routeKey) + return { + type: 'cwd-current', + sessionId: binding.sessionId, + directory: binding.directory, + projectId: binding.projectId, + } + } + + if (text.startsWith('/cwd ')) { + const raw = trimWrappedQuotes(text.slice(5).trim()) + if (!raw) return { type: 'failed', command: '/cwd', message: 'Directory is required' } + try { + const binding = await this.switchDirectory(routeKey, raw) + return { + type: 'cwd-switched', + sessionId: binding.sessionId, + directory: binding.directory ?? raw, + projectId: binding.projectId, + } + } catch (error) { + return { type: 'failed', command: '/cwd', message: error instanceof Error ? error.message : String(error) } + } + } + + if (text === '/project') { + const binding = await this.resolveOrCreateSession(routeKey) + const project = binding.projectId ? await this.options.controller.getProject(binding.projectId) : undefined + return { + type: 'project-current', + sessionId: binding.sessionId, + projectId: binding.projectId, + projectName: project ? projectDisplayName(project) : undefined, + directory: project ? projectDirectory(project) ?? binding.directory : binding.directory, + } + } + + if (text === '/project list') { + const projects = await this.sortedProjects() + return { + type: 'project-list', + projects: projects.map((project) => ({ + id: project.id, + name: projectDisplayName(project), + directory: projectDirectory(project), + })), + } + } + + if (text.startsWith('/project ')) { + const raw = trimWrappedQuotes(text.slice(9).trim()) + if (!raw) return { type: 'failed', command: '/project', message: 'Project id or name is required' } + try { + const binding = await this.switchProject(routeKey, raw) + return { + type: 'project-switched', + sessionId: binding.sessionId, + projectId: binding.project.id, + projectName: projectDisplayName(binding.project), + directory: binding.directory ?? projectDirectory(binding.project) ?? '', + } + } catch (error) { + return { type: 'failed', command: '/project', message: error instanceof Error ? error.message : String(error) } + } + } + + return { + type: 'unknown-command', + command: text, + } + } + + private async createAndBindSession(routeKey: FeishuIMRouteKey, directory?: string): Promise { + const created = await this.options.controller.createSession({ + title: titleForRoute(routeKey), + directory, + entry: feishuControllerEntry(), + }) + const binding: FeishuIMSessionBinding = { + routeKey, + sessionId: created.session.id, + directory: created.session.directory || directory, + projectId: created.session.projectID, + updatedAt: new Date().toISOString(), + } + await this.options.store.set(serializeFeishuRouteKey(routeKey), binding) + return binding + } + + private contextBlocksForBatch(batch: FeishuIMBufferedBatch): FeishuControllerContextBlock[] { + const history = this.history.list(batch.routeKeyString) + const blocks: FeishuControllerContextBlock[] = [{ + id: 'platform:feishu-im-route', + layer: 'platform', + source: 'feishu-im.route', + enabled: true, + priority: 68, + lifecycle: 'turn', + visibility: 'system-required', + mergeKey: batch.routeKeyString, + content: renderRoute(batch.routeKey), + }, { + id: 'turn:feishu-im-batch', + layer: 'turn', + source: 'feishu-im.batch', + enabled: true, + priority: 66, + lifecycle: 'turn', + visibility: 'system-required', + mergeKey: batch.routeKeyString, + content: renderMessages('Feishu messages in this turn', batch.messages), + }] + + if (history.length > 0) { + blocks.push({ + id: 'turn:feishu-im-history', + layer: 'turn', + source: 'feishu-im.group-history', + enabled: true, + priority: 58, + lifecycle: 'turn', + visibility: 'developer-toggle', + mergeKey: `${batch.routeKeyString}:history`, + content: renderMessages('Recent Feishu group history', history), + }) + } + + return blocks + } + + private async sortedProjects(): Promise { + const projects = await this.options.controller.listProjects() + return [...projects].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)) + } + + private defaultDirectory(): string | undefined { + return this.options.account.defaultDirectory ?? this.options.defaultDirectory + } + + private async resolveDirectory(baseDirectory: string | undefined, input: string): Promise { + return this.options.resolveDirectory + ? this.options.resolveDirectory(baseDirectory, input) + : input + } + + private async stopReplySink(sink: FeishuIMReplySinkHandle | undefined): Promise { + if (!sink) return + this.replySinks.delete(sink) + await sink.stop() + } + + private updateActiveTurn(routeKeyString: string, patch: Partial) { + const current = this.activeTurns.get(routeKeyString) + if (!current) return + this.activeTurns.set(routeKeyString, { + ...current, + ...patch, + }) + this.recordSnapshot() + } + + private recordSnapshot() { + recordFeishuIMSessionManagerSnapshot({ + activeTurns: this.activeTurns.size, + pendingBuffers: this.buffer.routeCount(), + bufferedMessages: this.buffer.messageCount(), + }) + } +} + +const CONTROL_COMMANDS = [ + '/control', + '/new', + '/cwd', + '/cwd ', + '/project', + '/project list', + '/project ', +] + +function shouldBypassMentionGateForControl(message: FeishuIMIncomingMessage): boolean { + const text = message.text?.trim() + if (!text?.startsWith('/')) return false + return isRecognizedControlCommandText(text) || isFeishuIMAbortMessage(message) +} + +function isRecognizedControlCommandText(text: string): boolean { + return ( + text === '/control' || + text === '/help' || + text === '/new' || + text === '/cwd' || + text.startsWith('/cwd ') || + text === '/project' || + text === '/project list' || + text.startsWith('/project ') + ) +} + +function partsFromBatch(batch: FeishuIMBufferedBatch): FeishuIMControllerMessagePart[] { + return [{ + type: 'text', + text: renderUserMessages(batch.messages), + }] +} + +function renderUserMessages(messages: FeishuIMIncomingMessage[]): string { + return messages + .map((message) => message.text?.trim() || `[${message.messageType}]`) + .filter(Boolean) + .join('\n\n') +} + +function renderRoute(routeKey: FeishuIMRouteKey): string { + return [ + 'Platform: Feishu/Lark IM', + `Account: ${routeKey.accountId}`, + `Route: ${serializeFeishuRouteKey(routeKey)}`, + `Chat: ${routeKey.chatId}`, + routeKey.openId ? `Open ID: ${routeKey.openId}` : undefined, + routeKey.threadId ? `Thread: ${routeKey.threadId}` : undefined, + `Controller capabilities: ${Object.keys(FEISHU_CONTROLLER_CAPABILITIES).join(', ')}`, + ].filter(Boolean).join('\n') +} + +function renderMessages(title: string, messages: FeishuIMIncomingMessage[]): string { + return [ + `${title}:`, + '', + ...messages.map((message, index) => [ + `[${index + 1}] ${senderLabel(message)}, ${timeLabel(message)}, message_id: ${message.messageId}`, + message.text || `[${message.messageType}]`, + ].join('\n')), + ].join('\n\n') +} + +function senderLabel(message: FeishuIMIncomingMessage): string { + return message.sender.name || message.sender.openId || message.sender.userId || message.sender.unionId || 'unknown sender' +} + +function timeLabel(message: FeishuIMIncomingMessage): string { + return message.createTime ? new Date(message.createTime).toISOString() : 'unknown time' +} + +function titleForRoute(routeKey: FeishuIMRouteKey): string { + if (routeKey.kind === 'dm') return `Feishu DM ${routeKey.openId || routeKey.chatId}` + if (routeKey.kind === 'thread') return `Feishu thread ${routeKey.threadId || routeKey.chatId}` + return `Feishu group ${routeKey.chatId}` +} + +function matchProject(projects: FeishuControllerProject[], input: string): FeishuControllerProject | undefined { + const byId = projects.find((project) => project.id === input) + if (byId) return byId + const byName = projects.filter((project) => projectDisplayName(project) === input) + return byName.length === 1 ? byName[0] : undefined +} + +function trimWrappedQuotes(input: string): string { + const trimmed = input.trim() + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1).trim() + } + return trimmed +} diff --git a/packages/platform-feishu/src/im/store/binding-store.ts b/packages/platform-feishu/src/im/store/binding-store.ts new file mode 100644 index 00000000..f67cf468 --- /dev/null +++ b/packages/platform-feishu/src/im/store/binding-store.ts @@ -0,0 +1,40 @@ +import type { FeishuIMRouteKey } from '../route' + +export type FeishuIMSessionBinding = { + routeKey: FeishuIMRouteKey + sessionId: string + projectId?: string + directory?: string + allowedAt?: string + updatedAt: string +} + +export interface FeishuIMBindingStore { + get(routeKey: string): Promise + set(routeKey: string, binding: FeishuIMSessionBinding): Promise + delete(routeKey: string): Promise +} + +export class MemoryFeishuIMBindingStore implements FeishuIMBindingStore { + private readonly bindings = new Map() + + async get(routeKey: string): Promise { + const binding = this.bindings.get(routeKey) + return binding ? cloneBinding(binding) : undefined + } + + async set(routeKey: string, binding: FeishuIMSessionBinding): Promise { + this.bindings.set(routeKey, cloneBinding(binding)) + } + + async delete(routeKey: string): Promise { + this.bindings.delete(routeKey) + } +} + +function cloneBinding(binding: FeishuIMSessionBinding): FeishuIMSessionBinding { + return { + ...binding, + routeKey: { ...binding.routeKey }, + } +} diff --git a/packages/platform-feishu/src/im/streaming-card-controller.ts b/packages/platform-feishu/src/im/streaming-card-controller.ts new file mode 100644 index 00000000..94572667 --- /dev/null +++ b/packages/platform-feishu/src/im/streaming-card-controller.ts @@ -0,0 +1,529 @@ +import type { FeishuRuntimeEventEnvelope } from './controller-bridge' +import { + FEISHU_STREAMING_CARD_CONTENT_ELEMENT_ID, + FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID, + renderFeishuStreamingCardKitFinalCard, + renderFeishuStreamingCardKitInitialCard, + renderFeishuStreamingTurnCard, + type FeishuStreamingToolStatus, + type FeishuTurnCardStatus, +} from './cards' +import type { FeishuIMRouteKey } from './route' +import { + type FeishuIMReplyClient, + type FeishuIMReplyDelivery, + type FeishuIMSentMessage, +} from './reply-client' +import { + recordFeishuIMCardUpdateFailure, + recordFeishuIMStreamingFallback, + recordFeishuIMStreamingTransport, +} from './reply-telemetry' + +const PATCH_FALLBACK_UPDATE_MS = 1_500 + +type FeishuStreamingCardPhase = + | 'idle' + | 'creating' + | 'streaming' + | 'final' + | 'error' + | 'timeout' + | 'aborted' + | 'fallback' + +type FeishuStreamingTransport = 'cardkit' | 'patch' | 'text' + +export type FeishuStreamingCardControllerOptions = { + accountId: string + routeKey: FeishuIMRouteKey + sessionId: string + turnSnapshotId?: string + client: FeishuIMReplyClient + delivery: FeishuIMReplyDelivery + updateMs?: number + maxChars?: number + continueUrl?: string + onError?: (error: Error) => void | Promise +} + +export class FeishuStreamingCardController { + private phase: FeishuStreamingCardPhase = 'idle' + private transport: FeishuStreamingTransport | undefined + private cardEntityId?: string + private sentCard?: FeishuIMSentMessage + private sequence = 0 + private textBuffer = '' + private resourceFailure?: string + private errorMessage?: string + private fallbackReason?: string + private fallbackTextSent = false + private terminal = false + private flushInProgress = false + private reflushRequested = false + private flushTimer?: ReturnType + private lastFlushAt = 0 + private patchContentFlushed = false + private readonly tools = new Map() + + constructor(private readonly options: FeishuStreamingCardControllerOptions) {} + + async start(turnSnapshotId?: string): Promise { + if (turnSnapshotId !== undefined) this.options.turnSnapshotId = turnSnapshotId + if (this.phase !== 'idle') return + await this.ensureRunningTransport() + if (this.transport === 'patch' && !this.sentCard?.messageId && !this.sentCard?.cardId) { + await this.flushPatch('running') + this.lastFlushAt = Date.now() + } + } + + async appendText(text: string): Promise { + if (!text || this.terminal) return + this.textBuffer += text + await this.scheduleRunningFlush() + } + + clearText(): void { + this.textBuffer = '' + this.patchContentFlushed = false + this.lastFlushAt = 0 + this.clearTimer() + } + + replaceText(text: string): void { + this.textBuffer = text + this.patchContentFlushed = false + this.lastFlushAt = 0 + this.clearTimer() + } + + async handleRuntimeEvent(event: FeishuRuntimeEventEnvelope): Promise { + const tool = toolStatusFromEvent(event) + if (!tool) return false + this.tools.set(tool.id, tool) + this.patchContentFlushed = false + this.lastFlushAt = 0 + this.clearTimer() + await this.scheduleRunningFlush() + return true + } + + async setResourceFailure(message: string): Promise { + if (this.terminal) return + this.resourceFailure = message + await this.flushNow('running') + } + + async finish(status: Exclude, message?: string): Promise { + if (this.terminal) return + this.terminal = true + this.phase = status + if (status === 'error' || status === 'timeout') { + this.errorMessage = message ?? (status === 'timeout' ? '飞书回复等待超时,请在 Web 端继续。' : '处理失败。') + } + this.clearTimer() + await this.flushNow(status) + } + + stop(): void { + this.terminal = true + this.phase = 'aborted' + this.clearTimer() + } + + private async ensureRunningTransport(): Promise { + if (this.transport) return + const capabilities = this.options.client + if ( + capabilities.createCardEntity && + capabilities.sendCardEntity && + capabilities.streamCardContent && + capabilities.updateCardEntity && + capabilities.setCardStreamingMode + ) { + this.transport = 'cardkit' + recordFeishuIMStreamingTransport('cardkit') + await this.createCardKitCard() + return + } + this.transport = 'patch' + recordFeishuIMStreamingTransport('patch') + } + + private async createCardKitCard(): Promise { + if (this.cardEntityId) return + this.phase = 'creating' + try { + const entity = await this.options.client.createCardEntity!({ + card: renderFeishuStreamingCardKitInitialCard(this.cardInput('running')), + }) + this.cardEntityId = entity.cardId + this.sentCard = await this.options.client.sendCardEntity!({ + ...this.options.delivery, + cardId: entity.cardId, + }) + this.phase = 'streaming' + this.lastFlushAt = Date.now() + } catch (error) { + await this.fallbackToPatch(error, 'cardkit create failed') + } + } + + private async scheduleRunningFlush(): Promise { + if (this.terminal) return + await this.ensureRunningTransport() + const throttleMs = this.transport === 'patch' + ? this.patchUpdateMs() + : Math.max(1, this.options.updateMs ?? 1_000) + const now = Date.now() + const elapsed = now - this.lastFlushAt + if (elapsed >= throttleMs) { + this.clearTimer() + await this.flushNow('running') + return + } + if (this.flushTimer) return + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined + this.flushNow('running').catch((error) => { + recordFeishuIMCardUpdateFailure(error) + }) + }, Math.max(1, throttleMs - elapsed)) + this.flushTimer.unref?.() + } + + private async flushNow(status: FeishuTurnCardStatus): Promise { + if (this.flushInProgress) { + this.reflushRequested = true + return + } + this.flushInProgress = true + this.reflushRequested = false + try { + await this.ensureRunningTransport() + if (this.transport === 'cardkit') { + await this.flushCardKit(status) + } else if (this.transport === 'patch') { + await this.flushPatch(status) + } else { + await this.flushText(status) + } + this.lastFlushAt = Date.now() + } finally { + this.flushInProgress = false + if (this.reflushRequested && !this.terminal) { + this.reflushRequested = false + await this.scheduleRunningFlush() + } + } + } + + private async flushCardKit(status: FeishuTurnCardStatus): Promise { + if (!this.cardEntityId) { + await this.createCardKitCard() + if (this.transport !== 'cardkit' || !this.cardEntityId) { + await this.flushPatch(status) + return + } + } + try { + if (status === 'running') { + await this.options.client.streamCardContent!({ + cardId: this.cardEntityId, + elementId: FEISHU_STREAMING_CARD_CONTENT_ELEMENT_ID, + content: this.visibleText() || '正在等待 Agent 输出...', + sequence: this.nextSequence(), + }) + await this.options.client.streamCardContent!({ + cardId: this.cardEntityId, + elementId: FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID, + content: this.runningToolStatusText(), + sequence: this.nextSequence(), + }) + return + } + await this.options.client.updateCardEntity!({ + cardId: this.cardEntityId, + card: renderFeishuStreamingCardKitFinalCard(this.cardInput(status)), + sequence: this.nextSequence(), + }) + await this.options.client.setCardStreamingMode!({ + cardId: this.cardEntityId, + streaming: false, + sequence: this.nextSequence(), + }) + } catch (error) { + await this.fallbackToPatch(error, `cardkit ${status === 'running' ? 'content' : 'final'} failed`) + await this.flushPatch(status) + } + } + + private async flushPatch(status: FeishuTurnCardStatus): Promise { + try { + await this.ensurePatchCard(status) + } catch (error) { + await this.fallbackToText(error, 'message patch failed') + await this.flushText(status) + } + } + + private async ensurePatchCard(status: FeishuTurnCardStatus): Promise { + const card = renderFeishuStreamingTurnCard(this.cardInput(status)) + if (!this.sentCard?.messageId && !this.sentCard?.cardId) { + this.sentCard = await this.options.client.sendCard({ + ...this.options.delivery, + card, + }) + this.phase = this.phase === 'idle' ? 'streaming' : this.phase + this.markPatchProgressFlushed(status) + return + } + this.sentCard = await this.options.client.updateCard({ + messageId: this.sentCard.messageId, + cardId: this.sentCard.cardId, + card, + }) + this.markPatchProgressFlushed(status) + } + + private async flushText(status: FeishuTurnCardStatus): Promise { + if (!this.fallbackTextSent) { + this.fallbackTextSent = true + await this.sendFallbackText() + } + if (status === 'running') return + const text = this.visibleText() + || this.errorMessage + || (status === 'final' ? '已完成。' : '飞书回复已结束,请在 Web 端继续。') + await this.options.client.sendText({ + ...this.options.delivery, + text, + }).catch((error) => { + recordFeishuIMCardUpdateFailure(error) + }) + } + + private async fallbackToPatch(error: unknown, reason: string): Promise { + const normalized = normalizeError(error) + recordFeishuIMCardUpdateFailure(normalized) + recordFeishuIMStreamingFallback(reason, 'patch') + await this.options.onError?.(normalized) + this.transport = 'patch' + this.phase = 'fallback' + this.fallbackReason = reason + this.cardEntityId = undefined + recordFeishuIMStreamingTransport('patch') + } + + private async fallbackToText(error: unknown, reason: string): Promise { + const normalized = normalizeError(error) + recordFeishuIMCardUpdateFailure(normalized) + recordFeishuIMStreamingFallback(reason, 'text') + await this.options.onError?.(normalized) + this.transport = 'text' + this.phase = 'fallback' + this.fallbackReason = reason + recordFeishuIMStreamingTransport('text') + } + + private async sendFallbackText(): Promise { + await this.options.client.sendText({ + ...this.options.delivery, + text: '飞书流式卡片更新失败,可以在 Web 端继续查看。', + }).catch((error) => { + recordFeishuIMCardUpdateFailure(error) + }) + } + + private cardInput(status: FeishuTurnCardStatus) { + return { + accountId: this.options.accountId, + status, + routeKey: this.options.routeKey, + sessionId: this.options.sessionId, + turnSnapshotId: this.options.turnSnapshotId, + continueUrl: this.options.continueUrl, + content: this.visibleText() || undefined, + error: this.errorMessage, + resourceFailure: this.resourceFailure, + maxChars: this.options.maxChars ?? 6_000, + tools: status === 'running' ? this.runningTools() : undefined, + transport: this.transport, + fallbackReason: this.fallbackReason, + } + } + + private runningTools(): FeishuStreamingToolStatus[] { + return [...this.tools.values()].filter((tool) => tool.status === 'running') + } + + private runningToolStatusText(): string { + const tools = this.runningTools() + return tools.length > 0 ? renderRunningToolStatusLines(tools) : '' + } + + private visibleText(): string { + const text = this.textBuffer.trim() + const maxChars = this.options.maxChars ?? 6_000 + if (!text || text.length <= maxChars) return text + return `${text.slice(0, maxChars).trimEnd()}\n\n...` + } + + private nextSequence(): number { + this.sequence += 1 + return this.sequence + } + + private patchUpdateMs(): number { + const base = Math.max(1, this.options.updateMs ?? 1_000) + return this.patchContentFlushed ? Math.max(PATCH_FALLBACK_UPDATE_MS, base) : base + } + + private markPatchProgressFlushed(status: FeishuTurnCardStatus): void { + if (status !== 'running') return + if (this.visibleText() || this.tools.size > 0 || this.resourceFailure) { + this.patchContentFlushed = true + } + } + + private clearTimer(): void { + if (this.flushTimer) clearTimeout(this.flushTimer) + this.flushTimer = undefined + } +} + +function toolStatusFromEvent(event: FeishuRuntimeEventEnvelope): FeishuStreamingToolStatus | undefined { + const data = eventData(event) + if (event.type === 'runtime.tool.started') { + const id = stringValue(data.toolCallId) ?? stringValue(data.partID) ?? stringValue(data.partId) + const name = stringValue(data.tool) + if (!id || !name) return undefined + return { + id, + name, + status: 'running', + detail: summarizeValue(data.input), + } + } + if (event.type === 'runtime.tool.completed') { + const id = stringValue(data.toolCallId) ?? stringValue(data.partID) ?? stringValue(data.partId) + const name = stringValue(data.tool) + if (!id || !name) return undefined + return { + id, + name, + status: 'completed', + detail: stringValue(data.title), + durationMs: numberValue(data.durationMs), + } + } + if (event.type === 'runtime.tool.failed') { + const id = stringValue(data.toolCallId) ?? stringValue(data.partID) ?? stringValue(data.partId) + const name = stringValue(data.tool) + if (!id || !name) return undefined + return { + id, + name, + status: 'failed', + durationMs: numberValue(data.durationMs), + error: sanitizeText(stringValue(data.errorMessage) ?? stringValue(data.errorType) ?? 'tool failed'), + } + } + + if (event.type !== 'runtime.message.part.updated' && event.type !== 'message.part.updated') return undefined + const part = asRecord(data.part) ?? asRecord(asRecord(event.properties)?.part) + if (part?.type !== 'tool') return undefined + const state = asRecord(part.state) + const id = stringValue(part.callID) ?? stringValue(part.callId) ?? stringValue(part.id) + const name = stringValue(part.tool) + if (!id || !name) return undefined + const status = toolPartStatus(stringValue(state?.status)) + return { + id, + name, + status, + detail: summarizeValue(state?.input) ?? stringValue(state?.title), + durationMs: durationFromState(state), + error: status === 'failed' ? sanitizeText(stringValue(state?.error) ?? 'tool failed') : undefined, + } +} + +function toolPartStatus(input: string | undefined): FeishuStreamingToolStatus['status'] { + if (input === 'pending') return 'pending' + if (input === 'completed') return 'completed' + if (input === 'error' || input === 'failed') return 'failed' + return 'running' +} + +function durationFromState(state: Record | undefined): number | undefined { + const time = asRecord(state?.time) + const start = numberValue(time?.start) + const end = numberValue(time?.end) + return start !== undefined && end !== undefined ? Math.max(0, end - start) : undefined +} + +function renderRunningToolStatusLines(tools: FeishuStreamingToolStatus[]): string { + return [ + '**工具状态**', + ...tools.map((tool) => { + const detail = tool.error ?? tool.detail + return `- 运行中 ${tool.name}${detail ? `:${detail}` : ''}` + }), + ].join('\n') +} + +function summarizeValue(input: unknown): string | undefined { + const record = asRecord(input) + if (record) { + const preferred = ['description', 'command', 'query', 'q', 'path', 'filePath', 'file_path', 'url'] + .map((key) => stringValue(record[key])) + .find(Boolean) + if (preferred) return truncate(sanitizeText(preferred), 160) + const safe: Record = {} + for (const [key, value] of Object.entries(record)) { + if (isSensitiveKey(key)) { + safe[key] = '[redacted]' + } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + safe[key] = value + } + } + const json = Object.keys(safe).length ? JSON.stringify(safe) : undefined + return json ? truncate(json, 160) : undefined + } + return typeof input === 'string' ? truncate(sanitizeText(input), 160) : undefined +} + +function eventData(event: FeishuRuntimeEventEnvelope): Record { + if (event.data && typeof event.data === 'object') return event.data as Record + if (event.properties) return event.properties + return {} +} + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function asRecord(input: unknown): Record | undefined { + return input && typeof input === 'object' ? input as Record : undefined +} + +function stringValue(input: unknown): string | undefined { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function numberValue(input: unknown): number | undefined { + return typeof input === 'number' && Number.isFinite(input) ? input : undefined +} + +function sanitizeText(input: string): string { + return input.replace(/\b(secret|token|password|api[_-]?key)=\S+/gi, '$1=[redacted]') +} + +function isSensitiveKey(input: string): boolean { + return /(secret|token|password|credential|api[_-]?key|app[_-]?key|authorization)/i.test(input) +} + +function truncate(input: string, max: number): string { + return input.length <= max ? input : `${input.slice(0, max).trimEnd()}...` +} diff --git a/packages/platform-feishu/src/im/types.ts b/packages/platform-feishu/src/im/types.ts new file mode 100644 index 00000000..4072201a --- /dev/null +++ b/packages/platform-feishu/src/im/types.ts @@ -0,0 +1,244 @@ +import type { PlatformRuntimeStatus, PlatformSecretRef } from '@nine1bot/platform-protocol' + +export type FeishuIMConnectionMode = 'websocket' + +export type FeishuIMChatType = 'p2p' | 'group' | 'unknown' + +export type FeishuIMRouteKind = 'dm' | 'group' | 'thread' + +export type FeishuIMReplyPresentation = 'auto' | 'text' | 'card' | 'streaming-card' + +export type FeishuIMAccount = { + id: string + name?: string + enabled: boolean + appId: string + appSecretRef: PlatformSecretRef + defaultDirectory?: string + connectionMode: FeishuIMConnectionMode +} + +export type FeishuIMPolicy = { + dmPolicy: 'allow' | 'deny' + groupPolicy: 'mention-only' | 'allow' | 'deny' + allowFrom: string[] + replyMode: 'message' | 'thread' + replyPresentation: FeishuIMReplyPresentation + replyTimeoutMs: number + streamingCardUpdateMs: number + streamingCardMaxChars: number + messageBufferMs: number + maxBufferMs: number + busyRejectText: string +} + +export type FeishuIMLegacyState = { + enabled: boolean + mode?: string + appId?: string + hasAppSecret: boolean + defaultDirectory?: string +} + +export type FeishuIMNormalizedConfig = { + enabled: boolean + connectionMode: FeishuIMConnectionMode + accounts: FeishuIMAccount[] + policy: FeishuIMPolicy + legacy: FeishuIMLegacyState + warnings: string[] +} + +export type FeishuIMRuntimePhase = 'disabled' | 'staged' | 'running' | 'stopped' | 'error' + +export type FeishuIMRuntimeSnapshot = { + phase: FeishuIMRuntimePhase + status: PlatformRuntimeStatus + accountCount: number + legacyActive: boolean + activeReplySinks?: number + pendingInteractions?: number + activeStreamingCards?: number + cardUpdateFailures?: number + streamingFallbacks?: number + lastReplyError?: string + lastCardAction?: string + lastCardUpdateError?: string + lastStreamingTransport?: string + lastStreamingFallbackReason?: string + updatedAt: string +} + +export type FeishuIMMention = { + key?: string + name?: string + openId?: string + userId?: string + unionId?: string +} + +export type FeishuIMSender = { + openId?: string + userId?: string + unionId?: string + tenantKey?: string + name?: string +} + +export type FeishuIMIncomingMessage = { + eventId?: string + messageId: string + rootId?: string + parentId?: string + chatId: string + chatType: FeishuIMChatType + messageType: string + text?: string + sender: FeishuIMSender + mentions: FeishuIMMention[] + createTime?: number + raw: unknown +} + +export type FeishuIMGateDecision = { + action: 'dispatch' | 'history' | 'drop' + allowed: boolean + reason?: 'dm-denied' | 'group-denied' | 'mention-required' | 'not-allowlisted' +} + +export type FeishuIMControllerTextPart = { + type: 'text' + text: string +} + +export type FeishuIMControllerFilePart = { + type: 'file' + filename: string + mime: string + url: string +} + +export type FeishuIMControllerMessagePart = FeishuIMControllerTextPart | FeishuIMControllerFilePart + +export type FeishuIMControlResult = + | { + type: 'control-panel' + sessionId: string + routeKey: string + directory?: string + projectId?: string + projectName?: string + } + | { + type: 'new-session' + sessionId: string + directory?: string + projectId?: string + } + | { + type: 'cwd-current' + sessionId: string + directory?: string + projectId?: string + } + | { + type: 'cwd-switched' + sessionId: string + directory: string + projectId?: string + } + | { + type: 'project-current' + sessionId: string + projectId?: string + projectName?: string + directory?: string + } + | { + type: 'project-list' + projects: Array<{ + id: string + name?: string + directory?: string + }> + } + | { + type: 'project-switched' + sessionId: string + projectId: string + projectName?: string + directory: string + } + | { + type: 'unknown-command' + command: string + } + | { + type: 'failed' + command: string + message: string + } + | { + type: 'help' + commands: string[] + } + | { + type: 'turn-aborted' + sessionId: string + turnSnapshotId?: string + message: string + } + +export type FeishuIMHandleMessageResult = + | { + status: 'ignored' + reason?: string + } + | { + status: 'history-recorded' + routeKey: string + } + | { + status: 'buffered' + routeKey: string + messageCount: number + } + | { + status: 'accepted' + routeKey: string + sessionId: string + turnSnapshotId?: string + } + | { + status: 'busy' + routeKey: string + message: string + } + | { + status: 'control' + routeKey: string + control: FeishuIMControlResult + } + | { + status: 'failed' + routeKey?: string + message: string + } + | { + status: 'aborted' + routeKey: string + sessionId: string + turnSnapshotId?: string + message: string + } + | { + status: 'abort-noop' + routeKey: string + message: string + } + | { + status: 'buffer-cancelled' + routeKey: string + messageCount: number + message: string + } diff --git a/packages/platform-feishu/src/index.ts b/packages/platform-feishu/src/index.ts index ca6b718d..ad524ddb 100644 --- a/packages/platform-feishu/src/index.ts +++ b/packages/platform-feishu/src/index.ts @@ -10,6 +10,18 @@ export { feishuPlatformContribution, feishuPlatformDescriptor, normalizeFeishuPagePayload, -} from './runtime' -export type { FeishuPlatformAdapter } from './runtime' +} from './platform-runtime' +export type { FeishuPlatformAdapter } from './platform-runtime' +export { + FEISHU_IM_DEFAULT_BUFFER_MS, + FEISHU_IM_DEFAULT_BUSY_TEXT, + FEISHU_IM_DEFAULT_MAX_BUFFER_MS, + FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS, + FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS, + FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS, + isPlatformSecretRef, + normalizeFeishuIMConfig, + validateFeishuIMConfig, +} from './im/config' +export type * from './im/types' export type * from './types' diff --git a/packages/platform-feishu/src/node.ts b/packages/platform-feishu/src/node.ts index d6f9eec1..ee35c206 100644 --- a/packages/platform-feishu/src/node.ts +++ b/packages/platform-feishu/src/node.ts @@ -30,3 +30,14 @@ export type { FeishuCliRunResult, FeishuCliRunner, } from './cli' +export { createHttpFeishuControllerBridge } from './im/node/http-controller-bridge' +export type { FeishuHttpControllerBridgeOptions } from './im/node/http-controller-bridge' +export { createFeishuNodeReplyClient } from './im/node/reply-client' +export type { FeishuNodeReplyClientOptions } from './im/node/reply-client' +export { + defaultFeishuIMBindingStorePath, + FeishuFileIMBindingStore, +} from './im/node/binding-store' +export type { FeishuFileIMBindingStoreOptions } from './im/node/binding-store' +export { createFeishuNodeIMGateway } from './im/node/ws-gateway' +export type { FeishuNodeIMGatewayOptions } from './im/node/ws-gateway' diff --git a/packages/platform-feishu/src/runtime.ts b/packages/platform-feishu/src/platform-runtime.ts similarity index 71% rename from packages/platform-feishu/src/runtime.ts rename to packages/platform-feishu/src/platform-runtime.ts index 8842893e..bd08ac0e 100644 --- a/packages/platform-feishu/src/runtime.ts +++ b/packages/platform-feishu/src/platform-runtime.ts @@ -10,7 +10,10 @@ import { getFeishuCliVersion, resolveFeishuCliPath, } from './cli' -import { readFeishuContextEnrichmentSettings } from './enrichment' +import { + FEISHU_DEFAULT_METADATA_TIMEOUT_MS, + readFeishuContextEnrichmentSettings, +} from './enrichment' import { FEISHU_CURRENT_PAGE_SKILL, directoryFromActionInput, @@ -19,6 +22,19 @@ import { inspectSkillDirectory, resolveOfficialSkillsDirectory, } from './skills' +import { + FEISHU_IM_DEFAULT_BUFFER_MS, + FEISHU_IM_DEFAULT_BUSY_TEXT, + FEISHU_IM_DEFAULT_MAX_BUFFER_MS, + FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS, + FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS, + FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS, + validateFeishuIMConfig, +} from './im/config' +import { + createFeishuIMBackgroundServices, + getFeishuIMRuntimeStatus, +} from './im/background-runtime' import type { PlatformActionResult, PlatformAdapterContext, @@ -75,6 +91,7 @@ export const feishuPlatformDescriptor = { type: 'string', label: 'lark-cli path', description: 'Optional explicit path to lark-cli. Leave empty to search PATH.', + placeholder: 'Leave empty to search PATH', }, { key: 'contextEnrichment', @@ -82,18 +99,150 @@ export const feishuPlatformDescriptor = { label: 'Context enrichment', description: 'Controls read-only Feishu metadata enrichment for browser side panel messages.', options: ['auto', 'visible-only', 'disabled'], + defaultValue: 'auto', }, { key: 'metadataTimeoutMs', type: 'number', label: 'Metadata timeout', description: 'Timeout in milliseconds for read-only metadata lookups. Default: 2000.', + defaultValue: FEISHU_DEFAULT_METADATA_TIMEOUT_MS, }, { key: 'officialSkillsDirectory', type: 'string', label: 'Official skills directory', description: 'External directory containing official lark-* skills. Defaults to ~/.agents/skills. Switching directories takes effect on the next resolve; file changes inside the same directory may take up to 30 seconds to rescan.', + placeholder: 'Leave empty to use ~/.agents/skills', + }, + ], + }, + { + id: 'im', + title: 'IM', + description: 'Feishu/Lark IM settings. The new platform-feishu IM layer stays staged until the legacy websocket path is migrated.', + fields: [ + { + key: 'imEnabled', + type: 'boolean', + label: 'Enable IM skeleton', + description: 'Stages the new platform-feishu IM layer. It does not open a production websocket in Phase 1.', + defaultValue: false, + }, + { + key: 'imDefaultAppId', + type: 'string', + label: 'Default app ID', + description: 'Feishu/Lark app_id for the default IM account.', + placeholder: 'Fill in after enabling IM', + }, + { + key: 'imDefaultAppSecret', + type: 'password', + label: 'Default app secret', + description: 'Stored as a Nine1Bot platform secret reference.', + placeholder: 'Fill in after enabling IM', + secret: true, + }, + { + key: 'imDefaultDirectory', + type: 'string', + label: 'Default directory', + description: 'Fallback workspace directory for new IM conversations.', + placeholder: 'Leave empty to use the current project directory', + }, + { + key: 'imConnectionMode', + type: 'select', + label: 'Connection mode', + description: 'Only websocket is supported for the first IM implementation.', + options: ['websocket'], + defaultValue: 'websocket', + }, + { + key: 'imDmPolicy', + type: 'select', + label: 'Private chat policy', + options: ['allow', 'deny'], + defaultValue: 'allow', + }, + { + key: 'imGroupPolicy', + type: 'select', + label: 'Group chat policy', + options: ['mention-only', 'allow', 'deny'], + defaultValue: 'mention-only', + }, + { + key: 'imAllowFrom', + type: 'string-list', + label: 'Allow from', + description: 'Optional allowlist of chat IDs or sender IDs.', + defaultValue: [], + }, + { + key: 'imReplyMode', + type: 'select', + label: 'Reply target', + description: 'Where IM replies are posted. Message replies to the incoming message; thread replies in Feishu thread context when available.', + options: ['message', 'thread'], + defaultValue: 'message', + }, + { + key: 'imReplyPresentation', + type: 'select', + label: 'Reply presentation', + description: 'How agent output is rendered in Feishu. Auto uses text for DM and streaming cards for groups or threads.', + options: ['auto', 'text', 'card', 'streaming-card'], + defaultValue: 'auto', + }, + { + key: 'imReplyTimeoutMs', + type: 'number', + label: 'Reply timeout', + description: 'Milliseconds before an active IM reply is marked timed out. Default: 600000.', + defaultValue: FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS, + }, + { + key: 'imStreamingCardUpdateMs', + type: 'number', + label: 'Streaming card update', + description: 'Minimum milliseconds between running streaming-card updates. Default: 1000.', + defaultValue: FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS, + }, + { + key: 'imStreamingCardMaxChars', + type: 'number', + label: 'Streaming card max chars', + description: 'Maximum characters shown in a streaming card before truncating with a Web continuation hint. Default: 6000.', + defaultValue: FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS, + }, + { + key: 'imMessageBufferMs', + type: 'number', + label: 'Message buffer', + description: 'Milliseconds to buffer adjacent IM messages before sending them into a Nine1Bot turn.', + defaultValue: FEISHU_IM_DEFAULT_BUFFER_MS, + }, + { + key: 'imMaxBufferMs', + type: 'number', + label: 'Max buffer', + description: 'Hard upper bound for IM message buffering.', + defaultValue: FEISHU_IM_DEFAULT_MAX_BUFFER_MS, + }, + { + key: 'imBusyRejectText', + type: 'string', + label: 'Busy reject text', + defaultValue: FEISHU_IM_DEFAULT_BUSY_TEXT, + }, + { + key: 'imAccounts', + type: 'json', + label: 'Accounts', + description: 'Array of account objects. Use appSecretRef; plaintext appSecret is rejected.', + defaultValue: [], }, ], }, @@ -128,10 +277,17 @@ export const feishuPlatformDescriptor = { type: 'string', label: 'Official skills directory', description: 'Directory containing official lark-* skills. Empty value clears the override. Directory changes apply immediately; same-directory file changes may take up to 30 seconds to rescan.', + placeholder: 'Leave empty to clear the override', }], }], }, }, + { + id: 'im.inspect', + label: 'Inspect IM skeleton', + description: 'Shows the normalized Feishu IM skeleton status without opening a websocket.', + kind: 'button', + }, ], } satisfies PlatformDescriptor @@ -141,7 +297,9 @@ export const feishuPlatformContribution = { createAdapter: createFeishuPlatformAdapter, sources: feishuRuntimeSources, }, + backgroundServices: createFeishuIMBackgroundServices, getStatus: getFeishuStatus, + validateConfig: async (settings) => validateFeishuIMConfig(settings), handleAction: handleFeishuAction, } satisfies PlatformAdapterContribution @@ -179,7 +337,7 @@ async function getFeishuStatus(ctx: PlatformAdapterContext): Promise 0 && skillStatus.official.skillCount > 0 const finalStatus = status === 'available' && !skillsReady ? 'degraded' : status - return { + return withFeishuIMStatus({ status: finalStatus, message: finalStatus === 'available' ? 'lark-cli is available, authenticated, and Feishu skills are detected.' @@ -253,7 +411,7 @@ async function getFeishuStatus(ctx: PlatformAdapterContext): Promise severity(left) ? right : left +} + +function severity(status: PlatformRuntimeStatus['status']): number { + if (status === 'error') return 60 + if (status === 'missing') return 50 + if (status === 'auth-required') return 40 + if (status === 'degraded') return 30 + if (status === 'disabled') return 10 + return 0 +} + function buildFeishuContextBlocks(page: PageContextPayload, observedAt: number): PlatformContextBlock[] | undefined { const adapted = normalizeFeishuPagePayload(page) if (!adapted) return undefined diff --git a/packages/platform-feishu/test/feishu-im-controller-bridge.test.ts b/packages/platform-feishu/test/feishu-im-controller-bridge.test.ts new file mode 100644 index 00000000..d0709d4e --- /dev/null +++ b/packages/platform-feishu/test/feishu-im-controller-bridge.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createHttpFeishuControllerBridge } from '../src/node' + +describe('Feishu HTTP controller bridge', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('creates sessions through the public controller API', async () => { + const seen: Array<{ url: string; init: RequestInit; body: any }> = [] + globalThis.fetch = mockFetch(async (url, init) => { + seen.push({ url, init, body: JSON.parse(String(init.body)) }) + return jsonResponse({ + sessionId: 'ses_1', + session: { + id: 'ses_1', + directory: 'C:/work', + projectID: 'proj_1', + }, + }) + }) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + authHeader: 'Basic test', + }) + await expect(bridge.createSession({ + directory: 'C:/work', + })).resolves.toMatchObject({ + sessionId: 'ses_1', + session: { + id: 'ses_1', + }, + }) + + expect(new URL(seen[0]!.url).pathname).toBe('/nine1bot/agent/sessions') + expect(seen[0]!.init.method).toBe('POST') + expect(new Headers(seen[0]!.init.headers).get('authorization')).toBe('Basic test') + expect(seen[0]!.body.entry).toMatchObject({ + source: 'feishu', + platform: 'feishu', + mode: 'feishu-im', + }) + }) + + test('maps controller busy responses instead of throwing on 409', async () => { + globalThis.fetch = mockFetch(async () => jsonResponse({ + accepted: false, + busy: true, + sessionId: 'ses_1', + fallbackAction: { + type: 'continue-in-web', + label: 'Continue in web', + }, + }, 409)) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + }) + + await expect(bridge.sendMessage({ + sessionId: 'ses_1', + directory: 'C:/work', + parts: [{ type: 'text', text: 'hello' }], + })).resolves.toMatchObject({ + accepted: false, + busy: true, + status: 409, + }) + }) + + test('does not pass Feishu message ids as controller messageID', async () => { + const seen: Array<{ url: string; init: RequestInit; body: any }> = [] + globalThis.fetch = mockFetch(async (url, init) => { + seen.push({ url, init, body: JSON.parse(String(init.body)) }) + return jsonResponse({ + accepted: true, + busy: false, + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + }) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + }) + + await expect(bridge.sendMessage({ + sessionId: 'ses_1', + directory: 'C:/work', + messageId: 'om_feishu_external', + parts: [{ type: 'text', text: 'hello' }], + })).resolves.toMatchObject({ + accepted: true, + turnSnapshotId: 'turn_1', + }) + + expect(new URL(seen[0]!.url).pathname).toBe('/nine1bot/agent/sessions/ses_1/messages') + expect(seen[0]!.body).not.toHaveProperty('messageID') + expect(seen[0]!.body.entry).toMatchObject({ + source: 'feishu', + platform: 'feishu', + traceId: 'om_feishu_external', + }) + }) + + test('reads sessions and projects from public APIs', async () => { + globalThis.fetch = mockFetch(async (url) => { + const path = new URL(url).pathname + if (path === '/session/ses_missing') return jsonResponse({ error: 'missing' }, 404) + if (path === '/session/ses_1') return jsonResponse({ id: 'ses_1', directory: 'C:/work' }) + if (path === '/project') return jsonResponse([ + { id: 'old', name: 'Old', time: { updated: 1 } }, + { id: 'new', name: 'New', time: { updated: 2 } }, + ]) + if (path === '/project/new') return jsonResponse({ id: 'new', name: 'New' }) + return jsonResponse({ error: 'not found' }, 404) + }) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + }) + + await expect(bridge.getSession({ sessionId: 'ses_1' })).resolves.toMatchObject({ id: 'ses_1' }) + await expect(bridge.getSession({ sessionId: 'ses_missing' })).resolves.toBeUndefined() + await expect(bridge.listProjects()).resolves.toMatchObject([ + { id: 'new' }, + { id: 'old' }, + ]) + await expect(bridge.getProject('new')).resolves.toMatchObject({ id: 'new' }) + }) + + test('uses shared platform controller for simple JSON requests', async () => { + const seen: Array<{ path: string; init: unknown }> = [] + globalThis.fetch = mockFetch(async () => { + throw new Error('fetch should not be called') + }) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + platformController: { + localUrl: 'http://127.0.0.1:4096', + async requestJson(path: string, init: unknown): Promise { + seen.push({ path, init }) + return [ + { id: 'old', name: 'Old', time: { updated: 1 } }, + { id: 'new', name: 'New', time: { updated: 2 } }, + ] as T + }, + }, + }) + + await expect(bridge.listProjects()).resolves.toMatchObject([ + { id: 'new' }, + { id: 'old' }, + ]) + expect(seen).toEqual([{ + path: '/project', + init: { + method: 'GET', + headers: {}, + body: undefined, + }, + }]) + }) + + test('aborts sessions through the public session API', async () => { + const seen: Array<{ url: string; init: RequestInit }> = [] + globalThis.fetch = mockFetch(async (url, init) => { + seen.push({ url, init }) + return jsonResponse(true) + }) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + authHeader: 'Basic test', + }) + + await expect(bridge.abortSession({ + sessionId: 'ses_1', + directory: 'C:/work', + })).resolves.toBe(true) + + expect(new URL(seen[0]!.url).pathname).toBe('/session/ses_1/abort') + expect(new URL(seen[0]!.url).searchParams.get('directory')).toBe('C:/work') + expect(seen[0]!.init.method).toBe('POST') + expect(new Headers(seen[0]!.init.headers).get('authorization')).toBe('Basic test') + }) + + test('parses CRLF and multi-line SSE data frames', async () => { + const encoder = new TextEncoder() + globalThis.fetch = mockFetch(async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":\r\n')) + controller.enqueue(encoder.encode('data: "runtime.turn.completed","turnSnapshotId":"turn_1"}\r\n\r\n')) + controller.close() + }, + }), { + status: 200, + headers: { + 'content-type': 'text/event-stream', + }, + })) + + const bridge = createHttpFeishuControllerBridge({ + localUrl: 'http://127.0.0.1:4096', + }) + const events: unknown[] = [] + const errors: Error[] = [] + const done = new Promise((resolve) => { + bridge.subscribeEvents({ + sessionId: 'ses_1', + onEvent(event) { + events.push(event) + resolve() + }, + onError(error) { + errors.push(error) + resolve() + }, + }) + }) + + await done + expect(errors).toEqual([]) + expect(events).toEqual([{ + type: 'runtime.turn.completed', + turnSnapshotId: 'turn_1', + }]) + }) +}) + +function mockFetch(handler: (url: string, init: RequestInit) => Promise): typeof fetch { + return ((input: string | URL | Request, init: RequestInit = {}) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + return handler(url, init) + }) as typeof fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + }, + }) +} diff --git a/packages/platform-feishu/test/feishu-im-gateway.test.ts b/packages/platform-feishu/test/feishu-im-gateway.test.ts new file mode 100644 index 00000000..d9e1eda0 --- /dev/null +++ b/packages/platform-feishu/test/feishu-im-gateway.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { parseFeishuIMEvent } from '../src/im' +import { + createFeishuNodeIMGateway, + setFeishuNodeGatewaySdkLoaderForTesting, +} from '../src/im/node/ws-gateway' + +type FakeWsClientInstance = { + logger: { + error: (...message: unknown[]) => void + warn: (...message: unknown[]) => void + info: (...message: unknown[]) => void + debug: (...message: unknown[]) => void + trace: (...message: unknown[]) => void + } + eventDispatcher?: { + handlers?: Record unknown> + } +} + +const fakeWsClients: FakeWsClientInstance[] = [] +let startBehavior: (() => Promise) | undefined + +afterEach(() => { + fakeWsClients.length = 0 + startBehavior = undefined + setFeishuNodeGatewaySdkLoaderForTesting(undefined) +}) + +describe('Feishu node gateway lifecycle', () => { + test('emits connected and stopped around gateway lifecycle', async () => { + const states: string[] = [] + installFakeSdk() + + const gateway = createFeishuNodeIMGateway({ + account: { + id: 'default', + enabled: true, + appId: 'cli_xxx', + appSecretRef: { provider: 'env', key: 'FEISHU_SECRET' }, + connectionMode: 'websocket', + }, + appSecret: 'secret', + onMessage: async () => {}, + onConnectionStateChange: async (event) => { + states.push(event.state) + }, + }) + + await gateway.start() + await gateway.stop() + + expect(states).toEqual(['connected', 'stopped']) + }) + + test('classifies reconnect warnings separately from operational warnings and errors', async () => { + const states: Array<{ state: string; message?: string }> = [] + const operationalErrors: string[] = [] + installFakeSdk() + + const gateway = createFeishuNodeIMGateway({ + account: { + id: 'default', + enabled: true, + appId: 'cli_xxx', + appSecretRef: { provider: 'env', key: 'FEISHU_SECRET' }, + connectionMode: 'websocket', + }, + appSecret: 'secret', + onMessage: async () => {}, + onConnectionStateChange: async (event) => { + states.push({ state: event.state, message: event.message }) + }, + onOperationalError: async (error) => { + operationalErrors.push(error.message) + }, + }) + + await gateway.start() + + fakeWsClients[0]!.logger.warn('socket closed by peer') + fakeWsClients[0]!.logger.warn('unexpected handler warning') + fakeWsClients[0]!.logger.error('websocket fatal error') + + expect(states).toEqual([ + { state: 'connected', message: undefined }, + { state: 'reconnecting', message: 'socket closed by peer' }, + { state: 'connection-error', message: 'websocket fatal error' }, + ]) + expect(operationalErrors).toEqual(['unexpected handler warning']) + }) + + test('routes invalid card actions and message handler failures to operational errors', async () => { + const operationalErrors: string[] = [] + installFakeSdk() + + const gateway = createFeishuNodeIMGateway({ + account: { + id: 'default', + enabled: true, + appId: 'cli_xxx', + appSecretRef: { provider: 'env', key: 'FEISHU_SECRET' }, + connectionMode: 'websocket', + }, + appSecret: 'secret', + onMessage: async () => { + throw new Error('message handler failed') + }, + onCardAction: async () => undefined, + onOperationalError: async (error) => { + operationalErrors.push(error.message) + }, + }) + + await gateway.start() + await gateway.injectCardAction({ invalid: true }) + await gateway.injectMessage(parseFeishuIMEvent({ + event: { + sender: { sender_id: { open_id: 'ou_sender' } }, + message: { + message_id: 'om_1', + chat_id: 'oc_p2p', + chat_type: 'p2p', + message_type: 'text', + content: JSON.stringify({ text: 'hello' }), + }, + }, + })!) + + expect(operationalErrors).toEqual([ + expect.stringContaining('Invalid Feishu card action'), + 'message handler failed', + ]) + }) +}) + +function installFakeSdk() { + setFeishuNodeGatewaySdkLoaderForTesting(async () => ({ + Domain: { Feishu: 'feishu' }, + LoggerLevel: { info: 'info' }, + EventDispatcher: class FakeEventDispatcher { + handlers: Record unknown> = {} + + register(events: Record unknown>) { + this.handlers = { ...this.handlers, ...events } + return this + } + }, + WSClient: class FakeWSClient { + readonly logger + eventDispatcher?: { handlers?: Record unknown> } + + constructor(input: FakeWsClientInstance) { + this.logger = input.logger + fakeWsClients.push(this) + } + + async start(input: { eventDispatcher: { handlers?: Record unknown> } }) { + this.eventDispatcher = input.eventDispatcher + await startBehavior?.() + } + + close() {} + } as any, + })) +} diff --git a/packages/platform-feishu/test/feishu-im-reply.test.ts b/packages/platform-feishu/test/feishu-im-reply.test.ts new file mode 100644 index 00000000..cd355ad6 --- /dev/null +++ b/packages/platform-feishu/test/feishu-im-reply.test.ts @@ -0,0 +1,1416 @@ +import { describe, expect, test } from 'bun:test' +import type { PlatformSecretRef } from '@nine1bot/platform-protocol' +import { + answerFeishuCardInteraction, + clearFeishuIMReplyRuntimeSummaryForTesting, + createFeishuIMCardActionHandler, + createFeishuIMImmediateReplyHandler, + createFeishuIMReplySinkFactory, + FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID, + FeishuIMSessionManager, + FeishuReplySink, + formatFeishuCardActionResponse, + getFeishuIMReplyRuntimeRecentEvents, + getFeishuIMReplyRuntimeSummary, + MemoryFeishuIMBindingStore, + MemoryFeishuIMReplyClient, + normalizeFeishuIMConfig, + parseFeishuCardAction, + renderFeishuStreamingTurnCard, + routeKeyForFeishuMessage, + serializeFeishuRouteKey, + type FeishuCardActionPayload, + type FeishuControllerBridge, + type FeishuControllerCreateSessionInput, + type FeishuControllerCreateSessionResult, + type FeishuControllerMessageResult, + type FeishuControllerProject, + type FeishuControllerSendMessageInput, + type FeishuControllerSession, + type FeishuControllerTurnResult, + type FeishuIMCard, + type FeishuIMCardEntity, + type FeishuIMAccount, + type FeishuIMIncomingMessage, + type FeishuIMSentMessage, + type FeishuRuntimeEventEnvelope, + type FeishuRuntimeEventSubscription, +} from '../src/im' +import { createFeishuNodeReplyClient } from '../src/node' + +const secretRef: PlatformSecretRef = { + provider: 'nine1bot-local', + key: 'platform:feishu:default:imDefaultAppSecret', +} + +const account: FeishuIMAccount = { + id: 'default', + enabled: true, + appId: 'cli_xxx', + appSecretRef: secretRef, + defaultDirectory: 'C:/work', + connectionMode: 'websocket', +} + +describe('Feishu IM reply sink', () => { + test('text sink sends deltas and finishes on normalized turn completion', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const routeKey = routeKeyForFeishuMessage(message(), { accountId: account.id }) + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey, + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'message', + presentation: 'text', + timeoutMs: 10_000, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'runtime.message.part.updated', + turnSnapshotId: 'turn_1', + data: { + delta: { text: 'hello' }, + }, + }) + await bridge.emit({ + type: 'runtime.turn.completed', + turnSnapshotId: 'turn_1', + data: { status: 'idle' }, + }) + + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + expect(client.texts).toEqual([expect.objectContaining({ text: 'hello' })]) + }) + + test('reply sink releases completion even when terminal delivery fails', async () => { + const bridge = new EventBridge() + const client = new FailingTextReplyClient() + const routeKey = routeKeyForFeishuMessage(message(), { accountId: account.id }) + const errors: Error[] = [] + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey, + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'message', + presentation: 'text', + timeoutMs: 10_000, + onError: (error) => { + errors.push(error) + }, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'runtime.turn.completed', + turnSnapshotId: 'turn_1', + data: { status: 'idle' }, + }) + + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + expect(errors.at(-1)?.message).toContain('send text failed') + }) + + test('streaming card polls completed session result when runtime events are missed', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + bridge.latestTurnResult = { + completed: true, + text: '已记录请假申请到飞书多维表格。', + } + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + directory: 'C:/work', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 5, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + + const finalCard = JSON.stringify(client.updates.at(-1)?.card) + expect(finalCard).toContain('已记录请假申请') + expect(client.updates.at(-1)?.card).toEqual(expect.objectContaining({ + header: expect.objectContaining({ template: 'green' }), + })) + }) + + test('card sink creates and updates simplified cards for progress and errors', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const routeKey = routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }) + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey, + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'card', + timeoutMs: 10_000, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'message.part.updated', + properties: { + turnSnapshotId: 'turn_1', + part: { id: 'part_1', type: 'text', text: 'first draft' }, + }, + }) + await bridge.emit({ + type: 'session.error', + properties: { + turnSnapshotId: 'turn_1', + error: { message: 'boom' }, + }, + }) + + await expect(sink.done).resolves.toMatchObject({ status: 'error' }) + expect(client.cards).toHaveLength(1) + expect(client.updates.length).toBeGreaterThanOrEqual(2) + expect(JSON.stringify(client.updates.at(-1)?.card)).toContain('boom') + }) + + test('permission and question card actions answer controller interactions', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const routeKey = routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }) + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey, + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'message', + presentation: 'card', + timeoutMs: 10_000, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'runtime.interaction.requested', + turnSnapshotId: 'turn_1', + data: { + kind: 'permission', + requestId: 'perm_1', + permission: 'edit', + patterns: ['src/*'], + }, + }) + await bridge.emit({ + type: 'runtime.interaction.requested', + turnSnapshotId: 'turn_1', + data: { + kind: 'question', + requestId: 'question_1', + questions: [{ + question: 'Choose one', + options: [{ label: 'A', description: 'Option A' }], + }], + }, + }) + + expect(client.cards).toHaveLength(3) + const permissionAction = parseFirstPayload(client.cards[1]!.card) + await expect(answerFeishuCardInteraction({ + controller: bridge, + payload: permissionAction, + expected: { + accountId: account.id, + routeKey: serializeFeishuRouteKey(routeKey), + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }, + })).resolves.toMatchObject({ status: 'answered', requestId: 'perm_1' }) + + const questionAction = parseFirstPayload(client.cards[2]!.card, 'question.answer') + await expect(answerFeishuCardInteraction({ + controller: bridge, + payload: questionAction, + value: { answer: 'A' }, + })).resolves.toMatchObject({ status: 'answered', requestId: 'question_1' }) + + expect(bridge.answers).toEqual([ + expect.objectContaining({ requestId: 'perm_1', answer: 'allow-once' }), + expect.objectContaining({ requestId: 'question_1', answer: { answers: [['A']] } }), + ]) + sink.stop() + }) + + test('auto presentation uses text for DM and streaming card for group routes', async () => { + const dmBridge = new EventBridge() + const dmClient = new MemoryFeishuIMReplyClient() + const dmSink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message(), { accountId: account.id }), + sessionId: 'ses_dm', + controller: dmBridge, + client: dmClient, + replyMode: 'message', + presentation: 'auto', + timeoutMs: 10_000, + }) + + await dmSink.start() + await dmSink.bindTurnSnapshotId('turn_dm') + await dmBridge.emit({ + type: 'runtime.message.part.updated', + turnSnapshotId: 'turn_dm', + data: { delta: { text: 'dm text' } }, + }) + await dmBridge.emit({ + type: 'runtime.turn.completed', + turnSnapshotId: 'turn_dm', + }) + await expect(dmSink.done).resolves.toMatchObject({ status: 'final' }) + expect(dmClient.texts).toEqual([expect.objectContaining({ text: 'dm text' })]) + expect(dmClient.cards).toHaveLength(0) + + const groupBridge = new EventBridge() + const groupClient = new MemoryFeishuIMReplyClient() + const groupSink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_group', + controller: groupBridge, + client: groupClient, + replyMode: 'thread', + presentation: 'auto', + timeoutMs: 10_000, + streamingCardUpdateMs: 20, + }) + + await groupSink.start() + await groupSink.bindTurnSnapshotId('turn_group') + expect(groupClient.cards).toHaveLength(1) + expect(JSON.stringify(groupClient.cards[0]?.card)).toContain('停止') + groupSink.stop() + }) + + test('streaming card throttles running updates and flushes terminal state immediately', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 30, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'a' } } }) + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'b' } } }) + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'c' } } }) + expect(client.updates).toHaveLength(0) + await sleep(45) + expect(client.updates).toHaveLength(1) + expect(JSON.stringify(client.updates[0]?.card)).toContain('abc') + + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'd' } } }) + await bridge.emit({ type: 'runtime.turn.completed', turnSnapshotId: 'turn_1' }) + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + expect(client.updates.at(-1)?.card).toEqual(expect.objectContaining({ + header: expect.objectContaining({ template: 'green' }), + })) + expect(JSON.stringify(client.updates.at(-1)?.card)).toContain('abcd') + }) + + test('streaming card hides pre-tool assistant notes and waits for post-tool final text', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 5, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'runtime.message.part.updated', + turnSnapshotId: 'turn_1', + data: { delta: { text: '用户想要查看当前目录下有什么文件。我需要使用 bash 命令来列出目录内容。' } }, + }) + await sleep(15) + expect(JSON.stringify(client.updates.at(-1)?.card)).toContain('我需要使用 bash') + + await bridge.emit({ + type: 'runtime.tool.started', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + input: { description: 'List files in current directory', command: 'ls -la' }, + }, + }) + await sleep(15) + const toolCard = JSON.stringify(client.updates.at(-1)?.card) + expect(toolCard).toContain('工具状态') + expect(toolCard).not.toContain('我需要使用 bash') + + await bridge.emit({ + type: 'runtime.tool.completed', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + title: 'List files in current directory', + durationMs: 105, + }, + }) + await sleep(15) + expect(JSON.stringify(client.updates.at(-1)?.card)).not.toContain('工具状态') + await bridge.emit({ + type: 'session.idle', + properties: { + turnSnapshotId: 'turn_1', + }, + }) + + await sleep(80) + await bridge.emit({ + type: 'runtime.message.part.updated', + turnSnapshotId: 'turn_1', + data: { + delta: { + text: '当前目录 C:\\code\\nine1bot 包含:docs、opencode、packages、scripts、web。', + }, + }, + }) + + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + const finalCard = JSON.stringify(client.updates.at(-1)?.card) + expect(finalCard).toContain('当前目录 C:\\\\code\\\\nine1bot 包含') + expect(finalCard).not.toContain('工具状态') + expect(finalCard).not.toContain('我需要使用 bash') + expect(finalCard).not.toContain('用户想要查看当前目录') + }) + + test('streaming card defers direct turn completion until post-tool final text arrives', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 5, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + let resolved = false + void sink.done.then(() => { + resolved = true + }) + + await bridge.emit({ + type: 'runtime.tool.started', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + input: { command: 'pwd' }, + }, + }) + await sleep(15) + expect(JSON.stringify(client.updates.at(-1)?.card)).toContain('工具状态') + + await bridge.emit({ + type: 'runtime.tool.completed', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + title: 'Print working directory', + }, + }) + await bridge.emit({ + type: 'runtime.turn.completed', + turnSnapshotId: 'turn_1', + }) + + await sleep(100) + expect(resolved).toBe(false) + + await bridge.emit({ + type: 'runtime.message.part.updated', + turnSnapshotId: 'turn_1', + data: { + delta: { + text: '当前工作目录是 C:\\code\\nine1bot。', + }, + }, + }) + + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + const finalCard = JSON.stringify(client.updates.at(-1)?.card) + expect(finalCard).toContain('当前工作目录是 C:\\\\code\\\\nine1bot') + expect(finalCard).not.toContain('工具状态') + }) + + test('node message patch keeps the original card identity when Feishu returns empty data', async () => { + const bridge = new EventBridge() + const calls = { + replies: [] as unknown[], + patches: [] as unknown[], + } + const client = createFeishuNodeReplyClient({ + client: { + im: { + message: { + reply: async (input: unknown) => { + calls.replies.push(input) + return { + code: 0, + data: { + message_id: 'om_card_1', + card_id: 'card_1', + }, + } + }, + patch: async (input: unknown) => { + calls.patches.push(input) + return { code: 0, data: {} } + }, + }, + }, + }, + }) + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 5, + rootMessageId: 'om_user_1', + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'hello' } } }) + await sleep(15) + await bridge.emit({ type: 'runtime.turn.completed', turnSnapshotId: 'turn_1' }) + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + + expect(calls.replies).toHaveLength(1) + expect(calls.patches.length).toBeGreaterThanOrEqual(1) + expect(calls.patches.every((call) => messageIdFromPatchCall(call) === 'om_card_1')).toBe(true) + }) + + test('CardKit create failure falls back to message patch without creating a second card', async () => { + const bridge = new EventBridge() + const calls = { + cardKitCreates: [] as unknown[], + replies: [] as unknown[], + patches: [] as unknown[], + } + const client = createFeishuNodeReplyClient({ + client: { + im: { + message: { + reply: async (input: unknown) => { + calls.replies.push(input) + return { + code: 0, + data: { + message_id: 'om_fallback_card', + card_id: 'fallback_card', + }, + } + }, + patch: async (input: unknown) => { + calls.patches.push(input) + return { code: 0, data: {} } + }, + }, + }, + cardkit: { + v1: { + card: { + create: async (input: unknown) => { + calls.cardKitCreates.push(input) + return { + code: 300303, + msg: 'cardkit create denied', + error: { + log_id: 'log_cardkit_create', + troubleshooter: 'https://open.feishu.cn/trouble', + }, + } + }, + }, + }, + }, + }, + }) + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 5, + rootMessageId: 'om_user_1', + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'fallback text' } } }) + await sleep(15) + await bridge.emit({ type: 'runtime.turn.completed', turnSnapshotId: 'turn_1' }) + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + + expect(calls.cardKitCreates).toHaveLength(1) + expect(calls.replies).toHaveLength(1) + expect(calls.patches.length).toBeGreaterThanOrEqual(1) + expect(calls.patches.every((call) => messageIdFromPatchCall(call) === 'om_fallback_card')).toBe(true) + }) + + test('streaming card uses CardKit native transport when client supports it', async () => { + const bridge = new EventBridge() + const client = new CardKitReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 20, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + expect(client.entities).toHaveLength(1) + expect(client.entityMessages).toEqual([expect.objectContaining({ cardId: 'entity_1' })]) + expect(client.cards).toHaveLength(0) + + await bridge.emit({ + type: 'runtime.tool.started', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + input: { command: 'pwd', token: 'hidden-secret' }, + }, + }) + await sleep(35) + expect(client.streams).toEqual(expect.arrayContaining([ + expect.objectContaining({ + cardId: 'entity_1', + elementId: 'nine1bot_streaming_content', + content: '正在等待 Agent 输出...', + }), + expect.objectContaining({ + cardId: 'entity_1', + elementId: FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID, + }), + ])) + const runningToolStream = lastCardKitStream(client.streams, FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID) + expect(runningToolStream?.content).toContain('工具状态') + expect(runningToolStream?.content).toContain('bash') + expect(runningToolStream?.content).toContain('pwd') + expect(runningToolStream?.content).not.toContain('hidden-secret') + + await bridge.emit({ + type: 'runtime.tool.completed', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + title: 'Print working directory', + }, + }) + await sleep(35) + expect(lastCardKitStream(client.streams, FEISHU_STREAMING_CARD_TOOL_ELEMENT_ID)?.content).toBe('') + + bridge.latestTurnResult = { + completed: true, + text: '当前工作目录是 C:/code/nine1bot。', + } + await bridge.emit({ type: 'runtime.turn.completed', turnSnapshotId: 'turn_1' }) + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + const finalCard = JSON.stringify(client.entityUpdates.at(-1)?.card) + expect(finalCard).toContain('当前工作目录是 C:/code/nine1bot。') + expect(finalCard).not.toContain('工具状态') + expect(client.settings.at(-1)).toEqual(expect.objectContaining({ + cardId: 'entity_1', + streaming: false, + })) + expect(client.updates).toHaveLength(0) + }) + + test('streaming card falls back from CardKit content to message patch', async () => { + clearFeishuIMReplyRuntimeSummaryForTesting() + const bridge = new EventBridge() + const client = new FailingCardKitContentReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 10, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'fallback text' } } }) + await sleep(20) + expect(client.streams).toHaveLength(1) + expect(client.updates.length).toBeGreaterThan(0) + expect(JSON.stringify(client.updates.at(-1)?.card)).toContain('fallback text') + expect(client.texts).toHaveLength(0) + const summary = getFeishuIMReplyRuntimeSummary() + expect(summary.streamingFallbacks).toBeGreaterThan(0) + expect(summary.lastStreamingTransport).toBe('patch') + expect(getFeishuIMReplyRuntimeRecentEvents()).toContainEqual(expect.objectContaining({ + stage: 'im-reply', + data: expect.objectContaining({ + event: 'streaming-fallback', + transport: 'patch', + reason: 'cardkit content failed', + }), + })) + sink.stop() + }) + + test('streaming group cards hide internal session route and transport metadata', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 10, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ type: 'runtime.message.part.updated', turnSnapshotId: 'turn_1', data: { delta: { text: 'clean content' } } }) + await sleep(15) + await bridge.emit({ type: 'runtime.turn.completed', turnSnapshotId: 'turn_1' }) + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + + for (const card of [client.cards[0]?.card, ...client.updates.map((update) => update.card)]) { + const rendered = JSON.stringify(card) + expect(rendered).not.toContain('Session') + expect(rendered).not.toContain('Route') + expect(rendered).not.toContain('投递') + expect(rendered).not.toContain('cardkit create failed') + } + }) + + test('new card action trigger responses wrap updated cards in the official raw card envelope', () => { + const routeKey = routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }) + const card = renderFeishuStreamingTurnCard({ + accountId: account.id, + routeKey, + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + status: 'running', + maxChars: 1000, + content: 'working', + }) + + const response = formatFeishuCardActionResponse({ + header: { + event_type: 'card.action.trigger', + }, + }, card) + expect(response).toMatchObject({ + toast: { + type: 'success', + }, + card: { + type: 'raw', + data: card, + }, + }) + expect(parseFirstPayload((response as { card: { data: FeishuIMCard } }).card.data, 'turn.abort')).toMatchObject({ + action: 'turn.abort', + routeKey: serializeFeishuRouteKey(routeKey), + }) + + expect(formatFeishuCardActionResponse({ + header: { + event_type: 'card.action.trigger_v1', + }, + }, card)).toBe(card) + }) + + test('Feishu API errors include response troubleshooting fields', async () => { + const client = createFeishuNodeReplyClient({ + client: { + im: { + message: { + reply: async () => ({ + code: 19001, + msg: 'permission denied', + error: { + log_id: 'log_permission', + troubleshooter: 'https://open.feishu.cn/trouble', + }, + }), + }, + }, + }, + }) + + await expect(client.sendCard({ + chatId: 'oc_group', + rootMessageId: 'om_user', + replyTarget: 'thread', + card: { elements: [] }, + })).rejects.toThrow(/im\.message\.reply failed: code=19001, msg=permission denied, log_id=log_permission, troubleshooter=https:\/\/open\.feishu\.cn\/trouble/) + }) + + test('streaming card renders only the current running tool status', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 10, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'runtime.tool.started', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + input: { + command: 'bun test', + token: 'super-secret', + }, + }, + }) + await sleep(15) + const rendered = JSON.stringify(client.updates.at(-1)?.card) + expect(rendered).toContain('工具状态') + expect(rendered).toContain('bash') + expect(rendered).toContain('bun test') + expect(rendered).not.toContain('super-secret') + + await bridge.emit({ + type: 'runtime.tool.completed', + turnSnapshotId: 'turn_1', + data: { + toolCallId: 'tool_1', + tool: 'bash', + title: 'Run tests', + }, + }) + await sleep(15) + expect(JSON.stringify(client.updates.at(-1)?.card)).not.toContain('工具状态') + sink.stop() + }) + + test('streaming card truncates long content and degrades when card update fails', async () => { + clearFeishuIMReplyRuntimeSummaryForTesting() + const bridge = new EventBridge() + const client = new FailingUpdateReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 10, + streamingCardMaxChars: 5, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + await bridge.emit({ + type: 'runtime.message.part.updated', + turnSnapshotId: 'turn_1', + data: { delta: { text: '123456789' } }, + }) + await sleep(25) + expect(client.updates).toHaveLength(1) + expect(JSON.stringify(client.updates[0]?.card)).toContain('内容较长') + expect(client.texts.at(-1)?.text).toContain('流式卡片更新失败') + expect(getFeishuIMReplyRuntimeSummary().cardUpdateFailures).toBeGreaterThan(0) + expect(getFeishuIMReplyRuntimeRecentEvents()).toContainEqual(expect.objectContaining({ + stage: 'im-reply', + data: expect.objectContaining({ + event: 'card-update-failed', + }), + })) + expect(getFeishuIMReplyRuntimeRecentEvents().filter((event) => event.data?.event === 'reply-error')).toHaveLength(0) + sink.stop() + }) + + test('streaming card degrades when initial card send fails without failing the sink', async () => { + const bridge = new EventBridge() + const client = new FailingSendCardReplyClient() + const sink = new FeishuReplySink({ + accountId: account.id, + routeKey: routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: account.id }), + sessionId: 'ses_1', + controller: bridge, + client, + replyMode: 'thread', + presentation: 'streaming-card', + timeoutMs: 10_000, + streamingCardUpdateMs: 10, + }) + + await sink.start() + await sink.bindTurnSnapshotId('turn_1') + expect(client.texts.at(-1)?.text).toContain('流式卡片更新失败') + await bridge.emit({ type: 'runtime.turn.completed', turnSnapshotId: 'turn_1' }) + await expect(sink.done).resolves.toMatchObject({ status: 'final' }) + }) +}) + +describe('Feishu IM reply coordinator with session manager', () => { + test('accepted turn keeps route busy until reply sink finishes', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: account.appId, + imDefaultAppSecret: secretRef, + imMessageBufferMs: 0, + imMaxBufferMs: 1000, + imBusyRejectText: 'busy text', + imReplyPresentation: 'text', + }) + const manager = new FeishuIMSessionManager({ + account, + config, + controller: bridge, + store: new MemoryFeishuIMBindingStore(), + replySinkFactory: createFeishuIMReplySinkFactory({ + account, + config, + controller: bridge, + client, + }), + onImmediateReply: createFeishuIMImmediateReplyHandler({ + account, + config, + client, + }), + }) + + await expect(manager.handleIncomingMessage(message({ text: 'hello', messageId: 'om_1' }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + await expect(manager.handleIncomingMessage(message({ text: 'second', messageId: 'om_2' }))).resolves.toMatchObject({ + status: 'busy', + message: 'busy text', + }) + expect(client.texts.at(-1)?.text).toBe('busy text') + + await bridge.emit({ + type: 'runtime.turn.completed', + turnSnapshotId: 'turn_1', + data: { status: 'idle' }, + }) + await expect(manager.handleIncomingMessage(message({ text: 'after done', messageId: 'om_3' }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_2', + }) + }) + + test('abort result is delivered as immediate reply text', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: account.appId, + imDefaultAppSecret: secretRef, + imMessageBufferMs: 0, + imMaxBufferMs: 1000, + imReplyPresentation: 'text', + }) + const manager = new FeishuIMSessionManager({ + account, + config, + controller: bridge, + store: new MemoryFeishuIMBindingStore(), + replySinkFactory: createFeishuIMReplySinkFactory({ + account, + config, + controller: bridge, + client, + }), + onImmediateReply: createFeishuIMImmediateReplyHandler({ + account, + config, + client, + }), + }) + + await expect(manager.handleIncomingMessage(message({ text: 'long task', messageId: 'om_1' }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + await expect(manager.handleIncomingMessage(message({ text: '/abort', messageId: 'om_abort' }))).resolves.toMatchObject({ + status: 'aborted', + sessionId: 'ses_1', + }) + + expect(bridge.aborts).toEqual([expect.objectContaining({ sessionId: 'ses_1', directory: 'C:/work' })]) + expect(client.texts.at(-1)?.text).toBe('已取消当前飞书会话的 Agent turn。') + }) + + test('control action handler supports new session and project list', async () => { + const bridge = new EventBridge({ + projects: [{ + id: 'proj_1', + name: 'Project One', + rootDirectory: 'C:/project-one', + }], + }) + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: account.appId, + imDefaultAppSecret: secretRef, + imMessageBufferMs: 0, + imMaxBufferMs: 1000, + }) + const manager = new FeishuIMSessionManager({ + account, + config, + controller: bridge, + store: new MemoryFeishuIMBindingStore(), + }) + const routeKey = routeKeyForFeishuMessage(message(), { accountId: account.id }) + const routeKeyString = serializeFeishuRouteKey(routeKey) + await manager.resolveOrCreateSession(routeKey) + + await expect(manager.handleIncomingMessage(message({ text: '/control' }))).resolves.toMatchObject({ + status: 'control', + control: { + type: 'control-panel', + routeKey: routeKeyString, + }, + }) + + const payload: FeishuCardActionPayload = { + v: 1, + accountId: account.id, + routeKey: routeKeyString, + sessionId: 'ses_1', + action: 'control.projectList', + nonce: 'nonce', + issuedAt: new Date().toISOString(), + } + await expect(manager.handleCardAction(payload)).resolves.toMatchObject({ + type: 'project-list', + projects: [{ id: 'proj_1', name: 'Project One' }], + }) + }) + + test('card action handler returns updated control cards for project list and cwd', async () => { + const bridge = new EventBridge({ + projects: [{ + id: 'proj_1', + name: 'Project One', + rootDirectory: 'C:/project-one', + }], + }) + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: account.appId, + imDefaultAppSecret: secretRef, + imMessageBufferMs: 0, + imMaxBufferMs: 1000, + }) + const manager = new FeishuIMSessionManager({ + account, + config, + controller: bridge, + store: new MemoryFeishuIMBindingStore(), + }) + const routeKey = routeKeyForFeishuMessage(message(), { accountId: account.id }) + const routeKeyString = serializeFeishuRouteKey(routeKey) + await manager.resolveOrCreateSession(routeKey) + const handler = createFeishuIMCardActionHandler({ + account, + controller: bridge, + manager, + continueUrlForSession: (sessionId) => `http://127.0.0.1:4096/?session=${sessionId}`, + }) + + const projectListPayload: FeishuCardActionPayload = { + v: 1, + accountId: account.id, + routeKey: routeKeyString, + sessionId: 'ses_1', + action: 'control.projectList', + nonce: 'nonce-project-list', + issuedAt: new Date().toISOString(), + } + const projectListCard = await handler({ + accountId: account.id, + payload: projectListPayload, + value: {}, + raw: {}, + }) + expect(JSON.stringify(projectListCard)).toContain('Project One') + expect(JSON.stringify(projectListCard)).toContain('control.showCwd') + + const cwdCard = await handler({ + accountId: account.id, + payload: { + ...projectListPayload, + action: 'control.showCwd', + nonce: 'nonce-cwd', + }, + value: {}, + raw: {}, + }) + expect(JSON.stringify(cwdCard)).toContain('当前目录') + expect(JSON.stringify(cwdCard)).toContain('C:/work') + }) + + test('streaming card abort action cancels only the current active turn', async () => { + const bridge = new EventBridge() + const client = new MemoryFeishuIMReplyClient() + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: account.appId, + imDefaultAppSecret: secretRef, + imMessageBufferMs: 0, + imMaxBufferMs: 1000, + imReplyPresentation: 'streaming-card', + imStreamingCardUpdateMs: 20, + }) + const manager = new FeishuIMSessionManager({ + account, + config, + controller: bridge, + store: new MemoryFeishuIMBindingStore(), + replySinkFactory: createFeishuIMReplySinkFactory({ + account, + config, + controller: bridge, + client, + }), + }) + + await expect(manager.handleIncomingMessage(message({ text: 'run', messageId: 'om_1' }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + const payload = parseFirstPayload(client.cards[0]!.card, 'turn.abort') + await expect(manager.handleCardAction({ + ...payload, + turnSnapshotId: 'old_turn', + })).resolves.toMatchObject({ + type: 'failed', + message: expect.stringContaining('turn'), + }) + const { turnSnapshotId: _turnSnapshotId, ...payloadWithoutTurn } = payload + await expect(manager.handleCardAction(payloadWithoutTurn)).resolves.toMatchObject({ + type: 'failed', + message: expect.stringContaining('turn'), + }) + expect(bridge.aborts).toHaveLength(0) + + await expect(manager.handleCardAction(payload)).resolves.toMatchObject({ + type: 'turn-aborted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + expect(bridge.aborts).toEqual([expect.objectContaining({ sessionId: 'ses_1', directory: 'C:/work' })]) + expect(manager.activeTurnSnapshot()).toEqual([]) + }) +}) + +function message(input: { + text?: string + messageId?: string + chatType?: 'p2p' | 'group' + chatId?: string + openId?: string + rootId?: string +} = {}): FeishuIMIncomingMessage { + return { + eventId: `evt_${input.messageId ?? '1'}`, + messageId: input.messageId ?? 'om_1', + chatId: input.chatId ?? 'oc_dm', + chatType: input.chatType ?? 'p2p', + rootId: input.rootId, + messageType: 'text', + text: input.text ?? 'hello', + sender: { + openId: input.openId ?? 'ou_alice', + name: 'Alice', + }, + mentions: [], + createTime: 1_778_000_000_000, + raw: {}, + } +} + +function parseFirstPayload(card: Record, action?: string): FeishuCardActionPayload { + const raw = JSON.stringify(card) + const parsed = JSON.parse(raw) as any + const buttons: any[] = [] + for (const element of parsed.elements ?? []) { + for (const button of element.actions ?? []) { + if (button.value?.nine1bot && (!action || button.value.nine1bot.action === action)) { + buttons.push(button) + } + } + } + const result = parseFeishuCardAction({ action: { value: buttons[0]!.value } }) + if (!result.ok) throw new Error(result.reason) + return result.payload +} + +function messageIdFromPatchCall(input: unknown): string | undefined { + const record = input && typeof input === 'object' ? input as Record : undefined + const path = record?.path && typeof record.path === 'object' ? record.path as Record : undefined + return typeof path?.message_id === 'string' ? path.message_id : undefined +} + +function lastCardKitStream( + streams: Array<{ cardId: string; elementId: string; content: string; sequence: number }>, + elementId: string, +) { + return [...streams].reverse().find((stream) => stream.elementId === elementId) +} + +class EventBridge implements FeishuControllerBridge { + sessions = new Map() + sent: FeishuControllerSendMessageInput[] = [] + aborts: any[] = [] + answers: any[] = [] + latestTurnResult?: FeishuControllerTurnResult + private sequence = 0 + private subscribers: Array<(event: FeishuRuntimeEventEnvelope) => void | Promise> = [] + + constructor(private readonly options: { + projects?: FeishuControllerProject[] + } = {}) {} + + async createSession(input: FeishuControllerCreateSessionInput): Promise { + const id = `ses_${++this.sequence}` + const session = { + id, + directory: input.directory ?? 'C:/work', + projectID: this.projectForDirectory(input.directory)?.id, + title: input.title, + } + this.sessions.set(id, session) + return { sessionId: id, session } + } + + async getSession(input: { sessionId: string }): Promise { + return this.sessions.get(input.sessionId) + } + + async sendMessage(input: FeishuControllerSendMessageInput): Promise { + this.sent.push(input) + return { + accepted: true, + sessionId: input.sessionId, + turnSnapshotId: `turn_${this.sent.length}`, + status: 202, + } + } + + async getLatestTurnResult(): Promise { + return this.latestTurnResult + } + + async abortSession(input: any): Promise { + this.aborts.push(input) + return true + } + + async answerInteraction(input: any): Promise { + this.answers.push(input) + return true + } + + async listProjects(): Promise { + return this.options.projects ?? [] + } + + async getProject(projectId: string): Promise { + return (this.options.projects ?? []).find((project) => project.id === projectId) + } + + subscribeEvents(input: { + onEvent: (event: FeishuRuntimeEventEnvelope) => void | Promise + }): FeishuRuntimeEventSubscription { + this.subscribers.push(input.onEvent) + return { + stop: () => { + this.subscribers = this.subscribers.filter((subscriber) => subscriber !== input.onEvent) + }, + } + } + + async emit(event: FeishuRuntimeEventEnvelope): Promise { + await Promise.all(this.subscribers.map((subscriber) => subscriber(event))) + } + + private projectForDirectory(directory: string | undefined): FeishuControllerProject | undefined { + return (this.options.projects ?? []).find((project) => project.rootDirectory === directory || project.worktree === directory) + } +} + +class FailingUpdateReplyClient extends MemoryFeishuIMReplyClient { + async updateCard(input: { messageId?: string; cardId?: string; card: Record }): Promise { + this.updates.push(JSON.parse(JSON.stringify(input))) + throw new Error('update failed') + } +} + +class FailingTextReplyClient extends MemoryFeishuIMReplyClient { + async sendText(input: { chatId: string; rootMessageId?: string; replyTarget: 'message' | 'thread'; text: string }): Promise { + this.texts.push(JSON.parse(JSON.stringify(input))) + throw new Error('send text failed') + } +} + +class CardKitReplyClient extends MemoryFeishuIMReplyClient { + readonly entities: Array<{ card: FeishuIMCard }> = [] + readonly entityMessages: Array<{ chatId: string; rootMessageId?: string; replyTarget: 'message' | 'thread'; cardId: string }> = [] + readonly streams: Array<{ cardId: string; elementId: string; content: string; sequence: number }> = [] + readonly entityUpdates: Array<{ cardId: string; card: FeishuIMCard; sequence: number }> = [] + readonly settings: Array<{ cardId: string; streaming: boolean; sequence: number }> = [] + + async createCardEntity(input: { card: FeishuIMCard }): Promise { + this.entities.push(JSON.parse(JSON.stringify(input))) + return { cardId: `entity_${this.entities.length}` } + } + + async sendCardEntity(input: { + chatId: string + rootMessageId?: string + replyTarget: 'message' | 'thread' + cardId: string + }): Promise { + this.entityMessages.push(JSON.parse(JSON.stringify(input))) + return { + messageId: `entity_message_${this.entityMessages.length}`, + cardId: input.cardId, + } + } + + async streamCardContent(input: { + cardId: string + elementId: string + content: string + sequence: number + }): Promise { + this.streams.push(JSON.parse(JSON.stringify(input))) + } + + async updateCardEntity(input: { + cardId: string + card: FeishuIMCard + sequence: number + }): Promise { + this.entityUpdates.push(JSON.parse(JSON.stringify(input))) + } + + async setCardStreamingMode(input: { + cardId: string + streaming: boolean + sequence: number + }): Promise { + this.settings.push(JSON.parse(JSON.stringify(input))) + } +} + +class FailingCardKitContentReplyClient extends CardKitReplyClient { + async streamCardContent(input: { + cardId: string + elementId: string + content: string + sequence: number + }): Promise { + this.streams.push(JSON.parse(JSON.stringify(input))) + throw new Error('cardkit content failed') + } +} + +class FailingSendCardReplyClient extends MemoryFeishuIMReplyClient { + async sendCard(input: { chatId: string; rootMessageId?: string; replyTarget: 'message' | 'thread'; card: Record }): Promise { + this.cards.push(JSON.parse(JSON.stringify(input))) + throw new Error('send failed') + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/platform-feishu/test/feishu-im-runtime.test.ts b/packages/platform-feishu/test/feishu-im-runtime.test.ts new file mode 100644 index 00000000..201858ca --- /dev/null +++ b/packages/platform-feishu/test/feishu-im-runtime.test.ts @@ -0,0 +1,379 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import type { + PlatformAdapterContext, + PlatformBackgroundServiceHandle, + PlatformRecentEvent, + PlatformSecretRef, +} from '@nine1bot/platform-protocol' +import { + clearFeishuIMReplyRuntimeSummaryForTesting, + createFeishuIMBackgroundServices, + recordFeishuIMCardUpdateFailure, + recordFeishuIMReplyError, + recordFeishuIMStreamingFallback, +} from '../src/im' +import { + clearFeishuIMRuntimeSnapshotForTesting, + setFeishuIMRuntimeTestHooksForTesting, +} from '../src/im/background-runtime' + +const secretRef: PlatformSecretRef = { + provider: 'nine1bot-local', + key: 'platform:feishu:default:imDefaultAppSecret', +} + +afterEach(() => { + clearFeishuIMReplyRuntimeSummaryForTesting() + clearFeishuIMRuntimeSnapshotForTesting() +}) + +describe('Feishu IM runtime supervisor', () => { + test('retries failed gateway startup with backoff and clears restart attempts after stability window', async () => { + const scheduler = createManualScheduler() + const gateways = createFakeGatewayHarness() + gateways.queueStart('default', { fail: 'boot failed' }) + gateways.queueStart('default', { autoConnect: true }) + + setFeishuIMRuntimeTestHooksForTesting({ + createGateway: gateways.factory, + scheduler: scheduler.scheduler, + retryBackoffMs: [1, 3, 10, 30, 60], + stabilityWindowMs: 5, + }) + + const handle = await startService({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + }) + + expect(handle.getStatus?.()).toMatchObject({ + status: 'error', + message: expect.stringContaining('boot failed'), + }) + expect(cardValue(handle, 'im-restart-attempts')).toBe('1') + expect(cardIds(handle)).toEqual(['im-runtime', 'im-gateway-state', 'im-restart-attempts', 'im-accounts']) + expect(scheduler.pendingDelays()).toEqual([1]) + + await scheduler.runNext() + + expect(gateways.count('default')).toBe(2) + expect(handle.getStatus?.()).toMatchObject({ + status: 'available', + message: expect.stringContaining('running for 1 account'), + }) + expect(cardValue(handle, 'im-restart-attempts')).toBe('1') + expect(scheduler.pendingDelays()).toEqual([5]) + expect(recentEvent(handle, 'restart-scheduled')).toBeDefined() + + await scheduler.runNext() + + expect(cardValue(handle, 'im-restart-attempts')).toBe('0') + await handle.stop() + }) + + test('reconnecting only degrades status and connection errors restart the affected account gateway', async () => { + const scheduler = createManualScheduler() + const gateways = createFakeGatewayHarness() + + setFeishuIMRuntimeTestHooksForTesting({ + createGateway: gateways.factory, + scheduler: scheduler.scheduler, + retryBackoffMs: [1, 3, 10, 30, 60], + stabilityWindowMs: 5, + }) + + const handle = await startService({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + imAccounts: [{ + id: 'team-a', + appId: 'cli_team', + appSecretRef: { provider: 'env', key: 'FEISHU_TEAM_SECRET' }, + }], + }) + + expect(handle.getStatus?.().status).toBe('available') + expect(gateways.count('default')).toBe(1) + expect(gateways.count('team-a')).toBe(1) + expect(recentEvent(handle, 'connected')).toBeUndefined() + expect(cardIds(handle)).toEqual(['im-runtime', 'im-gateway-state', 'im-restart-attempts', 'im-accounts']) + + await gateways.latest('default')!.emit('reconnecting', 'socket closed') + + expect(handle.getStatus?.()).toMatchObject({ + status: 'degraded', + message: expect.stringContaining('running for 1 account'), + }) + expect(scheduler.pendingCount()).toBe(0) + expect(cardValue(handle, 'im-gateway-state')).toContain('reconnecting') + + await gateways.latest('default')!.emit('connection-error', 'fatal ws error') + + expect(handle.getStatus?.().status).toBe('degraded') + expect(scheduler.pendingDelays()).toEqual([1]) + expect(gateways.count('default')).toBe(1) + expect(gateways.count('team-a')).toBe(1) + + await scheduler.runNext() + + expect(gateways.count('default')).toBe(2) + expect(gateways.count('team-a')).toBe(1) + expect(handle.getStatus?.().status).toBe('available') + expect(recentEvent(handle, 'connected')).toBeDefined() + await handle.stop() + }) + + test('stop cancels pending restarts before they can recreate a gateway', async () => { + const scheduler = createManualScheduler() + const gateways = createFakeGatewayHarness() + + setFeishuIMRuntimeTestHooksForTesting({ + createGateway: gateways.factory, + scheduler: scheduler.scheduler, + retryBackoffMs: [1, 3, 10, 30, 60], + stabilityWindowMs: 5, + }) + + const handle = await startService({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + }) + + await gateways.latest('default')!.emit('connection-error', 'fatal ws error') + + expect(scheduler.pendingDelays()).toEqual([1]) + expect(gateways.count('default')).toBe(1) + + await handle.stop() + await scheduler.runAll() + + expect(scheduler.pendingCount()).toBe(0) + expect(gateways.count('default')).toBe(1) + }) + + test('reply and streaming failures surface in recent events instead of runtime cards', async () => { + const scheduler = createManualScheduler() + const gateways = createFakeGatewayHarness() + + setFeishuIMRuntimeTestHooksForTesting({ + createGateway: gateways.factory, + scheduler: scheduler.scheduler, + retryBackoffMs: [1, 3, 10, 30, 60], + stabilityWindowMs: 5, + }) + + const handle = await startService({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + }) + + recordFeishuIMCardUpdateFailure(new Error('cardkit.card.create failed')) + recordFeishuIMStreamingFallback('cardkit create failed', 'patch') + recordFeishuIMReplyError(new Error('reply delivery failed')) + await flushMicrotasks() + + expect(cardIds(handle)).toEqual(['im-runtime', 'im-gateway-state', 'im-restart-attempts', 'im-accounts']) + expect(handle.getStatus?.().cards?.map((card) => card.label)).not.toContain('Reply error') + expect(handle.getStatus?.().cards?.map((card) => card.label)).not.toContain('Card update error') + expect(handle.getStatus?.().cards?.map((card) => card.label)).not.toContain('Streaming fallback') + + const events = handle.getStatus?.().recentEvents ?? [] + expect(events).toContainEqual(expect.objectContaining({ + stage: 'im-reply', + data: expect.objectContaining({ event: 'card-update-failed' }), + })) + expect(events).toContainEqual(expect.objectContaining({ + stage: 'im-reply', + data: expect.objectContaining({ event: 'streaming-fallback', transport: 'patch' }), + })) + expect(events).toContainEqual(expect.objectContaining({ + stage: 'im-reply', + data: expect.objectContaining({ event: 'reply-error', error: 'reply delivery failed' }), + })) + expect(events.filter((entry) => entry.data?.event === 'reply-error' && entry.data?.error === 'cardkit.card.create failed')).toHaveLength(0) + + await handle.stop() + }) +}) + +function cardValue(handle: PlatformBackgroundServiceHandle, id: string): string | undefined { + return handle.getStatus?.().cards?.find((card) => card.id === id)?.value +} + +function cardIds(handle: PlatformBackgroundServiceHandle): string[] { + return handle.getStatus?.().cards?.map((card) => card.id) ?? [] +} + +function recentEvent(handle: PlatformBackgroundServiceHandle, eventName: string): PlatformRecentEvent | undefined { + return handle.getStatus?.().recentEvents?.find((entry) => entry.data?.event === eventName) +} + +async function startService(settings: Record): Promise { + const ctx = platformContext(settings) + const services = createFeishuIMBackgroundServices(ctx) + expect(services).toHaveLength(1) + return await services[0]!.start({ + ...ctx, + localUrl: 'http://127.0.0.1:4096', + }) +} + +function platformContext(settings: Record): PlatformAdapterContext { + return { + platformId: 'feishu', + enabled: true, + settings, + features: {}, + env: {}, + secrets: { + async get() { + return 'secret' + }, + async set() {}, + async delete() {}, + async has() { + return true + }, + }, + audit: { + write() {}, + }, + } +} + +function createManualScheduler() { + let now = 0 + let nextId = 1 + const timers = new Map void }>() + + const scheduler = { + setTimeout(callback: () => void, delayMs: number) { + const timer = { + id: nextId++, + at: now + delayMs, + callback, + } + timers.set(timer.id, timer) + return timer.id + }, + clearTimeout(handle: unknown) { + timers.delete(handle as number) + }, + } + + return { + scheduler, + pendingCount() { + return timers.size + }, + pendingDelays() { + return [...timers.values()] + .map((timer) => timer.at - now) + .sort((left, right) => left - right) + }, + async runNext() { + const next = [...timers.values()].sort((left, right) => left.at - right.at || left.id - right.id)[0] + if (!next) return false + timers.delete(next.id) + now = next.at + next.callback() + await flushMicrotasks() + return true + }, + async runAll() { + while (await this.runNext()) { + // continue until no timers remain + } + }, + } +} + +function createFakeGatewayHarness() { + const plans = new Map>() + const instances: Array<{ + accountId: string + started: boolean + startCalls: number + stopCalls: number + emit: (state: 'connected' | 'reconnecting' | 'connection-error' | 'stopped', message?: string) => Promise + }> = [] + + return { + factory(options: { + account: { id: string } + onConnectionStateChange?: (event: { + accountId: string + state: 'connected' | 'reconnecting' | 'connection-error' | 'stopped' + at: string + message?: string + }) => void | Promise + }) { + const instance = { + accountId: options.account.id, + started: false, + startCalls: 0, + stopCalls: 0, + emit: async ( + state: 'connected' | 'reconnecting' | 'connection-error' | 'stopped', + message?: string, + ) => { + await options.onConnectionStateChange?.({ + accountId: options.account.id, + state, + at: new Date().toISOString(), + message, + }) + }, + } + instances.push(instance) + + return { + async start() { + instance.startCalls += 1 + const plan = plans.get(options.account.id)?.shift() + if (plan?.fail) { + throw new Error(plan.fail) + } + instance.started = true + if (plan?.autoConnect !== false) { + await instance.emit('connected') + } + }, + async stop() { + if (!instance.started) return + instance.started = false + instance.stopCalls += 1 + await instance.emit('stopped') + }, + async injectMessage() {}, + async injectCardAction() { + return undefined + }, + isStarted() { + return instance.started + }, + } + }, + queueStart(accountId: string, plan: { fail?: string; autoConnect?: boolean }) { + const queue = plans.get(accountId) ?? [] + queue.push(plan) + plans.set(accountId, queue) + }, + latest(accountId: string) { + return [...instances].reverse().find((instance) => instance.accountId === accountId) + }, + count(accountId: string) { + return instances.filter((instance) => instance.accountId === accountId).length + }, + } +} + +async function flushMicrotasks() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve() + } +} diff --git a/packages/platform-feishu/test/feishu-im-session-manager.test.ts b/packages/platform-feishu/test/feishu-im-session-manager.test.ts new file mode 100644 index 00000000..b61387d0 --- /dev/null +++ b/packages/platform-feishu/test/feishu-im-session-manager.test.ts @@ -0,0 +1,541 @@ +import { describe, expect, test } from 'bun:test' +import type { PlatformSecretRef } from '@nine1bot/platform-protocol' +import { + FeishuIMHistoryStore, + FeishuIMSessionManager, + MemoryFeishuIMBindingStore, + normalizeFeishuIMConfig, + routeKeyForFeishuMessage, + serializeFeishuRouteKey, + type FeishuControllerBridge, + type FeishuControllerCreateSessionInput, + type FeishuControllerCreateSessionResult, + type FeishuControllerMessageResult, + type FeishuControllerProject, + type FeishuControllerSendMessageInput, + type FeishuControllerSession, + type FeishuIMAccount, + type FeishuIMIncomingMessage, + type FeishuIMReplySinkFactoryInput, + type FeishuIMSessionManagerOptions, +} from '../src/im' + +const secretRef: PlatformSecretRef = { + provider: 'nine1bot-local', + key: 'platform:feishu:default:imDefaultAppSecret', +} + +const account: FeishuIMAccount = { + id: 'default', + enabled: true, + appId: 'cli_xxx', + appSecretRef: secretRef, + defaultDirectory: 'C:/work', + connectionMode: 'websocket', +} + +describe('Feishu IM session manager', () => { + test('creates separate session routes for DM, group, and thread', () => { + const dm = routeKeyForFeishuMessage(message({ chatType: 'p2p', chatId: 'oc_dm', openId: 'ou_alice' }), { accountId: 'acct' }) + const group = routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group' }), { accountId: 'acct' }) + const thread = routeKeyForFeishuMessage(message({ chatType: 'group', chatId: 'oc_group', rootId: 'omt_root' }), { accountId: 'acct' }) + + expect(serializeFeishuRouteKey(dm)).toBe('feishu:acct:dm:ou_alice') + expect(serializeFeishuRouteKey(group)).toBe('feishu:acct:group:oc_group') + expect(serializeFeishuRouteKey(thread)).toBe('feishu:acct:thread:oc_group:omt_root') + }) + + test('reuses bindings and /new forces a new session', async () => { + const bridge = new FakeBridge() + const manager = sessionManager({ bridge, messageBufferMs: 0 }) + const first = await manager.handleIncomingMessage(message({ text: 'hello' })) + const second = await manager.handleIncomingMessage(message({ text: 'again' })) + + expect(first).toMatchObject({ status: 'accepted', sessionId: 'ses_1' }) + expect(second).toMatchObject({ status: 'accepted', sessionId: 'ses_1' }) + + const reset = await manager.handleIncomingMessage(message({ text: '/new' })) + expect(reset).toMatchObject({ + status: 'control', + control: { + type: 'new-session', + sessionId: 'ses_2', + }, + }) + }) + + test('buffers adjacent messages and flushes them as one controller call', async () => { + const bridge = new FakeBridge() + const manager = sessionManager({ bridge, messageBufferMs: 100, maxBufferMs: 1000 }) + const first = message({ text: 'first', messageId: 'om_1' }) + const route = serializeFeishuRouteKey(routeKeyForFeishuMessage(first, { accountId: account.id })) + + await expect(manager.handleIncomingMessage(first)).resolves.toMatchObject({ + status: 'buffered', + messageCount: 1, + }) + await expect(manager.handleIncomingMessage(message({ text: 'second', messageId: 'om_2' }))).resolves.toMatchObject({ + status: 'buffered', + messageCount: 2, + }) + + await expect(manager.flushRoute(route)).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + }) + expect(bridge.sent).toHaveLength(1) + const text = (bridge.sent[0]?.parts[0] as { text: string }).text + expect(text).toBe('first\n\nsecond') + expect(text).not.toContain('message_id') + expect(text).not.toContain('Feishu messages in this turn') + }) + + test('abort text cancels pending buffer before it reaches controller', async () => { + const bridge = new FakeBridge() + const manager = sessionManager({ bridge, messageBufferMs: 100, maxBufferMs: 1000 }) + + await expect(manager.handleIncomingMessage(message({ text: 'first', messageId: 'om_1' }))).resolves.toMatchObject({ + status: 'buffered', + messageCount: 1, + }) + expect(manager.bufferSnapshot()).toMatchObject([{ + messageCount: 1, + lastMessageId: 'om_1', + }]) + + await expect(manager.handleIncomingMessage(message({ text: '取消', messageId: 'om_abort' }))).resolves.toMatchObject({ + status: 'buffer-cancelled', + messageCount: 1, + }) + expect(manager.bufferSnapshot()).toEqual([]) + expect(bridge.sent).toHaveLength(0) + }) + + test('max buffer timer flushes buffered messages', async () => { + const bridge = new FakeBridge() + const results: unknown[] = [] + const manager = sessionManager({ + bridge, + messageBufferMs: 1000, + maxBufferMs: 10, + onFlushResult: (result) => { + results.push(result) + }, + }) + + await manager.handleIncomingMessage(message({ text: 'flush by max timer' })) + await sleep(40) + + expect(results).toContainEqual(expect.objectContaining({ status: 'accepted' })) + expect(bridge.sent).toHaveLength(1) + }) + + test('mention-only group messages enter history, while allow without mention dispatches', async () => { + const mentionOnlyBridge = new FakeBridge() + const mentionOnly = sessionManager({ + bridge: mentionOnlyBridge, + messageBufferMs: 0, + groupPolicy: 'mention-only', + }) + await expect(mentionOnly.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + text: 'background context', + }))).resolves.toMatchObject({ status: 'history-recorded' }) + + await expect(mentionOnly.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + text: '@bot summarize', + mentions: [{ openId: 'ou_bot', name: 'bot' }], + }))).resolves.toMatchObject({ status: 'accepted' }) + expect(mentionOnlyBridge.sent[0]?.contextBlocks?.some((block) => + block.content.includes('background context') + )).toBe(true) + + const allowBridge = new FakeBridge() + const allow = sessionManager({ + bridge: allowBridge, + messageBufferMs: 0, + groupPolicy: 'allow', + }) + await expect(allow.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + text: 'no mention but dispatch', + }))).resolves.toMatchObject({ status: 'accepted' }) + }) + + test('controller busy returns busy without accepting into runtime queue', async () => { + const bridge = new FakeBridge({ busy: true }) + const manager = sessionManager({ bridge, messageBufferMs: 0 }) + + await expect(manager.handleIncomingMessage(message({ text: 'busy?' }))).resolves.toMatchObject({ + status: 'busy', + message: 'busy text', + }) + expect(bridge.sent).toHaveLength(1) + }) + + test('abort text cancels active route turn and releases busy state', async () => { + const bridge = new FakeBridge() + const sink = new ManualSink() + const manager = sessionManager({ + bridge, + messageBufferMs: 0, + replySinkFactory: () => sink, + }) + + await expect(manager.handleIncomingMessage(message({ text: 'run', messageId: 'om_1' }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + expect(manager.activeTurnSnapshot()).toHaveLength(1) + + await expect(manager.handleIncomingMessage(message({ text: '/abort', messageId: 'om_abort' }))).resolves.toMatchObject({ + status: 'aborted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + expect(bridge.aborts).toEqual([{ sessionId: 'ses_1', directory: 'C:/work', reason: 'feishu-im-abort' }]) + expect(sink.stopped).toBe(true) + expect(manager.activeTurnSnapshot()).toEqual([]) + + await expect(manager.handleIncomingMessage(message({ text: 'after abort', messageId: 'om_2' }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_2', + }) + }) + + test('same group different threads run in parallel while same thread remains busy', async () => { + const bridge = new FakeBridge() + const manager = sessionManager({ + bridge, + messageBufferMs: 0, + groupPolicy: 'allow', + replySinkFactory: () => new ManualSink(), + }) + + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + rootId: 'omt_a', + text: 'thread a', + messageId: 'om_a1', + }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + rootId: 'omt_b', + text: 'thread b', + messageId: 'om_b1', + }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_2', + turnSnapshotId: 'turn_2', + }) + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + rootId: 'omt_a', + text: 'thread a again', + messageId: 'om_a2', + }))).resolves.toMatchObject({ + status: 'busy', + message: 'busy text', + }) + + expect(manager.activeTurnSnapshot().map((turn) => turn.routeKeyString).sort()).toEqual([ + 'feishu:default:thread:oc_group:omt_a', + 'feishu:default:thread:oc_group:omt_b', + ]) + }) + + test('control commands expose cwd and project operations as structured results', async () => { + const bridge = new FakeBridge({ + projects: [{ + id: 'proj_1', + name: 'Project One', + rootDirectory: 'C:/project-one', + }], + }) + const manager = sessionManager({ + bridge, + messageBufferMs: 0, + resolveDirectory: async (_base, input) => `C:/resolved/${input}`, + }) + + await expect(manager.handleIncomingMessage(message({ text: '/cwd src' }))).resolves.toMatchObject({ + status: 'control', + control: { + type: 'cwd-switched', + directory: 'C:/resolved/src', + }, + }) + await expect(manager.handleIncomingMessage(message({ text: '/project list' }))).resolves.toMatchObject({ + status: 'control', + control: { + type: 'project-list', + projects: [{ + id: 'proj_1', + name: 'Project One', + directory: 'C:/project-one', + }], + }, + }) + await expect(manager.handleIncomingMessage(message({ text: '/project proj_1' }))).resolves.toMatchObject({ + status: 'control', + control: { + type: 'project-switched', + projectId: 'proj_1', + directory: 'C:/project-one', + }, + }) + }) + + test('mention-only group slash control commands bypass mention gate', async () => { + const bridge = new FakeBridge({ + projects: [{ + id: 'proj_1', + name: 'Project One', + rootDirectory: 'C:/project-one', + }], + }) + const manager = sessionManager({ + bridge, + messageBufferMs: 0, + groupPolicy: 'mention-only', + }) + + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + text: '/control', + messageId: 'om_control', + }))).resolves.toMatchObject({ + status: 'control', + control: { + type: 'control-panel', + }, + }) + + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + text: '/project list', + messageId: 'om_project_list', + }))).resolves.toMatchObject({ + status: 'control', + control: { + type: 'project-list', + projects: [expect.objectContaining({ + id: 'proj_1', + })], + }, + }) + }) + + test('mention-only group slash abort bypasses mention gate while plain text still does not', async () => { + const bridge = new FakeBridge() + const sink = new ManualSink() + const manager = sessionManager({ + bridge, + messageBufferMs: 0, + groupPolicy: 'mention-only', + replySinkFactory: () => sink, + }) + + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + rootId: 'omt_group', + text: '@bot start task', + mentions: [{ openId: 'ou_bot', name: 'bot' }], + messageId: 'om_group_1', + }))).resolves.toMatchObject({ + status: 'accepted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + rootId: 'omt_group', + text: '取消', + messageId: 'om_group_plain_abort', + }))).resolves.toMatchObject({ + status: 'history-recorded', + }) + expect(bridge.aborts).toEqual([]) + + await expect(manager.handleIncomingMessage(message({ + chatType: 'group', + chatId: 'oc_group', + rootId: 'omt_group', + text: '/abort', + messageId: 'om_group_abort', + }))).resolves.toMatchObject({ + status: 'aborted', + sessionId: 'ses_1', + turnSnapshotId: 'turn_1', + }) + expect(bridge.aborts).toEqual([expect.objectContaining({ + sessionId: 'ses_1', + reason: 'feishu-im-abort', + })]) + }) +}) + +function sessionManager(options: { + bridge?: FakeBridge + messageBufferMs?: number + maxBufferMs?: number + groupPolicy?: 'mention-only' | 'allow' | 'deny' + resolveDirectory?: (baseDirectory: string | undefined, input: string) => Promise + onFlushResult?: (result: any) => void | Promise + replySinkFactory?: FeishuIMSessionManagerOptions['replySinkFactory'] +}) { + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: account.appId, + imDefaultAppSecret: secretRef, + imMessageBufferMs: options.messageBufferMs ?? 0, + imMaxBufferMs: options.maxBufferMs ?? 1000, + imGroupPolicy: options.groupPolicy ?? 'mention-only', + imBusyRejectText: 'busy text', + }) + return new FeishuIMSessionManager({ + account, + config, + controller: options.bridge ?? new FakeBridge(), + store: new MemoryFeishuIMBindingStore(), + history: new FeishuIMHistoryStore({ ttlMs: 60_000, limit: 5 }), + botOpenId: 'ou_bot', + resolveDirectory: options.resolveDirectory, + onFlushResult: options.onFlushResult, + replySinkFactory: options.replySinkFactory, + }) +} + +function message(input: { + text?: string + messageId?: string + chatType?: 'p2p' | 'group' + chatId?: string + openId?: string + rootId?: string + mentions?: FeishuIMIncomingMessage['mentions'] +} = {}): FeishuIMIncomingMessage { + return { + eventId: `evt_${input.messageId ?? '1'}`, + messageId: input.messageId ?? 'om_1', + chatId: input.chatId ?? 'oc_dm', + chatType: input.chatType ?? 'p2p', + rootId: input.rootId, + messageType: 'text', + text: input.text ?? 'hello', + sender: { + openId: input.openId ?? 'ou_alice', + name: 'Alice', + }, + mentions: input.mentions ?? [], + createTime: 1_778_000_000_000, + raw: {}, + } +} + +class FakeBridge implements FeishuControllerBridge { + sessions = new Map() + sent: FeishuControllerSendMessageInput[] = [] + aborts: Array<{ sessionId: string; directory?: string; reason?: string }> = [] + private sequence = 0 + + constructor(private readonly options: { + busy?: boolean + projects?: FeishuControllerProject[] + } = {}) {} + + async createSession(input: FeishuControllerCreateSessionInput): Promise { + const id = `ses_${++this.sequence}` + const session = { + id, + directory: input.directory ?? 'C:/work', + projectID: this.projectForDirectory(input.directory)?.id, + title: input.title, + } + this.sessions.set(id, session) + return { + sessionId: id, + session, + } + } + + async getSession(input: { sessionId: string }): Promise { + return this.sessions.get(input.sessionId) + } + + async sendMessage(input: FeishuControllerSendMessageInput): Promise { + this.sent.push(input) + return { + accepted: !this.options.busy, + busy: this.options.busy, + sessionId: input.sessionId, + turnSnapshotId: this.options.busy ? undefined : `turn_${this.sent.length}`, + status: this.options.busy ? 409 : 202, + } + } + + async abortSession(input: { sessionId: string; directory?: string; reason?: string }): Promise { + this.aborts.push(input) + return true + } + + async answerInteraction(): Promise { + return true + } + + async listProjects(): Promise { + return this.options.projects ?? [] + } + + async getProject(projectId: string): Promise { + return (this.options.projects ?? []).find((project) => project.id === projectId) + } + + subscribeEvents() { + return { + stop() {}, + } + } + + private projectForDirectory(directory: string | undefined): FeishuControllerProject | undefined { + return (this.options.projects ?? []).find((project) => project.rootDirectory === directory || project.worktree === directory) + } +} + +class ManualSink { + stopped = false + boundTurnSnapshotId?: string + done = new Promise(() => undefined) + + start() {} + + bindTurnSnapshotId(turnSnapshotId?: string) { + this.boundTurnSnapshotId = turnSnapshotId + } + + stop() { + this.stopped = true + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/platform-feishu/test/feishu-im.test.ts b/packages/platform-feishu/test/feishu-im.test.ts new file mode 100644 index 00000000..3d721e7c --- /dev/null +++ b/packages/platform-feishu/test/feishu-im.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, test } from 'bun:test' +import type { PlatformAdapterContext, PlatformSecretRef } from '@nine1bot/platform-protocol' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + clearFeishuIMRuntimeSnapshotForTesting, + createFeishuIMBackgroundServices, + evaluateFeishuIMGate, + FeishuEventDeduplicator, + MemoryFeishuIMBindingStore, + normalizeFeishuIMConfig, + parseFeishuIMEvent, + routeKeyForFeishuMessage, + serializeFeishuRouteKey, + validateFeishuIMConfig, +} from '../src/im' +import { FeishuFileIMBindingStore } from '../src/node' + +const secretRef: PlatformSecretRef = { + provider: 'nine1bot-local', + key: 'platform:feishu:default:imDefaultAppSecret', +} + +describe('Feishu IM skeleton', () => { + test('normalizes platform settings without enabling from legacy config', () => { + const config = normalizeFeishuIMConfig({ + imEnabled: false, + }, { + legacyConfig: { + enabled: true, + appId: 'legacy-app', + appSecret: 'legacy-secret', + defaultDirectory: 'C:/legacy', + }, + }) + + expect(config.enabled).toBe(false) + expect(config.accounts).toEqual([]) + expect(config.legacy).toMatchObject({ + enabled: true, + appId: 'legacy-app', + hasAppSecret: true, + defaultDirectory: 'C:/legacy', + }) + }) + + test('normalizes default account and rejects plaintext secrets inside account JSON', () => { + const valid = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + imDefaultDirectory: 'C:/work', + imMessageBufferMs: 1200, + imMaxBufferMs: 3000, + imAccounts: [{ + id: 'team-a', + appId: 'cli_team', + appSecretRef: { + provider: 'env', + key: 'FEISHU_TEAM_SECRET', + }, + }], + }) + + expect(valid.accounts.map((account) => account.id)).toEqual(['default', 'team-a']) + expect(valid.policy).toMatchObject({ + messageBufferMs: 1200, + maxBufferMs: 3000, + groupPolicy: 'mention-only', + replyPresentation: 'auto', + replyTimeoutMs: 600_000, + streamingCardUpdateMs: 1_000, + streamingCardMaxChars: 6_000, + }) + + expect(validateFeishuIMConfig({ + imEnabled: true, + imAccounts: [{ + id: 'bad', + appId: 'cli_bad', + appSecret: 'plaintext', + }], + })).toMatchObject({ + ok: false, + fieldErrors: { + imAccounts: expect.stringContaining('plaintext appSecret'), + }, + }) + }) + + test('validates enabled IM requires at least one secret-backed account', () => { + expect(validateFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + })).toMatchObject({ + ok: false, + fieldErrors: { + imAccounts: expect.stringContaining('At least one IM account'), + }, + }) + }) + + test('normalizes reply presentation and validates timeout settings', () => { + expect(normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + imReplyPresentation: 'streaming-card', + imReplyTimeoutMs: 12_000, + imStreamingCardUpdateMs: 800, + imStreamingCardMaxChars: 2000, + }).policy).toMatchObject({ + replyPresentation: 'streaming-card', + replyTimeoutMs: 12_000, + streamingCardUpdateMs: 800, + streamingCardMaxChars: 2000, + }) + + expect(validateFeishuIMConfig({ + imReplyPresentation: 'unknown', + imReplyTimeoutMs: 0, + imStreamingCardUpdateMs: 0, + imStreamingCardMaxChars: 0, + })).toMatchObject({ + ok: false, + fieldErrors: { + imReplyPresentation: expect.stringContaining('auto'), + imReplyTimeoutMs: expect.stringContaining('positive'), + imStreamingCardUpdateMs: expect.stringContaining('positive'), + imStreamingCardMaxChars: expect.stringContaining('positive'), + }, + }) + }) + + test('parses receive events, deduplicates events, and evaluates gate policies', () => { + const message = parseFeishuIMEvent({ + header: { + event_id: 'evt_1', + }, + event: { + sender: { + sender_type: 'user', + sender_id: { + open_id: 'ou_sender', + }, + }, + message: { + message_id: 'om_1', + chat_id: 'oc_group', + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: '@bot hello' }), + mentions: [{ + id: { + open_id: 'ou_bot', + }, + name: 'bot', + }], + }, + }, + }) + + expect(message).toMatchObject({ + eventId: 'evt_1', + messageId: 'om_1', + chatId: 'oc_group', + chatType: 'group', + text: '@bot hello', + sender: { + openId: 'ou_sender', + name: undefined, + }, + }) + + const dedup = new FeishuEventDeduplicator() + expect(dedup.accept(message?.eventId)).toBe(true) + expect(dedup.accept(message?.eventId)).toBe(false) + + const config = normalizeFeishuIMConfig({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + imGroupPolicy: 'mention-only', + }) + expect(evaluateFeishuIMGate(message!, config, { botOpenId: 'ou_bot' })).toMatchObject({ + action: 'dispatch', + allowed: true, + }) + expect(evaluateFeishuIMGate(message!, config)).toMatchObject({ + action: 'dispatch', + allowed: true, + }) + expect(evaluateFeishuIMGate({ + ...message!, + mentions: [], + }, config)).toEqual({ + action: 'history', + allowed: false, + reason: 'mention-required', + }) + }) + + test('parses Feishu thread identifiers into isolated thread routes', () => { + const message = parseFeishuIMEvent({ + event: { + sender: { sender_id: { open_id: 'ou_sender' } }, + message: { + message_id: 'om_thread', + chat_id: 'oc_group', + chat_type: 'group', + message_type: 'text', + thread_id: 'omt_thread', + content: JSON.stringify({ text: 'thread message' }), + }, + }, + })! + + expect(serializeFeishuRouteKey(routeKeyForFeishuMessage(message, { accountId: 'acct' }))).toBe( + 'feishu:acct:thread:oc_group:omt_thread', + ) + }) + + test('builds stable route keys and stores bindings', async () => { + const message = parseFeishuIMEvent({ + event: { + sender: { sender_id: { open_id: 'ou_sender' } }, + message: { + message_id: 'om_1', + chat_id: 'oc_p2p', + chat_type: 'p2p', + message_type: 'text', + content: JSON.stringify({ text: 'hello' }), + }, + }, + })! + const routeKey = routeKeyForFeishuMessage(message) + const serialized = serializeFeishuRouteKey(routeKey) + const store = new MemoryFeishuIMBindingStore() + + await store.set(serialized, { + routeKey, + sessionId: 'ses_1', + directory: 'C:/work', + updatedAt: '2026-05-04T00:00:00.000Z', + }) + + await expect(store.get(serialized)).resolves.toMatchObject({ + sessionId: 'ses_1', + routeKey: { + accountId: 'default', + kind: 'dm', + chatId: 'oc_p2p', + }, + }) + }) + + test('persists v2 route bindings outside the removed legacy store', async () => { + const directory = await mkdtemp(join(tmpdir(), 'nine1bot-feishu-im-store-')) + try { + const filepath = join(directory, 'bindings.json') + const message = parseFeishuIMEvent({ + event: { + sender: { sender_id: { open_id: 'ou_sender' } }, + message: { + message_id: 'om_store', + chat_id: 'oc_p2p', + chat_type: 'p2p', + message_type: 'text', + content: JSON.stringify({ text: 'persist me' }), + }, + }, + })! + const routeKey = routeKeyForFeishuMessage(message, { accountId: 'acct' }) + const serialized = serializeFeishuRouteKey(routeKey) + + const first = new FeishuFileIMBindingStore({ filepath }) + await first.set(serialized, { + routeKey, + sessionId: 'ses_persisted', + directory: 'C:/work', + updatedAt: '2026-05-04T00:00:00.000Z', + }) + + const second = new FeishuFileIMBindingStore({ filepath }) + await expect(second.get(serialized)).resolves.toMatchObject({ + sessionId: 'ses_persisted', + routeKey: { + accountId: 'acct', + kind: 'dm', + openId: 'ou_sender', + }, + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test('stages platform IM on Windows when legacy Feishu config is still enabled', async () => { + clearFeishuIMRuntimeSnapshotForTesting() + const ctx = platformContext({ + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: secretRef, + }) + const services = createFeishuIMBackgroundServices(ctx) + expect(services).toHaveLength(1) + + const handle = await services[0]!.start({ + ...ctx, + localUrl: 'http://127.0.0.1:4096', + legacySettings: { + feishu: { + enabled: true, + appId: 'legacy', + appSecret: 'secret', + }, + }, + }) + + expect(handle.getStatus?.()).toMatchObject({ + status: process.platform === 'win32' ? 'degraded' : 'error', + message: process.platform === 'win32' + ? expect.stringContaining('legacy feishu.enabled') + : expect.stringContaining('Secret ref is missing'), + }) + expect(handle.getStatus?.().recentEvents).toContainEqual(expect.objectContaining({ + message: process.platform === 'win32' + ? expect.stringContaining('not started while legacy Feishu config is still enabled') + : expect.stringContaining('legacy Feishu service is disabled'), + })) + await handle.stop() + }) +}) + +function platformContext(settings: Record): PlatformAdapterContext { + return { + platformId: 'feishu', + enabled: true, + settings, + features: {}, + env: {}, + secrets: { + async get() { return undefined }, + async set() {}, + async delete() {}, + async has() { return false }, + }, + audit: { + write() {}, + }, + } +} diff --git a/packages/platform-feishu/test/feishu-platform.test.ts b/packages/platform-feishu/test/feishu-platform.test.ts index fb87fb4e..235c0ca1 100644 --- a/packages/platform-feishu/test/feishu-platform.test.ts +++ b/packages/platform-feishu/test/feishu-platform.test.ts @@ -2,15 +2,31 @@ import { describe, expect, test } from 'bun:test' import { buildFeishuPageContextPayload, createFeishuPlatformAdapter, + FEISHU_IM_DEFAULT_BUFFER_MS, + FEISHU_IM_DEFAULT_BUSY_TEXT, + FEISHU_IM_DEFAULT_MAX_BUFFER_MS, + FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS, + FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS, + FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS, feishuPlatformContribution, feishuTemplateIdsForPage, + normalizeFeishuIMConfig, parseFeishuUrl, } from '../src' +import { + clearFeishuIMReplyRuntimeSummaryForTesting, + createFeishuIMBackgroundServices, +} from '../src/im' +import { + clearFeishuIMRuntimeSnapshotForTesting, + setFeishuIMRuntimeTestHooksForTesting, +} from '../src/im/background-runtime' import { enrichFeishuPageContext, getFeishuAuthStatus, getFeishuCliVersion, parseVersion, + readFeishuContextEnrichmentSettings, resolveFeishuCliPath, runFeishuCliJsonWithFile, type FeishuCliRunner, @@ -236,6 +252,136 @@ describe('Feishu platform adapter package', () => { expect(status?.cards).toContainEqual(expect.objectContaining({ id: 'skills' })) }) + test('declares first-run defaults and placeholders in platform descriptor', () => { + const fields = new Map( + feishuPlatformContribution.descriptor.config?.sections + .flatMap((section) => section.fields) + .map((field) => [field.key, field]) ?? [], + ) + const imDefaults = normalizeFeishuIMConfig({}) + const enrichmentDefaults = readFeishuContextEnrichmentSettings({}) + + expect(fields.get('cliPath')).toMatchObject({ + placeholder: expect.stringContaining('PATH'), + }) + expect(fields.get('officialSkillsDirectory')).toMatchObject({ + placeholder: expect.stringContaining('~/.agents/skills'), + }) + expect(fields.get('contextEnrichment')).toMatchObject({ + defaultValue: enrichmentDefaults.contextEnrichment, + }) + expect(fields.get('metadataTimeoutMs')).toMatchObject({ + defaultValue: enrichmentDefaults.metadataTimeoutMs, + }) + + expect(fields.get('imEnabled')).toMatchObject({ defaultValue: false }) + expect(fields.get('imDefaultAppId')).toMatchObject({ + placeholder: expect.stringContaining('enabling IM'), + }) + expect(fields.get('imDefaultAppId')?.defaultValue).toBeUndefined() + expect(fields.get('imDefaultAppSecret')).toMatchObject({ + secret: true, + placeholder: expect.stringContaining('enabling IM'), + }) + expect(fields.get('imDefaultAppSecret')?.defaultValue).toBeUndefined() + expect(fields.get('imDefaultDirectory')).toMatchObject({ + placeholder: expect.stringContaining('current project directory'), + }) + expect(fields.get('imDefaultDirectory')?.defaultValue).toBeUndefined() + + expect(fields.get('imConnectionMode')).toMatchObject({ defaultValue: imDefaults.connectionMode }) + expect(fields.get('imDmPolicy')).toMatchObject({ defaultValue: imDefaults.policy.dmPolicy }) + expect(fields.get('imGroupPolicy')).toMatchObject({ defaultValue: imDefaults.policy.groupPolicy }) + expect(fields.get('imAllowFrom')).toMatchObject({ defaultValue: imDefaults.policy.allowFrom }) + expect(fields.get('imReplyMode')).toMatchObject({ defaultValue: imDefaults.policy.replyMode }) + expect(fields.get('imReplyPresentation')).toMatchObject({ defaultValue: imDefaults.policy.replyPresentation }) + expect(fields.get('imReplyTimeoutMs')).toMatchObject({ defaultValue: FEISHU_IM_DEFAULT_REPLY_TIMEOUT_MS }) + expect(fields.get('imStreamingCardUpdateMs')).toMatchObject({ defaultValue: FEISHU_IM_DEFAULT_STREAMING_CARD_UPDATE_MS }) + expect(fields.get('imStreamingCardMaxChars')).toMatchObject({ defaultValue: FEISHU_IM_DEFAULT_STREAMING_CARD_MAX_CHARS }) + expect(fields.get('imMessageBufferMs')).toMatchObject({ defaultValue: FEISHU_IM_DEFAULT_BUFFER_MS }) + expect(fields.get('imMaxBufferMs')).toMatchObject({ defaultValue: FEISHU_IM_DEFAULT_MAX_BUFFER_MS }) + expect(fields.get('imBusyRejectText')).toMatchObject({ defaultValue: FEISHU_IM_DEFAULT_BUSY_TEXT }) + expect(fields.get('imAccounts')).toMatchObject({ defaultValue: [] }) + }) + + test('registers Feishu IM reply settings in platform descriptor', () => { + const imSection = feishuPlatformContribution.descriptor.config?.sections.find((section) => section.id === 'im') + expect(imSection?.fields.map((field) => field.key)).toContain('imReplyPresentation') + expect(imSection?.fields.map((field) => field.key)).toContain('imReplyTimeoutMs') + expect(imSection?.fields.map((field) => field.key)).toContain('imStreamingCardUpdateMs') + expect(imSection?.fields.map((field) => field.key)).toContain('imStreamingCardMaxChars') + expect(imSection?.fields.find((field) => field.key === 'imReplyPresentation')).toMatchObject({ + type: 'select', + options: ['auto', 'text', 'card', 'streaming-card'], + }) + }) + + test('merges compact IM runtime cards into platform status details', async () => { + clearFeishuIMReplyRuntimeSummaryForTesting() + clearFeishuIMRuntimeSnapshotForTesting() + setFeishuIMRuntimeTestHooksForTesting({ + createGateway: createAutoConnectedGatewayFactory(), + }) + + const ctx: PlatformAdapterContext = { + platformId: 'feishu', + enabled: true, + settings: { + imEnabled: true, + imDefaultAppId: 'cli_xxx', + imDefaultAppSecret: { + provider: 'nine1bot-local', + key: 'platform:feishu:default:imDefaultAppSecret', + }, + }, + features: {}, + env: { PATH: '' }, + secrets: { + async get() { return 'secret' }, + async set() {}, + async delete() {}, + async has() { return true }, + }, + audit: { + write() {}, + }, + } + + const services = createFeishuIMBackgroundServices(ctx) + expect(services).toHaveLength(1) + const handle = await services[0]!.start({ + ...ctx, + localUrl: 'http://127.0.0.1:4096', + }) + + try { + const status = await feishuPlatformContribution.getStatus?.(ctx) + const ids = status?.cards?.map((card) => card.id) ?? [] + + expect(ids).toEqual(expect.arrayContaining([ + 'cli', + 'auth', + 'context', + 'companion', + 'skills', + 'im-runtime', + 'im-gateway-state', + 'im-restart-attempts', + 'im-accounts', + ])) + expect(ids).not.toContain('im-last-reply-error') + expect(ids).not.toContain('im-last-card-update-error') + expect(ids).not.toContain('im-last-streaming-fallback') + expect(ids).not.toContain('im-streaming-fallbacks') + expect(ids).not.toContain('im-buffer') + expect(ids).not.toContain('im-reply') + } finally { + await handle.stop() + clearFeishuIMReplyRuntimeSummaryForTesting() + clearFeishuIMRuntimeSnapshotForTesting() + } + }) + test('handles official skills directory actions without mutating CLI auth state', async () => { await withTempDir(async (officialDirectory) => { await writeSkill(officialDirectory, 'lark-doc') @@ -659,3 +805,37 @@ function restoreEnv(key: string, value: string | undefined) { } process.env[key] = value } + +function createAutoConnectedGatewayFactory() { + return (options: { + account: { id: string } + onConnectionStateChange?: (event: { + accountId: string + state: 'connected' | 'reconnecting' | 'connection-error' | 'stopped' + at: string + message?: string + }) => void | Promise + }) => ({ + async start() { + await options.onConnectionStateChange?.({ + accountId: options.account.id, + state: 'connected', + at: new Date().toISOString(), + }) + }, + async stop() { + await options.onConnectionStateChange?.({ + accountId: options.account.id, + state: 'stopped', + at: new Date().toISOString(), + }) + }, + async injectMessage() {}, + async injectCardAction() { + return undefined + }, + isStarted() { + return true + }, + }) +} diff --git a/packages/platform-protocol/src/index.ts b/packages/platform-protocol/src/index.ts index fe9d0ccc..b62b8a37 100644 --- a/packages/platform-protocol/src/index.ts +++ b/packages/platform-protocol/src/index.ts @@ -47,6 +47,8 @@ export type PlatformConfigField = { description?: string required?: boolean options?: string[] + defaultValue?: unknown + placeholder?: string secret?: boolean } @@ -134,6 +136,35 @@ export type PlatformAdapterContext = { audit: PlatformAuditWriter } +export type PlatformControllerRequestInit = { + method?: string + headers?: Record + body?: unknown +} + +export type PlatformControllerBridge = { + localUrl: string + authHeader?: string + requestJson?(path: string, init?: PlatformControllerRequestInit): Promise +} + +export type PlatformBackgroundServiceContext = PlatformAdapterContext & { + localUrl: string + authHeader?: string + controller?: PlatformControllerBridge + legacySettings?: Record +} + +export type PlatformBackgroundServiceHandle = { + stop(): Promise + getStatus?(): PlatformRuntimeStatus +} + +export type PlatformBackgroundService = { + id: string + start(ctx: PlatformBackgroundServiceContext): Promise +} + export type PlatformValidationResult = { ok: boolean message?: string @@ -183,6 +214,7 @@ export type PlatformAdapterContribution = { createAdapter: (ctx: PlatformAdapterContext) => PlatformRuntimeAdapter sources?: PlatformRuntimeSourcesProvider } + backgroundServices?: (ctx: PlatformAdapterContext) => PlatformBackgroundService[] getStatus?: (ctx: PlatformAdapterContext) => Promise validateConfig?: (settings: unknown, ctx: PlatformAdapterContext) => Promise handleAction?: ( diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3d45bf18..57616e07 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1771,6 +1771,8 @@ export interface PlatformConfigField { description?: string required?: boolean options?: string[] + defaultValue?: unknown + placeholder?: string secret?: boolean } diff --git a/web/src/components/PlatformManager.vue b/web/src/components/PlatformManager.vue index e6d96bb4..f0c0f741 100644 --- a/web/src/components/PlatformManager.vue +++ b/web/src/components/PlatformManager.vue @@ -60,19 +60,7 @@ function resetForm(platform: PlatformDetail | null) { for (const field of configFields.value) { const value = platform.settings[field.key] secretClears[field.key] = false - if (isSecretField(field) && isRedactedSecret(value)) { - formValues[field.key] = '' - } else if (field.type === 'string-list') { - formValues[field.key] = Array.isArray(value) ? value.join('\n') : '' - } else if (field.type === 'json') { - formValues[field.key] = value === undefined ? '' : JSON.stringify(value, null, 2) - } else if (field.type === 'boolean') { - formValues[field.key] = typeof value === 'boolean' ? value : false - } else if (field.type === 'number') { - formValues[field.key] = typeof value === 'number' ? value : '' - } else { - formValues[field.key] = typeof value === 'string' ? value : '' - } + formValues[field.key] = fieldFormValue(field, value) } for (const action of platform.actions) { @@ -80,17 +68,7 @@ function resetForm(platform: PlatformDetail | null) { actionFormValues[action.id] = {} for (const field of actionFields(action)) { const value = platform.settings[field.key] - if (field.type === 'string-list') { - actionFormValues[action.id][field.key] = Array.isArray(value) ? value.join('\n') : '' - } else if (field.type === 'json') { - actionFormValues[action.id][field.key] = value === undefined ? '' : JSON.stringify(value, null, 2) - } else if (field.type === 'boolean') { - actionFormValues[action.id][field.key] = typeof value === 'boolean' ? value : false - } else if (field.type === 'number') { - actionFormValues[action.id][field.key] = typeof value === 'number' ? value : '' - } else { - actionFormValues[action.id][field.key] = typeof value === 'string' ? value : '' - } + actionFormValues[action.id][field.key] = fieldFormValue(field, value) } } } @@ -160,6 +138,20 @@ function isRedactedSecret(value: unknown): value is { redacted: true; hasValue: return Boolean(value && typeof value === 'object' && !Array.isArray(value) && (value as any).redacted === true) } +function descriptorValue(field: PlatformConfigField, savedValue: unknown) { + return savedValue === undefined ? field.defaultValue : savedValue +} + +function fieldFormValue(field: PlatformConfigField, savedValue: unknown): string | number | boolean { + if (isSecretField(field)) return '' + const value = descriptorValue(field, savedValue) + if (field.type === 'string-list') return Array.isArray(value) ? value.join('\n') : '' + if (field.type === 'json') return value === undefined ? '' : JSON.stringify(value, null, 2) ?? '' + if (field.type === 'boolean') return typeof value === 'boolean' ? value : false + if (field.type === 'number') return typeof value === 'number' ? value : '' + return typeof value === 'string' ? value : '' +} + function secretStatus(field: PlatformConfigField) { const value = props.selectedPlatform?.settings[field.key] if (!isRedactedSecret(value)) return '' @@ -167,6 +159,14 @@ function secretStatus(field: PlatformConfigField) { return value.hasValue ? `已保存${value.provider ? ` · ${value.provider}` : ''}` : '未设置' } +function fieldPlaceholder(field: PlatformConfigField) { + if (isSecretField(field)) return secretStatus(field) || field.placeholder || '输入新值' + if (field.placeholder) return field.placeholder + if (field.type === 'string-list') return '每行一个值' + if (field.type === 'json') return '{}' + return '' +} + function fieldInputType(field: PlatformConfigField) { if (field.type === 'password') return 'password' if (field.type === 'number') return 'number' @@ -486,7 +486,7 @@ function buildActionInput(action: PlatformActionDescriptor) { v-else-if="field.type === 'string-list' || field.type === 'json'" :value="textValue(field.key)" class="input platform-textarea" - :placeholder="field.type === 'string-list' ? '每行一个值' : '{}'" + :placeholder="fieldPlaceholder(field)" @input="setTextValue(field, $event)" > @@ -495,7 +495,7 @@ function buildActionInput(action: PlatformActionDescriptor) { :value="textValue(field.key)" class="input platform-input" :type="fieldInputType(field)" - :placeholder="isSecretField(field) ? secretStatus(field) || '输入新值' : ''" + :placeholder="fieldPlaceholder(field)" @input="setTextValue(field, $event)" /> @@ -579,7 +579,7 @@ function buildActionInput(action: PlatformActionDescriptor) { :id="actionFieldId(action, field)" :value="actionTextValue(action, field.key)" class="input platform-textarea" - :placeholder="field.type === 'string-list' ? '每行一个值' : '{}'" + :placeholder="fieldPlaceholder(field)" @input="setActionTextValue(action, field, $event)" > @@ -589,6 +589,7 @@ function buildActionInput(action: PlatformActionDescriptor) { :value="actionTextValue(action, field.key)" class="input platform-input" :type="fieldInputType(field)" + :placeholder="fieldPlaceholder(field)" @input="setActionTextValue(action, field, $event)" />