From 16d2f1af5d93fe5aedbabdd1f13a6421527d6acb Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 13 Mar 2026 18:29:20 +0800 Subject: [PATCH 1/5] chore: restore feishu worktree snapshot --- .mcp.json | 10 + ARCHITECTURE.md | 27 +- docs/handover/bridge-system.md | 38 + .../mobile-remote-control-overall-plan.md | 8 +- package-lock.json | 880 +++++++++++++++- src/app/api/bridge/route.ts | 6 +- src/app/api/chat/route.ts | 17 +- src/i18n/en.ts | 47 + src/i18n/zh.ts | 47 + src/lib/bridge/adapters/feishu-adapter.ts | 955 +----------------- src/lib/bridge/bridge-manager.ts | 235 ++++- src/lib/bridge/channel-adapter.ts | 6 + src/lib/bridge/conversation-engine.ts | 15 + src/lib/db.ts | 13 + 14 files changed, 1314 insertions(+), 990 deletions(-) diff --git a/.mcp.json b/.mcp.json index ec1ba23..93bc19c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -8,6 +8,16 @@ "chrome-devtools-mcp@latest" ], "env": {} + }, + "feishu": { + "command": "node", + "args": ["packages/feishu-mcp/dist/index.js"], + "env": { + "FEISHU_APP_ID": "${bridge_feishu_app_id}", + "FEISHU_APP_SECRET": "${bridge_feishu_app_secret}", + "FEISHU_DOMAIN": "${bridge_feishu_domain}", + "FEISHU_USER_ACCESS_TOKEN": "${bridge_feishu_user_token}" + } } } } \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d5f9f4..91692a5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,7 +107,32 @@ Schema 定义在 `src/lib/db.ts`,12 张表: - `bridge-manager.ts` — 生命周期编排 - `markdown/` — Markdown → IR → 渠道特定格式(Telegram HTML / 飞书卡片) - `adapters/telegram-adapter.ts` — Telegram 长轮询 -- `adapters/feishu-adapter.ts` — 飞书 WebSocket + REST +- `adapters/feishu-adapter.ts` — 飞书薄代理(委托给 Channel Plugin) + +### Channel Plugin 层 + +`src/lib/channels/` — 结构化渠道插件,提供 `ChannelPlugin` 合约。 + +- `types.ts` — `ChannelPlugin`/`ChannelCapabilities`/`ProbeResult`/`CardStreamController` 接口 +- `channel-plugin-adapter.ts` — Plugin → `BaseChannelAdapter` 桥接适配器 +- `feishu/` — 飞书渠道插件(模块化拆分) + - `types.ts` — 飞书内部类型常量 + - `config.ts` — `FeishuConfig` 结构化配置 + 校验 + - `gateway.ts` — WSClient 生命周期 + 连接状态机 + probe + - `inbound.ts` — 入站消息处理 + 资源下载 + - `outbound.ts` — 出站消息渲染(card/post/permission) + - `identity.ts` — Bot 身份解析 + @mention 检测 + - `policy.ts` — 访问控制 + 群策略 + - `card-controller.ts` — CardStreamController 接口 + 占位 + - `index.ts` — `FeishuChannelPlugin` 入口 + +### Remote Core 层 + +`src/lib/remote/` — 远程 Host/Controller/Session/Lease 合约(接口 + 骨架)。 + +- `types.ts` — `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口 +- `remote-manager.ts` — 轻量运行时骨架 +- `index.ts` — 公开导出 详细文档:`docs/handover/bridge-system.md` diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index 2430dff..cd9ac47 100644 --- a/docs/handover/bridge-system.md +++ b/docs/handover/bridge-system.md @@ -281,3 +281,41 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案): - Android App、桌面 Controller 和 IM 渠道都将共享同一套 Remote Core - 飞书不再只是一个 adapter,而会逐步拆分成独立的渠道模块族 - 当前 Bridge 仍是实现基础,但不再是远程能力的最终抽象边界 + +### V2 实施状态(codex/feishu-remote-v2) + +**已完成:** + +1. **Channel Plugin 合约** (`src/lib/channels/types.ts`) + - `ChannelPlugin` 接口:config/capabilities/lifecycle/inbound/outbound/policy + - `ChannelCapabilities`:streaming、inlineButtons、reactions 等能力声明 + - `ProbeResult`:健康检查结果 + - `CardStreamController`:流式卡片接口(占位) + +2. **ChannelPluginAdapter** (`src/lib/channels/channel-plugin-adapter.ts`) + - 将 `ChannelPlugin` 桥接为 `BaseChannelAdapter` + - bridge-manager 无需修改即可使用新插件 + +3. **飞书模块拆分** (`src/lib/channels/feishu/`) + - `types.ts` — 内部类型 + 常量 + - `config.ts` — `FeishuConfig` 结构化配置 + 校验 + `configFromSettings()` + - `gateway.ts` — WSClient 生命周期 + 连接状态机 + 指标 + probe + - `inbound.ts` — 入站消息处理 + 内容解析 + 资源下载 + - `outbound.ts` — 出站消息渲染(card/post/text/permission) + - `identity.ts` — Bot 身份解析 + @mention 检测 + - `policy.ts` — 用户授权 + 群聊策略 + - `card-controller.ts` — CardStreamController 占位 + - `index.ts` — `FeishuChannelPlugin` 组合入口 + +4. **结构化配置** (`channel_configs` DB 表 + API 路由) + - `GET/PUT /api/channels/feishu/config` — 读写结构化配置 + - 向后兼容:读 channel_configs → 回退到 settings + +5. **Status / Probe** (`/api/channels/feishu/status`) + - 连接状态、Bot 身份、指标、可选 probe + +6. **Remote Core 合约** (`src/lib/remote/`) + - `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口 + - `RemoteManager` 轻量骨架 + +7. **原 feishu-adapter.ts** 改为薄代理(~15 行) diff --git a/docs/research/mobile-remote-control-overall-plan.md b/docs/research/mobile-remote-control-overall-plan.md index c01a10e..d5f6908 100644 --- a/docs/research/mobile-remote-control-overall-plan.md +++ b/docs/research/mobile-remote-control-overall-plan.md @@ -516,7 +516,7 @@ OpenClaw 官方飞书插件最值得借的不是“功能数量”,而是能 如果继续推进,下一份文档最值得做的是: -- `远程方案 V2 执行计划` -- `Remote Contract / Channel Contract 草案` -- `Feishu V2 渠道模块拆分设计` -- `Remote App 信息架构 + API 清单` +- ~~`远程方案 V2 执行计划`~~ ✅ 已完成 → `docs/exec-plans/active/feishu-v2-channel-plugin.md` +- ~~`Remote Contract / Channel Contract 草案`~~ ✅ 已完成 → `src/lib/channels/types.ts` + `src/lib/remote/types.ts` +- ~~`Feishu V2 渠道模块拆分设计`~~ ✅ 已完成 → `src/lib/channels/feishu/` +- `Remote App 信息架构 + API 清单` — 待后续实施 diff --git a/package-lock.json b/package-lock.json index 212fff3..16e3220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1479,6 +1479,56 @@ "zod": "^4.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2082,6 +2132,10 @@ "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", "license": "Apache-2.0" }, + "node_modules/@codepilot/feishu-mcp": { + "resolved": "packages/feishu-mcp", + "link": true + }, "node_modules/@codepilot/site": { "resolved": "apps/site", "link": true @@ -7955,6 +8009,356 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -9147,6 +9551,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -9891,6 +10305,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -9959,6 +10385,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ahooks": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.6.tgz", @@ -10221,6 +10659,13 @@ "react": ">=18" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/app-builder-bin": { "version": "5.0.0-alpha.12", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", @@ -11137,6 +11582,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -11157,6 +11618,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -11790,6 +12261,16 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -13351,7 +13832,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14287,6 +14767,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -14813,6 +15302,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -14924,6 +15425,12 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, "node_modules/format": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -14932,6 +15439,28 @@ "node": ">=0.4.x" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -16126,6 +16655,15 @@ "node": ">=18.18.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -17099,6 +17637,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -17651,6 +18199,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -17920,6 +18481,16 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -20092,6 +20663,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nan": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", @@ -21098,6 +21681,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -23078,6 +23671,51 @@ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", @@ -24699,6 +25337,39 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -24929,6 +25600,29 @@ "node": ">= 10.0.0" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -25110,6 +25804,12 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -25172,6 +25872,13 @@ "node": ">=6.10" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/ts-md5": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-2.0.1.tgz", @@ -26123,6 +26830,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -26492,6 +27215,161 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/feishu-mcp": { + "name": "@codepilot/feishu-mcp", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0" + }, + "bin": { + "feishu-mcp": "dist/index.js" + }, + "devDependencies": { + "tsup": "^8.0.0", + "tsx": "^4.0.0" + } + }, + "packages/feishu-mcp/node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/feishu-mcp/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/feishu-mcp/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "packages/feishu-mcp/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "packages/feishu-mcp/node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "packages/feishu-mcp/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/src/app/api/bridge/route.ts b/src/app/api/bridge/route.ts index c1021cc..14a530f 100644 --- a/src/app/api/bridge/route.ts +++ b/src/app/api/bridge/route.ts @@ -5,7 +5,11 @@ export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; /** - * GET /api/bridge — Return bridge status (pure query, no side effects) + * GET /api/bridge — Return bridge status (pure query, no side effects). + * + * Does NOT run probe — probe is an explicit action via the per-channel + * status endpoint (e.g. /api/channels/feishu/status?probe=true). + * This endpoint is polled every 5s by useBridgeStatus so it must stay cheap. */ export async function GET() { try { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index c3a488f..884aa24 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -14,7 +14,7 @@ import type { MCPServerConfig } from '@/types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */ +/** Read MCP server configs from ~/.claude.json, ~/.claude/settings.json, and project .mcp.json */ function loadMcpServers(): Record | undefined { try { const readJson = (p: string): Record => { @@ -23,10 +23,25 @@ function loadMcpServers(): Record | undefined { }; const userConfig = readJson(path.join(os.homedir(), '.claude.json')); const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); + // Also read project-level .mcp.json + const projectMcp = readJson(path.join(process.cwd(), '.mcp.json')); const merged = { ...((userConfig.mcpServers || {}) as Record), ...((settings.mcpServers || {}) as Record), + ...((projectMcp.mcpServers || {}) as Record), }; + // Resolve ${...} placeholders in env values against DB settings + for (const server of Object.values(merged)) { + if (server.env) { + for (const [key, value] of Object.entries(server.env)) { + if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { + const settingKey = value.slice(2, -1); + const resolved = getSetting(settingKey); + server.env[key] = resolved || ''; + } + } + } + } return Object.keys(merged).length > 0 ? merged : undefined; } catch { return undefined; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d81877d..b6838df 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -555,6 +555,53 @@ const en = { 'feishu.step4': 'Add event subscription: im.message.receive_v1', 'feishu.step5': 'Publish the app version and approve it in the admin console', 'feishu.step6': 'Paste the credentials above and click "Test Connection" to verify', + 'feishu.replyMode': 'Reply Mode', + 'feishu.replyModeInline': 'Inline — reply directly in chat', + 'feishu.replyModeThread': 'Thread — reply in message thread', + 'feishu.streaming': 'Streaming Cards', + 'feishu.streamingDesc': 'Stream responses via updateable cards (requires CardKit v2)', + 'feishu.blockStreaming': 'Block Streaming', + 'feishu.blockStreamingDesc': 'Stream responses at block level', + 'feishu.threadSession': 'Thread Sessions', + 'feishu.threadSessionDesc': 'Use message threads as separate conversation sessions', + 'feishu.connectionState': 'Connection State', + 'feishu.connectionStateConnected': 'Connected', + 'feishu.connectionStateDisconnected': 'Disconnected', + 'feishu.connectionStateConnecting': 'Connecting...', + 'feishu.connectionStateReconnecting': 'Reconnecting...', + 'feishu.metrics': 'Metrics', + 'feishu.metricsMessagesIn': 'Messages In', + 'feishu.metricsMessagesOut': 'Messages Out', + 'feishu.metricsErrors': 'Errors', + 'feishu.probe': 'Probe', + 'feishu.probeSuccess': 'Probe successful', + 'feishu.probeFailed': 'Probe failed', + 'feishu.capabilities': 'Platform Capabilities', + 'feishu.capabilities.doc': 'Documents', + 'feishu.capabilities.sheet': 'Spreadsheets', + 'feishu.capabilities.calendar': 'Calendar', + 'feishu.capabilities.task': 'Tasks', + 'feishu.capabilities.bitable': 'Bitable (Tables)', + 'feishu.diagnose': 'Diagnostics', + 'feishu.diagnose.title': 'Feishu Connection Diagnostics', + 'feishu.diagnose.config': 'Configuration', + 'feishu.diagnose.connection': 'Connection', + 'feishu.diagnose.permissions': 'Permissions', + 'feishu.diagnose.running': 'Running diagnostics...', + + // ── Channels (generic) ──────────────────────────────────── + 'channels.configSaved': 'Configuration saved', + 'channels.configError': 'Configuration error', + + // ── Remote ──────────────────────────────────────────────── + 'remote.title': 'Remote Control', + 'remote.host': 'Host', + 'remote.controllers': 'Controllers', + 'remote.sessions': 'Remote Sessions', + 'remote.lease': 'Session Lease', + 'remote.leaseAcquired': 'Lease acquired', + 'remote.leaseReleased': 'Lease released', + 'remote.leaseExpired': 'Lease expired', // ── Settings: Remote Bridge ──────────────────────────────── 'settings.bridge': 'Remote Bridge', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index abb540f..ce4ec79 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -552,6 +552,53 @@ const zh: Record = { 'feishu.step4': '添加事件订阅:im.message.receive_v1', 'feishu.step5': '发布应用版本并在管理后台审批通过', 'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证', + 'feishu.replyMode': '回复模式', + 'feishu.replyModeInline': '内联 — 直接在聊天中回复', + 'feishu.replyModeThread': '话题 — 在消息话题中回复', + 'feishu.streaming': '流式卡片', + 'feishu.streamingDesc': '通过可更新卡片流式输出响应(需要 CardKit v2)', + 'feishu.blockStreaming': 'Block 流式', + 'feishu.blockStreamingDesc': '按 Block 级别流式输出响应', + 'feishu.threadSession': '话题会话', + 'feishu.threadSessionDesc': '将消息话题作为独立的对话会话', + 'feishu.connectionState': '连接状态', + 'feishu.connectionStateConnected': '已连接', + 'feishu.connectionStateDisconnected': '未连接', + 'feishu.connectionStateConnecting': '连接中...', + 'feishu.connectionStateReconnecting': '重连中...', + 'feishu.metrics': '指标', + 'feishu.metricsMessagesIn': '接收消息', + 'feishu.metricsMessagesOut': '发送消息', + 'feishu.metricsErrors': '错误', + 'feishu.probe': '探测', + 'feishu.probeSuccess': '探测成功', + 'feishu.probeFailed': '探测失败', + 'feishu.capabilities': '平台能力', + 'feishu.capabilities.doc': '云文档', + 'feishu.capabilities.sheet': '电子表格', + 'feishu.capabilities.calendar': '日历', + 'feishu.capabilities.task': '任务', + 'feishu.capabilities.bitable': '多维表格', + 'feishu.diagnose': '诊断', + 'feishu.diagnose.title': '飞书连接诊断', + 'feishu.diagnose.config': '配置', + 'feishu.diagnose.connection': '连接', + 'feishu.diagnose.permissions': '权限', + 'feishu.diagnose.running': '正在运行诊断...', + + // ── 渠道(通用)──────────────────────────────────────────── + 'channels.configSaved': '配置已保存', + 'channels.configError': '配置错误', + + // ── 远程控制 ────────────────────────────────────────────── + 'remote.title': '远程控制', + 'remote.host': '主机', + 'remote.controllers': '控制器', + 'remote.sessions': '远程会话', + 'remote.lease': '会话租约', + 'remote.leaseAcquired': '租约已获取', + 'remote.leaseReleased': '租约已释放', + 'remote.leaseExpired': '租约已过期', // ── Settings: Remote Bridge ──────────────────────────────── 'settings.bridge': '远程桥接', diff --git a/src/lib/bridge/adapters/feishu-adapter.ts b/src/lib/bridge/adapters/feishu-adapter.ts index 9f9fe6e..1174254 100644 --- a/src/lib/bridge/adapters/feishu-adapter.ts +++ b/src/lib/bridge/adapters/feishu-adapter.ts @@ -1,951 +1,16 @@ /** - * Feishu (Lark) Adapter — implements BaseChannelAdapter for Feishu Bot API. + * Feishu adapter — thin proxy that delegates to FeishuChannelPlugin. * - * Uses the official @larksuiteoapi/node-sdk WSClient for real-time event - * subscription and REST Client for message sending / resource downloading. - * Routes messages through an internal async queue (same pattern as Telegram). - * - * Rendering strategy (aligned with Openclaw): - * - Code blocks / tables → interactive card (schema 2.0 markdown) - * - Other text → post (msg_type: 'post') with md tag - * - Permission prompts → interactive card with action buttons - * - * card.action.trigger events are handled via EventDispatcher (Openclaw pattern): - * button clicks are converted to synthetic text messages and routed through - * the normal /perm command processing pipeline. + * The actual implementation lives in src/lib/channels/feishu/. + * This file exists only to maintain the existing self-registration pattern + * used by bridge-manager via adapters/index.ts. */ -import crypto from 'crypto'; -import * as lark from '@larksuiteoapi/node-sdk'; -import type { - ChannelType, - InboundMessage, - OutboundMessage, - SendResult, -} from '../types'; -import type { FileAttachment } from '@/types'; -import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; -import { insertAuditLog } from '../../db'; -import { getSetting } from '../../db'; -import { - htmlToFeishuMarkdown, - preprocessFeishuMarkdown, - hasComplexMarkdown, - buildCardContent, - buildPostContent, -} from '../markdown/feishu'; - -/** Max number of message_ids to keep for dedup. */ -const DEDUP_MAX = 1000; - -/** Max file download size (20 MB). */ -const MAX_FILE_SIZE = 20 * 1024 * 1024; - -/** Feishu emoji type for typing indicator (same as Openclaw). */ -const TYPING_EMOJI = 'Typing'; - -/** Shape of the SDK's im.message.receive_v1 event data. */ -type FeishuMessageEventData = { - sender: { - sender_id?: { - open_id?: string; - union_id?: string; - user_id?: string; - }; - sender_type: string; - tenant_key?: string; - }; - message: { - message_id: string; - chat_id: string; - chat_type: string; - message_type: string; - content: string; - create_time: string; - mentions?: Array<{ - key: string; - id: { open_id?: string; union_id?: string; user_id?: string }; - name: string; - }>; - }; -}; - - -/** MIME type guesses by message_type. */ -const MIME_BY_TYPE: Record = { - image: 'image/png', - file: 'application/octet-stream', - audio: 'audio/ogg', - video: 'video/mp4', - media: 'application/octet-stream', -}; - -export class FeishuAdapter extends BaseChannelAdapter { - readonly channelType: ChannelType = 'feishu'; - - private running = false; - private queue: InboundMessage[] = []; - private waiters: Array<(msg: InboundMessage | null) => void> = []; - private wsClient: lark.WSClient | null = null; - private restClient: lark.Client | null = null; - private seenMessageIds = new Map(); - private botOpenId: string | null = null; - /** All known bot IDs (open_id, user_id, union_id) for mention matching. */ - private botIds = new Set(); - /** Track last incoming message ID per chat for typing indicator. */ - private lastIncomingMessageId = new Map(); - /** Track active typing reaction IDs per chat for cleanup. */ - private typingReactions = new Map(); - - // ── Lifecycle ─────────────────────────────────────────────── - - async start(): Promise { - if (this.running) return; - - const configError = this.validateConfig(); - if (configError) { - console.warn('[feishu-adapter] Cannot start:', configError); - return; - } - - const appId = getSetting('bridge_feishu_app_id') || ''; - const appSecret = getSetting('bridge_feishu_app_secret') || ''; - const domainSetting = getSetting('bridge_feishu_domain') || 'feishu'; - const domain = domainSetting === 'lark' - ? lark.Domain.Lark - : lark.Domain.Feishu; - - // Create REST client - this.restClient = new lark.Client({ - appId, - appSecret, - domain, - }); - - // Resolve bot identity for @mention detection - await this.resolveBotIdentity(appId, appSecret, domain); - - this.running = true; - - // Create EventDispatcher and register event handlers. - // NOTE: card.action.trigger requires HTTP webhook (not supported via WSClient). - // Openclaw uses an HTTP server for card callbacks — CodePilot is a desktop app - // without a public endpoint, so we rely on text-based /perm commands instead. - const dispatcher = new lark.EventDispatcher({}).register({ - 'im.message.receive_v1': async (data) => { - await this.handleIncomingEvent(data as FeishuMessageEventData); - }, - }); - - // Create and start WSClient - this.wsClient = new lark.WSClient({ - appId, - appSecret, - domain, - }); - this.wsClient.start({ eventDispatcher: dispatcher }); - - console.log('[feishu-adapter] Started (botOpenId:', this.botOpenId || 'unknown', ')'); - } - - async stop(): Promise { - if (!this.running) return; - this.running = false; - - // Close WebSocket connection (SDK exposes close()) - if (this.wsClient) { - try { - this.wsClient.close({ force: true }); - } catch (err) { - console.warn('[feishu-adapter] WSClient close error:', err instanceof Error ? err.message : err); - } - this.wsClient = null; - } - this.restClient = null; - - // Reject all waiting consumers - for (const waiter of this.waiters) { - waiter(null); - } - this.waiters = []; - - // Clear state - this.seenMessageIds.clear(); - this.lastIncomingMessageId.clear(); - this.typingReactions.clear(); - - console.log('[feishu-adapter] Stopped'); - } - - isRunning(): boolean { - return this.running; - } - - // ── Queue ─────────────────────────────────────────────────── - - consumeOne(): Promise { - const queued = this.queue.shift(); - if (queued) return Promise.resolve(queued); - - if (!this.running) return Promise.resolve(null); - - return new Promise((resolve) => { - this.waiters.push(resolve); - }); - } - - private enqueue(msg: InboundMessage): void { - const waiter = this.waiters.shift(); - if (waiter) { - waiter(msg); - } else { - this.queue.push(msg); - } - } - - // ── Typing indicator (Openclaw-style reaction) ───────────── - - /** - * Add a "Typing" emoji reaction to the user's message. - * Called by bridge-manager via onMessageStart(). - */ - onMessageStart(chatId: string): void { - const messageId = this.lastIncomingMessageId.get(chatId); - if (!messageId || !this.restClient) return; - - // Fire-and-forget — typing indicator is non-critical - this.restClient.im.messageReaction.create({ - path: { message_id: messageId }, - data: { reaction_type: { emoji_type: TYPING_EMOJI } }, - }).then((res) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const reactionId = (res as any)?.data?.reaction_id; - if (reactionId) { - this.typingReactions.set(chatId, reactionId); - } - }).catch((err) => { - // Non-critical — don't log rate limit errors - const code = (err as { code?: number })?.code; - if (code !== 99991400 && code !== 99991403) { - console.warn('[feishu-adapter] Typing indicator failed:', err instanceof Error ? err.message : err); - } - }); - } - - /** - * Remove the "Typing" emoji reaction from the user's message. - * Called by bridge-manager via onMessageEnd(). - */ - onMessageEnd(chatId: string): void { - const reactionId = this.typingReactions.get(chatId); - const messageId = this.lastIncomingMessageId.get(chatId); - if (!reactionId || !messageId || !this.restClient) return; - - this.typingReactions.delete(chatId); - - // Fire-and-forget — failure is fine (reaction may already be gone) - this.restClient.im.messageReaction.delete({ - path: { message_id: messageId, reaction_id: reactionId }, - }).catch(() => { /* ignore */ }); - } - - // ── Send ──────────────────────────────────────────────────── - - async send(message: OutboundMessage): Promise { - if (!this.restClient) { - return { ok: false, error: 'Feishu client not initialized' }; - } - - let text = message.text; - - // Convert HTML to markdown for Feishu rendering (e.g. command responses) - if (message.parseMode === 'HTML') { - text = htmlToFeishuMarkdown(text); - } - - // Preprocess markdown for Claude responses - if (message.parseMode === 'Markdown') { - text = preprocessFeishuMarkdown(text); - } - - // If there are inline buttons (permission prompts), send card with action buttons - if (message.inlineButtons && message.inlineButtons.length > 0) { - return this.sendPermissionCard(message.address.chatId, text, message.inlineButtons); - } - - // Rendering strategy (aligned with Openclaw): - // - Code blocks / tables → interactive card (schema 2.0 markdown) - // - Other text → post (md tag) - if (hasComplexMarkdown(text)) { - return this.sendAsCard(message.address.chatId, text); - } - return this.sendAsPost(message.address.chatId, text); - } - - /** - * Send text as an interactive card (schema 2.0 markdown). - * Used for code blocks and tables — card renders them properly. - */ - private async sendAsCard(chatId: string, text: string): Promise { - const cardContent = buildCardContent(text); - - try { - const res = await this.restClient!.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: chatId, - msg_type: 'interactive', - content: cardContent, - }, - }); - - if (res?.data?.message_id) { - return { ok: true, messageId: res.data.message_id }; - } - console.warn('[feishu-adapter] Card send failed:', res?.msg, res?.code); - } catch (err) { - console.warn('[feishu-adapter] Card send error, falling back to post:', err instanceof Error ? err.message : err); - } - - // Fallback to post - return this.sendAsPost(chatId, text); - } - - /** - * Send text as a post message (msg_type: 'post') with md tag. - * Used for simple text — renders bold, italic, inline code, links. - */ - private async sendAsPost(chatId: string, text: string): Promise { - const postContent = buildPostContent(text); - - try { - const res = await this.restClient!.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: chatId, - msg_type: 'post', - content: postContent, - }, - }); - - if (res?.data?.message_id) { - return { ok: true, messageId: res.data.message_id }; - } - console.warn('[feishu-adapter] Post send failed:', res?.msg, res?.code); - } catch (err) { - console.warn('[feishu-adapter] Post send error, falling back to text:', err instanceof Error ? err.message : err); - } - - // Final fallback: plain text - try { - const res = await this.restClient!.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: chatId, - msg_type: 'text', - content: JSON.stringify({ text }), - }, - }); - if (res?.data?.message_id) { - return { ok: true, messageId: res.data.message_id }; - } - return { ok: false, error: res?.msg || 'Send failed' }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : 'Send failed' }; - } - } - - // ── Permission card (with real action buttons) ───────────── - - /** - * Send a permission card with real Feishu card action buttons. - * Button clicks trigger card.action.trigger events handled by handleCardActionEvent(). - * Feishu card action callbacks require HTTP webhook (not supported via WSClient). - * CodePilot is a desktop app without a public endpoint, so we send a - * well-formatted card with /perm text commands instead of clickable buttons. - * The user replies with the /perm command to approve/deny. - */ - private async sendPermissionCard( - chatId: string, - text: string, - inlineButtons: import('../types').InlineButton[][], - ): Promise { - if (!this.restClient) { - return { ok: false, error: 'Feishu client not initialized' }; - } - - // Build /perm command lines from inline buttons - const permCommands = inlineButtons.flat().map((btn) => { - if (btn.callbackData.startsWith('perm:')) { - const parts = btn.callbackData.split(':'); - const action = parts[1]; - const permId = parts.slice(2).join(':'); - return `\`/perm ${action} ${permId}\``; - } - return btn.text; - }); - - // Schema 2.0 card with markdown — permission info + copyable commands - const cardContent = [ - text, - '', - '---', - '**Reply with one of these commands:**', - ...permCommands, - ].join('\n'); - - const cardJson = JSON.stringify({ - schema: '2.0', - config: { wide_screen_mode: true }, - header: { - template: 'orange', - title: { tag: 'plain_text', content: '🔐 Permission Required' }, - }, - body: { - elements: [ - { tag: 'markdown', content: cardContent }, - ], - }, - }); - - try { - const res = await this.restClient.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: chatId, - msg_type: 'interactive', - content: cardJson, - }, - }); - if (res?.data?.message_id) { - return { ok: true, messageId: res.data.message_id }; - } - console.warn('[feishu-adapter] Permission card send failed:', res?.msg); - } catch (err) { - console.warn('[feishu-adapter] Permission card error:', err instanceof Error ? err.message : err); - } - - // Fallback: plain text - const plainCommands = inlineButtons.flat().map((btn) => { - if (btn.callbackData.startsWith('perm:')) { - const parts = btn.callbackData.split(':'); - return `/perm ${parts[1]} ${parts.slice(2).join(':')}`; - } - return btn.text; - }); - const fallbackText = text + '\n\nReply with:\n' + plainCommands.join('\n'); - - try { - const res = await this.restClient.im.message.create({ - params: { receive_id_type: 'chat_id' }, - data: { - receive_id: chatId, - msg_type: 'text', - content: JSON.stringify({ text: fallbackText }), - }, - }); - if (res?.data?.message_id) { - return { ok: true, messageId: res.data.message_id }; - } - return { ok: false, error: res?.msg || 'Send failed' }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : 'Send failed' }; - } - } - - // ── Config & Auth ─────────────────────────────────────────── - - validateConfig(): string | null { - const enabled = getSetting('bridge_feishu_enabled'); - if (enabled !== 'true') return 'bridge_feishu_enabled is not true'; - - const appId = getSetting('bridge_feishu_app_id'); - if (!appId) return 'bridge_feishu_app_id not configured'; - - const appSecret = getSetting('bridge_feishu_app_secret'); - if (!appSecret) return 'bridge_feishu_app_secret not configured'; - - return null; - } - - isAuthorized(userId: string, chatId: string): boolean { - const allowedUsers = getSetting('bridge_feishu_allowed_users') || ''; - if (!allowedUsers) { - // No restriction configured — allow all - return true; - } - - const allowed = allowedUsers - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - if (allowed.length === 0) return true; - - return allowed.includes(userId) || allowed.includes(chatId); - } - - // ── Incoming event handler ────────────────────────────────── - - private async handleIncomingEvent(data: FeishuMessageEventData): Promise { - try { - await this.processIncomingEvent(data); - } catch (err) { - console.error( - '[feishu-adapter] Unhandled error in event handler:', - err instanceof Error ? err.stack || err.message : err, - ); - } - } - - private async processIncomingEvent(data: FeishuMessageEventData): Promise { - const msg = data.message; - const sender = data.sender; - - // [P1] Filter out bot messages to prevent self-triggering loops - if (sender.sender_type === 'bot') return; - - // Dedup by message_id - if (this.seenMessageIds.has(msg.message_id)) return; - this.addToDedup(msg.message_id); - - const chatId = msg.chat_id; - // [P2] Complete sender ID fallback chain: open_id > user_id > union_id - const userId = sender.sender_id?.open_id - || sender.sender_id?.user_id - || sender.sender_id?.union_id - || ''; - const isGroup = msg.chat_type === 'group'; - - // Authorization check - if (!this.isAuthorized(userId, chatId)) { - console.warn('[feishu-adapter] Unauthorized message from userId:', userId, 'chatId:', chatId); - return; - } - - // Group chat policy - if (isGroup) { - const policy = getSetting('bridge_feishu_group_policy') || 'open'; - - if (policy === 'disabled') { - console.log('[feishu-adapter] Group message ignored (policy=disabled), chatId:', chatId); - return; - } - - if (policy === 'allowlist') { - const allowedGroups = (getSetting('bridge_feishu_group_allow_from') || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (!allowedGroups.includes(chatId)) { - console.log('[feishu-adapter] Group message ignored (not in allowlist), chatId:', chatId); - return; - } - } - - // Require @mention check - const requireMention = getSetting('bridge_feishu_require_mention') !== 'false'; - if (requireMention && !this.isBotMentioned(msg.mentions)) { - console.log('[feishu-adapter] Group message ignored (bot not @mentioned), chatId:', chatId, 'msgId:', msg.message_id); - try { - insertAuditLog({ - channelType: 'feishu', - chatId, - direction: 'inbound', - messageId: msg.message_id, - summary: '[FILTERED] Group message dropped: bot not @mentioned (require_mention=true)', - }); - } catch { /* best effort */ } - return; - } - } - - // Track last message ID per chat for typing indicator - this.lastIncomingMessageId.set(chatId, msg.message_id); - - // Extract content based on message type - const messageType = msg.message_type; - let text = ''; - const attachments: FileAttachment[] = []; - - if (messageType === 'text') { - text = this.parseTextContent(msg.content); - } else if (messageType === 'image') { - // [P1] Download image with failure fallback - console.log('[feishu-adapter] Image message received, content:', msg.content); - const fileKey = this.extractFileKey(msg.content); - console.log('[feishu-adapter] Extracted fileKey:', fileKey); - if (fileKey) { - const attachment = await this.downloadResource(msg.message_id, fileKey, 'image'); - if (attachment) { - attachments.push(attachment); - } else { - text = '[image download failed]'; - try { - insertAuditLog({ - channelType: 'feishu', - chatId, - direction: 'inbound', - messageId: msg.message_id, - summary: `[ERROR] Image download failed for key: ${fileKey}`, - }); - } catch { /* best effort */ } - } - } - } else if (messageType === 'file' || messageType === 'audio' || messageType === 'video' || messageType === 'media') { - // [P2] Support file/audio/video/media downloads - const fileKey = this.extractFileKey(msg.content); - if (fileKey) { - const resourceType = messageType === 'audio' || messageType === 'video' || messageType === 'media' - ? messageType - : 'file'; - const attachment = await this.downloadResource(msg.message_id, fileKey, resourceType); - if (attachment) { - attachments.push(attachment); - } else { - text = `[${messageType} download failed]`; - try { - insertAuditLog({ - channelType: 'feishu', - chatId, - direction: 'inbound', - messageId: msg.message_id, - summary: `[ERROR] ${messageType} download failed for key: ${fileKey}`, - }); - } catch { /* best effort */ } - } - } - } else if (messageType === 'post') { - // [P2] Extract text and image keys from rich text (post) messages - const { extractedText, imageKeys } = this.parsePostContent(msg.content); - text = extractedText; - for (const key of imageKeys) { - const attachment = await this.downloadResource(msg.message_id, key, 'image'); - if (attachment) { - attachments.push(attachment); - } - // Don't add fallback text for individual post images — the text already carries context - } - } else { - // Unsupported type — log and skip - console.log(`[feishu-adapter] Unsupported message type: ${messageType}, msgId: ${msg.message_id}`); - return; - } - - // Strip @mention markers from text - text = this.stripMentionMarkers(text); - - if (!text.trim() && attachments.length === 0) return; - - const timestamp = parseInt(msg.create_time, 10) || Date.now(); - const address = { - channelType: 'feishu' as const, - chatId, - userId, - }; - - // [P1] Check for /perm text command (permission approval fallback) - const trimmedText = text.trim(); - if (trimmedText.startsWith('/perm ')) { - const permParts = trimmedText.split(/\s+/); - // /perm - if (permParts.length >= 3) { - const action = permParts[1]; // allow / allow_session / deny - const permId = permParts.slice(2).join(' '); - const callbackData = `perm:${action}:${permId}`; - - const inbound: InboundMessage = { - messageId: msg.message_id, - address, - text: trimmedText, - timestamp, - callbackData, - }; - this.enqueue(inbound); - return; - } - } - - const inbound: InboundMessage = { - messageId: msg.message_id, - address, - text: text.trim(), - timestamp, - attachments: attachments.length > 0 ? attachments : undefined, - }; - - // Audit log - try { - const summary = attachments.length > 0 - ? `[${attachments.length} attachment(s)] ${text.slice(0, 150)}` - : text.slice(0, 200); - insertAuditLog({ - channelType: 'feishu', - chatId, - direction: 'inbound', - messageId: msg.message_id, - summary, - }); - } catch { /* best effort */ } - - this.enqueue(inbound); - } - - // ── Content parsing ───────────────────────────────────────── - - private parseTextContent(content: string): string { - try { - const parsed = JSON.parse(content); - return parsed.text || ''; - } catch { - return content; - } - } - - /** - * Extract file key from message content JSON. - * Handles multiple key names: image_key, file_key, imageKey, fileKey. - */ - private extractFileKey(content: string): string | null { - try { - const parsed = JSON.parse(content); - return parsed.image_key || parsed.file_key || parsed.imageKey || parsed.fileKey || null; - } catch { - return null; - } - } - - /** - * Parse rich text (post) content. - * Extracts plain text from text elements and image keys from img elements. - */ - private parsePostContent(content: string): { extractedText: string; imageKeys: string[] } { - const imageKeys: string[] = []; - const textParts: string[] = []; - - try { - const parsed = JSON.parse(content); - // Post content structure: { title, content: [[{tag, text/image_key}]] } - const title = parsed.title; - if (title) textParts.push(title); - - const paragraphs = parsed.content; - if (Array.isArray(paragraphs)) { - for (const paragraph of paragraphs) { - if (!Array.isArray(paragraph)) continue; - for (const element of paragraph) { - if (element.tag === 'text' && element.text) { - textParts.push(element.text); - } else if (element.tag === 'a' && element.text) { - textParts.push(element.text); - } else if (element.tag === 'at' && element.user_id) { - // Mention in post — handled by isBotMentioned for group policy - } else if (element.tag === 'img') { - const key = element.image_key || element.file_key || element.imageKey; - if (key) imageKeys.push(key); - } - } - textParts.push('\n'); - } - } - } catch { - // Failed to parse post content - } - - return { extractedText: textParts.join('').trim(), imageKeys }; - } - - // ── Bot identity ──────────────────────────────────────────── - - /** - * Resolve bot identity via the Feishu REST API /bot/v3/info/. - * Collects all available bot IDs for comprehensive mention matching. - */ - private async resolveBotIdentity( - appId: string, - appSecret: string, - domain: lark.Domain, - ): Promise { - try { - const baseUrl = domain === lark.Domain.Lark - ? 'https://open.larksuite.com' - : 'https://open.feishu.cn'; - - const tokenRes = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ app_id: appId, app_secret: appSecret }), - signal: AbortSignal.timeout(10_000), - }); - const tokenData = await tokenRes.json(); - if (!tokenData.tenant_access_token) { - console.warn('[feishu-adapter] Failed to get tenant access token'); - return; - } - - const botRes = await fetch(`${baseUrl}/open-apis/bot/v3/info/`, { - method: 'GET', - headers: { Authorization: `Bearer ${tokenData.tenant_access_token}` }, - signal: AbortSignal.timeout(10_000), - }); - const botData = await botRes.json(); - if (botData?.bot?.open_id) { - this.botOpenId = botData.bot.open_id; - this.botIds.add(botData.bot.open_id); - } - // Also record app_id-based IDs if available - if (botData?.bot?.bot_id) { - this.botIds.add(botData.bot.bot_id); - } - if (!this.botOpenId) { - console.warn('[feishu-adapter] Could not resolve bot open_id'); - } - } catch (err) { - console.warn( - '[feishu-adapter] Failed to resolve bot identity:', - err instanceof Error ? err.message : err, - ); - } - } - - // ── @Mention detection ────────────────────────────────────── - - /** - * [P2] Check if bot is mentioned — matches against open_id, user_id, union_id. - */ - private isBotMentioned( - mentions?: FeishuMessageEventData['message']['mentions'], - ): boolean { - if (!mentions || this.botIds.size === 0) return false; - return mentions.some((m) => { - const ids = [m.id.open_id, m.id.user_id, m.id.union_id].filter(Boolean) as string[]; - return ids.some((id) => this.botIds.has(id)); - }); - } - - private stripMentionMarkers(text: string): string { - // Feishu uses @_user_N placeholders for mentions - return text.replace(/@_user_\d+/g, '').trim(); - } - - // ── Resource download ─────────────────────────────────────── - - /** - * Download a message resource (image/file/audio/video) via SDK. - * Returns null on failure (caller decides fallback behavior). - */ - private async downloadResource( - messageId: string, - fileKey: string, - resourceType: string, - ): Promise { - if (!this.restClient) return null; - - try { - console.log(`[feishu-adapter] Downloading resource: type=${resourceType}, key=${fileKey}, msgId=${messageId}`); - - const res = await this.restClient.im.messageResource.get({ - path: { - message_id: messageId, - file_key: fileKey, - }, - params: { - type: resourceType === 'image' ? 'image' : 'file', - }, - }); - - if (!res) { - console.warn('[feishu-adapter] messageResource.get returned null/undefined'); - return null; - } - - // SDK returns { writeFile, getReadableStream, headers } - // Try stream approach first, fall back to writeFile + read if stream fails - let buffer: Buffer; - - try { - const readable = res.getReadableStream(); - const chunks: Buffer[] = []; - let totalSize = 0; - - for await (const chunk of readable) { - const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - totalSize += buf.length; - if (totalSize > MAX_FILE_SIZE) { - console.warn(`[feishu-adapter] Resource too large (>${MAX_FILE_SIZE} bytes), key: ${fileKey}`); - return null; - } - chunks.push(buf); - } - buffer = Buffer.concat(chunks); - } catch (streamErr) { - // Stream approach failed — fall back to writeFile + read - console.warn('[feishu-adapter] Stream read failed, falling back to writeFile:', streamErr instanceof Error ? streamErr.message : streamErr); - - const fs = await import('fs'); - const os = await import('os'); - const path = await import('path'); - const tmpPath = path.join(os.tmpdir(), `feishu-dl-${crypto.randomUUID()}`); - try { - await res.writeFile(tmpPath); - buffer = fs.readFileSync(tmpPath); - if (buffer.length > MAX_FILE_SIZE) { - console.warn(`[feishu-adapter] Resource too large (>${MAX_FILE_SIZE} bytes), key: ${fileKey}`); - return null; - } - } finally { - try { fs.unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ } - } - } - - if (!buffer || buffer.length === 0) { - console.warn('[feishu-adapter] Downloaded resource is empty, key:', fileKey); - return null; - } - - const base64 = buffer.toString('base64'); - const id = crypto.randomUUID(); - const mimeType = MIME_BY_TYPE[resourceType] || 'application/octet-stream'; - const ext = resourceType === 'image' ? 'png' - : resourceType === 'audio' ? 'ogg' - : resourceType === 'video' ? 'mp4' - : 'bin'; - - console.log(`[feishu-adapter] Resource downloaded: ${buffer.length} bytes, key=${fileKey}`); - - return { - id, - name: `${fileKey}.${ext}`, - type: mimeType, - size: buffer.length, - data: base64, - }; - } catch (err) { - console.error( - `[feishu-adapter] Resource download failed (type=${resourceType}, key=${fileKey}):`, - err instanceof Error ? err.stack || err.message : err, - ); - return null; - } - } - - // ── Utilities ─────────────────────────────────────────────── - - private addToDedup(messageId: string): void { - this.seenMessageIds.set(messageId, true); - - // LRU eviction: remove oldest entries when exceeding limit - if (this.seenMessageIds.size > DEDUP_MAX) { - const excess = this.seenMessageIds.size - DEDUP_MAX; - let removed = 0; - for (const key of this.seenMessageIds.keys()) { - if (removed >= excess) break; - this.seenMessageIds.delete(key); - removed++; - } - } - } -} +import { registerAdapterFactory } from '../channel-adapter'; +import { ChannelPluginAdapter } from '../../channels/channel-plugin-adapter'; +import { FeishuChannelPlugin } from '../../channels/feishu'; // Self-register so bridge-manager can create FeishuAdapter via the registry. -registerAdapterFactory('feishu', () => new FeishuAdapter()); +registerAdapterFactory('feishu', () => + new ChannelPluginAdapter(new FeishuChannelPlugin()) +); diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index 912b27b..dbeb673 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -29,6 +29,16 @@ import { sanitizeInput, validateMode, } from './security/validators'; +import { ChannelPluginAdapter } from '../channels/channel-plugin-adapter'; + +/** + * Extract the real platform chat_id from a potentially synthetic thread-session address. + * Thread-session mode encodes addresses as `{real_chat_id}:thread:{root_id}`. + */ +function extractRealChatId(chatId: string): string { + const threadIdx = chatId.indexOf(':thread:'); + return threadIdx >= 0 ? chatId.slice(0, threadIdx) : chatId; +} const GLOBAL_KEY = '__bridge_manager__'; @@ -369,6 +379,15 @@ export function registerAdapter(adapter: BaseChannelAdapter): void { state.adapters.set(adapter.channelType, adapter); } +/** + * Get a running adapter by channel type. + * Returns null if the adapter is not registered. + */ +export function getAdapter(channelType: string): BaseChannelAdapter | null { + const state = getState(); + return state.adapters.get(channelType) ?? null; +} + /** * Run the event loop for a single adapter. * Messages for different sessions are dispatched concurrently; @@ -513,51 +532,90 @@ async function handleMessage( }; } + // ── Card streaming setup (Feishu) ────────────────────────────── + let cardController: import('../channels/types').CardStreamController | null = null; + let cardMessageId: string | null = null; + let cardCreating = false; + let cardBufferedText = ''; + let cardFinalized = false; + + if (!previewState && adapter.getCardStreamController) { + cardController = adapter.getCardStreamController(); + } + const streamCfg = previewState ? getStreamConfig(adapter.channelType) : null; - // Build the onPartialText callback (or undefined if preview not supported) - const onPartialText = (previewState && streamCfg) ? (fullText: string) => { - const ps = previewState!; - const cfg = streamCfg!; - if (ps.degraded) return; + // Build the onPartialText callback — preview streaming OR card streaming + let onPartialText: ((fullText: string) => void) | undefined; - // Truncate to maxChars + ellipsis - ps.pendingText = fullText.length > cfg.maxChars - ? fullText.slice(0, cfg.maxChars) + '...' - : fullText; + if (previewState && streamCfg) { + // Preview-based streaming (Telegram, etc.) + const ps = previewState; + const cfg = streamCfg; + onPartialText = (fullText: string) => { + if (ps.degraded) return; - const delta = ps.pendingText.length - ps.lastSentText.length; - const elapsed = Date.now() - ps.lastSentAt; + ps.pendingText = fullText.length > cfg.maxChars + ? fullText.slice(0, cfg.maxChars) + '...' + : fullText; - if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) { - // Not enough new content — schedule trailing-edge timer if not already set - if (!ps.throttleTimer) { - ps.throttleTimer = setTimeout(() => { - ps.throttleTimer = null; - if (!ps.degraded) flushPreview(adapter, ps, cfg); - }, cfg.intervalMs); + const delta = ps.pendingText.length - ps.lastSentText.length; + const elapsed = Date.now() - ps.lastSentAt; + + if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) { + if (!ps.throttleTimer) { + ps.throttleTimer = setTimeout(() => { + ps.throttleTimer = null; + if (!ps.degraded) flushPreview(adapter, ps, cfg); + }, cfg.intervalMs); + } + return; } - return; - } - if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) { - // Too soon — schedule trailing-edge timer to ensure latest text is sent - if (!ps.throttleTimer) { - ps.throttleTimer = setTimeout(() => { - ps.throttleTimer = null; - if (!ps.degraded) flushPreview(adapter, ps, cfg); - }, cfg.intervalMs - elapsed); + if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) { + if (!ps.throttleTimer) { + ps.throttleTimer = setTimeout(() => { + ps.throttleTimer = null; + if (!ps.degraded) flushPreview(adapter, ps, cfg); + }, cfg.intervalMs - elapsed); + } + return; } - return; - } - // Clear any pending trailing-edge timer and flush immediately - if (ps.throttleTimer) { - clearTimeout(ps.throttleTimer); - ps.throttleTimer = null; - } - flushPreview(adapter, ps, cfg); - } : undefined; + if (ps.throttleTimer) { + clearTimeout(ps.throttleTimer); + ps.throttleTimer = null; + } + flushPreview(adapter, ps, cfg); + }; + } else if (cardController) { + // Card-based streaming (Feishu) + onPartialText = (fullText: string) => { + if (cardCreating) { + cardBufferedText = fullText; + return; + } + + if (!cardMessageId) { + // First call — create the card + cardCreating = true; + cardBufferedText = fullText; + cardController!.create(msg.address.chatId, fullText, msg.messageId).then((msgId) => { + cardCreating = false; + cardMessageId = msgId || null; + // Flush any buffered text that arrived during creation + if (cardMessageId && cardBufferedText && cardBufferedText !== fullText) { + cardController!.update(cardMessageId, cardBufferedText).catch(() => {}); + } + }).catch(() => { + cardCreating = false; + }); + return; + } + + cardController!.update(cardMessageId, fullText).catch(() => {}); + }; + } try { // Pass permission callback so requests are forwarded to IM immediately @@ -580,7 +638,13 @@ async function handleMessage( // Send response text — render via channel-appropriate format if (result.responseText) { - await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId); + if (cardController && cardMessageId) { + // Finalize streaming card with final content + await cardController.finalize(cardMessageId, result.responseText); + cardFinalized = true; + } else { + await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId); + } } else if (result.hasError) { const errorResponse: OutboundMessage = { address: msg.address, @@ -614,6 +678,11 @@ async function handleMessage( adapter.endPreview?.(msg.address.chatId, previewState.draftId); } + // Clean up card streaming state + if (cardController && cardMessageId && !cardFinalized) { + cardController.finalize(cardMessageId, '⚠️ Response interrupted.').catch(() => {}); + } + state.activeTasks.delete(binding.codepilotSessionId); // Notify adapter that message processing ended adapter.onMessageEnd?.(msg.address.chatId); @@ -674,6 +743,8 @@ async function handleCommand( '/sessions - List recent sessions', '/stop - Stop current session', '/perm allow|allow_session|deny <id> - Respond to permission', + '/history [count] - Show recent messages', + '/search <keyword> - Search messages', '/help - Show this help', ].join('\n'); break; @@ -800,6 +871,94 @@ async function handleCommand( break; } + case '/history': { + // Fetch recent messages from the current chat + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'History is not supported for this channel type.'; + break; + } + const plugin = adapter.getPlugin(); + if (!plugin.getCardStreamController && !(plugin as any).meta?.channelType) { + response = 'History is not available.'; + break; + } + // Use message-actions if the plugin has Feishu-type capabilities + try { + const { readMessages } = await import('../channels/feishu/message-actions'); + const restClient = (plugin as any).gateway?.getRestClient?.(); + if (!restClient) { + response = 'Channel not connected.'; + break; + } + const pageSize = parseInt(args, 10) || 10; + const realChatId = extractRealChatId(msg.address.chatId); + const result = await readMessages(restClient, realChatId, { pageSize }); + if (result.items.length === 0) { + response = 'No messages found.'; + } else { + const lines = [`Recent ${result.items.length} messages:`, '']; + for (const item of result.items) { + const time = item.createTime ? new Date(parseInt(item.createTime, 10) * 1000).toLocaleString() : '?'; + let content = ''; + try { + const parsed = JSON.parse(item.content); + content = (parsed.text ?? '').slice(0, 80); + } catch { + content = item.content.slice(0, 80); + } + lines.push(`[${time}] ${escapeHtml(content)}`); + } + if (result.hasMore) lines.push('\n(more messages available)'); + response = lines.join('\n'); + } + } catch (err) { + response = `Failed to fetch history: ${err instanceof Error ? escapeHtml(err.message) : 'unknown error'}`; + } + break; + } + + case '/search': { + if (!args) { + response = 'Usage: /search <keyword>'; + break; + } + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'Search is not supported for this channel type.'; + break; + } + try { + const { searchMessages } = await import('../channels/feishu/message-actions'); + const plugin = adapter.getPlugin(); + const restClient = (plugin as any).gateway?.getRestClient?.(); + if (!restClient) { + response = 'Channel not connected.'; + break; + } + const realChatId = extractRealChatId(msg.address.chatId); + const result = await searchMessages(restClient, realChatId, args, { pageSize: 10 }); + if (result.items.length === 0) { + response = `No messages matching "${escapeHtml(args)}".`; + } else { + const lines = [`${result.items.length} result(s) for "${escapeHtml(args)}":`, '']; + for (const item of result.items) { + const time = item.createTime ? new Date(parseInt(item.createTime, 10) * 1000).toLocaleString() : '?'; + let content = ''; + try { + const parsed = JSON.parse(item.content); + content = (parsed.text ?? '').slice(0, 100); + } catch { + content = item.content.slice(0, 100); + } + lines.push(`[${time}] ${escapeHtml(content)}`); + } + response = lines.join('\n'); + } + } catch (err) { + response = `Search failed: ${err instanceof Error ? escapeHtml(err.message) : 'unknown error'}`; + } + break; + } + case '/help': response = [ 'CodePilot Bridge Commands', @@ -812,6 +971,8 @@ async function handleCommand( '/sessions - List recent sessions', '/stop - Stop current session', '/perm allow|allow_session|deny <id> - Respond to permission request', + '/history [count] - Show recent messages', + '/search <keyword> - Search messages', '/help - Show this help', ].join('\n'); break; diff --git a/src/lib/bridge/channel-adapter.ts b/src/lib/bridge/channel-adapter.ts index 12cc752..2fe367f 100644 --- a/src/lib/bridge/channel-adapter.ts +++ b/src/lib/bridge/channel-adapter.ts @@ -96,6 +96,12 @@ export abstract class BaseChannelAdapter { * normal delivery path, so this is typically a no-op. */ endPreview?(_chatId: string, _draftId: number): void; + + /** + * Get a card stream controller for streaming card updates. + * Returns null if not supported by this adapter. + */ + getCardStreamController?(): import('../channels/types').CardStreamController | null; } // ── Adapter Registry ──────────────────────────────────────────── diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index f290c5b..689a06c 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -37,10 +37,25 @@ function loadMcpServers(): Record | undefined { }; const userConfig = readJson(path.join(os.homedir(), '.claude.json')); const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')); + // Also read project-level .mcp.json + const projectMcp = readJson(path.join(process.cwd(), '.mcp.json')); const merged = { ...((userConfig.mcpServers || {}) as Record), ...((settings.mcpServers || {}) as Record), + ...((projectMcp.mcpServers || {}) as Record), }; + // Resolve ${...} placeholders in env values against DB settings + for (const server of Object.values(merged)) { + if (server.env) { + for (const [key, value] of Object.entries(server.env)) { + if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { + const settingKey = value.slice(2, -1); + const resolved = getSetting(settingKey); + server.env[key] = resolved || ''; + } + } + } + } return Object.keys(merged).length > 0 ? merged : undefined; } catch { return undefined; diff --git a/src/lib/db.ts b/src/lib/db.ts index 73dfc6f..7329197 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -698,6 +698,19 @@ function migrateDb(db: Database.Database): void { if (permLinkColNames.length > 0 && !permLinkColNames.includes('resolved')) { db.exec("ALTER TABLE channel_permission_links ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0"); } + + // Channel configs table (structured config for channel plugins) + db.exec(` + CREATE TABLE IF NOT EXISTS channel_configs ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + account_id TEXT NOT NULL DEFAULT 'default', + config_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(channel_type, account_id) + ); + `); } // ========================================== From 9ca9bce78815b6afb49e7045d8ee1f7943d20464 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Sat, 14 Mar 2026 09:55:03 +0800 Subject: [PATCH 2/5] fix: feishu card action 200340 and bridge session lock cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu card button clicks: - Add encryptKey/verificationToken to FeishuConfig for EventDispatcher - Gateway monkey-patch: rewrite type="card" → "event" for WSClient - Gateway safeCardActionHandler: 2.5s timeout guarantee with fallback toast - Embed chatId in button value (Feishu card.action.trigger has no open_chat_id) - Support both callback_data and action/operation_id button value formats - Domain resolution: 'feishu' → Domain.Feishu, 'lark' → Domain.Lark Bridge reliability: - Clear sessionLocks and activeTasks on bridge stop() to prevent stuck sessions after restart (processWithSessionLock chains to unresolved promises from previous bridge lifecycle) Settings UI: - Add encryptKey and verificationToken fields to Feishu settings - Mask encryptKey in GET, skip masked values in PUT - Add i18n strings (marked as optional for WSClient mode) Co-Authored-By: Claude Opus 4.6 --- src/app/api/settings/feishu/route.ts | 18 +- src/components/bridge/FeishuBridgeSection.tsx | 230 ++++++++++++++++-- src/i18n/en.ts | 34 ++- src/i18n/zh.ts | 34 ++- src/lib/bridge/bridge-manager.ts | 155 +++++++++++- src/lib/channels/channel-plugin-adapter.ts | 73 ++++++ src/lib/channels/feishu/card-controller.ts | 228 +++++++++++++++++ src/lib/channels/feishu/config.ts | 90 +++++++ src/lib/channels/feishu/gateway.ts | 190 +++++++++++++++ src/lib/channels/feishu/identity.ts | 25 ++ src/lib/channels/feishu/inbound.ts | 61 +++++ src/lib/channels/feishu/index.ts | 205 ++++++++++++++++ src/lib/channels/feishu/message-actions.ts | 191 +++++++++++++++ src/lib/channels/feishu/outbound.ts | 183 ++++++++++++++ src/lib/channels/feishu/policy.ts | 50 ++++ src/lib/channels/feishu/types.ts | 85 +++++++ src/lib/channels/types.ts | 112 +++++++++ src/lib/claude-client.ts | 8 +- 18 files changed, 1921 insertions(+), 51 deletions(-) create mode 100644 src/lib/channels/channel-plugin-adapter.ts create mode 100644 src/lib/channels/feishu/card-controller.ts create mode 100644 src/lib/channels/feishu/config.ts create mode 100644 src/lib/channels/feishu/gateway.ts create mode 100644 src/lib/channels/feishu/identity.ts create mode 100644 src/lib/channels/feishu/inbound.ts create mode 100644 src/lib/channels/feishu/index.ts create mode 100644 src/lib/channels/feishu/message-actions.ts create mode 100644 src/lib/channels/feishu/outbound.ts create mode 100644 src/lib/channels/feishu/policy.ts create mode 100644 src/lib/channels/feishu/types.ts create mode 100644 src/lib/channels/types.ts diff --git a/src/app/api/settings/feishu/route.ts b/src/app/api/settings/feishu/route.ts index b0254e5..985fd15 100644 --- a/src/app/api/settings/feishu/route.ts +++ b/src/app/api/settings/feishu/route.ts @@ -11,7 +11,15 @@ const FEISHU_KEYS = [ 'bridge_feishu_app_id', 'bridge_feishu_app_secret', 'bridge_feishu_domain', - 'bridge_feishu_allowed_users', + 'bridge_feishu_encrypt_key', + 'bridge_feishu_verification_token', + 'bridge_feishu_allow_from', + 'bridge_feishu_dm_policy', + 'bridge_feishu_render_mode', + 'bridge_feishu_thread_session', + 'bridge_feishu_block_streaming', + 'bridge_feishu_footer_status', + 'bridge_feishu_footer_elapsed', 'bridge_feishu_group_policy', 'bridge_feishu_group_allow_from', 'bridge_feishu_require_mention', @@ -23,8 +31,8 @@ export async function GET() { for (const key of FEISHU_KEYS) { const value = getSetting(key); if (value !== undefined) { - // Mask app secret for security - if (key === 'bridge_feishu_app_secret' && value.length > 8) { + // Mask sensitive fields for security + if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && value.length > 8) { result[key] = '***' + value.slice(-8); } else { result[key] = value; @@ -52,8 +60,8 @@ export async function PUT(request: NextRequest) { if (!FEISHU_KEYS.includes(key as typeof FEISHU_KEYS[number])) continue; const strValue = String(value ?? '').trim(); - // Don't overwrite secret if user sent the masked version back - if (key === 'bridge_feishu_app_secret' && strValue.startsWith('***')) { + // Don't overwrite secrets if user sent the masked version back + if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && strValue.startsWith('***')) { continue; } diff --git a/src/components/bridge/FeishuBridgeSection.tsx b/src/components/bridge/FeishuBridgeSection.tsx index 7cdd170..bea1928 100644 --- a/src/components/bridge/FeishuBridgeSection.tsx +++ b/src/components/bridge/FeishuBridgeSection.tsx @@ -21,7 +21,15 @@ interface FeishuBridgeSettings { bridge_feishu_app_id: string; bridge_feishu_app_secret: string; bridge_feishu_domain: string; - bridge_feishu_allowed_users: string; + bridge_feishu_encrypt_key: string; + bridge_feishu_verification_token: string; + bridge_feishu_allow_from: string; + bridge_feishu_dm_policy: string; + bridge_feishu_render_mode: string; + bridge_feishu_thread_session: string; + bridge_feishu_block_streaming: string; + bridge_feishu_footer_status: string; + bridge_feishu_footer_elapsed: string; bridge_feishu_group_policy: string; bridge_feishu_group_allow_from: string; bridge_feishu_require_mention: string; @@ -31,7 +39,15 @@ const DEFAULT_SETTINGS: FeishuBridgeSettings = { bridge_feishu_app_id: "", bridge_feishu_app_secret: "", bridge_feishu_domain: "feishu", - bridge_feishu_allowed_users: "", + bridge_feishu_encrypt_key: "", + bridge_feishu_verification_token: "", + bridge_feishu_allow_from: "", + bridge_feishu_dm_policy: "open", + bridge_feishu_render_mode: "auto", + bridge_feishu_thread_session: "false", + bridge_feishu_block_streaming: "false", + bridge_feishu_footer_status: "false", + bridge_feishu_footer_elapsed: "false", bridge_feishu_group_policy: "open", bridge_feishu_group_allow_from: "", bridge_feishu_require_mention: "false", @@ -43,7 +59,15 @@ export function FeishuBridgeSection() { const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); const [domain, setDomain] = useState("feishu"); - const [allowedUsers, setAllowedUsers] = useState(""); + const [encryptKey, setEncryptKey] = useState(""); + const [verificationToken, setVerificationToken] = useState(""); + const [allowFrom, setAllowFrom] = useState(""); + const [dmPolicy, setDmPolicy] = useState("open"); + const [renderMode, setRenderMode] = useState("auto"); + const [threadSession, setThreadSession] = useState(false); + const [blockStreaming, setBlockStreaming] = useState(false); + const [footerStatus, setFooterStatus] = useState(false); + const [footerElapsed, setFooterElapsed] = useState(false); const [groupPolicy, setGroupPolicy] = useState("open"); const [groupAllowFrom, setGroupAllowFrom] = useState(""); const [requireMention, setRequireMention] = useState(false); @@ -65,7 +89,15 @@ export function FeishuBridgeSection() { setAppId(s.bridge_feishu_app_id); setAppSecret(s.bridge_feishu_app_secret); setDomain(s.bridge_feishu_domain || "feishu"); - setAllowedUsers(s.bridge_feishu_allowed_users); + setEncryptKey(s.bridge_feishu_encrypt_key || ""); + setVerificationToken(s.bridge_feishu_verification_token || ""); + setAllowFrom(s.bridge_feishu_allow_from); + setDmPolicy(s.bridge_feishu_dm_policy || "open"); + setRenderMode(s.bridge_feishu_render_mode || "auto"); + setThreadSession(s.bridge_feishu_thread_session === "true"); + setBlockStreaming(s.bridge_feishu_block_streaming === "true"); + setFooterStatus(s.bridge_feishu_footer_status === "true"); + setFooterElapsed(s.bridge_feishu_footer_elapsed === "true"); setGroupPolicy(s.bridge_feishu_group_policy || "open"); setGroupAllowFrom(s.bridge_feishu_group_allow_from); setRequireMention(s.bridge_feishu_require_mention === "true"); @@ -101,16 +133,36 @@ export function FeishuBridgeSection() { const updates: Partial = { bridge_feishu_app_id: appId, bridge_feishu_domain: domain, + bridge_feishu_verification_token: verificationToken, }; if (appSecret && !appSecret.startsWith("***")) { updates.bridge_feishu_app_secret = appSecret; } + if (encryptKey && !encryptKey.startsWith("***")) { + updates.bridge_feishu_encrypt_key = encryptKey; + } saveSettings(updates); }; + const handleSaveDmSettings = () => { + saveSettings({ + bridge_feishu_allow_from: allowFrom, + bridge_feishu_dm_policy: dmPolicy, + }); + }; + + const handleSaveBehaviorSettings = () => { + saveSettings({ + bridge_feishu_render_mode: renderMode, + bridge_feishu_thread_session: threadSession ? "true" : "false", + bridge_feishu_block_streaming: blockStreaming ? "true" : "false", + bridge_feishu_footer_status: footerStatus ? "true" : "false", + bridge_feishu_footer_elapsed: footerElapsed ? "true" : "false", + }); + }; + const handleSaveGroupSettings = () => { saveSettings({ - bridge_feishu_allowed_users: allowedUsers, bridge_feishu_group_policy: groupPolicy, bridge_feishu_group_allow_from: groupAllowFrom, bridge_feishu_require_mention: requireMention ? "true" : "false", @@ -214,6 +266,37 @@ export function FeishuBridgeSection() { {t("feishu.domainHint")}

