feat: 飞书(Feishu/Lark)Bridge 适配器 + 发布 v0.25.0

新增飞书 IM 通道,通过 @larksuiteoapi/node-sdk WSClient 接收消息、REST Client 发送回复。

适配器核心 (feishu-adapter.ts):
- WSClient 长连接接收 im.message.receive_v1 事件
- 消息去重 (message_id LRU 1000)、授权校验、群聊策略 (open/allowlist/disabled)
- @提及检测 (bot open_id/user_id/union_id 匹配)
- 图片/文件/音频/视频/富文本(post) 下载 → base64 FileAttachment
- Typing 指示器 (Openclaw 方案: emoji reaction)
- /perm 文本命令处理权限审批

渲染策略 (markdown/feishu.ts):
- 代码块/表格 → schema 2.0 interactive card (markdown 元素)
- 纯文本 → post (md tag), 支持粗体/斜体/行内代码/链接
- 权限卡片 → schema 2.0 card + /perm 文本命令 (无按钮, 见下方决策)
- 每层降级: card → post → text

关键决策 — 权限交互无按钮:
- 飞书 card.action.trigger 回调需 HTTP webhook, WSClient 不支持
- schema 2.0 不支持 action 标签 (错误码 200861)
- schema 1.0 按钮可渲染但点击报 200340 (无 webhook 端点)
- Openclaw 用 http.createServer + Lark.adaptDefault 解决, CodePilot 桌面端无公网端点
- 最终方案: 格式化卡片 + /perm 文本命令, 用户复制发送即可审批

其他变更:
- permission-broker.ts: permissionRequestId 去重 (30s TTL)
- telegram-bot.ts: bridge 模式互斥 (globalThis 标志)
- bridge-manager.ts: 飞书渲染分发 (deliverResponse feishu 分支)
- UI: FeishuBridgeSection 设置页 + 侧边栏入口 + 通道开关
- i18n: 中英文飞书配置文案
- 交接文档: bridge-system.md 更新飞书架构、数据流、6 项设计决策

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-04 19:03:13 +08:00
1 parent b1a043a995
commit 243724a765
18 files changed
+1844 -38

No files matched your search

