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")}
-
-
-
+