+ +
+ + setEncryptKey(e.target.value)} + placeholder={t("feishu.encryptKeyPlaceholder")} + className="font-mono text-sm" + /> +

+ {t("feishu.encryptKeyHint")} +

+
+ +
+ + setVerificationToken(e.target.value)} + placeholder={t("feishu.verificationTokenPlaceholder")} + className="font-mono text-sm" + /> +

+ {t("feishu.verificationTokenHint")} +

+
@@ -246,22 +329,133 @@ export function FeishuBridgeSection() { )} - {/* Allowed Users */} + {/* DM Policy & Allow From */} -
- setAllowedUsers(e.target.value)} - placeholder="ou_xxxxxxxxxx, ou_yyyyyyyyyy" - className="font-mono text-sm" - /> -

- {t("feishu.allowedUsersHint")} -

+
+
+ + +
+ +
+ setAllowFrom(e.target.value)} + placeholder="*, ou_xxxxxxxxxx, ou_yyyyyyyyyy" + className="font-mono text-sm" + /> +

+ {t("feishu.allowFromHint")} +

+
+ + + + + {/* Behavior Settings: Render Mode, Thread Session, Block Streaming, Footer */} + +
+
+ + +
+ + + + + + + + + + +
+ + + + + + +
+
+
+ +
{/* Group Chat Settings */} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b6838df..d5ff9ff 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -530,14 +530,30 @@ const en = { 'feishu.domainFeishu': 'Feishu (feishu.cn)', 'feishu.domainLark': 'Lark (larksuite.com)', 'feishu.domainHint': 'Choose Feishu for China mainland or Lark for international', + 'feishu.encryptKey': 'Encrypt Key', + 'feishu.encryptKeyPlaceholder': 'Encrypt Key from Feishu event subscription page', + 'feishu.encryptKeyHint': 'Optional, needed for HTTP Webhook mode (Feishu Console → Events & Callbacks → Encrypt Key)', + 'feishu.verificationToken': 'Verification Token', + 'feishu.verificationTokenPlaceholder': 'Verification Token from Feishu console', + 'feishu.verificationTokenHint': 'Optional, needed for HTTP Webhook mode (Feishu Console → Events & Callbacks → Verification Token)', 'feishu.verify': 'Test Connection', 'feishu.verified': 'Connection verified', 'feishu.verifiedAs': 'Connected as {name}', 'feishu.verifyFailed': 'Connection failed', 'feishu.enterCredentialsFirst': 'Enter App ID and App Secret first', - 'feishu.allowedUsers': 'Allowed Users', - 'feishu.allowedUsersDesc': 'Comma-separated open_id or chat_id values allowed to use bridge', - 'feishu.allowedUsersHint': 'Leave empty to allow all users', + 'feishu.allowFrom': 'Allow From', + 'feishu.allowFromDesc': 'Control which users can send DMs to the bot', + 'feishu.allowFromHint': 'Comma-separated open_id values. Use * to allow all users.', + 'feishu.dmPolicy': 'DM Policy', + 'feishu.dmPolicyOpen': 'Open — accept DMs from all users', + 'feishu.dmPolicyPairing': 'Pairing — require pairing handshake', + 'feishu.dmPolicyAllowlist': 'Allowlist — only listed users', + 'feishu.dmPolicyDisabled': 'Disabled — ignore all DMs', + 'feishu.renderMode': 'Render Mode', + 'feishu.renderModeDesc': 'Control how the bot renders responses', + 'feishu.renderModeAuto': 'Auto — choose best rendering', + 'feishu.renderModeStatic': 'Static — send as plain text', + 'feishu.renderModeStreaming': 'Streaming — stream via cards', 'feishu.groupSettings': 'Group Chat Settings', 'feishu.groupSettingsDesc': 'Control how the bot responds in group chats', 'feishu.groupPolicy': 'Group Policy', @@ -555,15 +571,13 @@ const en = { 'feishu.step4': 'Add event subscription: im.message.receive_v1', 'feishu.step5': 'Publish the app version and approve it in the admin console', 'feishu.step6': 'Paste the credentials above and click "Test Connection" to verify', - 'feishu.replyMode': 'Reply Mode', - 'feishu.replyModeInline': 'Inline — reply directly in chat', - 'feishu.replyModeThread': 'Thread — reply in message thread', - 'feishu.streaming': 'Streaming Cards', - 'feishu.streamingDesc': 'Stream responses via updateable cards (requires CardKit v2)', 'feishu.blockStreaming': 'Block Streaming', - 'feishu.blockStreamingDesc': 'Stream responses at block level', + 'feishu.blockStreamingDesc': 'Stream responses at block level with coalesce buffering', 'feishu.threadSession': 'Thread Sessions', - 'feishu.threadSessionDesc': 'Use message threads as separate conversation sessions', + 'feishu.threadSessionDesc': 'Enable per-thread independent context (parallel conversations in different threads)', + 'feishu.footer': 'Footer', + 'feishu.footerStatus': 'Show Status', + 'feishu.footerElapsed': 'Show Elapsed Time', 'feishu.connectionState': 'Connection State', 'feishu.connectionStateConnected': 'Connected', 'feishu.connectionStateDisconnected': 'Disconnected', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ce4ec79..3920ea3 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -527,14 +527,30 @@ const zh: Record = { 'feishu.domainFeishu': '飞书 (feishu.cn)', 'feishu.domainLark': 'Lark (larksuite.com)', 'feishu.domainHint': '中国大陆选择飞书,海外选择 Lark', + 'feishu.encryptKey': 'Encrypt Key', + 'feishu.encryptKeyPlaceholder': '飞书后台「事件订阅」页面的 Encrypt Key', + 'feishu.encryptKeyHint': '可选,HTTP Webhook 模式需要(飞书后台 → 事件与回调 → Encrypt Key)', + 'feishu.verificationToken': 'Verification Token', + 'feishu.verificationTokenPlaceholder': '飞书后台的 Verification Token', + 'feishu.verificationTokenHint': '可选,HTTP Webhook 模式需要(飞书后台 → 事件与回调 → Verification Token)', 'feishu.verify': '测试连接', 'feishu.verified': '连接验证成功', 'feishu.verifiedAs': '已连接为 {name}', 'feishu.verifyFailed': '连接失败', 'feishu.enterCredentialsFirst': '请先输入 App ID 和 App Secret', - 'feishu.allowedUsers': '允许的用户', - 'feishu.allowedUsersDesc': '允许使用桥接的 open_id 或 chat_id,逗号分隔', - 'feishu.allowedUsersHint': '留空则允许所有用户', + 'feishu.allowFrom': '允许来源', + 'feishu.allowFromDesc': '控制哪些用户可以向机器人发送私信', + 'feishu.allowFromHint': '逗号分隔的 open_id。使用 * 允许所有用户。', + 'feishu.dmPolicy': '私信策略', + 'feishu.dmPolicyOpen': '开放 — 接受所有用户私信', + 'feishu.dmPolicyPairing': '配对 — 需要配对握手', + 'feishu.dmPolicyAllowlist': '白名单 — 仅允许列表中的用户', + 'feishu.dmPolicyDisabled': '禁用 — 忽略所有私信', + 'feishu.renderMode': '渲染模式', + 'feishu.renderModeDesc': '控制机器人如何渲染响应', + 'feishu.renderModeAuto': '自动 — 选择最佳渲染方式', + 'feishu.renderModeStatic': '静态 — 以纯文本发送', + 'feishu.renderModeStreaming': '流式 — 通过卡片流式输出', 'feishu.groupSettings': '群聊设置', 'feishu.groupSettingsDesc': '控制机器人在群聊中的响应方式', 'feishu.groupPolicy': '群聊策略', @@ -552,15 +568,13 @@ const zh: Record = { 'feishu.step4': '添加事件订阅:im.message.receive_v1', 'feishu.step5': '发布应用版本并在管理后台审批通过', 'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证', - 'feishu.replyMode': '回复模式', - 'feishu.replyModeInline': '内联 — 直接在聊天中回复', - 'feishu.replyModeThread': '话题 — 在消息话题中回复', - 'feishu.streaming': '流式卡片', - 'feishu.streamingDesc': '通过可更新卡片流式输出响应(需要 CardKit v2)', 'feishu.blockStreaming': 'Block 流式', - 'feishu.blockStreamingDesc': '按 Block 级别流式输出响应', + 'feishu.blockStreamingDesc': '按 Block 级别流式输出响应(配合合并缓冲配置)', 'feishu.threadSession': '话题会话', - 'feishu.threadSessionDesc': '将消息话题作为独立的对话会话', + 'feishu.threadSessionDesc': '启用每话题独立上下文(不同话题中的并行对话)', + 'feishu.footer': '页脚', + 'feishu.footerStatus': '显示状态', + 'feishu.footerElapsed': '显示耗时', 'feishu.connectionState': '连接状态', 'feishu.connectionStateConnected': '已连接', 'feishu.connectionStateDisconnected': '未连接', diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index dbeb673..5d632c2 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -323,6 +323,8 @@ export async function stop(): Promise { state.adapters.clear(); state.adapterMeta.clear(); + state.sessionLocks.clear(); + state.activeTasks.clear(); state.startedAt = null; // Re-enable notification bot polling @@ -745,6 +747,7 @@ async function handleCommand( '/perm allow|allow_session|deny <id> - Respond to permission', '/history [count] - Show recent messages', '/search <keyword> - Search messages', + '/feishu <subcommand> - Feishu bridge management', '/help - Show this help', ].join('\n'); break; @@ -872,7 +875,7 @@ async function handleCommand( } case '/history': { - // Fetch recent messages from the current chat + // Fetch recent messages from the current chat (or thread) if (!(adapter instanceof ChannelPluginAdapter)) { response = 'History is not supported for this channel type.'; break; @@ -884,15 +887,26 @@ async function handleCommand( } // Use message-actions if the plugin has Feishu-type capabilities try { - const { readMessages } = await import('../channels/feishu/message-actions'); + const { readMessages, readThreadMessages } = await import('../channels/feishu/message-actions'); const restClient = (plugin as any).gateway?.getRestClient?.(); if (!restClient) { response = 'Channel not connected.'; break; } const pageSize = parseInt(args, 10) || 10; - const realChatId = extractRealChatId(msg.address.chatId); - const result = await readMessages(restClient, realChatId, { pageSize }); + const chatIdRaw = msg.address.chatId; + const threadIdx = chatIdRaw.indexOf(':thread:'); + + let result; + if (threadIdx >= 0) { + // Thread history: extract thread ID and use readThreadMessages + const threadId = chatIdRaw.slice(threadIdx + ':thread:'.length); + result = await readThreadMessages(restClient, threadId, { pageSize }); + } else { + const realChatId = extractRealChatId(chatIdRaw); + result = await readMessages(restClient, realChatId, { pageSize }); + } + if (result.items.length === 0) { response = 'No messages found.'; } else { @@ -918,6 +932,9 @@ async function handleCommand( } case '/search': { + // Simplified local search — lists recent messages and filters client-side. + // This is NOT equivalent to OpenClaw's server-side search (search.message.create API + // with user_access_token). Results are limited to recent messages in the current chat. if (!args) { response = 'Usage: /search <keyword>'; break; @@ -959,6 +976,135 @@ async function handleCommand( break; } + case '/feishu': { + const subArgs = args.split(/\s+/); + const subcommand = subArgs[0]?.toLowerCase() || 'help'; + + switch (subcommand) { + case 'start': { + // Validate Feishu config + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'This command is only available in Feishu channels.'; + break; + } + const plugin = adapter.getPlugin(); + const config = (plugin as any).getConfig?.(); + if (!config) { + response = '❌ Feishu plugin not configured.\n\nPlease set App ID and App Secret in CodePilot settings, or use /feishu auth.'; + break; + } + const validationError = plugin.validateConfig(); + if (validationError) { + response = `❌ Configuration error: ${validationError}`; + break; + } + const capabilities = plugin.getCapabilities(); + const lines = [ + '✅ Feishu Bridge is running', + '', + `Streaming: ${capabilities.streaming ? '✅ Enabled' : '❌ Disabled'}`, + `Thread Reply: ${capabilities.threadReply ? '✅' : '❌'}`, + `Search: ${capabilities.search ? '✅' : '❌'}`, + `History: ${capabilities.history ? '✅' : '❌'}`, + ]; + response = lines.join('\n'); + break; + } + + case 'auth': { + // Show auth status and guidance + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'This command is only available in Feishu channels.'; + break; + } + const plugin = adapter.getPlugin(); + const config = (plugin as any).getConfig?.(); + if (!config) { + response = '❌ App credentials not configured.\n\nPlease configure App ID and App Secret in CodePilot Settings → Bridge → Feishu.'; + break; + } + // Note: CodePilot currently uses app-level bot tokens (no user OAuth) + // This is a simplified version compared to OpenClaw's full OAuth Device Flow + response = [ + '🔐 Feishu Auth Status', + '', + `App ID: ${config.appId}`, + `DM Policy: ${config.dmPolicy}`, + `Allow From: ${(config.allowFrom || []).join(', ') || '(all)'}`, + '', + 'ℹ️ CodePilot uses app-level bot tokens.', + 'User-level OAuth (user_access_token) is not yet supported.', + 'Some features requiring user identity (cross-chat search, sending as user) are unavailable.', + ].join('\n'); + break; + } + + case 'doctor': { + // Run diagnostics + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'This command is only available in Feishu channels.'; + break; + } + const plugin = adapter.getPlugin(); + const config = (plugin as any).getConfig?.(); + const lines = ['🔍 Feishu Doctor', '']; + + // Config check + if (!config) { + lines.push('❌ Configuration: Not configured'); + } else { + lines.push('✅ Configuration: OK'); + lines.push(` App ID: ${config.appId}`); + lines.push(` DM Policy: ${config.dmPolicy}`); + lines.push(` Render Mode: ${config.renderMode}`); + lines.push(` Thread Session: ${config.threadSession ? 'Yes' : 'No'}`); + lines.push(` Streaming: ${config.cardStreamConfig ? 'Enabled' : 'Disabled'}`); + } + + // Connection check + if (plugin.isRunning()) { + lines.push('✅ Connection: WebSocket connected'); + } else { + lines.push('❌ Connection: Not running'); + } + + // Capabilities + const caps = plugin.getCapabilities(); + lines.push(''); + lines.push('Capabilities:'); + lines.push(` Streaming Cards: ${caps.streaming ? '✅' : '❌'}`); + lines.push(` Thread Reply: ${caps.threadReply ? '✅' : '❌'}`); + lines.push(` Message Search: ${caps.search ? '✅' : '❌ (requires user_access_token)'}`); + lines.push(` Message History: ${caps.history ? '✅' : '❌'}`); + + // Known limitations + lines.push(''); + lines.push('Known Limitations (CodePilot vs OpenClaw):'); + lines.push(' • No user_access_token / OAuth Device Flow'); + lines.push(' • No cross-chat search (search.message.create requires UAT)'); + lines.push(' • No "send as user" capability'); + lines.push(' • Simplified card streaming (no reasoning phase display)'); + + response = lines.join('\n'); + break; + } + + default: { + // /feishu help or unknown subcommand + response = [ + 'Feishu Bridge Commands', + '', + '/feishu start — Check plugin status and configuration', + '/feishu auth — View auth status and guidance', + '/feishu doctor — Run diagnostics', + '/feishu help — Show this help', + ].join('\n'); + break; + } + } + break; + } + case '/help': response = [ 'CodePilot Bridge Commands', @@ -973,6 +1119,7 @@ async function handleCommand( '/perm allow|allow_session|deny <id> - Respond to permission request', '/history [count] - Show recent messages', '/search <keyword> - Search messages', + '/feishu <subcommand> - Feishu bridge management', '/help - Show this help', ].join('\n'); break; diff --git a/src/lib/channels/channel-plugin-adapter.ts b/src/lib/channels/channel-plugin-adapter.ts new file mode 100644 index 0000000..7fa687e --- /dev/null +++ b/src/lib/channels/channel-plugin-adapter.ts @@ -0,0 +1,73 @@ +/** + * ChannelPluginAdapter — bridges a ChannelPlugin to BaseChannelAdapter. + * + * This allows any ChannelPlugin implementation to be used as a + * BaseChannelAdapter in the bridge system without modification. + */ + +import { BaseChannelAdapter } from '../bridge/channel-adapter'; +import type { + ChannelType, + InboundMessage, + OutboundMessage, + SendResult, +} from '../bridge/types'; +import type { CardStreamController, ChannelPlugin } from './types'; + +export class ChannelPluginAdapter extends BaseChannelAdapter { + private plugin: ChannelPlugin; + + constructor(plugin: ChannelPlugin) { + super(); + this.plugin = plugin; + } + + get channelType(): ChannelType { + return this.plugin.meta.channelType; + } + + /** Expose the underlying plugin for direct access (e.g. message-actions). */ + getPlugin(): ChannelPlugin { + return this.plugin; + } + + async start(): Promise { + return this.plugin.start(); + } + + async stop(): Promise { + return this.plugin.stop(); + } + + isRunning(): boolean { + return this.plugin.isRunning(); + } + + async consumeOne(): Promise { + return this.plugin.consumeOne(); + } + + async send(message: OutboundMessage): Promise { + return this.plugin.send(message); + } + + validateConfig(): string | null { + return this.plugin.validateConfig(); + } + + isAuthorized(userId: string, chatId: string): boolean { + return this.plugin.isAuthorized(userId, chatId); + } + + onMessageStart(chatId: string): void { + this.plugin.onMessageStart?.(chatId); + } + + onMessageEnd(chatId: string): void { + this.plugin.onMessageEnd?.(chatId); + } + + getCardStreamController(): CardStreamController | null { + return this.plugin.getCardStreamController?.() ?? null; + } +} diff --git a/src/lib/channels/feishu/card-controller.ts b/src/lib/channels/feishu/card-controller.ts new file mode 100644 index 0000000..9cedb8d --- /dev/null +++ b/src/lib/channels/feishu/card-controller.ts @@ -0,0 +1,228 @@ +/** + * CodePilot Simplified Card Streaming + * + * Manages streaming card lifecycle via CardKit v2 API. + * State machine: idle → creating → streaming → completed | error + * + * Differences from OpenClaw's StreamingCardController: + * - No reasoning phase streaming (thinking indicator) + * - No FlushController / UnavailableGuard + * - No IM patch fallback (if CardKit fails, falls back to static text) + * - No reply boundary detection + * - Footer is simplified (just elapsed time and status text, no animated status) + * + * These are intentional simplifications for CodePilot's use case. + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { CardStreamController } from '../types'; +import type { CardStreamConfig } from './types'; + +const LOG_TAG = '[card-controller]'; + +interface CardState { + cardId: string; + messageId: string; + sequence: number; + lastUpdateAt: number; + startTime: number; + throttleTimer: ReturnType | null; + pendingText: string | null; +} + +class FeishuCardStreamController implements CardStreamController { + private client: lark.Client; + private config: CardStreamConfig; + private cards = new Map(); + + constructor(client: lark.Client, config: CardStreamConfig) { + this.client = client; + this.config = config; + } + + async create(chatId: string, initialText: string, replyToMessageId?: string): Promise { + try { + // 1. Create streaming card via CardKit v2 + const cardBody = { + schema: '2.0', + config: { streaming_mode: true, wide_screen_mode: true }, + body: { + elements: [{ + tag: 'markdown', + content: initialText || '...', + element_id: 'streaming_content', + }], + }, + }; + + const createResp = await (this.client as any).cardkit.v2.card.create({ + data: { type: 'card_json', data: JSON.stringify(cardBody) }, + }); + const cardId = createResp?.data?.card_id; + if (!cardId) { + console.error(LOG_TAG, 'Card create returned no card_id'); + return ''; + } + console.log(LOG_TAG, 'Created card:', cardId); + + // 2. Send card as IM message + const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } }); + let msgResp: any; + if (replyToMessageId) { + msgResp = await this.client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { content: cardContent, msg_type: 'interactive' }, + }); + } else { + msgResp = await this.client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' }, + }); + } + const messageId = msgResp?.data?.message_id || ''; + console.log(LOG_TAG, 'Sent card message:', messageId); + + this.cards.set(messageId, { + cardId, + messageId, + sequence: 0, + lastUpdateAt: Date.now(), + startTime: Date.now(), + throttleTimer: null, + pendingText: null, + }); + + return messageId; + } catch (err: any) { + console.error(LOG_TAG, 'Card create failed:', err?.message || err); + return ''; + } + } + + async update(messageId: string, text: string): Promise<'ok' | 'fail'> { + const state = this.cards.get(messageId); + if (!state) return 'fail'; + + state.pendingText = text; + + const elapsed = Date.now() - state.lastUpdateAt; + if (elapsed < this.config.throttleMs) { + // Schedule trailing-edge flush + if (!state.throttleTimer) { + state.throttleTimer = setTimeout(() => { + state.throttleTimer = null; + if (state.pendingText) { + this.flushUpdate(state).catch(() => {}); + } + }, this.config.throttleMs - elapsed); + } + return 'ok'; + } + + return this.flushUpdate(state); + } + + private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> { + if (!state.pendingText) return 'ok'; + const text = state.pendingText; + state.pendingText = null; + + try { + state.sequence++; + await (this.client as any).cardkit.v2.card.streamContent({ + path: { card_id: state.cardId }, + data: { content: text, sequence: state.sequence }, + }); + state.lastUpdateAt = Date.now(); + return 'ok'; + } catch (err: any) { + console.error(LOG_TAG, 'Stream update failed:', err?.message || err); + return 'fail'; + } + } + + async finalize( + messageId: string, + finalText: string, + status: 'completed' | 'interrupted' | 'error' = 'completed', + ): Promise { + const state = this.cards.get(messageId); + if (!state) return; + + // Clear any pending throttle + if (state.throttleTimer) { + clearTimeout(state.throttleTimer); + state.throttleTimer = null; + } + + try { + // Close streaming mode + await (this.client as any).cardkit.v2.card.setStreamingMode({ + path: { card_id: state.cardId }, + params: { streaming_mode: false }, + }); + + // Build footer elements if configured + const footerElements: Array<{ tag: string; content: string; element_id: string }> = []; + const footerCfg = this.config.footer; + + if (footerCfg?.elapsed) { + const elapsedMs = Date.now() - state.startTime; + const elapsedSec = (elapsedMs / 1000).toFixed(1); + footerElements.push({ + tag: 'markdown', + content: `*${elapsedSec}s*`, + element_id: 'footer_elapsed', + }); + } + + if (footerCfg?.status) { + const statusLabels: Record = { + completed: 'Completed', + interrupted: 'Interrupted', + error: 'Error', + }; + footerElements.push({ + tag: 'markdown', + content: `**${statusLabels[status] || status}**`, + element_id: 'footer_status', + }); + } + + // Update final card content + const finalCard = { + schema: '2.0', + config: { wide_screen_mode: true }, + body: { + elements: [ + { + tag: 'markdown', + content: finalText, + element_id: 'streaming_content', + }, + ...footerElements, + ], + }, + }; + await (this.client as any).cardkit.v2.card.update({ + path: { card_id: state.cardId }, + data: { type: 'card_json', data: JSON.stringify(finalCard) }, + }); + } catch (err: any) { + console.error(LOG_TAG, 'Finalize failed:', err?.message || err); + } + + console.log(LOG_TAG, `Finalized card ${state.cardId} as ${status}`); + this.cards.delete(messageId); + } +} + +/** + * Factory function for creating a card stream controller. + */ +export function createCardStreamController( + client: lark.Client, + config: CardStreamConfig, +): CardStreamController { + return new FeishuCardStreamController(client, config); +} diff --git a/src/lib/channels/feishu/config.ts b/src/lib/channels/feishu/config.ts new file mode 100644 index 0000000..d872a9c --- /dev/null +++ b/src/lib/channels/feishu/config.ts @@ -0,0 +1,90 @@ +/** + * Feishu configuration loading and validation. + */ + +import { getSetting } from '../../db'; +import type { FeishuConfig, CardStreamConfig, BlockStreamingCoalesce, FooterConfig } from './types'; + +/** Parse a comma-separated string into a trimmed, non-empty string array. */ +function parseList(raw: string | undefined): string[] { + if (!raw) return []; + return raw.split(',').map(s => s.trim()).filter(Boolean); +} + +/** Load Feishu config from settings DB. Returns null if incomplete. */ +export function loadFeishuConfig(): FeishuConfig | null { + const appId = getSetting('bridge_feishu_app_id'); + const appSecret = getSetting('bridge_feishu_app_secret'); + if (!appId || !appSecret) return null; + + const domain = getSetting('bridge_feishu_domain') || 'feishu'; + const encryptKey = getSetting('bridge_feishu_encrypt_key') || ''; + const verificationToken = getSetting('bridge_feishu_verification_token') || ''; + + const allowFrom = parseList(getSetting('bridge_feishu_allow_from')); + const groupAllowFrom = parseList(getSetting('bridge_feishu_group_allow_from')); + const dmPolicy = (getSetting('bridge_feishu_dm_policy') || 'open') as FeishuConfig['dmPolicy']; + const groupPolicy = (getSetting('bridge_feishu_group_policy') || 'open') as FeishuConfig['groupPolicy']; + const renderMode = (getSetting('bridge_feishu_render_mode') || 'auto') as FeishuConfig['renderMode']; + const requireMention = getSetting('bridge_feishu_require_mention') === 'true'; + const threadSession = getSetting('bridge_feishu_thread_session') === 'true'; + const blockStreaming = getSetting('bridge_feishu_block_streaming') === 'true'; + + // Validation: when dmPolicy is 'open', allowFrom should include '*' + if (dmPolicy === 'open' && allowFrom.length > 0 && !allowFrom.includes('*')) { + allowFrom.push('*'); + } + + let cardStreamConfig: CardStreamConfig | null = null; + if (!blockStreaming && renderMode !== 'static') { + cardStreamConfig = { + throttleMs: 200, + }; + } + + let blockStreamingCoalesce: BlockStreamingCoalesce | null = null; + if (blockStreaming) { + blockStreamingCoalesce = { + minChars: 20, + maxChars: 4096, + idleMs: 300, + }; + } + + // Footer config + const footerStatus = getSetting('bridge_feishu_footer_status'); + const footerElapsed = getSetting('bridge_feishu_footer_elapsed'); + let footer: FooterConfig | null = null; + if (footerStatus !== undefined || footerElapsed !== undefined) { + footer = { + status: footerStatus === 'true', + elapsed: footerElapsed === 'true', + }; + } + + return { + appId, + appSecret, + domain, + encryptKey, + verificationToken, + allowFrom, + groupAllowFrom, + dmPolicy, + groupPolicy, + renderMode, + requireMention, + threadSession, + cardStreamConfig, + blockStreamingCoalesce, + footer, + }; +} + +/** Validate a Feishu config. Returns null if valid, error message otherwise. */ +export function validateFeishuConfig(config: FeishuConfig | null): string | null { + if (!config) return 'Feishu App ID and App Secret are required.'; + if (!config.appId) return 'Feishu App ID is required.'; + if (!config.appSecret) return 'Feishu App Secret is required.'; + return null; +} diff --git a/src/lib/channels/feishu/gateway.ts b/src/lib/channels/feishu/gateway.ts new file mode 100644 index 0000000..99ae8a4 --- /dev/null +++ b/src/lib/channels/feishu/gateway.ts @@ -0,0 +1,190 @@ +/** + * Feishu WebSocket gateway — manages lark SDK client and WS connection lifecycle. + * + * Key design decisions: + * - EventDispatcher receives encryptKey / verificationToken so card.action.trigger + * callbacks can be decrypted and verified by the SDK. + * - Client and WSClient receive the resolved SDK domain (Feishu vs Lark). + * - card.action.trigger handler guarantees a valid response within 3 seconds, + * regardless of what the upper-layer handler does. Heavy logic is fire-and-forget. + */ + +import * as lark from '@larksuiteoapi/node-sdk'; +import type { FeishuConfig } from './types'; + +const LOG_TAG = '[feishu/gateway]'; + +/** Map brand string to SDK domain constant. */ +function resolveDomain(brand: string): lark.Domain | string { + if (brand === 'lark') return lark.Domain.Lark; + if (brand === 'feishu') return lark.Domain.Feishu; + // Custom domain (e.g. self-hosted): pass through as-is + return brand.replace(/\/+$/, ''); +} + +/** Default toast returned when the upper-layer handler fails or times out. */ +const FALLBACK_TOAST = { + toast: { type: 'info' as const, content: '已收到,正在处理...' }, +}; + +/** + * Card action callback handler type. + * Receives the raw event data and returns a toast/card response object. + * May also return void/undefined — gateway will fill in a default toast. + */ +export type CardActionHandler = (data: unknown) => Promise; + +export class FeishuGateway { + private client: lark.Client | null = null; + private wsClient: lark.WSClient | null = null; + private running = false; + private config: FeishuConfig; + private eventDispatcher: lark.EventDispatcher; + private onMessage: ((data: unknown) => void) | null = null; + private cardActionHandler: CardActionHandler | null = null; + + constructor(config: FeishuConfig) { + this.config = config; + // Pass encryptKey and verificationToken so the SDK can decrypt/verify + // card.action.trigger callbacks delivered over WebSocket. + this.eventDispatcher = new lark.EventDispatcher({ + encryptKey: config.encryptKey || '', + verificationToken: config.verificationToken || '', + }); + } + + /** Get the REST client (for API calls). */ + getRestClient(): lark.Client | null { + return this.client; + } + + /** Get the event dispatcher for handler registration. */ + getEventDispatcher(): lark.EventDispatcher { + return this.eventDispatcher; + } + + /** Register the im.message.receive_v1 handler. */ + registerMessageHandler(handler: (data: unknown) => void): void { + this.onMessage = handler; + this.eventDispatcher.register({ + 'im.message.receive_v1': ((data: unknown) => { + handler(data); + }) as any, + }); + } + + /** + * Register a card action handler. + * + * The gateway wraps this handler with a 3-second timeout guarantee: + * - If the handler resolves within 3s, its return value is used. + * - If it throws or times out, a safe fallback toast is returned. + * - The handler itself should keep synchronous work minimal and + * push heavy logic to fire-and-forget (setImmediate / queueMicrotask). + * + * Supports both button value formats: + * - value.callback_data (CodePilot permission buttons) + * - value.action / value.operation_id (OpenClaw-style buttons) + */ + registerCardActionHandler(handler: CardActionHandler): void { + this.cardActionHandler = handler; + + // Register the safe wrapper on the EventDispatcher. + // The wrapper guarantees a response object is always returned. + this.eventDispatcher.register({ + 'card.action.trigger': ((data: unknown) => this.safeCardActionHandler(data)) as any, + }); + } + + /** + * Gateway-level card action handler that enforces the 3-second response contract. + * + * Flow: + * 1. Race the upper-layer handler against a 2.5s timeout (leaving 500ms margin). + * 2. If the handler returns a valid object, use it. + * 3. If it throws, times out, or returns nothing, return FALLBACK_TOAST. + */ + private async safeCardActionHandler(data: unknown): Promise { + const handler = this.cardActionHandler; + if (!handler) return FALLBACK_TOAST; + + const TIMEOUT_MS = 2500; // 2.5s — leave 500ms margin for SDK overhead + + try { + const result = await Promise.race([ + handler(data), + new Promise((resolve) => setTimeout(() => resolve(undefined), TIMEOUT_MS)), + ]); + + // Handler resolved with a valid response + if (result && typeof result === 'object') return result; + + // Handler returned void/undefined or timed out — use fallback + return FALLBACK_TOAST; + } catch (err) { + console.error(LOG_TAG, 'Card action handler error:', err); + return FALLBACK_TOAST; + } + } + + /** Start the WebSocket connection. */ + async start(): Promise { + if (this.running) return; + + const domain = resolveDomain(this.config.domain); + + this.client = new lark.Client({ + appId: this.config.appId, + appSecret: this.config.appSecret, + domain, + disableTokenCache: false, + }); + + this.wsClient = new lark.WSClient({ + appId: this.config.appId, + appSecret: this.config.appSecret, + domain, + loggerLevel: lark.LoggerLevel.warn, + }); + + // Monkey-patch handleEventData to support card action events (type: "card"). + // The SDK's WSClient only handles type="event" by default; card action + // callbacks arrive as type="card" and would be silently dropped. + // Patch: rewrite type header from "card" to "event" before dispatching. + const wsClientAny = this.wsClient as any; + if (typeof wsClientAny.handleEventData === 'function') { + const origHandleEventData = wsClientAny.handleEventData.bind(wsClientAny); + wsClientAny.handleEventData = (data: any) => { + const msgType = data.headers?.find?.((h: any) => h.key === 'type')?.value; + if (msgType !== 'event') console.log(LOG_TAG, 'handleEventData type:', msgType); + if (msgType === 'card') { + const patchedData = { + ...data, + headers: data.headers.map((h: any) => + h.key === 'type' ? { ...h, value: 'event' } : h, + ), + }; + return origHandleEventData(patchedData); + } + return origHandleEventData(data); + }; + } + + await this.wsClient.start({ eventDispatcher: this.eventDispatcher }); + this.running = true; + console.log(LOG_TAG, 'WebSocket connected'); + } + + /** Stop the WebSocket connection. */ + async stop(): Promise { + if (!this.running) return; + this.running = false; + // WSClient doesn't expose a clean stop — set refs to null + this.wsClient = null; + console.log(LOG_TAG, 'Stopped'); + } + + isRunning(): boolean { + return this.running; + } +} diff --git a/src/lib/channels/feishu/identity.ts b/src/lib/channels/feishu/identity.ts new file mode 100644 index 0000000..e8408c2 --- /dev/null +++ b/src/lib/channels/feishu/identity.ts @@ -0,0 +1,25 @@ +/** + * Feishu bot identity resolution. + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { FeishuBotInfo } from './types'; + +const LOG_TAG = '[feishu/identity]'; + +/** Fetch bot info from Feishu API. */ +export async function getBotInfo(client: lark.Client): Promise { + try { + const resp = await (client as any).bot.v3.botInfo.list(); + const bot = resp?.data?.bot; + if (!bot) return null; + return { + appId: bot.app_id || '', + botName: bot.app_name || '', + openId: bot.open_id || '', + }; + } catch (err) { + console.error(LOG_TAG, 'Failed to get bot info:', err); + return null; + } +} diff --git a/src/lib/channels/feishu/inbound.ts b/src/lib/channels/feishu/inbound.ts new file mode 100644 index 0000000..590f5ae --- /dev/null +++ b/src/lib/channels/feishu/inbound.ts @@ -0,0 +1,61 @@ +/** + * Feishu inbound message processing. + * + * Converts raw Feishu event data into InboundMessage for the bridge queue. + */ + +import type { InboundMessage } from '../../bridge/types'; +import type { FeishuConfig } from './types'; + +const LOG_TAG = '[feishu/inbound]'; + +/** Parse a raw Feishu im.message.receive_v1 event into an InboundMessage. */ +export function parseInboundMessage( + eventData: any, + _config: FeishuConfig, +): InboundMessage | null { + try { + const event = eventData?.event ?? eventData; + const message = event?.message; + if (!message) return null; + + const chatId = message.chat_id || ''; + const messageId = message.message_id || ''; + const sender = event.sender?.sender_id?.open_id || ''; + const msgType = message.message_type; + + // Only handle text messages for now + let text = ''; + if (msgType === 'text') { + try { + const content = JSON.parse(message.content || '{}'); + text = content.text || ''; + } catch { + text = message.content || ''; + } + } else { + // Non-text messages — skip silently + return null; + } + + if (!text.trim()) return null; + + // Build thread-session address if applicable + const rootId = message.root_id || ''; + const effectiveChatId = rootId ? `${chatId}:thread:${rootId}` : chatId; + + return { + messageId, + address: { + channelType: 'feishu', + chatId: effectiveChatId, + userId: sender, + }, + text: text.trim(), + timestamp: parseInt(message.create_time, 10) || Date.now(), + }; + } catch (err) { + console.error(LOG_TAG, 'Failed to parse inbound message:', err); + return null; + } +} diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts new file mode 100644 index 0000000..522c412 --- /dev/null +++ b/src/lib/channels/feishu/index.ts @@ -0,0 +1,205 @@ +/** + * FeishuChannelPlugin — implements ChannelPlugin for Feishu/Lark. + * + * Composes: gateway (WS), inbound (parsing), outbound (sending), + * identity (bot info), policy (access control), card-controller (streaming). + */ + +import type { InboundMessage, OutboundMessage, SendResult } from '../../bridge/types'; +import type { ChannelPlugin, ChannelCapabilities, ChannelMeta, CardStreamController } from '../types'; +import type { FeishuConfig } from './types'; +import { loadFeishuConfig, validateFeishuConfig } from './config'; +import { FeishuGateway } from './gateway'; +import { parseInboundMessage } from './inbound'; +import { sendMessage } from './outbound'; +import { isUserAuthorized } from './policy'; +import { createCardStreamController } from './card-controller'; + +export class FeishuChannelPlugin implements ChannelPlugin { + readonly meta: ChannelMeta = { + channelType: 'feishu', + displayName: 'Feishu / Lark', + }; + + private config: FeishuConfig | null = null; + private gateway: FeishuGateway | null = null; + private messageQueue: InboundMessage[] = []; + private waitResolve: ((msg: InboundMessage | null) => void) | null = null; + + loadConfig(): FeishuConfig | null { + this.config = loadFeishuConfig(); + return this.config; + } + + getConfig(): FeishuConfig | null { + return this.config; + } + + getCapabilities(): ChannelCapabilities { + return { + streaming: !!(this.config?.cardStreamConfig), + threadReply: true, + search: false, // True server-side search requires user_access_token; we only have local filtering + history: true, + reactions: false, + }; + } + + validateConfig(): string | null { + if (!this.config) { + this.loadConfig(); + } + return validateFeishuConfig(this.config); + } + + async start(): Promise { + if (!this.config) { + this.config = loadFeishuConfig(); + } + if (!this.config) throw new Error('Feishu config not loaded'); + + this.gateway = new FeishuGateway(this.config); + + // Register message handler — pushes to internal queue + this.gateway.registerMessageHandler((data: unknown) => { + const msg = parseInboundMessage(data, this.config!); + if (!msg) return; + this.enqueueMessage(msg); + }); + + // Register card action handler — converts button clicks to callback messages. + // Gateway guarantees 3-second response; this handler should stay lightweight. + // Supports two button value formats: + // 1. { callback_data: "perm:allow:xxx" } — CodePilot permission buttons + // 2. { action: "app_auth_done", operation_id: "xxx" } — OpenClaw-style buttons + this.gateway.registerCardActionHandler(async (data: unknown) => { + const event = data as any; + console.log('[feishu/plugin]', 'Card action raw event:', JSON.stringify(event).slice(0, 500)); + const value = event?.action?.value ?? {}; + // Feishu card.action.trigger v2 callback structure (per official docs): + // event.operator.open_id, event.context.open_chat_id, event.context.open_message_id + // SDK InteractiveCardActionEvent (older type) flattens to: + // event.open_id, event.open_message_id + // WSClient monkey-patch may deliver either format — try both paths. + // Additionally, we embed chatId in button value as ultimate fallback. + const chatId = event?.context?.open_chat_id || value.chatId || ''; + const messageId = event?.context?.open_message_id || event?.open_message_id || ''; + const userId = event?.operator?.open_id || event?.open_id || ''; + + // Format 1: callback_data (permission buttons) + const callbackData = value.callback_data; + if (callbackData && chatId) { + const callbackMsg: InboundMessage = { + messageId: messageId || `card_action_${Date.now()}`, + address: { + channelType: 'feishu', + chatId, + userId, + }, + text: '', + timestamp: Date.now(), + callbackData, + callbackMessageId: messageId, + }; + console.log('[feishu/plugin]', 'Card action (callback_data):', callbackData); + this.enqueueMessage(callbackMsg); + return { + toast: { type: 'info' as const, content: '已收到,正在处理...' }, + }; + } + + // Format 2: action / operation_id (OpenClaw-style buttons) + const action = value.action; + const operationId = value.operation_id; + if (action) { + // Encode as callbackData so the existing bridge-manager callback path + // can handle it. Format: "action:{action}:{operation_id}" + const syntheticCallback = operationId + ? `action:${action}:${operationId}` + : `action:${action}`; + const actionMsg: InboundMessage = { + messageId: messageId || `card_action_${Date.now()}`, + address: { + channelType: 'feishu', + chatId, + userId, + }, + text: '', + timestamp: Date.now(), + callbackData: syntheticCallback, + callbackMessageId: messageId, + }; + console.log('[feishu/plugin]', 'Card action (action):', action, operationId ?? ''); + this.enqueueMessage(actionMsg); + return { + toast: { type: 'info' as const, content: '已收到,正在处理...' }, + }; + } + + // Unknown button format — still return a valid toast to prevent 200340 + console.warn('[feishu/plugin]', 'Unknown card action value:', JSON.stringify(value).slice(0, 200)); + return { + toast: { type: 'info' as const, content: '已收到' }, + }; + }); + + await this.gateway.start(); + } + + async stop(): Promise { + if (this.gateway) { + await this.gateway.stop(); + this.gateway = null; + } + // Unblock any waiting consumer + if (this.waitResolve) { + this.waitResolve(null); + this.waitResolve = null; + } + } + + isRunning(): boolean { + return this.gateway?.isRunning() ?? false; + } + + private enqueueMessage(msg: InboundMessage): void { + if (this.waitResolve) { + const resolve = this.waitResolve; + this.waitResolve = null; + resolve(msg); + } else { + this.messageQueue.push(msg); + } + } + + async consumeOne(): Promise { + if (this.messageQueue.length > 0) { + return this.messageQueue.shift()!; + } + return new Promise((resolve) => { + this.waitResolve = resolve; + }); + } + + async send(message: OutboundMessage): Promise { + const client = this.gateway?.getRestClient(); + if (!client) return { ok: false, error: 'Not connected' }; + return sendMessage(client, message); + } + + isAuthorized(userId: string, chatId: string): boolean { + if (!this.config) return false; + return isUserAuthorized(this.config, userId, chatId); + } + + getCardStreamController(): CardStreamController | null { + const client = this.gateway?.getRestClient(); + if (!client || !this.config?.cardStreamConfig) return null; + return createCardStreamController(client, this.config.cardStreamConfig); + } + + /** Expose gateway for direct access (e.g. message-actions need restClient). */ + get _gateway(): FeishuGateway | null { + return this.gateway; + } +} diff --git a/src/lib/channels/feishu/message-actions.ts b/src/lib/channels/feishu/message-actions.ts new file mode 100644 index 0000000..a839bc5 --- /dev/null +++ b/src/lib/channels/feishu/message-actions.ts @@ -0,0 +1,191 @@ +/** + * Feishu message-actions — readMessages, readThreadMessages, and searchMessagesLocal. + * + * These functions are dynamically imported by bridge-manager for /history and /search commands. + * + * Identity note: All functions here use bot identity (app_access_token) via the + * Lark SDK client. OpenClaw's equivalent (feishu_im_user_*) uses user_access_token + * for richer results and broader access. + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { MessageListResult, FeishuMessageItem } from './types'; + +interface ReadOptions { + pageSize?: number; + pageToken?: string; +} + +interface SearchOptions { + pageSize?: number; + pageToken?: string; +} + +/** + * Read recent messages from a Feishu chat using bot identity (app_access_token). + * + * OpenClaw's equivalent (feishu_im_user_get_chat_messages) uses user_access_token + * for richer results including messages from all participants. + */ +export async function readMessages( + client: lark.Client, + chatId: string, + options: ReadOptions = {}, +): Promise { + const { pageSize = 10, pageToken } = options; + + try { + const resp = await client.im.message.list({ + params: { + container_id_type: 'chat', + container_id: chatId, + page_size: pageSize, + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + + const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({ + messageId: item.message_id || '', + rootId: item.root_id || undefined, + parentId: item.parent_id || undefined, + msgType: item.msg_type || '', + createTime: item.create_time || '', + updateTime: item.update_time || undefined, + content: item.body?.content || '', + sender: { + id: item.sender?.id || '', + idType: item.sender?.id_type || '', + senderType: item.sender?.sender_type || '', + tenantKey: item.sender?.tenant_key || undefined, + }, + })); + + return { + items, + hasMore: resp?.data?.has_more || false, + pageToken: resp?.data?.page_token || undefined, + }; + } catch (err: any) { + console.error('[feishu/message-actions] readMessages failed:', err?.message || err); + return { items: [], hasMore: false }; + } +} + +/** + * Read messages from a Feishu thread (reply chain). + * + * Uses `container_id_type: 'thread'` to fetch all messages in a specific thread. + * OpenClaw's equivalent is feishu_im_user_get_thread_messages. + */ +export async function readThreadMessages( + client: lark.Client, + threadId: string, + options: ReadOptions = {}, +): Promise { + const { pageSize = 10, pageToken } = options; + + try { + const resp = await client.im.message.list({ + params: { + container_id_type: 'thread', + container_id: threadId, + page_size: pageSize, + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + + const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({ + messageId: item.message_id || '', + rootId: item.root_id || undefined, + parentId: item.parent_id || undefined, + msgType: item.msg_type || '', + createTime: item.create_time || '', + updateTime: item.update_time || undefined, + content: item.body?.content || '', + sender: { + id: item.sender?.id || '', + idType: item.sender?.id_type || '', + senderType: item.sender?.sender_type || '', + tenantKey: item.sender?.tenant_key || undefined, + }, + })); + + return { + items, + hasMore: resp?.data?.has_more || false, + pageToken: resp?.data?.page_token || undefined, + }; + } catch (err: any) { + console.error('[feishu/message-actions] readThreadMessages failed:', err?.message || err); + return { items: [], hasMore: false }; + } +} + +/** + * Simplified local search — lists recent messages and filters client-side. + * + * Unlike OpenClaw's feishu_im_user_search_messages which uses the search.message.create + * API with user_access_token for true cross-chat server-side search, this function + * only fetches recent messages from a single chat and applies a client-side keyword filter. + * Results are limited and pagination is not reliable. + */ +export async function searchMessagesLocal( + client: lark.Client, + chatId: string, + query: string, + options: SearchOptions = {}, +): Promise { + const { pageSize = 10, pageToken } = options; + + try { + // Use im.message.list and filter client-side. + // True server-side search requires user_access_token (search.message.create API). + const resp = await client.im.message.list({ + params: { + container_id_type: 'chat', + container_id: chatId, + page_size: Math.min(pageSize * 5, 50), // fetch more to filter + sort_type: 'ByCreateTimeDesc' as any, + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + + const allItems: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({ + messageId: item.message_id || '', + rootId: item.root_id || undefined, + parentId: item.parent_id || undefined, + msgType: item.msg_type || '', + createTime: item.create_time || '', + updateTime: item.update_time || undefined, + content: item.body?.content || '', + sender: { + id: item.sender?.id || '', + idType: item.sender?.id_type || '', + senderType: item.sender?.sender_type || '', + tenantKey: item.sender?.tenant_key || undefined, + }, + })); + + // Client-side keyword filter + const lowerQuery = query.toLowerCase(); + const filtered = allItems.filter(item => { + try { + const parsed = JSON.parse(item.content); + return (parsed.text || '').toLowerCase().includes(lowerQuery); + } catch { + return item.content.toLowerCase().includes(lowerQuery); + } + }).slice(0, pageSize); + + return { + items: filtered, + hasMore: false, // can't paginate client-side filter reliably + }; + } catch (err: any) { + console.error('[feishu/message-actions] searchMessagesLocal failed:', err?.message || err); + return { items: [], hasMore: false }; + } +} + +/** Backward-compatible alias for searchMessagesLocal. */ +export const searchMessages = searchMessagesLocal; diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts new file mode 100644 index 0000000..99a72ae --- /dev/null +++ b/src/lib/channels/feishu/outbound.ts @@ -0,0 +1,183 @@ +/** + * Feishu outbound message rendering and sending. + * + * Missing capabilities compared to OpenClaw's ChannelMessageActionAdapter: + * - No react/unreact support (add/remove emoji reactions) + * - No message delete/unsend support + * - No user identity (user_access_token) sending — bot identity only + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { OutboundMessage, SendResult } from '../../bridge/types'; + +const LOG_TAG = '[feishu/outbound]'; + +/** + * Send a message to Feishu using bot identity (app_access_token). + * + * If the message contains inlineButtons, it is rendered as an interactive card + * (Schema V2 with column_set + button layout). Otherwise it's sent as plain text. + * + * OpenClaw supports both bot and user identity via its ChannelMessageActionAdapter, + * allowing messages to be sent as the authenticated user when user_access_token is available. + * + * This handles static message delivery — card streaming is handled upstream by bridge-manager. + */ +export async function sendMessage( + client: lark.Client, + message: OutboundMessage, +): Promise { + try { + // Extract real chat ID (strip thread prefix if present) + const chatId = message.address.chatId.split(':thread:')[0]; + const replyId = message.replyToMessageId; + + // If message has inline buttons, send as interactive card + if (message.inlineButtons && message.inlineButtons.length > 0) { + return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId); + } + + const content = JSON.stringify({ text: message.text }); + + let resp: any; + if (replyId) { + resp = await client.im.message.reply({ + path: { message_id: replyId }, + data: { + content, + msg_type: 'text', + }, + }); + } else { + resp = await client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + content, + msg_type: 'text', + }, + }); + } + + const msgId = resp?.data?.message_id || ''; + return { ok: true, messageId: msgId }; + } catch (err: any) { + console.error(LOG_TAG, 'Send failed:', err?.message || err); + return { ok: false, error: err?.message || 'Unknown error' }; + } +} + +/** + * Build and send a Feishu interactive card with buttons. + * + * Uses Schema V2 with column_set + column + button layout because + * Schema V2 does NOT support the `action` tag directly. + */ +async function sendAsInteractiveCard( + client: lark.Client, + chatId: string, + text: string, + inlineButtons: import('../../bridge/types').InlineButton[][], + replyToMessageId?: string, +): Promise { + try { + // Strip HTML tags for Feishu (convert to **, to `,
 to ```)
+    const mdText = text
+      .replace(/(.*?)<\/b>/gi, '**$1**')
+      .replace(/(.*?)<\/code>/gi, '`$1`')
+      .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+      .replace(/<\/?[^>]+>/g, '');
+
+    // Build button elements using column_set layout (Schema V2 compatible)
+    const buttonRows = inlineButtons.map((row) => ({
+      tag: 'column_set' as const,
+      flex_mode: 'stretch' as const,
+      columns: row.map((btn) => ({
+        tag: 'column' as const,
+        width: 'weighted' as const,
+        weight: 1,
+        elements: [{
+          tag: 'button' as const,
+          text: { tag: 'plain_text' as const, content: btn.text },
+          type: 'primary' as const,
+          value: { callback_data: btn.callbackData, chatId },
+        }],
+      })),
+    }));
+
+    const card = {
+      schema: '2.0',
+      config: { wide_screen_mode: true },
+      body: {
+        elements: [
+          { tag: 'markdown', content: mdText },
+          ...buttonRows,
+        ],
+      },
+    };
+
+    const cardContent = JSON.stringify(card);
+
+    let resp: any;
+    if (replyToMessageId) {
+      resp = await client.im.message.reply({
+        path: { message_id: replyToMessageId },
+        data: { content: cardContent, msg_type: 'interactive' },
+      });
+    } else {
+      resp = await client.im.message.create({
+        params: { receive_id_type: 'chat_id' },
+        data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' },
+      });
+    }
+
+    const msgId = resp?.data?.message_id || '';
+    console.log(LOG_TAG, 'Sent interactive card:', msgId);
+    return { ok: true, messageId: msgId };
+  } catch (err: any) {
+    console.error(LOG_TAG, 'Interactive card send failed:', err?.message || err);
+    return { ok: false, error: err?.message || 'Unknown error' };
+  }
+}
+
+/**
+ * Send a permission card with interactive buttons using bot identity (app_access_token).
+ *
+ * OpenClaw supports both bot and user identity for card delivery.
+ */
+export async function sendPermissionCard(
+  client: lark.Client,
+  chatId: string,
+  cardContent: string,
+  replyToMessageId?: string,
+): Promise {
+  try {
+    const realChatId = chatId.split(':thread:')[0];
+
+    let resp: any;
+    if (replyToMessageId) {
+      resp = await client.im.message.reply({
+        path: { message_id: replyToMessageId },
+        data: {
+          content: cardContent,
+          msg_type: 'interactive',
+        },
+      });
+    } else {
+      resp = await client.im.message.create({
+        params: { receive_id_type: 'chat_id' },
+        data: {
+          receive_id: realChatId,
+          content: cardContent,
+          msg_type: 'interactive',
+        },
+      });
+    }
+
+    const msgId = resp?.data?.message_id || '';
+    return { ok: true, messageId: msgId };
+  } catch (err: any) {
+    console.error(LOG_TAG, 'Permission card send failed:', err?.message || err);
+    return { ok: false, error: err?.message || 'Unknown error' };
+  }
+}
diff --git a/src/lib/channels/feishu/policy.ts b/src/lib/channels/feishu/policy.ts
new file mode 100644
index 0000000..68900ad
--- /dev/null
+++ b/src/lib/channels/feishu/policy.ts
@@ -0,0 +1,50 @@
+/**
+ * Feishu access control and group policy.
+ */
+
+import type { FeishuConfig } from './types';
+
+/**
+ * Check if a user is authorized based on the current config.
+ *
+ * DM policy logic:
+ * - disabled → false
+ * - open → allowFrom must include '*' or user's open_id
+ * - allowlist → allowFrom must include user's open_id
+ * - pairing → always true (pairing handled elsewhere)
+ *
+ * Group chat logic (chatId starts with 'oc_'):
+ * - groupPolicy === 'disabled' → false
+ * - groupPolicy === 'allowlist' → groupAllowFrom must include chatId
+ * - groupPolicy === 'open' → true
+ */
+export function isUserAuthorized(
+  config: FeishuConfig,
+  userId: string,
+  chatId: string,
+): boolean {
+  // Group chat check
+  if (chatId.startsWith('oc_')) {
+    if (config.groupPolicy === 'disabled') return false;
+    if (config.groupPolicy === 'allowlist') {
+      if (!config.groupAllowFrom.includes(chatId)) return false;
+    }
+    // groupPolicy === 'open' → allowed
+    return true;
+  }
+
+  // DM policy check
+  if (config.dmPolicy === 'disabled') return false;
+  if (config.dmPolicy === 'pairing') return true;
+
+  if (config.dmPolicy === 'open') {
+    if (config.allowFrom.length === 0) return true;
+    return config.allowFrom.includes('*') || config.allowFrom.includes(userId);
+  }
+
+  if (config.dmPolicy === 'allowlist') {
+    return config.allowFrom.includes(userId);
+  }
+
+  return false;
+}
diff --git a/src/lib/channels/feishu/types.ts b/src/lib/channels/feishu/types.ts
new file mode 100644
index 0000000..d3ce4b0
--- /dev/null
+++ b/src/lib/channels/feishu/types.ts
@@ -0,0 +1,85 @@
+/**
+ * Feishu channel internal types and constants.
+ */
+
+export interface FeishuConfig {
+  appId: string;
+  appSecret: string;
+  /** SDK domain: 'feishu' | 'lark' or a custom base URL. */
+  domain: string;
+  /** Encrypt key from Feishu app (used by EventDispatcher for card callbacks). */
+  encryptKey: string;
+  /** Verification token from Feishu app (used by EventDispatcher for card callbacks). */
+  verificationToken: string;
+  /** Allowed user IDs (open_id). ['*'] means all users. */
+  allowFrom: string[];
+  /** Allowed group chat IDs. */
+  groupAllowFrom: string[];
+  /** DM policy: open / pairing / allowlist / disabled */
+  dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled';
+  /** Group policy: open / allowlist / disabled */
+  groupPolicy: 'open' | 'allowlist' | 'disabled';
+  /** Rendering mode: auto / static / streaming */
+  renderMode: 'auto' | 'static' | 'streaming';
+  /** Whether to require @mention in group chats */
+  requireMention: boolean;
+  /** Whether to use per-thread sessions */
+  threadSession: boolean;
+  /** Card streaming config (null = disabled) */
+  cardStreamConfig: CardStreamConfig | null;
+  /** Block streaming coalesce config (null = disabled) */
+  blockStreamingCoalesce: BlockStreamingCoalesce | null;
+  /** Footer config (null = disabled) */
+  footer: FooterConfig | null;
+}
+
+export interface CardStreamConfig {
+  /** Throttle interval for stream updates (ms) */
+  throttleMs: number;
+  /** Footer display options (status line and elapsed time) */
+  footer?: {
+    status: boolean;
+    elapsed: boolean;
+  };
+}
+
+export interface BlockStreamingCoalesce {
+  minChars: number;
+  maxChars: number;
+  idleMs: number;
+}
+
+export interface FooterConfig {
+  status: boolean;
+  elapsed: boolean;
+}
+
+export interface FeishuBotInfo {
+  appId: string;
+  botName: string;
+  openId: string;
+}
+
+/** Feishu message item from list/search API */
+export interface FeishuMessageItem {
+  messageId: string;
+  rootId?: string;
+  parentId?: string;
+  msgType: string;
+  createTime: string;
+  updateTime?: string;
+  content: string;
+  sender: {
+    id: string;
+    idType: string;
+    senderType: string;
+    tenantKey?: string;
+  };
+}
+
+/** Result from readMessages / searchMessages */
+export interface MessageListResult {
+  items: FeishuMessageItem[];
+  hasMore: boolean;
+  pageToken?: string;
+}
diff --git a/src/lib/channels/types.ts b/src/lib/channels/types.ts
new file mode 100644
index 0000000..5473f48
--- /dev/null
+++ b/src/lib/channels/types.ts
@@ -0,0 +1,112 @@
+/**
+ * Channel plugin system types.
+ *
+ * These interfaces define the contract between channel plugins (Feishu, etc.)
+ * and the bridge system via ChannelPluginAdapter.
+ */
+
+import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '../bridge/types';
+
+// ── Card Stream Controller ──────────────────────────────────────
+
+/** Controls a streaming card lifecycle (create → update → finalize). */
+export interface CardStreamController {
+  /**
+   * Create a streaming card in the given chat.
+   * @param chatId Target chat ID
+   * @param initialText Initial markdown content
+   * @param replyToMessageId Optional message to reply to
+   * @returns The platform message ID, or empty string if creation failed (triggers fallback)
+   */
+  create(chatId: string, initialText: string, replyToMessageId?: string): Promise;
+
+  /**
+   * Stream-update the card content (throttled internally).
+   * @returns 'ok' on success, 'fail' on error
+   */
+  update(messageId: string, text: string): Promise<'ok' | 'fail'>;
+
+  /**
+   * Finalize the card with the complete content and close streaming mode.
+   * @param status 'completed' | 'interrupted' | 'error'
+   */
+  finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise;
+}
+
+// ── Channel Capabilities ────────────────────────────────────────
+
+export interface ChannelCapabilities {
+  /** Whether the channel supports streaming card updates */
+  streaming: boolean;
+  /** Whether the channel supports thread replies */
+  threadReply: boolean;
+  /** Whether the channel supports message search */
+  search: boolean;
+  /** Whether the channel supports reading message history */
+  history: boolean;
+  /** Whether the channel supports reactions */
+  reactions: boolean;
+}
+
+// ── Channel Meta ────────────────────────────────────────────────
+
+export interface ChannelMeta {
+  channelType: ChannelType;
+  displayName: string;
+}
+
+// ── Probe Result ────────────────────────────────────────────────
+
+export interface ProbeResult {
+  ok: boolean;
+  error?: string;
+  botName?: string;
+  botId?: string;
+}
+
+// ── Channel Plugin ──────────────────────────────────────────────
+
+/**
+ * Plugin interface for a channel implementation.
+ * Generic parameter T is the plugin's config type.
+ */
+export interface ChannelPlugin {
+  readonly meta: ChannelMeta;
+
+  /** Load and validate configuration. */
+  loadConfig(): T | null;
+
+  /** Get current capabilities (may depend on config). */
+  getCapabilities(): ChannelCapabilities;
+
+  /** Start the plugin (connect WS, start polling, etc.). */
+  start(): Promise;
+
+  /** Stop the plugin gracefully. */
+  stop(): Promise;
+
+  /** Whether the plugin is currently running. */
+  isRunning(): boolean;
+
+  /** Consume the next inbound message. Blocks until available or stopped. */
+  consumeOne(): Promise;
+
+  /** Send an outbound message. */
+  send(message: OutboundMessage): Promise;
+
+  /** Validate config completeness. Returns null if valid, error message otherwise. */
+  validateConfig(): string | null;
+
+  /** Check user authorization. */
+  isAuthorized(userId: string, chatId: string): boolean;
+
+  /** Optional: get a card stream controller. */
+  getCardStreamController?(): CardStreamController | null;
+
+  /** Optional: probe connectivity. */
+  probe?(): Promise;
+
+  /** Optional: lifecycle hooks. */
+  onMessageStart?(chatId: string): void;
+  onMessageEnd?(chatId: string): void;
+}
diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts
index 57345f2..a0c21db 100644
--- a/src/lib/claude-client.ts
+++ b/src/lib/claude-client.ts
@@ -693,7 +693,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream = [];
 
@@ -702,7 +702,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream c.type === 'text')
-                            .map((c: { text: string }) => c.text)
+                            .filter((c: any) => c.type === 'text')
+                            .map((c: any) => c.text)
                             .join('\n')
                         : String(block.content ?? '');
                     controller.enqueue(formatSSE({

From 647fa041d18979bb75053ef68cb24da612ea81be Mon Sep 17 00:00:00 2001
From: 7418 <7418@openclaw.ai>
Date: Sat, 14 Mar 2026 11:10:01 +0800
Subject: [PATCH 3/5] =?UTF-8?q?feat:=20improve=20Feishu=20messaging=20?=
 =?UTF-8?q?=E2=80=94=20markdown=20rendering,=20card=20styling,=20reaction?=
 =?UTF-8?q?=20lifecycle?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

outbound.ts:
- Switch from msg_type 'text' to 'post' with {tag: 'md'} for markdown rendering
- Add optimizeMarkdown(): heading demotion, table spacing, code block padding, image key stripping
- Redesign permission card: blue header with lock icon, notation-sized timeout hint,
  column_set button layout (Schema V2 compatible, 'action' tag deprecated)
- Add addReaction/removeReaction for emoji acknowledgment lifecycle

index.ts:
- Implement onMessageStart: add 'OnIt' emoji on message receipt
- Implement onMessageEnd: remove reaction after response sent
- Track lastMessageIdByChat for reaction targeting

Co-Authored-By: Claude Opus 4.6 
---
 src/lib/channels/feishu/index.ts    |  32 ++-
 src/lib/channels/feishu/outbound.ts | 309 +++++++++++++++++++++-------
 2 files changed, 265 insertions(+), 76 deletions(-)

diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts
index 522c412..9964baa 100644
--- a/src/lib/channels/feishu/index.ts
+++ b/src/lib/channels/feishu/index.ts
@@ -11,7 +11,7 @@ import type { FeishuConfig } from './types';
 import { loadFeishuConfig, validateFeishuConfig } from './config';
 import { FeishuGateway } from './gateway';
 import { parseInboundMessage } from './inbound';
-import { sendMessage } from './outbound';
+import { sendMessage, addReaction, removeReaction } from './outbound';
 import { isUserAuthorized } from './policy';
 import { createCardStreamController } from './card-controller';
 
@@ -25,6 +25,10 @@ export class FeishuChannelPlugin implements ChannelPlugin {
   private gateway: FeishuGateway | null = null;
   private messageQueue: InboundMessage[] = [];
   private waitResolve: ((msg: InboundMessage | null) => void) | null = null;
+  /** Track last received messageId per chatId for reaction acknowledgment. */
+  private lastMessageIdByChat = new Map();
+  /** Track active reaction IDs per chatId so we can remove them on completion. */
+  private activeReactions = new Map();
 
   loadConfig(): FeishuConfig | null {
     this.config = loadFeishuConfig();
@@ -163,6 +167,10 @@ export class FeishuChannelPlugin implements ChannelPlugin {
   }
 
   private enqueueMessage(msg: InboundMessage): void {
+    // Track messageId for reaction acknowledgment (skip callback messages)
+    if (msg.messageId && !msg.callbackData) {
+      this.lastMessageIdByChat.set(msg.address.chatId, msg.messageId);
+    }
     if (this.waitResolve) {
       const resolve = this.waitResolve;
       this.waitResolve = null;
@@ -192,6 +200,28 @@ export class FeishuChannelPlugin implements ChannelPlugin {
     return isUserAuthorized(this.config, userId, chatId);
   }
 
+  /** Add emoji reaction to acknowledge message receipt. */
+  onMessageStart(chatId: string): void {
+    const client = this.gateway?.getRestClient();
+    const messageId = this.lastMessageIdByChat.get(chatId);
+    if (!client || !messageId) return;
+    // Fire-and-forget — don't block message processing
+    addReaction(client, messageId, 'OnIt').then((reactionId) => {
+      if (reactionId) {
+        this.activeReactions.set(chatId, { messageId, reactionId });
+      }
+    }).catch(() => {});
+  }
+
+  /** Remove the "processing" reaction after response is sent. */
+  onMessageEnd(chatId: string): void {
+    const client = this.gateway?.getRestClient();
+    const reaction = this.activeReactions.get(chatId);
+    if (!client || !reaction) return;
+    this.activeReactions.delete(chatId);
+    removeReaction(client, reaction.messageId, reaction.reactionId).catch(() => {});
+  }
+
   getCardStreamController(): CardStreamController | null {
     const client = this.gateway?.getRestClient();
     if (!client || !this.config?.cardStreamConfig) return null;
diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts
index 99a72ae..fb2520b 100644
--- a/src/lib/channels/feishu/outbound.ts
+++ b/src/lib/channels/feishu/outbound.ts
@@ -1,10 +1,11 @@
 /**
  * Feishu outbound message rendering and sending.
  *
- * Missing capabilities compared to OpenClaw's ChannelMessageActionAdapter:
- * - No react/unreact support (add/remove emoji reactions)
- * - No message delete/unsend support
- * - No user identity (user_access_token) sending — bot identity only
+ * Text messages use `post` format with `md` tag for markdown rendering.
+ * Card messages (buttons, permissions) use Schema V2 interactive cards.
+ *
+ * Markdown is optimized for Feishu: heading demotion, table spacing,
+ * code block padding, and invalid image key stripping.
  */
 
 import type * as lark from '@larksuiteoapi/node-sdk';
@@ -12,66 +13,156 @@ import type { OutboundMessage, SendResult } from '../../bridge/types';
 
 const LOG_TAG = '[feishu/outbound]';
 
+// ─── Markdown optimization for Feishu ────────────────────────────────────────
+
 /**
- * Send a message to Feishu using bot identity (app_access_token).
+ * Optimize markdown for Feishu rendering compatibility.
  *
- * If the message contains inlineButtons, it is rendered as an interactive card
- * (Schema V2 with column_set + button layout). Otherwise it's sent as plain text.
+ * Based on OpenClaw's markdown-style.ts:
+ * - Demote headings: H1 → H4, H2-H6 → H5 (Feishu renders H1-H3 too large)
+ * - Add spacing around tables with 
tags + * - Pad code blocks with
for visual separation + * - Strip invalid image keys (prevent CardKit error 200570) + * - Compress excessive newlines (3+ → 2) + */ +function optimizeMarkdown(text: string): string { + try { + return _optimizeMarkdown(text); + } catch { + return text; + } +} + +function _optimizeMarkdown(text: string): string { + // 1. Extract code blocks — protect from transformation + const MARK = '___CB_'; + const codeBlocks: string[] = []; + let r = text.replace(/```[\s\S]*?```/g, (m) => { + return `${MARK}${codeBlocks.push(m) - 1}___`; + }); + + // 2. Heading demotion (only if H1-H3 exist) + const hasLargeHeadings = /^#{1,3} /m.test(r); + if (hasLargeHeadings) { + r = r.replace(/^#{2,6} (.+)$/gm, '##### $1'); // H2-H6 → H5 + r = r.replace(/^# (.+)$/gm, '#### $1'); // H1 → H4 + } + + // 3. Spacing between consecutive headings + r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n
\n$2'); + + // 4. Table spacing — add
before/after table blocks + r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2'); + r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n
\n\n$1'); + r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '$1\n
\n'); + + // 5. Restore code blocks with
padding + codeBlocks.forEach((block, i) => { + r = r.replace(`${MARK}${i}___`, `\n
\n${block}\n
\n`); + }); + + // 6. Strip invalid image keys (only allow img_xxx and http(s) URLs) + if (r.includes('![')) { + r = r.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_full, _alt, value) => { + if (value.startsWith('img_') || value.startsWith('http://') || value.startsWith('https://')) { + return _full; + } + return value; + }); + } + + // 7. Compress excessive newlines + r = r.replace(/\n{3,}/g, '\n\n'); + + return r; +} + +/** + * Convert HTML-formatted text (from permission-broker) to Feishu markdown. + */ +function htmlToFeishuMarkdown(text: string): string { + return text + .replace(/(.*?)<\/b>/gi, '**$1**') + .replace(/(.*?)<\/code>/gi, '`$1`') + .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+    .replace(/<\/?[^>]+>/g, '');
+}
+
+// ─── Message sending ─────────────────────────────────────────────────────────
+
+/**
+ * Send a message to Feishu.
  *
- * OpenClaw supports both bot and user identity via its ChannelMessageActionAdapter,
- * allowing messages to be sent as the authenticated user when user_access_token is available.
- *
- * This handles static message delivery — card streaming is handled upstream by bridge-manager.
+ * - With inlineButtons → interactive card (Schema V2)
+ * - Without → post format with md tag (supports markdown rendering)
  */
 export async function sendMessage(
   client: lark.Client,
   message: OutboundMessage,
 ): Promise {
   try {
-    // Extract real chat ID (strip thread prefix if present)
     const chatId = message.address.chatId.split(':thread:')[0];
     const replyId = message.replyToMessageId;
 
-    // If message has inline buttons, send as interactive card
+    // Interactive card for messages with buttons
     if (message.inlineButtons && message.inlineButtons.length > 0) {
       return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId);
     }
 
-    const content = JSON.stringify({ text: message.text });
-
-    let resp: any;
-    if (replyId) {
-      resp = await client.im.message.reply({
-        path: { message_id: replyId },
-        data: {
-          content,
-          msg_type: 'text',
-        },
-      });
-    } else {
-      resp = await client.im.message.create({
-        params: { receive_id_type: 'chat_id' },
-        data: {
-          receive_id: chatId,
-          content,
-          msg_type: 'text',
-        },
-      });
-    }
-
-    const msgId = resp?.data?.message_id || '';
-    return { ok: true, messageId: msgId };
+    // Post format with md tag for markdown support
+    return sendAsPost(client, chatId, message.text, message.parseMode, replyId);
   } catch (err: any) {
     console.error(LOG_TAG, 'Send failed:', err?.message || err);
     return { ok: false, error: err?.message || 'Unknown error' };
   }
 }
 
+/**
+ * Send a message using post format with md tag.
+ * This enables markdown rendering in Feishu (bold, code, lists, tables, etc).
+ */
+async function sendAsPost(
+  client: lark.Client,
+  chatId: string,
+  text: string,
+  parseMode?: string,
+  replyToMessageId?: string,
+): Promise {
+  // Convert HTML to markdown if needed, then optimize for Feishu
+  let mdText = parseMode === 'HTML' ? htmlToFeishuMarkdown(text) : text;
+  mdText = optimizeMarkdown(mdText);
+
+  const content = JSON.stringify({
+    zh_cn: {
+      content: [[{ tag: 'md', text: mdText }]],
+    },
+  });
+
+  let resp: any;
+  if (replyToMessageId) {
+    resp = await client.im.message.reply({
+      path: { message_id: replyToMessageId },
+      data: { content, msg_type: 'post' },
+    });
+  } else {
+    resp = await client.im.message.create({
+      params: { receive_id_type: 'chat_id' },
+      data: { receive_id: chatId, content, msg_type: 'post' },
+    });
+  }
+
+  const msgId = resp?.data?.message_id || '';
+  return { ok: true, messageId: msgId };
+}
+
+// ─── Interactive cards ───────────────────────────────────────────────────────
+
 /**
  * Build and send a Feishu interactive card with buttons.
  *
- * Uses Schema V2 with column_set + column + button layout because
- * Schema V2 does NOT support the `action` tag directly.
+ * Uses Schema V2 with:
+ * - markdown element for text content
+ * - action element for buttons (Allow = primary, Deny = danger)
  */
 async function sendAsInteractiveCard(
   client: lark.Client,
@@ -81,37 +172,72 @@ async function sendAsInteractiveCard(
   replyToMessageId?: string,
 ): Promise {
   try {
-    // Strip HTML tags for Feishu (convert  to **,  to `, 
 to ```)
-    const mdText = text
-      .replace(/(.*?)<\/b>/gi, '**$1**')
-      .replace(/(.*?)<\/code>/gi, '`$1`')
-      .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
-      .replace(/<\/?[^>]+>/g, '');
+    const mdText = htmlToFeishuMarkdown(text);
 
-    // Build button elements using column_set layout (Schema V2 compatible)
-    const buttonRows = inlineButtons.map((row) => ({
-      tag: 'column_set' as const,
-      flex_mode: 'stretch' as const,
-      columns: row.map((btn) => ({
+    // Build button columns — Schema V2 doesn't support 'action' tag,
+    // use column_set with individual button elements instead
+    const buttonColumns = inlineButtons.flat().map((btn) => {
+      let btnType: 'primary' | 'danger' | 'default' = 'default';
+      const lowerText = btn.text.toLowerCase();
+      if (lowerText.includes('deny') || lowerText.includes('拒绝')) {
+        btnType = 'danger';
+      } else if (lowerText.includes('allow') || lowerText.includes('允许')) {
+        btnType = 'primary';
+      }
+
+      return {
         tag: 'column' as const,
-        width: 'weighted' as const,
-        weight: 1,
-        elements: [{
-          tag: 'button' as const,
-          text: { tag: 'plain_text' as const, content: btn.text },
-          type: 'primary' as const,
-          value: { callback_data: btn.callbackData, chatId },
-        }],
-      })),
-    }));
+        width: 'auto' as const,
+        elements: [
+          {
+            tag: 'button' as const,
+            text: { tag: 'plain_text' as const, content: btn.text },
+            type: btnType,
+            size: 'medium' as const,
+            value: { callback_data: btn.callbackData, chatId },
+          },
+        ],
+      };
+    });
 
     const card = {
       schema: '2.0',
-      config: { wide_screen_mode: true },
+      config: {
+        wide_screen_mode: true,
+        style: {
+          color: {
+            'light-orange-bg': {
+              light_mode: 'rgba(255, 214, 102, 0.12)',
+              dark_mode: 'rgba(255, 214, 102, 0.08)',
+            },
+          },
+        },
+      },
+      header: {
+        title: { tag: 'plain_text' as const, content: 'Permission Required' },
+        template: 'blue' as const,
+        icon: { tag: 'standard_icon' as const, token: 'lock-chat_filled' },
+        padding: '12px 12px 12px 12px',
+      },
       body: {
         elements: [
-          { tag: 'markdown', content: mdText },
-          ...buttonRows,
+          {
+            tag: 'markdown' as const,
+            content: mdText,
+            text_size: 'normal' as const,
+          },
+          {
+            tag: 'markdown' as const,
+            content: '⏱ This request will expire in 5 minutes',
+            text_size: 'notation' as const,
+          },
+          { tag: 'hr' as const },
+          {
+            tag: 'column_set' as const,
+            flex_mode: 'none' as const,
+            horizontal_align: 'left' as const,
+            columns: buttonColumns,
+          },
         ],
       },
     };
@@ -140,10 +266,50 @@ async function sendAsInteractiveCard(
   }
 }
 
+// ─── Reactions ──────────────────────────────────────────────────────────────
+
 /**
- * Send a permission card with interactive buttons using bot identity (app_access_token).
- *
- * OpenClaw supports both bot and user identity for card delivery.
+ * Add an emoji reaction to a Feishu message.
+ * Used as a "message received" acknowledgment before processing starts.
+ */
+export async function addReaction(
+  client: lark.Client,
+  messageId: string,
+  emojiType: string = 'OnIt',
+): Promise {
+  try {
+    const resp = await client.im.messageReaction.create({
+      path: { message_id: messageId },
+      data: { reaction_type: { emoji_type: emojiType } },
+    });
+    return (resp?.data as any)?.reaction_id || null;
+  } catch (err: any) {
+    // Non-critical — log and swallow
+    console.warn(LOG_TAG, 'Failed to add reaction:', err?.message || err);
+    return null;
+  }
+}
+
+/**
+ * Remove an emoji reaction from a Feishu message.
+ * Used to clear the "processing" indicator after response is sent.
+ */
+export async function removeReaction(
+  client: lark.Client,
+  messageId: string,
+  reactionId: string,
+): Promise {
+  try {
+    await client.im.messageReaction.delete({
+      path: { message_id: messageId, reaction_id: reactionId },
+    });
+  } catch (err: any) {
+    console.warn(LOG_TAG, 'Failed to remove reaction:', err?.message || err);
+  }
+}
+
+/**
+ * Send a pre-built card (for permission cards or custom cards).
  */
 export async function sendPermissionCard(
   client: lark.Client,
@@ -158,19 +324,12 @@ export async function sendPermissionCard(
     if (replyToMessageId) {
       resp = await client.im.message.reply({
         path: { message_id: replyToMessageId },
-        data: {
-          content: cardContent,
-          msg_type: 'interactive',
-        },
+        data: { content: cardContent, msg_type: 'interactive' },
       });
     } else {
       resp = await client.im.message.create({
         params: { receive_id_type: 'chat_id' },
-        data: {
-          receive_id: realChatId,
-          content: cardContent,
-          msg_type: 'interactive',
-        },
+        data: { receive_id: realChatId, content: cardContent, msg_type: 'interactive' },
       });
     }
 

From cdb4155771a2775d08e4db0d734bf0ea350c0577 Mon Sep 17 00:00:00 2001
From: 7418 <7418@openclaw.ai>
Date: Sat, 14 Mar 2026 11:30:58 +0800
Subject: [PATCH 4/5] =?UTF-8?q?feat:=20Feishu=20chat=20bridge=20=E2=80=94?=
 =?UTF-8?q?=20strip=20MCP=20residuals,=20enhance=20streaming=20cards,=20ad?=
 =?UTF-8?q?d=20tool=20progress?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Phase 1: Strip Feishu MCP residuals
- Remove 'feishu' server entry from .mcp.json
- Remove @codepilot/feishu-mcp and packages/feishu-mcp from package-lock.json
- General MCP infrastructure (McpManager, /api/plugins/mcp) preserved

Phase 2: Streaming card improvements
- card-controller.ts: Add thinking state (💭 Thinking...), tool call progress
  display (🔄/✅/❌), better finalize with status footer (✅/⚠️/❌ + elapsed time),
  markdown optimization on final card, proper sequence numbering for setStreamingMode
- conversation-engine.ts: Add OnToolEvent callback type, fire tool_use/tool_result
  events during stream consumption
- bridge-manager.ts: Wire tool event tracking to card controller, pass proper status
  (completed/interrupted/error) to finalize, render errors in card when streaming

Phase 3: Typing indicator & UX
- Change reaction emoji from 'OnIt' to 'Typing' (OpenClaw convention)
- Improve /help and /start command output with grouped sections
- Export optimizeMarkdown() for reuse in card-controller

types.ts:
- Add ToolCallInfo interface for card tool progress
- Add updateToolCalls?() and setThinking?() to CardStreamController

Tests: 374 pass, 0 fail
TypeScript: clean (npx tsc --noEmit)

Co-Authored-By: Claude Opus 4.6 
---
 .mcp.json                                  |  10 --
 package-lock.json                          | 159 ------------------
 src/lib/bridge/bridge-manager.ts           |  81 +++++----
 src/lib/bridge/conversation-engine.ts      |  16 +-
 src/lib/channels/feishu/card-controller.ts | 181 +++++++++++++++------
 src/lib/channels/feishu/index.ts           |   2 +-
 src/lib/channels/feishu/outbound.ts        |   2 +-
 src/lib/channels/types.ts                  |  13 ++
 8 files changed, 211 insertions(+), 253 deletions(-)

diff --git a/.mcp.json b/.mcp.json
index 93bc19c..ec1ba23 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -8,16 +8,6 @@
         "chrome-devtools-mcp@latest"
       ],
       "env": {}
-    },
-    "feishu": {
-      "command": "node",
-      "args": ["packages/feishu-mcp/dist/index.js"],
-      "env": {
-        "FEISHU_APP_ID": "${bridge_feishu_app_id}",
-        "FEISHU_APP_SECRET": "${bridge_feishu_app_secret}",
-        "FEISHU_DOMAIN": "${bridge_feishu_domain}",
-        "FEISHU_USER_ACCESS_TOKEN": "${bridge_feishu_user_token}"
-      }
     }
   }
 }
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 16e3220..6110782 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2132,10 +2132,6 @@
       "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
       "license": "Apache-2.0"
     },
-    "node_modules/@codepilot/feishu-mcp": {
-      "resolved": "packages/feishu-mcp",
-      "link": true
-    },
     "node_modules/@codepilot/site": {
       "resolved": "apps/site",
       "link": true
@@ -27215,161 +27211,6 @@
         "type": "github",
         "url": "https://github.com/sponsors/wooorm"
       }
-    },
-    "packages/feishu-mcp": {
-      "name": "@codepilot/feishu-mcp",
-      "version": "0.1.0",
-      "dependencies": {
-        "@anthropic-ai/sdk": "^0.39.0"
-      },
-      "bin": {
-        "feishu-mcp": "dist/index.js"
-      },
-      "devDependencies": {
-        "tsup": "^8.0.0",
-        "tsx": "^4.0.0"
-      }
-    },
-    "packages/feishu-mcp/node_modules/postcss-load-config": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
-      "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "lilconfig": "^3.1.1"
-      },
-      "engines": {
-        "node": ">= 18"
-      },
-      "peerDependencies": {
-        "jiti": ">=1.21.0",
-        "postcss": ">=8.0.9",
-        "tsx": "^4.8.1",
-        "yaml": "^2.4.2"
-      },
-      "peerDependenciesMeta": {
-        "jiti": {
-          "optional": true
-        },
-        "postcss": {
-          "optional": true
-        },
-        "tsx": {
-          "optional": true
-        },
-        "yaml": {
-          "optional": true
-        }
-      }
-    },
-    "packages/feishu-mcp/node_modules/resolve-from": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
-      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "packages/feishu-mcp/node_modules/source-map": {
-      "version": "0.7.6",
-      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
-      "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">= 12"
-      }
-    },
-    "packages/feishu-mcp/node_modules/tinyexec": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
-      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "packages/feishu-mcp/node_modules/tsup": {
-      "version": "8.5.1",
-      "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
-      "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "bundle-require": "^5.1.0",
-        "cac": "^6.7.14",
-        "chokidar": "^4.0.3",
-        "consola": "^3.4.0",
-        "debug": "^4.4.0",
-        "esbuild": "^0.27.0",
-        "fix-dts-default-cjs-exports": "^1.0.0",
-        "joycon": "^3.1.1",
-        "picocolors": "^1.1.1",
-        "postcss-load-config": "^6.0.1",
-        "resolve-from": "^5.0.0",
-        "rollup": "^4.34.8",
-        "source-map": "^0.7.6",
-        "sucrase": "^3.35.0",
-        "tinyexec": "^0.3.2",
-        "tinyglobby": "^0.2.11",
-        "tree-kill": "^1.2.2"
-      },
-      "bin": {
-        "tsup": "dist/cli-default.js",
-        "tsup-node": "dist/cli-node.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "@microsoft/api-extractor": "^7.36.0",
-        "@swc/core": "^1",
-        "postcss": "^8.4.12",
-        "typescript": ">=4.5.0"
-      },
-      "peerDependenciesMeta": {
-        "@microsoft/api-extractor": {
-          "optional": true
-        },
-        "@swc/core": {
-          "optional": true
-        },
-        "postcss": {
-          "optional": true
-        },
-        "typescript": {
-          "optional": true
-        }
-      }
-    },
-    "packages/feishu-mcp/node_modules/yaml": {
-      "version": "2.8.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
-      "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
-      "dev": true,
-      "license": "ISC",
-      "optional": true,
-      "peer": true,
-      "bin": {
-        "yaml": "bin.mjs"
-      },
-      "engines": {
-        "node": ">= 14.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/eemeli"
-      }
     }
   }
 }
diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts
index 5d632c2..6398b80 100644
--- a/src/lib/bridge/bridge-manager.ts
+++ b/src/lib/bridge/bridge-manager.ts
@@ -540,6 +540,8 @@ async function handleMessage(
   let cardCreating = false;
   let cardBufferedText = '';
   let cardFinalized = false;
+  /** Track tool calls for card progress display */
+  const cardToolCalls: import('../channels/types').ToolCallInfo[] = [];
 
   if (!previewState && adapter.getCardStreamController) {
     cardController = adapter.getCardStreamController();
@@ -619,6 +621,23 @@ async function handleMessage(
     };
   }
 
+  // Build onToolEvent callback for card tool progress
+  let onToolEvent: ((event: any) => void) | undefined;
+  if (cardController) {
+    onToolEvent = (event: any) => {
+      if (event.type === 'tool_use') {
+        cardToolCalls.push({ id: event.id, name: event.name, status: 'running' });
+      } else if (event.type === 'tool_result') {
+        const tc = cardToolCalls.find((t) => t.id === event.tool_use_id);
+        if (tc) tc.status = event.is_error ? 'error' : 'complete';
+      }
+      // Update card display if we have a message ID
+      if (cardMessageId && cardController?.updateToolCalls) {
+        cardController.updateToolCalls(cardMessageId, cardToolCalls);
+      }
+    };
+  }
+
   try {
     // Pass permission callback so requests are forwarded to IM immediately
     // during streaming (the stream blocks until permission is resolved).
@@ -636,25 +655,31 @@ async function handleMessage(
         perm.suggestions,
         msg.messageId,
       );
-    }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText);
+    }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText, onToolEvent);
 
     // Send response text — render via channel-appropriate format
     if (result.responseText) {
       if (cardController && cardMessageId) {
         // Finalize streaming card with final content
-        await cardController.finalize(cardMessageId, result.responseText);
+        const finalStatus = result.hasError ? 'error' : 'completed';
+        await cardController.finalize(cardMessageId, result.responseText, finalStatus);
         cardFinalized = true;
       } else {
         await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
       }
     } else if (result.hasError) {
-      const errorResponse: OutboundMessage = {
-        address: msg.address,
-        text: `Error: ${escapeHtml(result.errorMessage)}`,
-        parseMode: 'HTML',
-        replyToMessageId: msg.messageId,
-      };
-      await deliver(adapter, errorResponse);
+      if (cardController && cardMessageId) {
+        await cardController.finalize(cardMessageId, `❌ Error: ${result.errorMessage}`, 'error');
+        cardFinalized = true;
+      } else {
+        const errorResponse: OutboundMessage = {
+          address: msg.address,
+          text: `Error: ${escapeHtml(result.errorMessage)}`,
+          parseMode: 'HTML',
+          replyToMessageId: msg.messageId,
+        };
+        await deliver(adapter, errorResponse);
+      }
     }
 
     // Persist the actual SDK session ID for future resume.
@@ -682,7 +707,7 @@ async function handleMessage(
 
     // Clean up card streaming state
     if (cardController && cardMessageId && !cardFinalized) {
-      cardController.finalize(cardMessageId, '⚠️ Response interrupted.').catch(() => {});
+      cardController.finalize(cardMessageId, '⚠️ Response interrupted.', 'interrupted').catch(() => {});
     }
 
     state.activeTasks.delete(binding.codepilotSessionId);
@@ -735,20 +760,7 @@ async function handleCommand(
         'CodePilot Bridge',
         '',
         'Send any message to interact with Claude.',
-        '',
-        'Commands:',
-        '/new [path] - Start new session',
-        '/bind <session_id> - Bind to existing session',
-        '/cwd /path - Change working directory',
-        '/mode plan|code|ask - Change mode',
-        '/status - Show current status',
-        '/sessions - List recent sessions',
-        '/stop - Stop current session',
-        '/perm allow|allow_session|deny <id> - Respond to permission',
-        '/history [count] - Show recent messages',
-        '/search <keyword> - Search messages',
-        '/feishu <subcommand> - Feishu bridge management',
-        '/help - Show this help',
+        'Type /help for available commands.',
       ].join('\n');
       break;
 
@@ -1109,18 +1121,23 @@ async function handleCommand(
       response = [
         'CodePilot Bridge Commands',
         '',
-        '/new [path] - Start new session',
+        'Session:',
+        '/new [path] - Create new session (optional: specify CWD)',
+        '/cwd /path - Change CWD, reset context',
         '/bind <session_id> - Bind to existing session',
-        '/cwd /path - Change working directory',
         '/mode plan|code|ask - Change mode',
-        '/status - Show current status',
+        '/status - Show session / CWD / mode / model',
         '/sessions - List recent sessions',
-        '/stop - Stop current session',
-        '/perm allow|allow_session|deny <id> - Respond to permission request',
+        '/stop - Stop current task',
+        '',
+        'Messages:',
         '/history [count] - Show recent messages',
-        '/search <keyword> - Search messages',
-        '/feishu <subcommand> - Feishu bridge management',
-        '/help - Show this help',
+        '/search <keyword> - Search in current chat',
+        '/perm allow|deny <id> - Permission response',
+        '',
+        'Feishu:',
+        '/feishu doctor - Run diagnostics',
+        '/feishu auth - View auth status',
       ].join('\n');
       break;
 
diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts
index 689a06c..005b31e 100644
--- a/src/lib/bridge/conversation-engine.ts
+++ b/src/lib/bridge/conversation-engine.ts
@@ -83,6 +83,12 @@ export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise
  */
 export type OnPartialText = (fullText: string) => void;
 
+/**
+ * Callback invoked on tool_use / tool_result SSE events.
+ * Used by card streaming to show tool progress indicators.
+ */
+export type OnToolEvent = (event: { type: 'tool_use'; id: string; name: string } | { type: 'tool_result'; tool_use_id: string; is_error: boolean }) => void;
+
 export interface ConversationResult {
   responseText: string;
   tokenUsage: TokenUsage | null;
@@ -105,6 +111,7 @@ export async function processMessage(
   abortSignal?: AbortSignal,
   files?: FileAttachment[],
   onPartialText?: OnPartialText,
+  onToolEvent?: OnToolEvent,
 ): Promise {
   const sessionId = binding.codepilotSessionId;
 
@@ -232,7 +239,7 @@ export async function processMessage(
     // Consume the stream server-side (replicate collectStreamResponse pattern).
     // Permission requests are forwarded immediately via the callback during streaming
     // because the stream blocks until permission is resolved — we can't wait until after.
-    return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText);
+    return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText, onToolEvent);
   } finally {
     clearInterval(renewalInterval);
     releaseSessionLock(sessionId, lockId);
@@ -249,6 +256,7 @@ async function consumeStream(
   sessionId: string,
   onPermissionRequest?: OnPermissionRequest,
   onPartialText?: OnPartialText,
+  onToolEvent?: OnToolEvent,
 ): Promise {
   const reader = stream.getReader();
   const contentBlocks: MessageContentBlock[] = [];
@@ -300,6 +308,9 @@ async function consumeStream(
                 name: toolData.name,
                 input: toolData.input,
               });
+              if (onToolEvent) {
+                try { onToolEvent({ type: 'tool_use', id: toolData.id, name: toolData.name }); } catch { /* non-critical */ }
+              }
             } catch { /* skip */ }
             break;
           }
@@ -322,6 +333,9 @@ async function consumeStream(
                 seenToolResultIds.add(resultData.tool_use_id);
                 contentBlocks.push(newBlock);
               }
+              if (onToolEvent) {
+                try { onToolEvent({ type: 'tool_result', tool_use_id: resultData.tool_use_id, is_error: resultData.is_error || false }); } catch { /* non-critical */ }
+              }
             } catch { /* skip */ }
             break;
           }
diff --git a/src/lib/channels/feishu/card-controller.ts b/src/lib/channels/feishu/card-controller.ts
index 9cedb8d..46a6107 100644
--- a/src/lib/channels/feishu/card-controller.ts
+++ b/src/lib/channels/feishu/card-controller.ts
@@ -1,22 +1,21 @@
 /**
- * CodePilot Simplified Card Streaming
+ * Feishu Card Streaming Controller
  *
  * Manages streaming card lifecycle via CardKit v2 API.
- * State machine: idle → creating → streaming → completed | error
+ * State machine: idle → creating → streaming → completed | interrupted | error
  *
- * Differences from OpenClaw's StreamingCardController:
- * - No reasoning phase streaming (thinking indicator)
- * - No FlushController / UnavailableGuard
- * - No IM patch fallback (if CardKit fails, falls back to static text)
- * - No reply boundary detection
- * - Footer is simplified (just elapsed time and status text, no animated status)
- *
- * These are intentional simplifications for CodePilot's use case.
+ * Features:
+ * - Thinking state display (💭 Thinking...)
+ * - Streaming text with throttled updates
+ * - Tool call progress indicators (🔄/✅/❌)
+ * - Final card with status footer and elapsed time
+ * - Markdown optimization for Feishu rendering
  */
 
 import type * as lark from '@larksuiteoapi/node-sdk';
-import type { CardStreamController } from '../types';
+import type { CardStreamController, ToolCallInfo } from '../types';
 import type { CardStreamConfig } from './types';
+import { optimizeMarkdown } from './outbound';
 
 const LOG_TAG = '[card-controller]';
 
@@ -28,6 +27,30 @@ interface CardState {
   startTime: number;
   throttleTimer: ReturnType | null;
   pendingText: string | null;
+  /** Current tool calls being tracked */
+  toolCalls: ToolCallInfo[];
+  /** Whether we're in thinking state (before text starts flowing) */
+  thinking: boolean;
+}
+
+/** Format elapsed time for footer display */
+function formatElapsed(ms: number): string {
+  if (ms < 1000) return `${ms}ms`;
+  const sec = ms / 1000;
+  if (sec < 60) return `${sec.toFixed(1)}s`;
+  const min = Math.floor(sec / 60);
+  const remSec = Math.floor(sec % 60);
+  return `${min}m ${remSec}s`;
+}
+
+/** Build tool progress lines for card display */
+function buildToolProgressMarkdown(tools: ToolCallInfo[]): string {
+  if (tools.length === 0) return '';
+  const lines = tools.map((tc) => {
+    const icon = tc.status === 'running' ? '🔄' : tc.status === 'complete' ? '✅' : '❌';
+    return `${icon} \`${tc.name}\``;
+  });
+  return lines.join('\n');
 }
 
 class FeishuCardStreamController implements CardStreamController {
@@ -45,11 +68,17 @@ class FeishuCardStreamController implements CardStreamController {
       // 1. Create streaming card via CardKit v2
       const cardBody = {
         schema: '2.0',
-        config: { streaming_mode: true, wide_screen_mode: true },
+        config: {
+          streaming_mode: true,
+          wide_screen_mode: true,
+          summary: { content: '思考中...' },
+        },
         body: {
           elements: [{
             tag: 'markdown',
-            content: initialText || '...',
+            content: initialText || '💭 Thinking...',
+            text_align: 'left',
+            text_size: 'normal',
             element_id: 'streaming_content',
           }],
         },
@@ -63,7 +92,6 @@ class FeishuCardStreamController implements CardStreamController {
         console.error(LOG_TAG, 'Card create returned no card_id');
         return '';
       }
-      console.log(LOG_TAG, 'Created card:', cardId);
 
       // 2. Send card as IM message
       const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } });
@@ -80,7 +108,6 @@ class FeishuCardStreamController implements CardStreamController {
         });
       }
       const messageId = msgResp?.data?.message_id || '';