+63 -2
View File
@@ -19,11 +19,15 @@ 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
│ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
│ └── feishu-adapter.ts # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册
└── security/
├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟)
└── validators.ts # 路径/SessionID/危险输入校验
@@ -31,6 +35,25 @@ src/lib/bridge/
## 数据流
### 飞书
```
飞书消息 → 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()
→ BridgeManager.runAdapterLoop() → handleMessage()
→ deliverResponse():
→ hasComplexMarkdown(代码块/表格)? → sendAsCard() [schema 2.0 markdown]
→ 纯文本? → sendAsPost() [msg_type: post, md tag]
→ 权限请求 → sendPermissionCard() [schema 2.0 卡片 + /perm 文本命令]
```
### Telegram
```
Telegram 消息 → TelegramAdapter.pollLoop()
→ 纯文本/caption → enqueue()
@@ -117,6 +140,32 @@ 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。
**14. 飞书渲染策略 — Card vs Post**
Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- 含代码块(` ``` `)或表格 → `msg_type: 'interactive'`,schema 2.0 卡片(`{ tag: 'markdown', content }` 元素),代码高亮和表格正常渲染。
- 纯文本 → `msg_type: 'post'`,`{ tag: 'md', text }` 格式,渲染粗体、斜体、行内代码、链接。
- 每层发送失败自动降级:card → post → text。
`markdown/feishu.ts` 的 `hasComplexMarkdown()` 负责路由判断,`buildCardContent()` / `buildPostContent()` 构建消息体。
**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()` 标准流程。
**16. 飞书 Typing 指示器 — Emoji Reaction**
飞书无 typing indicator API。使用 Openclaw 方案:`onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastIncomingMessageId` Map 追踪每个 chat 的最新消息 ID。非关键路径,fire-and-forget。
**17. 飞书 @提及检测**
通过 `/bot/v3/info/` REST API 获取 bot 的 `open_id`/`bot_id`,存入 `botIds` Set。群聊消息检查 `event.message.mentions` 数组中是否有匹配的 ID。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。
**18. Telegram 通知模式互斥**
`telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。
## 设置项(settings 表)
| Key | 说明 |
@@ -135,6 +184,14 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
| bridge_telegram_stream_min_delta_chars | 最小增量字符数(默认 20) |
| bridge_telegram_stream_max_chars | 草稿截断阈值(默认 3900) |
| bridge_telegram_stream_private_only | 仅私聊启用预览(默认 true,群聊自动跳过) |
| bridge_feishu_enabled | 飞书通道开关 |
| 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_group_policy | 群消息策略:`open`(默认)/ `allowlist` / `disabled` |
| bridge_feishu_group_allow_from | 群聊白名单 chat_id(逗号分隔) |
| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 true) |
## API 路由
@@ -163,7 +220,11 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
- `src/lib/telegram-bot.ts` — 通知模式(UI 发起会话的通知),与 bridge 模式互斥
- `src/lib/permission-registry.ts` — 权限 Promise 注册表,bridge 和 UI 共用
- `src/lib/claude-client.ts` — streamClaude(),bridge 和 UI 共用
- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge)
- `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/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info)
- `electron/main.ts` — 窗口关闭时 bridge 活跃则保持后台运行;启动时通过 POST `auto-start` 触发桥接恢复
- `src/app/api/settings/telegram/verify/route.ts` — 支持 `register_commands` action 注册 Telegram 命令菜单
+45 -30
View File
@@ -1,12 +1,12 @@
{
"name": "codepilot",
"version": "0.24.0",
"version": "0.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
"version": "0.24.0",
"version": "0.25.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.47",
"@ai-sdk/google": "^3.0.31",
@@ -15,6 +15,7 @@
"@google/genai": "^1.43.0",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@lobehub/icons": "^4.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@streamdown/cjk": "^1.0.1",
@@ -2949,6 +2950,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@larksuiteoapi/node-sdk": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.59.0.tgz",
"integrity": "sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA==",
"license": "MIT",
"dependencies": {
"axios": "~1.13.3",
"lodash.identity": "^3.0.0",
"lodash.merge": "^4.6.2",
"lodash.pickby": "^4.6.0",
"protobufjs": "^7.2.6",
"qs": "^6.14.2",
"ws": "^8.19.0"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
@@ -8435,7 +8451,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/at-least-node": {
@@ -8488,7 +8503,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -8931,7 +8945,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -8945,7 +8958,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -9304,7 +9316,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -10216,7 +10227,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -10425,7 +10435,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -10901,7 +10910,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10911,7 +10919,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10949,7 +10956,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -10962,7 +10968,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -11995,7 +12000,6 @@
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"dev": true,
"funding": [
{
"type": "individual",
@@ -12070,7 +12074,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -12373,7 +12376,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -12407,7 +12409,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -12618,7 +12619,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -12721,7 +12721,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -12734,7 +12733,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -14624,6 +14622,12 @@
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.identity": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
"integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -14635,7 +14639,12 @@
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.pickby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz",
"integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==",
"license": "MIT"
},
"node_modules/log-symbols": {
@@ -14863,7 +14872,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -16121,7 +16129,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -16131,7 +16138,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -16811,7 +16817,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -17624,7 +17629,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/pump": {
@@ -17656,6 +17660,21 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz",
@@ -19510,7 +19529,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -19530,7 +19548,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -19547,7 +19564,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -19566,7 +19582,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.24.0",
"version": "0.25.0",
"private": true,
"author": {
"name": "op7418",
@@ -28,6 +28,7 @@
"@google/genai": "^1.43.0",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@lobehub/icons": "^4.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@streamdown/cjk": "^1.0.1",
+8
View File
@@ -14,6 +14,14 @@ const BRIDGE_SETTING_KEYS = [
'bridge_telegram_stream_min_delta_chars',
'bridge_telegram_stream_max_chars',
'bridge_telegram_stream_private_only',
'bridge_feishu_enabled',
'bridge_feishu_app_id',
'bridge_feishu_app_secret',
'bridge_feishu_domain',
'bridge_feishu_allowed_users',
'bridge_feishu_group_policy',
'bridge_feishu_group_allow_from',
'bridge_feishu_require_mention',
] as const;
export async function GET() {
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting, setSetting } from '@/lib/db';
/**
* Feishu Bot bridge settings.
* Stored in the SQLite settings table (same as other app settings).
*/
const FEISHU_KEYS = [
'bridge_feishu_enabled',
'bridge_feishu_app_id',
'bridge_feishu_app_secret',
'bridge_feishu_domain',
'bridge_feishu_allowed_users',
'bridge_feishu_group_policy',
'bridge_feishu_group_allow_from',
'bridge_feishu_require_mention',
] as const;
export async function GET() {
try {
const result: Record<string, string> = {};
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) {
result[key] = '***' + value.slice(-8);
} else {
result[key] = value;
}
}
}
return NextResponse.json({ settings: result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read Feishu settings';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { settings } = body;
if (!settings || typeof settings !== 'object') {
return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 });
}
for (const [key, value] of Object.entries(settings)) {
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('***')) {
continue;
}
setSetting(key, strValue);
}
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save Feishu settings';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting } from '@/lib/db';
/**
* POST /api/settings/feishu/verify
*
* Verifies Feishu app credentials by calling the bot info API.
* If app_secret starts with "***" (masked), falls back to the stored secret.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
let { app_id, app_secret } = body;
const domainSetting = body.domain || getSetting('bridge_feishu_domain') || 'feishu';
// Fall back to stored values if not provided or masked
if (!app_id) {
app_id = getSetting('bridge_feishu_app_id') || '';
}
if (!app_secret || app_secret.startsWith('***')) {
app_secret = getSetting('bridge_feishu_app_secret') || '';
}
if (!app_id || !app_secret) {
return NextResponse.json(
{ verified: false, error: 'App ID and App Secret are required' },
{ status: 400 },
);
}
const baseUrl = domainSetting === 'lark'
? 'https://open.larksuite.com'
: 'https://open.feishu.cn';
// Get tenant access token
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, app_secret }),
signal: AbortSignal.timeout(10_000),
});
const tokenData = await tokenRes.json();
if (!tokenData.tenant_access_token) {
return NextResponse.json({
verified: false,
error: tokenData.msg || 'Failed to get access token',
});
}
// Get bot info
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) {
return NextResponse.json({
verified: true,
botName: botData.bot.app_name || botData.bot.open_id,
});
}
return NextResponse.json({
verified: false,
error: botData?.msg || 'Could not retrieve bot info',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
return NextResponse.json({ verified: false, error: message }, { status: 500 });
}
}
+6 -2
View File
@@ -3,14 +3,15 @@
import { useState, useCallback, useSyncExternalStore } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { Wifi01Icon, TelegramIcon } from "@hugeicons/core-free-icons";
import { Wifi01Icon, TelegramIcon, BubbleChatIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
import { BridgeSection } from "./BridgeSection";
import { TelegramBridgeSection } from "./TelegramBridgeSection";
import { FeishuBridgeSection } from "./FeishuBridgeSection";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
type Section = "bridge" | "telegram";
type Section = "bridge" | "telegram" | "feishu";
interface SidebarItem {
id: Section;
@@ -21,6 +22,7 @@ interface SidebarItem {
const sidebarItems: SidebarItem[] = [
{ id: "bridge", label: "Bridge", icon: Wifi01Icon },
{ id: "telegram", label: "Telegram", icon: TelegramIcon },
{ id: "feishu", label: "Feishu", icon: BubbleChatIcon },
];
function getSectionFromHash(): Section {
@@ -47,6 +49,7 @@ export function BridgeLayout() {
const bridgeLabelKeys: Record<string, TranslationKey> = {
'Bridge': 'bridge.title',
'Telegram': 'bridge.telegramSettings',
'Feishu': 'bridge.feishuSettings',
};
const handleSectionChange = useCallback((section: Section) => {
@@ -86,6 +89,7 @@ export function BridgeLayout() {
<div className="flex-1 overflow-auto p-6">
{activeSection === "bridge" && <BridgeSection />}
{activeSection === "telegram" && <TelegramBridgeSection />}
{activeSection === "feishu" && <FeishuBridgeSection />}
</div>
</div>
</div>
+28 -1
View File
@@ -14,7 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon } from "@hugeicons/core-free-icons";
import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon } from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import type { ProviderModelGroup } from "@/types";
@@ -35,6 +35,7 @@ interface BridgeStatus {
interface BridgeSettings {
remote_bridge_enabled: string;
bridge_telegram_enabled: string;
bridge_feishu_enabled: string;
bridge_auto_start: string;
bridge_default_work_dir: string;
bridge_default_model: string;
@@ -44,6 +45,7 @@ interface BridgeSettings {
const DEFAULT_SETTINGS: BridgeSettings = {
remote_bridge_enabled: "",
bridge_telegram_enabled: "",
bridge_feishu_enabled: "",
bridge_auto_start: "",
bridge_default_work_dir: "",
bridge_default_model: "",
@@ -157,6 +159,10 @@ export function BridgeSection() {
saveSettings({ bridge_telegram_enabled: checked ? "true" : "" });
};
const handleToggleFeishu = (checked: boolean) => {
saveSettings({ bridge_feishu_enabled: checked ? "true" : "" });
};
const handleSaveDefaults = () => {
// Split composite "provider_id::model" value
const parts = model.split("::");
@@ -226,6 +232,7 @@ export function BridgeSection() {
const isEnabled = settings.remote_bridge_enabled === "true";
const isTelegramEnabled = settings.bridge_telegram_enabled === "true";
const isFeishuEnabled = settings.bridge_feishu_enabled === "true";
const isAutoStart = settings.bridge_auto_start === "true";
const isRunning = bridgeStatus?.running ?? false;
const adapterCount = bridgeStatus?.adapters?.length ?? 0;
@@ -355,6 +362,26 @@ export function BridgeSection() {
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div className="flex items-center gap-3">
<HugeiconsIcon
icon={BubbleChatIcon}
className="h-4 w-4 text-muted-foreground"
/>
<div>
<p className="text-sm">{t("bridge.feishuChannel")}</p>
<p className="text-xs text-muted-foreground">
{t("bridge.feishuChannelDesc")}
</p>
</div>
</div>
<Switch
checked={isFeishuEnabled}
onCheckedChange={handleToggleFeishu}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div>
<p className="text-sm">{t("bridge.autoStart")}</p>
@@ -0,0 +1,372 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Loading02Icon,
CheckmarkCircle02Icon,
Alert02Icon,
} from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
interface FeishuBridgeSettings {
bridge_feishu_app_id: string;
bridge_feishu_app_secret: string;
bridge_feishu_domain: string;
bridge_feishu_allowed_users: string;
bridge_feishu_group_policy: string;
bridge_feishu_group_allow_from: string;
bridge_feishu_require_mention: string;
}
const DEFAULT_SETTINGS: FeishuBridgeSettings = {
bridge_feishu_app_id: "",
bridge_feishu_app_secret: "",
bridge_feishu_domain: "feishu",
bridge_feishu_allowed_users: "",
bridge_feishu_group_policy: "open",
bridge_feishu_group_allow_from: "",
bridge_feishu_require_mention: "false",
};
export function FeishuBridgeSection() {
const [, setSettings] =
useState<FeishuBridgeSettings>(DEFAULT_SETTINGS);
const [appId, setAppId] = useState("");
const [appSecret, setAppSecret] = useState("");
const [domain, setDomain] = useState("feishu");
const [allowedUsers, setAllowedUsers] = useState("");
const [groupPolicy, setGroupPolicy] = useState("open");
const [groupAllowFrom, setGroupAllowFrom] = useState("");
const [requireMention, setRequireMention] = useState(false);
const [saving, setSaving] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<{
ok: boolean;
message: string;
} | null>(null);
const { t } = useTranslation();
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");
setAllowedUsers(s.bridge_feishu_allowed_users);
setGroupPolicy(s.bridge_feishu_group_policy || "open");
setGroupAllowFrom(s.bridge_feishu_group_allow_from);
setRequireMention(s.bridge_feishu_require_mention === "true");
}
} catch {
// ignore
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const saveSettings = async (updates: Partial<FeishuBridgeSettings>) => {
setSaving(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 }));
}
} catch {
// ignore
} finally {
setSaving(false);
}
};
const handleSaveCredentials = () => {
const updates: Partial<FeishuBridgeSettings> = {
bridge_feishu_app_id: appId,
bridge_feishu_domain: domain,
};
if (appSecret && !appSecret.startsWith("***")) {
updates.bridge_feishu_app_secret = appSecret;
}
saveSettings(updates);
};
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",
});
};
const handleVerify = async () => {
setVerifying(true);
setVerifyResult(null);
try {
if (!appId) {
setVerifyResult({
ok: false,
message: t("feishu.enterCredentialsFirst"),
});
return;
}
const res = await fetch("/api/settings/feishu/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: appId,
app_secret: appSecret,
domain,
}),
});
const data = await res.json();
if (data.verified) {
setVerifyResult({
ok: true,
message: data.botName
? t("feishu.verifiedAs", { name: data.botName })
: t("feishu.verified"),
});
} else {
setVerifyResult({
ok: false,
message: data.error || t("feishu.verifyFailed"),
});
}
} catch {
setVerifyResult({ ok: false, message: t("feishu.verifyFailed") });
} finally {
setVerifying(false);
}
};
return (
<div className="max-w-3xl space-y-6">
{/* App Credentials */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("feishu.credentials")}</h2>
<p className="text-xs text-muted-foreground">
{t("feishu.credentialsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.appId")}
</label>
<Input
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="cli_xxxxxxxxxx"
className="font-mono text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.appSecret")}
</label>
<Input
type="password"
value={appSecret}
onChange={(e) => setAppSecret(e.target.value)}
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
className="font-mono text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.domain")}
</label>
<Select value={domain} onValueChange={setDomain}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="feishu">
{t("feishu.domainFeishu")}
</SelectItem>
<SelectItem value="lark">
{t("feishu.domainLark")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.domainHint")}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSaveCredentials} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleVerify}
disabled={verifying || !appId}
>
{verifying ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{t("feishu.verify")}
</Button>
</div>
{verifyResult && (
<div
className={`flex items-center gap-2 rounded-md px-3 py-2 text-xs ${
verifyResult.ok
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-red-500/10 text-red-600 dark:text-red-400"
}`}
>
<HugeiconsIcon
icon={verifyResult.ok ? CheckmarkCircle02Icon : Alert02Icon}
className="h-4 w-4 shrink-0"
/>
{verifyResult.message}
</div>
)}
</div>
{/* Allowed Users */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">
{t("feishu.allowedUsers")}
</h2>
<p className="text-xs text-muted-foreground">
{t("feishu.allowedUsersDesc")}
</p>
</div>
<div>
<Input
value={allowedUsers}
onChange={(e) => setAllowedUsers(e.target.value)}
placeholder="ou_xxxxxxxxxx, ou_yyyyyyyyyy"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.allowedUsersHint")}
</p>
</div>
</div>
{/* Group Chat Settings */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">
{t("feishu.groupSettings")}
</h2>
<p className="text-xs text-muted-foreground">
{t("feishu.groupSettingsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.groupPolicy")}
</label>
<Select value={groupPolicy} onValueChange={setGroupPolicy}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">
{t("feishu.groupPolicyOpen")}
</SelectItem>
<SelectItem value="allowlist">
{t("feishu.groupPolicyAllowlist")}
</SelectItem>
<SelectItem value="disabled">
{t("feishu.groupPolicyDisabled")}
</SelectItem>
</SelectContent>
</Select>
</div>
{groupPolicy === "allowlist" && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.groupAllowFrom")}
</label>
<Input
value={groupAllowFrom}
onChange={(e) => setGroupAllowFrom(e.target.value)}
placeholder="oc_xxxxxxxxxx, oc_yyyyyyyyyy"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.groupAllowFromHint")}
</p>
</div>
)}
<div className="flex items-center justify-between">
<div>
<p className="text-sm">{t("feishu.requireMention")}</p>
<p className="text-xs text-muted-foreground">
{t("feishu.requireMentionDesc")}
</p>
</div>
<Switch
checked={requireMention}
onCheckedChange={setRequireMention}
/>
</div>
</div>
<Button size="sm" onClick={handleSaveGroupSettings} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
{/* Setup Guide */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium mb-2">
{t("feishu.setupGuide")}
</h2>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("feishu.step1")}</li>
<li>{t("feishu.step2")}</li>
<li>{t("feishu.step3")}</li>
<li>{t("feishu.step4")}</li>
<li>{t("feishu.step5")}</li>
<li>{t("feishu.step6")}</li>
</ol>
</div>
</div>
);
}
+40 -1
View File
@@ -491,10 +491,45 @@ const en = {
'telegram.step5': 'Send /start to your bot, then click "Auto Detect" next to the Chat ID field',
'telegram.step6': 'Click "Save" to store your credentials',
// ── Feishu (Bridge) ──────────────────────────────────────
'feishu.credentials': 'App Credentials',
'feishu.credentialsDesc': 'Enter your Feishu App ID and App Secret',
'feishu.appId': 'App ID',
'feishu.appSecret': 'App Secret',
'feishu.domain': 'Platform',
'feishu.domainFeishu': 'Feishu (feishu.cn)',
'feishu.domainLark': 'Lark (larksuite.com)',
'feishu.domainHint': 'Choose Feishu for China mainland or Lark for international',
'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.groupSettings': 'Group Chat Settings',
'feishu.groupSettingsDesc': 'Control how the bot responds in group chats',
'feishu.groupPolicy': 'Group Policy',
'feishu.groupPolicyOpen': 'Open — respond in all groups',
'feishu.groupPolicyAllowlist': 'Allowlist — only specified groups',
'feishu.groupPolicyDisabled': 'Disabled — ignore all group messages',
'feishu.groupAllowFrom': 'Allowed Groups',
'feishu.groupAllowFromHint': 'Comma-separated chat_id values of allowed groups',
'feishu.requireMention': 'Require @mention',
'feishu.requireMentionDesc': 'Only respond in groups when the bot is @mentioned',
'feishu.setupGuide': 'Setup Guide',
'feishu.step1': 'Go to Feishu Open Platform (open.feishu.cn) and create a custom app',
'feishu.step2': 'Enable the Bot capability in the app features',
'feishu.step3': 'Copy the App ID and App Secret from the Credentials page',
'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',
// ── Settings: Remote Bridge ────────────────────────────────
'settings.bridge': 'Remote Bridge',
'bridge.title': 'Remote Bridge',
'bridge.description': 'Control Claude from external channels like Telegram',
'bridge.description': 'Control Claude from external channels like Telegram or Feishu',
'bridge.enabled': 'Enable Remote Bridge',
'bridge.enabledDesc': 'Allow external messaging channels to interact with Claude',
'bridge.activeHint': 'Bridge is active. External channels can send tasks to Claude.',
@@ -540,6 +575,10 @@ const en = {
'bridge.allowedUsers': 'Allowed Users',
'bridge.allowedUsersDesc': 'Comma-separated Telegram user IDs allowed to use bridge',
'bridge.allowedUsersHint': 'Leave empty to allow only the Chat ID configured above',
'bridge.feishuChannel': 'Feishu',
'bridge.feishuChannelDesc': 'Receive and respond to messages via Feishu Bot',
'bridge.feishuSettings': 'Feishu Settings',
'bridge.feishuSettingsDesc': 'Configure Feishu App credentials for bridge',
} as const;
export type TranslationKey = keyof typeof en;
+40 -1
View File
@@ -488,10 +488,45 @@ const zh: Record<TranslationKey, string> = {
'telegram.step5': '向您的 Bot 发送 /start,然后点击 Chat ID 旁的「自动检测」按钮',
'telegram.step6': '点击「保存」存储凭据',
// ── Feishu (Bridge) ──────────────────────────────────────
'feishu.credentials': '应用凭据',
'feishu.credentialsDesc': '输入您的飞书 App ID 和 App Secret',
'feishu.appId': 'App ID',
'feishu.appSecret': 'App Secret',
'feishu.domain': '平台',
'feishu.domainFeishu': '飞书 (feishu.cn)',
'feishu.domainLark': 'Lark (larksuite.com)',
'feishu.domainHint': '中国大陆选择飞书,海外选择 Lark',
'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.groupSettings': '群聊设置',
'feishu.groupSettingsDesc': '控制机器人在群聊中的响应方式',
'feishu.groupPolicy': '群聊策略',
'feishu.groupPolicyOpen': '开放 — 响应所有群消息',
'feishu.groupPolicyAllowlist': '白名单 — 仅指定群组',
'feishu.groupPolicyDisabled': '禁用 — 忽略所有群消息',
'feishu.groupAllowFrom': '允许的群组',
'feishu.groupAllowFromHint': '允许的群组 chat_id,逗号分隔',
'feishu.requireMention': '需要 @提及',
'feishu.requireMentionDesc': '群聊中仅在 @机器人时响应',
'feishu.setupGuide': '设置指南',
'feishu.step1': '前往飞书开放平台 (open.feishu.cn) 创建自建应用',
'feishu.step2': '在应用功能中启用「机器人」能力',
'feishu.step3': '在凭证页面复制 App ID 和 App Secret',
'feishu.step4': '添加事件订阅:im.message.receive_v1',
'feishu.step5': '发布应用版本并在管理后台审批通过',
'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证',
// ── Settings: Remote Bridge ────────────────────────────────
'settings.bridge': '远程桥接',
'bridge.title': '远程桥接',
'bridge.description': '通过 Telegram 等外部渠道控制 Claude',
'bridge.description': '通过 Telegram、飞书等外部渠道控制 Claude',
'bridge.enabled': '启用远程桥接',
'bridge.enabledDesc': '允许外部消息渠道与 Claude 交互',
'bridge.activeHint': '桥接已激活。外部渠道可以向 Claude 发送任务。',
@@ -537,6 +572,10 @@ const zh: Record<TranslationKey, string> = {
'bridge.allowedUsers': '允许的用户',
'bridge.allowedUsersDesc': '允许使用桥接的 Telegram 用户 ID,逗号分隔',
'bridge.allowedUsersHint': '留空则仅允许上方配置的 Chat ID',
'bridge.feishuChannel': '飞书',
'bridge.feishuChannelDesc': '通过飞书机器人接收和回复消息',
'bridge.feishuSettings': '飞书设置',
'bridge.feishuSettingsDesc': '配置桥接使用的飞书应用凭据',
};
export default zh;
+951
View File
@@ -0,0 +1,951 @@
/**
* Feishu (Lark) Adapter — implements BaseChannelAdapter for Feishu Bot API.
*
* 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.
*/
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<string, string> = {
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<string, boolean>();
private botOpenId: string | null = null;
/** All known bot IDs (open_id, user_id, union_id) for mention matching. */
private botIds = new Set<string>();
/** Track last incoming message ID per chat for typing indicator. */
private lastIncomingMessageId = new Map<string, string>();
/** Track active typing reaction IDs per chat for cleanup. */
private typingReactions = new Map<string, string>();
// ── Lifecycle ───────────────────────────────────────────────
async start(): Promise<void> {
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<void> {
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<InboundMessage | null> {
const queued = this.queue.shift();
if (queued) return Promise.resolve(queued);
if (!this.running) return Promise.resolve(null);
return new Promise<InboundMessage | null>((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<SendResult> {
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<SendResult> {
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<SendResult> {
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<SendResult> {
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<void> {
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<void> {
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 <action> <permId>
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<void> {
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<FileAttachment | null> {
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++;
}
}
}
}
// Self-register so bridge-manager can create FeishuAdapter via the registry.
registerAdapterFactory('feishu', () => new FeishuAdapter());
+1
View File
@@ -10,3 +10,4 @@
*/
import './telegram-adapter';
import './feishu-adapter';
+30
View File
@@ -95,6 +95,14 @@ async function deliverResponse(
}
return { ok: true };
}
if (adapter.channelType === 'feishu') {
// Feishu: pass markdown through for adapter to format as post/card
return deliver(adapter, {
address,
text: responseText,
parseMode: 'Markdown',
}, { sessionId });
}
// Generic fallback: deliver as plain text (deliver() handles chunking internally)
return deliver(adapter, {
address,
@@ -608,6 +616,7 @@ async function handleCommand(
'/status - Show current status',
'/sessions - List recent sessions',
'/stop - Stop current session',
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission',
'/help - Show this help',
].join('\n');
break;
@@ -714,6 +723,26 @@ async function handleCommand(
break;
}
case '/perm': {
// Text-based permission approval fallback (for channels without inline buttons)
// Usage: /perm allow <id> | /perm allow_session <id> | /perm deny <id>
const permParts = args.split(/\s+/);
const permAction = permParts[0];
const permId = permParts.slice(1).join(' ');
if (!permAction || !permId || !['allow', 'allow_session', 'deny'].includes(permAction)) {
response = 'Usage: /perm allow|allow_session|deny &lt;permission_id&gt;';
break;
}
const callbackData = `perm:${permAction}:${permId}`;
const handled = broker.handlePermissionCallback(callbackData, msg.address.chatId);
if (handled) {
response = `Permission ${permAction}: recorded.`;
} else {
response = `Permission not found or already resolved.`;
}
break;
}
case '/help':
response = [
'<b>CodePilot Bridge Commands</b>',
@@ -725,6 +754,7 @@ async function handleCommand(
'/status - Show current status',
'/sessions - List recent sessions',
'/stop - Stop current session',
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission request',
'/help - Show this help',
].join('\n');
break;
+90
View File
@@ -0,0 +1,90 @@
/**
* Feishu-specific Markdown processing.
*
* Rendering strategy (aligned with Openclaw):
* - Code blocks / tables → interactive card (schema 2.0 markdown)
* - Other text → post (msg_type: 'post') with md tag
*
* Schema 2.0 cards render code blocks, tables, bold, italic, links properly.
* Post messages with md tag render bold, italic, inline code, links.
*/
/**
* Detect complex markdown (code blocks / tables).
* Used by send() to decide between card and post rendering.
*/
export function hasComplexMarkdown(text: string): boolean {
// Fenced code blocks
if (/```[\s\S]*?```/.test(text)) return true;
// Tables: header row followed by separator row with pipes and dashes
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
return false;
}
/**
* Preprocess markdown for Feishu rendering.
* Only ensures code fences have a newline before them.
* Does NOT touch the text after ``` to preserve language tags like ```python.
*/
export function preprocessFeishuMarkdown(text: string): string {
// Ensure ``` has newline before it (unless at start of text)
return text.replace(/([^\n])```/g, '$1\n```');
}
/**
* Build Feishu interactive card content (schema 2.0 markdown).
* Renders code blocks, tables, bold, italic, links, inline code properly.
* Aligned with Openclaw's buildMarkdownCard().
*/
export function buildCardContent(text: string): string {
return JSON.stringify({
schema: '2.0',
config: {
wide_screen_mode: true,
},
body: {
elements: [
{
tag: 'markdown',
content: text,
},
],
},
});
}
/**
* Build Feishu post message content (msg_type: 'post') with md tag.
* Used for simple text without code blocks or tables.
* Aligned with Openclaw's buildFeishuPostMessagePayload().
*/
export function buildPostContent(text: string): string {
return JSON.stringify({
zh_cn: {
content: [[{ tag: 'md', text }]],
},
});
}
/**
* Convert simple HTML (from command responses) to markdown for Feishu.
* Handles common tags: <b>, <i>, <code>, <br>, entities.
*/
export function htmlToFeishuMarkdown(html: string): string {
return html
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
.replace(/<i>(.*?)<\/i>/gi, '*$1*')
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim();
}
+20
View File
@@ -17,6 +17,12 @@ import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved } f
import { resolvePendingPermission } from '../permission-registry';
import { escapeHtml } from './adapters/telegram-utils';
/**
* Dedup recent permission forwards to prevent duplicate cards.
* Key: permissionRequestId, value: timestamp. Entries expire after 30s.
*/
const recentPermissionForwards = new Map<string, number>();
/**
* Forward a permission request to an IM channel as an interactive message.
*/
@@ -29,6 +35,20 @@ export async function forwardPermissionRequest(
sessionId?: string,
suggestions?: unknown[],
): Promise<void> {
// Dedup: prevent duplicate forwarding of the same permission request
const now = Date.now();
if (recentPermissionForwards.has(permissionRequestId)) {
console.warn(`[permission-broker] Duplicate forward suppressed for ${permissionRequestId}`);
return;
}
recentPermissionForwards.set(permissionRequestId, now);
// Clean up old entries
for (const [id, ts] of recentPermissionForwards) {
if (now - ts > 30_000) recentPermissionForwards.delete(id);
}
console.log(`[permission-broker] Forwarding permission request: ${permissionRequestId} tool=${toolName} channel=${adapter.channelType}`);
// Format the input summary (truncated)
const inputStr = JSON.stringify(toolInput, null, 2);
const truncatedInput = inputStr.length > 300
+1
View File
@@ -171,4 +171,5 @@ export const PLATFORM_LIMITS: Record<string, number> = {
telegram: 4096,
discord: 2000,
slack: 40000,
feishu: 30000,
};
+5
View File
@@ -149,6 +149,7 @@ async function sendMessage(text: string, parseMode: 'HTML' | 'Markdown' = 'HTML'
export async function notifySessionStart(opts?: TelegramNotifyOptions): Promise<void> {
const config = getTelegramConfig();
if (!config.enabled || !config.notifyStart) return;
if (isBridgeModeActive()) return;
const header = formatSessionHeader(opts);
const msg = `▶️ <b>Task Started</b>\n${header}`.trim();
@@ -168,6 +169,7 @@ export async function notifySessionComplete(
): Promise<void> {
const config = getTelegramConfig();
if (!config.enabled || !config.notifyComplete) return;
if (isBridgeModeActive()) return;
const header = formatSessionHeader(opts);
let msg = `✅ <b>Task Completed</b>\n${header}`.trim();
@@ -193,6 +195,7 @@ export async function notifySessionError(
): Promise<void> {
const config = getTelegramConfig();
if (!config.enabled || !config.notifyError) return;
if (isBridgeModeActive()) return;
const header = formatSessionHeader(opts);
const truncatedError = errorMessage.length > 500
@@ -216,6 +219,8 @@ export async function notifyPermissionRequest(
): Promise<void> {
const config = getTelegramConfig();
if (!config.enabled || !config.notifyPermission) return;
// Bridge system handles permission forwarding via its own adapters
if (isBridgeModeActive()) return;
const header = formatSessionHeader(opts);
const inputStr = JSON.stringify(toolInput, null, 2);