-      console.log(LOG_TAG, 'Sent card message:', messageId);
 
       this.cards.set(messageId, {
         cardId,
@@ -90,6 +117,8 @@ class FeishuCardStreamController implements CardStreamController {
         startTime: Date.now(),
         throttleTimer: null,
         pendingText: null,
+        toolCalls: [],
+        thinking: !initialText,
       });
 
       return messageId;
@@ -103,6 +132,11 @@ class FeishuCardStreamController implements CardStreamController {
     const state = this.cards.get(messageId);
     if (!state) return 'fail';
 
+    // Clear thinking state once text starts flowing
+    if (state.thinking && text.trim()) {
+      state.thinking = false;
+    }
+
     state.pendingText = text;
 
     const elapsed = Date.now() - state.lastUpdateAt;
@@ -123,15 +157,21 @@ class FeishuCardStreamController implements CardStreamController {
   }
 
   private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> {
-    if (!state.pendingText) return 'ok';
-    const text = state.pendingText;
+    if (!state.pendingText && state.toolCalls.length === 0) return 'ok';
+
+    // Build content: main text + tool progress
+    let content = state.pendingText || '';
+    const toolMd = buildToolProgressMarkdown(state.toolCalls);
+    if (toolMd) {
+      content = content ? `${content}\n\n${toolMd}` : toolMd;
+    }
     state.pendingText = null;
 
     try {
       state.sequence++;
       await (this.client as any).cardkit.v2.card.streamContent({
         path: { card_id: state.cardId },
-        data: { content: text, sequence: state.sequence },
+        data: { content, sequence: state.sequence },
       });
       state.lastUpdateAt = Date.now();
       return 'ok';
@@ -141,6 +181,31 @@ class FeishuCardStreamController implements CardStreamController {
     }
   }
 
+  /** Update tool call progress — triggers a card update */
+  updateToolCalls(messageId: string, tools: ToolCallInfo[]): void {
+    const state = this.cards.get(messageId);
+    if (!state) return;
+    state.toolCalls = tools;
+
+    // Force a flush with current text + updated tool progress
+    const elapsed = Date.now() - state.lastUpdateAt;
+    if (elapsed >= this.config.throttleMs) {
+      this.flushUpdate(state).catch(() => {});
+    } else if (!state.throttleTimer) {
+      state.throttleTimer = setTimeout(() => {
+        state.throttleTimer = null;
+        this.flushUpdate(state).catch(() => {});
+      }, this.config.throttleMs - elapsed);
+    }
+  }
+
+  /** Set thinking state — shows 💭 Thinking... in card */
+  setThinking(messageId: string): void {
+    const state = this.cards.get(messageId);
+    if (!state) return;
+    state.thinking = true;
+  }
+
   async finalize(
     messageId: string,
     finalText: string,
@@ -157,62 +222,80 @@ class FeishuCardStreamController implements CardStreamController {
 
     try {
       // Close streaming mode
+      state.sequence++;
       await (this.client as any).cardkit.v2.card.setStreamingMode({
         path: { card_id: state.cardId },
-        params: { streaming_mode: false },
+        data: { streaming_mode: false, sequence: state.sequence },
       });
 
-      // Build footer elements if configured
-      const footerElements: Array<{ tag: string; content: string; element_id: string }> = [];
-      const footerCfg = this.config.footer;
+      // Build final card elements
+      const elements: any[] = [];
 
-      if (footerCfg?.elapsed) {
-        const elapsedMs = Date.now() - state.startTime;
-        const elapsedSec = (elapsedMs / 1000).toFixed(1);
-        footerElements.push({
-          tag: 'markdown',
-          content: `*${elapsedSec}s*`,
-          element_id: 'footer_elapsed',
-        });
+      // Main content (optimize markdown for Feishu rendering)
+      elements.push({
+        tag: 'markdown',
+        content: optimizeMarkdown(finalText),
+        text_size: 'normal',
+        element_id: 'streaming_content',
+      });
+
+      // Tool call summary (if any tools were used)
+      if (state.toolCalls.length > 0) {
+        const toolMd = buildToolProgressMarkdown(state.toolCalls);
+        if (toolMd) {
+          elements.push({
+            tag: 'markdown',
+            content: toolMd,
+            text_size: 'notation',
+            element_id: 'tool_summary',
+          });
+        }
       }
 
+      // Footer
+      const footerCfg = this.config.footer;
+      const footerParts: string[] = [];
+
       if (footerCfg?.status) {
         const statusLabels: Record = {
-          completed: 'Completed',
-          interrupted: 'Interrupted',
-          error: 'Error',
+          completed: '✅ Completed',
+          interrupted: '⚠️ Interrupted',
+          error: '❌ Error',
         };
-        footerElements.push({
+        footerParts.push(statusLabels[status] || status);
+      }
+
+      if (footerCfg?.elapsed) {
+        const elapsedMs = Date.now() - state.startTime;
+        footerParts.push(formatElapsed(elapsedMs));
+      }
+
+      if (footerParts.length > 0) {
+        elements.push({ tag: 'hr' });
+        elements.push({
           tag: 'markdown',
-          content: `**${statusLabels[status] || status}**`,
-          element_id: 'footer_status',
+          content: footerParts.join(' · '),
+          text_size: 'notation',
+          element_id: 'footer',
         });
       }
 
-      // Update final card content
+      // Update final card
       const finalCard = {
         schema: '2.0',
         config: { wide_screen_mode: true },
-        body: {
-          elements: [
-            {
-              tag: 'markdown',
-              content: finalText,
-              element_id: 'streaming_content',
-            },
-            ...footerElements,
-          ],
-        },
+        body: { elements },
       };
+
+      state.sequence++;
       await (this.client as any).cardkit.v2.card.update({
         path: { card_id: state.cardId },
-        data: { type: 'card_json', data: JSON.stringify(finalCard) },
+        data: { type: 'card_json', data: JSON.stringify(finalCard), sequence: state.sequence },
       });
     } catch (err: any) {
       console.error(LOG_TAG, 'Finalize failed:', err?.message || err);
     }
 
-    console.log(LOG_TAG, `Finalized card ${state.cardId} as ${status}`);
     this.cards.delete(messageId);
   }
 }
diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts
index 9964baa..4449750 100644
--- a/src/lib/channels/feishu/index.ts
+++ b/src/lib/channels/feishu/index.ts
@@ -206,7 +206,7 @@ export class FeishuChannelPlugin implements ChannelPlugin {
     const messageId = this.lastMessageIdByChat.get(chatId);
     if (!client || !messageId) return;
     // Fire-and-forget — don't block message processing
-    addReaction(client, messageId, 'OnIt').then((reactionId) => {
+    addReaction(client, messageId, 'Typing').then((reactionId) => {
       if (reactionId) {
         this.activeReactions.set(chatId, { messageId, reactionId });
       }
diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts
index fb2520b..1ba0724 100644
--- a/src/lib/channels/feishu/outbound.ts
+++ b/src/lib/channels/feishu/outbound.ts
@@ -25,7 +25,7 @@ const LOG_TAG = '[feishu/outbound]';
  * - Strip invalid image keys (prevent CardKit error 200570)
  * - Compress excessive newlines (3+ → 2)
  */
-function optimizeMarkdown(text: string): string {
+export function optimizeMarkdown(text: string): string {
   try {
     return _optimizeMarkdown(text);
   } catch {
diff --git a/src/lib/channels/types.ts b/src/lib/channels/types.ts
index 5473f48..0a8b459 100644
--- a/src/lib/channels/types.ts
+++ b/src/lib/channels/types.ts
@@ -10,6 +10,13 @@ import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '.
 // ── Card Stream Controller ──────────────────────────────────────
 
 /** Controls a streaming card lifecycle (create → update → finalize). */
+/** Tool call info for card display */
+export interface ToolCallInfo {
+  id: string;
+  name: string;
+  status: 'running' | 'complete' | 'error';
+}
+
 export interface CardStreamController {
   /**
    * Create a streaming card in the given chat.
@@ -31,6 +38,12 @@ export interface CardStreamController {
    * @param status 'completed' | 'interrupted' | 'error'
    */
   finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise;
+
+  /** Optional: update tool call progress display */
+  updateToolCalls?(messageId: string, tools: ToolCallInfo[]): void;
+
+  /** Optional: set thinking state before text starts flowing */
+  setThinking?(messageId: string): void;
 }
 
 // ── Channel Capabilities ────────────────────────────────────────

From 7cc9c90242b2a9e69f3b635bd185ef6656c32444 Mon Sep 17 00:00:00 2001
From: 7418 <7418@openclaw.ai>
Date: Sat, 14 Mar 2026 16:30:15 +0800
Subject: [PATCH 5/5] refactor: simplify Feishu settings, fix card race
 conditions, update docs

Settings cleanup:
- Remove encryptKey/verificationToken (WSClient handles auth internally)
- Remove renderMode/blockStreaming toggles (streaming always enabled)
- Remove footer toggles (always show status + elapsed)
- Simplify FeishuConfig type, always-on cardStreamConfig
- Merge UI into 2 cards (credentials + access/behavior) with dirty-state save buttons

Bug fixes:
- Fix card creation race: cardCreatePromise awaited before finalize/interrupt
- Fix tool-first turns: onToolEvent bootstraps card when no text precedes tools
- Fix /cwd picker: source from channel_bindings (not chat_sessions), filter active only
- Remove stale renderMode reference in bridge-manager /status

Docs:
- Update bridge-system.md: Feishu data flow, design decisions 13-18, V2 status, settings table
- Add design decision 18: single-operator model for /cwd picker scope

Co-Authored-By: Claude Opus 4.6 
---
 docs/handover/bridge-system.md                | 150 ++++---
 src/app/api/settings/feishu/route.ts          |  10 +-
 src/components/bridge/FeishuBridgeSection.tsx | 403 ++++++++----------
 src/i18n/en.ts                                |  19 +-
 src/i18n/zh.ts                                |  19 +-
 src/lib/bridge/bridge-manager.ts              | 130 +++++-
 src/lib/channels/feishu/config.ts             |  43 +-
 src/lib/channels/feishu/gateway.ts            |  10 +-
 src/lib/channels/feishu/index.ts              |  11 +-
 src/lib/channels/feishu/outbound.ts           | 115 +++--
 src/lib/channels/feishu/types.ts              |  25 +-
 11 files changed, 485 insertions(+), 450 deletions(-)

diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md
index cd9ac47..8520396 100644
--- a/docs/handover/bridge-system.md
+++ b/docs/handover/bridge-system.md
@@ -19,42 +19,70 @@ src/lib/bridge/
 │   ├── ir.ts                # Markdown → IR 中间表示解析器(基于 markdown-it)
 │   ├── render.ts            # IR → 格式化输出的通用标记渲染器
 │   └── telegram.ts          # Telegram HTML 渲染 + 文件引用保护 + render-first 分片
-├── markdown/
-│   ├── ...                  # (见上)
-│   └── feishu.ts            # 飞书 Markdown 处理:hasComplexMarkdown / buildCardContent / buildPostContent / htmlToFeishuMarkdown
 ├── adapters/
 │   ├── index.ts             # Adapter 目录文件(side-effect import 自注册所有 adapter)
 │   ├── telegram-adapter.ts  # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
 │   ├── telegram-media.ts    # Telegram 图片下载、尺寸选择、base64 转换
 │   ├── telegram-utils.ts    # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
-│   ├── feishu-adapter.ts    # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册
+│   ├── feishu-adapter.ts    # 薄代理 → ChannelPluginAdapter(FeishuChannelPlugin)
 │   └── discord-adapter.ts   # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册
 ├── markdown/
 │   └── discord.ts           # Discord 消息分片(2000 字符限制)+ 代码围栏平衡
 └── security/
     ├── rate-limiter.ts      # 按 chat 滑动窗口限流(20 条/分钟)
     └── validators.ts        # 路径/SessionID/危险输入校验
+
+src/lib/channels/
+├── types.ts                 # ChannelPlugin / ChannelCapabilities / CardStreamController / ToolCallInfo 接口
+├── channel-plugin-adapter.ts # ChannelPlugin → BaseChannelAdapter 桥接
+└── feishu/
+    ├── index.ts             # FeishuChannelPlugin 组合入口
+    ├── types.ts             # FeishuConfig / CardStreamConfig / FeishuBotInfo 等内部类型
+    ├── config.ts            # 从 settings DB 加载配置 + 校验
+    ├── gateway.ts           # WSClient 生命周期 + card.action.trigger monkey-patch + 超时保护
+    ├── inbound.ts           # 入站消息解析 + 内容提取 + 资源下载
+    ├── outbound.ts          # 出站消息渲染(post md / interactive card / reaction)+ Markdown 优化
+    ├── policy.ts            # 用户授权 + DM/群聊策略
+    ├── identity.ts          # Bot 身份解析 + @mention 检测
+    └── card-controller.ts   # CardKit v2 流式卡片(create/update/finalize/thinking/toolCalls)
 ```
 
 ## 数据流
 
-### 飞书
+### 飞书(V2 — ChannelPlugin 架构 + 流式卡片)
 
 ```
 飞书消息 → WSClient(WebSocket) → EventDispatcher
-  → im.message.receive_v1 → handleIncomingEvent()
-    → 去重(message_id LRU 1000) → 授权检查 → 群策略过滤 → @提及检查
-    → text → parseTextContent() → enqueue()
-    → image → downloadResource(stream/writeFile) → base64 FileAttachment → enqueue()
-    → post → parsePostContent() 提取文本+图片 → enqueue()
-    → /perm 文本命令 → 构造 callbackData → enqueue()
+  → im.message.receive_v1 → FeishuGateway → messageHandler()
+    → parseInboundMessage() → 去重(message_id) → 授权检查(policy.ts) → 群策略过滤 → @提及检查
+    → text/image/post → enqueue()
+  → card.action.trigger → FeishuGateway.safeCardActionHandler() (2.5s 超时保护)
+    → FeishuChannelPlugin.cardActionHandler()
+      → callback_data (perm:allow/deny) → enqueue(callbackMsg)
+      → callback_data (cwd:/path) → enqueue(callbackMsg)
+      → action/operation_id → enqueue(syntheticCallback)
+      → 返回 toast 给飞书客户端
   → BridgeManager.runAdapterLoop() → handleMessage()
-    → deliverResponse():
-      → hasComplexMarkdown(代码块/表格)? → sendAsCard() [schema 2.0 markdown]
-      → 纯文本? → sendAsPost() [msg_type: post, md tag]
-    → 权限请求 → sendPermissionCard() [schema 2.0 卡片 + /perm 文本命令]
+    → 普通消息 → processMessage():
+      → CardStreamController.create() 创建流式卡片
+      → consumeStream() 服务端消费 SSE:
+        → text → onPartialText → CardStreamController.update() 流式推送
+        → tool_use/tool_result → onToolEvent → cardToolCalls 追踪 → updateToolCalls() 渲染 🔄/✅/❌
+        → permission_request → PermissionBroker 转发 → 内联按钮卡片(Schema V2 column_set)
+      → CardStreamController.finalize() 最终渲染 + 页脚(状态+耗时)
+    → 回调消息 → handlePermissionCallback() / handleCwdCallback()
+    → 命令 → handleCommand():
+      → /cwd 无参 → 项目选择器卡片(内联按钮, turquoise header)
+      → /new → 继承当前 binding 的 workingDirectory
 ```
 
+**飞书 V2 关键变化(相比初版 adapter):**
+- **ChannelPlugin 架构**:`FeishuChannelPlugin` 实现 `ChannelPlugin` 接口,通过 `ChannelPluginAdapter` 桥接为 `BaseChannelAdapter`,bridge-manager 无感知
+- **流式卡片**:使用 CardKit v2 API(`cardkit.v2.card.create/streamContent/setStreamingMode/update`),替代旧的 card/post 分流渲染
+- **WSClient 卡片回调**:通过 monkey-patch `handleEventData()` 将 `type:"card"` 重写为 `type:"event"`,使 SDK 的 EventDispatcher 能处理卡片交互事件
+- **配置简化**:移除 encryptKey/verificationToken(WSClient 不需要)、renderMode/blockStreaming(流式始终开启)、footer 开关(始终显示)
+- **卡片创建竞态保护**:`cardCreatePromise` 确保 finalize 路径不会在 create 完成前执行
+
 ### Discord
 
 ```
@@ -170,30 +198,31 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
 - **降级**:`sendPreview` 返回 `'sent'|'skip'|'degrade'` 三态。400/404(API 不支持)→ 永久降级该 chatId;429/网络错误 → 仅跳过本次。`previewDegraded` Set 在 adapter `stop()` 时清空。
 - **线程安全**:`processWithSessionLock` 保证同 session 串行 → 同时刻只有一个 `previewState`。多个 in-flight `sendMessageDraft` 安全:Telegram 对同 `draft_id` last-write-wins。
 
-**13. 飞书适配器 — WSClient + 渲染分流**
-飞书使用 `@larksuiteoapi/node-sdk` 的 `WSClient`(长连接 WebSocket)接收事件,`Client`(REST)发送消息和下载资源。与 Telegram 的 HTTP 长轮询不同,WSClient 由 SDK 管理重连。消息去重使用内存 Map LRU(上限 1000),无需持久化 offset。
+**13. 飞书 ChannelPlugin 架构**
+飞书从原 `BaseChannelAdapter` 子类迁移为 `ChannelPlugin` 实现。`src/lib/channels/feishu/` 拆分为独立模块:`gateway.ts`(WSClient 生命周期)、`inbound.ts`(消息解析)、`outbound.ts`(消息发送 + Markdown 优化)、`policy.ts`(授权策略)、`card-controller.ts`(流式卡片)、`config.ts`(配置加载)。通过 `ChannelPluginAdapter` 桥接为 `BaseChannelAdapter`,bridge-manager 无需修改。WSClient 由 SDK 管理重连,消息去重使用内存 LRU。
 
-**14. 飞书渲染策略 — Card vs Post**
-Claude 回复按内容分流渲染(对齐 Openclaw 方案):
-- 含代码块(` ``` `)或表格 → `msg_type: 'interactive'`,schema 2.0 卡片(`{ tag: 'markdown', content }` 元素),代码高亮和表格正常渲染。
-- 纯文本 → `msg_type: 'post'`,`{ tag: 'md', text }` 格式,渲染粗体、斜体、行内代码、链接。
-- 每层发送失败自动降级:card → post → text。
+**14. 飞书流式卡片渲染**
+所有 Claude 回复通过 CardKit v2 流式卡片输出(替代旧的 card/post 分流)。流程:`cardController.create()` 创建卡片 → `update()` 节流推送文本(200ms)→ `finalize()` 停止流式 + 渲染最终内容 + 页脚。卡片支持:
+- **Thinking 状态**:文本到达前显示 `💭 Thinking...`
+- **Tool 进度**:`🔄 Running` / `✅ Complete` / `❌ Error` 实时显示
+- **Markdown 优化**:标题降级(H1→H4, H2-6→H5)、表格间距、代码块填充、无效图片 key 剥离
+- **页脚**:状态 emoji(✅/⚠️/❌)+ 耗时,始终显示
 
-`markdown/feishu.ts` 的 `hasComplexMarkdown()` 负责路由判断,`buildCardContent()` / `buildPostContent()` 构建消息体。
+非卡片消息(命令响应等)使用 `post` 格式 + `md` tag。注意:post md tag 不支持 HTML `
`(会渲染为字面文本),必须用空行 `\n\n` 代替。 -**15. 飞书权限交互 — 无按钮,文本命令兜底** -**关键限制**:飞书卡片交互回调(card.action.trigger)需要 HTTP webhook 端点,不支持通过 WSClient 长连接接收。Openclaw 通过 `http.createServer()` + `Lark.adaptDefault()` 暴露公网 webhook 解决。CodePilot 是桌面应用无公网端点,因此: -- Schema 2.0 不支持 `action` 标签(错误码 200861) -- Schema 1.0 的 `action` 标签可渲染按钮,但点击报 200340(无 webhook 端点接收回调) -- **最终方案**:权限卡片使用 schema 2.0 markdown 展示信息 + `/perm` 文本命令。用户复制命令发送即可审批。`processIncomingEvent()` 检测 `/perm` 前缀并构造 `callbackData`,走 `permission-broker.handlePermissionCallback()` 标准流程。 +**15. 飞书权限交互 — Schema V2 内联按钮** +通过 monkey-patch WSClient 的 `handleEventData()` 方法,将 `type:"card"` 事件重写为 `type:"event"`,使 SDK 的 EventDispatcher 能接收 `card.action.trigger` 回调。这解决了之前的 200340 错误(无 webhook 端点)。Schema V2 卡片不支持 `action` tag(错误码 200861),按钮使用 `column_set` + `column` + `button` 布局。按钮 value 中嵌入 `chatId` 作为兜底(WSClient 回调的 context 字段可能缺失)。Gateway 层提供 2.5s 超时保护,确保 3s 内必定返回 toast 响应。 **16. 飞书 Typing 指示器 — Emoji Reaction** -飞书无 typing indicator API。使用 Openclaw 方案:`onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastIncomingMessageId` Map 追踪每个 chat 的最新消息 ID。非关键路径,fire-and-forget。 +`FeishuChannelPlugin.onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastMessageIdByChat` Map 追踪每个 chat 的最新消息 ID,`activeReactions` Map 追踪活跃 reaction ID。非关键路径,fire-and-forget。 **17. 飞书 @提及检测** -通过 `/bot/v3/info/` REST API 获取 bot 的 `open_id`/`bot_id`,存入 `botIds` Set。群聊消息检查 `event.message.mentions` 数组中是否有匹配的 ID。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。 +`inbound.ts` 解析 `event.message.mentions` 数组检测 bot 是否被 @。bot 身份通过 `identity.ts` 的 `/bot/v3/info/` REST API 获取(`open_id`/`bot_id`)。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。 -**18. Telegram 通知模式互斥** +**18. 飞书 Bridge 单操作者模型** +当前飞书 bridge 按「单操作者桌面应用」模型设计。虽然有 dmPolicy/groupPolicy/allowFrom 等多入口访问控制,但所有飞书聊天绑定共享同一操作者身份。`/cwd` 项目选择器展示同一 Feishu 渠道下所有活跃项目目录,作为「最近项目快捷切换」使用,不做 chat-level 隔离。如果未来需要多用户/多租户隔离,`/cwd` picker 应按 userId 或 chatId 进一步收窄数据源。 + +**19. Telegram 通知模式互斥** `telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。 ## 设置项(settings 表) @@ -218,10 +247,12 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案): | bridge_feishu_app_id | 飞书应用 App ID | | bridge_feishu_app_secret | 飞书应用 App Secret(API 返回脱敏) | | bridge_feishu_domain | 平台域名:`feishu`(默认)或 `lark` | -| bridge_feishu_allowed_users | 允许的 open_id/chat_id(逗号分隔,空=不限) | +| bridge_feishu_allow_from | 允许的 open_id(逗号分隔,`*`=不限) | +| bridge_feishu_dm_policy | 私信策略:`open`(默认)/ `pairing` / `allowlist` / `disabled` | +| bridge_feishu_thread_session | 每话题独立上下文(默认 false) | | bridge_feishu_group_policy | 群消息策略:`open`(默认)/ `allowlist` / `disabled` | | bridge_feishu_group_allow_from | 群聊白名单 chat_id(逗号分隔) | -| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 true) | +| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 false) | ## API 路由 @@ -253,9 +284,10 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案): - `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/飞书通道开关 - `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram + Feishu 入口) - `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram) -- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书凭据 + 群聊策略 + 域名选择 UI(/bridge#feishu) -- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API +- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书设置 UI:凭据 + 访问与行为(2 卡片 2 保存按钮 + 脏状态追踪) +- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API(简化后 10 个 key) - `src/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info) +- `src/lib/channels/` — V2 ChannelPlugin 架构(见目录结构) - `electron/main.ts` — 窗口关闭时 bridge 活跃则保持后台运行;启动时通过 POST `auto-start` 触发桥接恢复 - `src/app/api/settings/telegram/verify/route.ts` — 支持 `register_commands` action 注册 Telegram 命令菜单 @@ -288,34 +320,44 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案): 1. **Channel Plugin 合约** (`src/lib/channels/types.ts`) - `ChannelPlugin` 接口:config/capabilities/lifecycle/inbound/outbound/policy - - `ChannelCapabilities`:streaming、inlineButtons、reactions 等能力声明 - - `ProbeResult`:健康检查结果 - - `CardStreamController`:流式卡片接口(占位) + - `ChannelCapabilities`:streaming、threadReply、search、history、reactions 能力声明 + - `CardStreamController`:流式卡片接口(create/update/finalize/setThinking/updateToolCalls) + - `ToolCallInfo`:工具调用进度追踪(id/name/status) 2. **ChannelPluginAdapter** (`src/lib/channels/channel-plugin-adapter.ts`) - 将 `ChannelPlugin` 桥接为 `BaseChannelAdapter` + - 自动代理 `getCardStreamController()`、`onMessageStart/End()` 等 - bridge-manager 无需修改即可使用新插件 3. **飞书模块拆分** (`src/lib/channels/feishu/`) - - `types.ts` — 内部类型 + 常量 - - `config.ts` — `FeishuConfig` 结构化配置 + 校验 + `configFromSettings()` - - `gateway.ts` — WSClient 生命周期 + 连接状态机 + 指标 + probe + - `types.ts` — FeishuConfig、CardStreamConfig(简化后无 renderMode/blockStreaming/footer 开关) + - `config.ts` — 从 settings DB 加载配置,cardStreamConfig 始终启用(footer 始终显示) + - `gateway.ts` — WSClient 生命周期 + card.action.trigger monkey-patch + 2.5s 超时保护 - `inbound.ts` — 入站消息处理 + 内容解析 + 资源下载 - - `outbound.ts` — 出站消息渲染(card/post/text/permission) + - `outbound.ts` — 出站渲染(post md + interactive card)+ optimizeMarkdown() + 权限/CWD 卡片 - `identity.ts` — Bot 身份解析 + @mention 检测 - - `policy.ts` — 用户授权 + 群聊策略 - - `card-controller.ts` — CardStreamController 占位 - - `index.ts` — `FeishuChannelPlugin` 组合入口 + - `policy.ts` — 用户授权 + DM/群聊策略 + - `card-controller.ts` — CardKit v2 流式卡片(thinking/streaming/tool progress/footer) + - `index.ts` — FeishuChannelPlugin 组合入口 + Typing reaction 管理 -4. **结构化配置** (`channel_configs` DB 表 + API 路由) - - `GET/PUT /api/channels/feishu/config` — 读写结构化配置 - - 向后兼容:读 channel_configs → 回退到 settings +4. **流式卡片 + 工具进度** (`bridge-manager.ts` + `card-controller.ts`) + - `onPartialText` 回调 → CardStreamController.update() 节流推送 + - `onToolEvent` 回调 → cardToolCalls[] 追踪 → updateToolCalls() 实时渲染 + - Tool-first 回合(无文本直接调工具):onToolEvent 自动 bootstrap 卡片 + - `cardCreatePromise` 竞态保护:finalize 路径 await 创建完成后再执行 -5. **Status / Probe** (`/api/channels/feishu/status`) - - 连接状态、Bot 身份、指标、可选 probe +5. **权限内联按钮** + - Schema V2 `column_set` + `column` + `button` 布局(`action` tag 已废弃 → 200861) + - 权限卡片:蓝色 header + lock icon + Allow(primary)/Deny(danger) 按钮 + 5 分钟过期提示 + - CWD 选择器卡片:turquoise header + folder icon + 垂直堆叠按钮 + 📍 当前项目高亮 + - 按钮 value 嵌入 chatId 兜底(WSClient 回调 context 可能缺失) -6. **Remote Core 合约** (`src/lib/remote/`) - - `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口 - - `RemoteManager` 轻量骨架 +6. **MCP 残留剥离** + - 移除 `.mcp.json` 中 feishu MCP 入口 + - 移除 `@codepilot/feishu-mcp` workspace 依赖 + - 原 feishu-adapter.ts 改为薄代理(~15 行) -7. **原 feishu-adapter.ts** 改为薄代理(~15 行) +7. **设置 UI 简化** (`FeishuBridgeSection.tsx`) + - 移除:encryptKey、verificationToken、renderMode、blockStreaming、footer 开关 + - 合并为 2 个卡片:凭据 + 访问与行为 + - 保存按钮脏状态追踪:修改后显示"保存",保存后显示"已保存" diff --git a/src/app/api/settings/feishu/route.ts b/src/app/api/settings/feishu/route.ts index 985fd15..2e3e8bd 100644 --- a/src/app/api/settings/feishu/route.ts +++ b/src/app/api/settings/feishu/route.ts @@ -11,15 +11,9 @@ const FEISHU_KEYS = [ 'bridge_feishu_app_id', 'bridge_feishu_app_secret', 'bridge_feishu_domain', - 'bridge_feishu_encrypt_key', - 'bridge_feishu_verification_token', 'bridge_feishu_allow_from', 'bridge_feishu_dm_policy', - 'bridge_feishu_render_mode', 'bridge_feishu_thread_session', - 'bridge_feishu_block_streaming', - 'bridge_feishu_footer_status', - 'bridge_feishu_footer_elapsed', 'bridge_feishu_group_policy', 'bridge_feishu_group_allow_from', 'bridge_feishu_require_mention', @@ -32,7 +26,7 @@ export async function GET() { const value = getSetting(key); if (value !== undefined) { // Mask sensitive fields for security - if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && value.length > 8) { + if (key === 'bridge_feishu_app_secret' && value.length > 8) { result[key] = '***' + value.slice(-8); } else { result[key] = value; @@ -61,7 +55,7 @@ export async function PUT(request: NextRequest) { const strValue = String(value ?? '').trim(); // Don't overwrite secrets if user sent the masked version back - if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && strValue.startsWith('***')) { + if (key === 'bridge_feishu_app_secret' && strValue.startsWith('***')) { continue; } diff --git a/src/components/bridge/FeishuBridgeSection.tsx b/src/components/bridge/FeishuBridgeSection.tsx index bea1928..4870e6f 100644 --- a/src/components/bridge/FeishuBridgeSection.tsx +++ b/src/components/bridge/FeishuBridgeSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -21,15 +21,9 @@ interface FeishuBridgeSettings { bridge_feishu_app_id: string; bridge_feishu_app_secret: string; bridge_feishu_domain: string; - bridge_feishu_encrypt_key: string; - bridge_feishu_verification_token: string; bridge_feishu_allow_from: string; bridge_feishu_dm_policy: string; - bridge_feishu_render_mode: string; bridge_feishu_thread_session: string; - bridge_feishu_block_streaming: string; - bridge_feishu_footer_status: string; - bridge_feishu_footer_elapsed: string; bridge_feishu_group_policy: string; bridge_feishu_group_allow_from: string; bridge_feishu_require_mention: string; @@ -39,39 +33,68 @@ const DEFAULT_SETTINGS: FeishuBridgeSettings = { bridge_feishu_app_id: "", bridge_feishu_app_secret: "", bridge_feishu_domain: "feishu", - bridge_feishu_encrypt_key: "", - bridge_feishu_verification_token: "", bridge_feishu_allow_from: "", bridge_feishu_dm_policy: "open", - bridge_feishu_render_mode: "auto", bridge_feishu_thread_session: "false", - bridge_feishu_block_streaming: "false", - bridge_feishu_footer_status: "false", - bridge_feishu_footer_elapsed: "false", bridge_feishu_group_policy: "open", bridge_feishu_group_allow_from: "", bridge_feishu_require_mention: "false", }; +/** SaveButton: shows Save / Saving / Saved based on dirty + saving state. */ +function SaveButton({ + dirty, + saving, + onClick, + label, + savedLabel, +}: { + dirty: boolean; + saving: boolean; + onClick: () => void; + label: string; + savedLabel: string; +}) { + return ( + + ); +} + export function FeishuBridgeSection() { - const [, setSettings] = - useState(DEFAULT_SETTINGS); + // ── Credentials state ── const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); const [domain, setDomain] = useState("feishu"); - const [encryptKey, setEncryptKey] = useState(""); - const [verificationToken, setVerificationToken] = useState(""); + const [credentialsSaving, setCredentialsSaving] = useState(false); + const [credentialsDirty, setCredentialsDirty] = useState(false); + const savedCredentials = useRef({ appId: "", appSecret: "", domain: "feishu" }); + + // ── Access & Behavior state ── const [allowFrom, setAllowFrom] = useState(""); const [dmPolicy, setDmPolicy] = useState("open"); - const [renderMode, setRenderMode] = useState("auto"); const [threadSession, setThreadSession] = useState(false); - const [blockStreaming, setBlockStreaming] = useState(false); - const [footerStatus, setFooterStatus] = useState(false); - const [footerElapsed, setFooterElapsed] = useState(false); const [groupPolicy, setGroupPolicy] = useState("open"); const [groupAllowFrom, setGroupAllowFrom] = useState(""); const [requireMention, setRequireMention] = useState(false); - const [saving, setSaving] = useState(false); + const [behaviorSaving, setBehaviorSaving] = useState(false); + const [behaviorDirty, setBehaviorDirty] = useState(false); + const savedBehavior = useRef({ + allowFrom: "", dmPolicy: "open", threadSession: false, + groupPolicy: "open", groupAllowFrom: "", requireMention: false, + }); + + // ── Verify state ── const [verifying, setVerifying] = useState(false); const [verifyResult, setVerifyResult] = useState<{ ok: boolean; @@ -79,28 +102,59 @@ export function FeishuBridgeSection() { } | null>(null); const { t } = useTranslation(); + // ── Dirty tracking ── + useEffect(() => { + const s = savedCredentials.current; + setCredentialsDirty( + appId !== s.appId || appSecret !== s.appSecret || domain !== s.domain + ); + }, [appId, appSecret, domain]); + + useEffect(() => { + const s = savedBehavior.current; + setBehaviorDirty( + allowFrom !== s.allowFrom || + dmPolicy !== s.dmPolicy || + threadSession !== s.threadSession || + groupPolicy !== s.groupPolicy || + groupAllowFrom !== s.groupAllowFrom || + requireMention !== s.requireMention + ); + }, [allowFrom, dmPolicy, threadSession, groupPolicy, groupAllowFrom, requireMention]); + + // ── Fetch ── const fetchSettings = useCallback(async () => { try { const res = await fetch("/api/settings/feishu"); if (res.ok) { const data = await res.json(); const s = { ...DEFAULT_SETTINGS, ...data.settings }; - setSettings(s); setAppId(s.bridge_feishu_app_id); setAppSecret(s.bridge_feishu_app_secret); setDomain(s.bridge_feishu_domain || "feishu"); - setEncryptKey(s.bridge_feishu_encrypt_key || ""); - setVerificationToken(s.bridge_feishu_verification_token || ""); setAllowFrom(s.bridge_feishu_allow_from); setDmPolicy(s.bridge_feishu_dm_policy || "open"); - setRenderMode(s.bridge_feishu_render_mode || "auto"); setThreadSession(s.bridge_feishu_thread_session === "true"); - setBlockStreaming(s.bridge_feishu_block_streaming === "true"); - setFooterStatus(s.bridge_feishu_footer_status === "true"); - setFooterElapsed(s.bridge_feishu_footer_elapsed === "true"); setGroupPolicy(s.bridge_feishu_group_policy || "open"); setGroupAllowFrom(s.bridge_feishu_group_allow_from); setRequireMention(s.bridge_feishu_require_mention === "true"); + + // Snapshot as "saved" baseline + savedCredentials.current = { + appId: s.bridge_feishu_app_id, + appSecret: s.bridge_feishu_app_secret, + domain: s.bridge_feishu_domain || "feishu", + }; + savedBehavior.current = { + allowFrom: s.bridge_feishu_allow_from, + dmPolicy: s.bridge_feishu_dm_policy || "open", + threadSession: s.bridge_feishu_thread_session === "true", + groupPolicy: s.bridge_feishu_group_policy || "open", + groupAllowFrom: s.bridge_feishu_group_allow_from, + requireMention: s.bridge_feishu_require_mention === "true", + }; + setCredentialsDirty(false); + setBehaviorDirty(false); } } catch { // ignore @@ -111,62 +165,60 @@ export function FeishuBridgeSection() { fetchSettings(); }, [fetchSettings]); - const saveSettings = async (updates: Partial) => { - setSaving(true); + // ── Save helpers ── + const saveToApi = async (updates: Partial) => { + const res = await fetch("/api/settings/feishu", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: updates }), + }); + return res.ok; + }; + + const handleSaveCredentials = async () => { + setCredentialsSaving(true); try { - const res = await fetch("/api/settings/feishu", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings: updates }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...updates })); + const updates: Partial = { + bridge_feishu_app_id: appId, + bridge_feishu_domain: domain, + }; + if (appSecret && !appSecret.startsWith("***")) { + updates.bridge_feishu_app_secret = appSecret; + } + if (await saveToApi(updates)) { + savedCredentials.current = { appId, appSecret, domain }; + setCredentialsDirty(false); } } catch { // ignore } finally { - setSaving(false); + setCredentialsSaving(false); } }; - const handleSaveCredentials = () => { - const updates: Partial = { - bridge_feishu_app_id: appId, - bridge_feishu_domain: domain, - bridge_feishu_verification_token: verificationToken, - }; - if (appSecret && !appSecret.startsWith("***")) { - updates.bridge_feishu_app_secret = appSecret; + const handleSaveBehavior = async () => { + setBehaviorSaving(true); + try { + const ok = await saveToApi({ + bridge_feishu_allow_from: allowFrom, + bridge_feishu_dm_policy: dmPolicy, + bridge_feishu_thread_session: threadSession ? "true" : "false", + bridge_feishu_group_policy: groupPolicy, + bridge_feishu_group_allow_from: groupAllowFrom, + bridge_feishu_require_mention: requireMention ? "true" : "false", + }); + if (ok) { + savedBehavior.current = { + allowFrom, dmPolicy, threadSession, + groupPolicy, groupAllowFrom, requireMention, + }; + setBehaviorDirty(false); + } + } catch { + // ignore + } finally { + setBehaviorSaving(false); } - if (encryptKey && !encryptKey.startsWith("***")) { - updates.bridge_feishu_encrypt_key = encryptKey; - } - saveSettings(updates); - }; - - const handleSaveDmSettings = () => { - saveSettings({ - bridge_feishu_allow_from: allowFrom, - bridge_feishu_dm_policy: dmPolicy, - }); - }; - - const handleSaveBehaviorSettings = () => { - saveSettings({ - bridge_feishu_render_mode: renderMode, - bridge_feishu_thread_session: threadSession ? "true" : "false", - bridge_feishu_block_streaming: blockStreaming ? "true" : "false", - bridge_feishu_footer_status: footerStatus ? "true" : "false", - bridge_feishu_footer_elapsed: footerElapsed ? "true" : "false", - }); - }; - - const handleSaveGroupSettings = () => { - saveSettings({ - bridge_feishu_group_policy: groupPolicy, - bridge_feishu_group_allow_from: groupAllowFrom, - bridge_feishu_require_mention: requireMention ? "true" : "false", - }); }; const handleVerify = async () => { @@ -214,7 +266,7 @@ export function FeishuBridgeSection() { return (
- {/* App Credentials */} + {/* ── App Credentials ── */}
- -
- - setEncryptKey(e.target.value)} - placeholder={t("feishu.encryptKeyPlaceholder")} - className="font-mono text-sm" - /> -

- {t("feishu.encryptKeyHint")} -

-
- -
- - setVerificationToken(e.target.value)} - placeholder={t("feishu.verificationTokenPlaceholder")} - className="font-mono text-sm" - /> -

- {t("feishu.verificationTokenHint")} -

-
- + - - - {/* Behavior Settings: Render Mode, Thread Session, Block Streaming, Footer */} - -
-
- - -
- - - - - - - - - - -
- - - - - - -
-
-
- - -
- - {/* Group Chat Settings */} - -
-
-