From 243724a76567d42a6a682c82d2817f94b4d2d411 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Wed, 4 Mar 2026 19:03:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A3=9E=E4=B9=A6=EF=BC=88Feishu/Lark?= =?UTF-8?q?=EF=BC=89Bridge=20=E9=80=82=E9=85=8D=E5=99=A8=20+=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20v0.25.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增飞书 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 --- docs/handover/bridge-system.md | 65 +- package-lock.json | 75 +- package.json | 3 +- src/app/api/bridge/settings/route.ts | 8 + src/app/api/settings/feishu/route.ts | 68 ++ src/app/api/settings/feishu/verify/route.ts | 74 ++ src/components/bridge/BridgeLayout.tsx | 8 +- src/components/bridge/BridgeSection.tsx | 29 +- src/components/bridge/FeishuBridgeSection.tsx | 372 +++++++ src/i18n/en.ts | 41 +- src/i18n/zh.ts | 41 +- src/lib/bridge/adapters/feishu-adapter.ts | 951 ++++++++++++++++++ src/lib/bridge/adapters/index.ts | 1 + src/lib/bridge/bridge-manager.ts | 30 + src/lib/bridge/markdown/feishu.ts | 90 ++ src/lib/bridge/permission-broker.ts | 20 + src/lib/bridge/types.ts | 1 + src/lib/telegram-bot.ts | 5 + 18 files changed, 1844 insertions(+), 38 deletions(-) create mode 100644 src/app/api/settings/feishu/route.ts create mode 100644 src/app/api/settings/feishu/verify/route.ts create mode 100644 src/components/bridge/FeishuBridgeSection.tsx create mode 100644 src/lib/bridge/adapters/feishu-adapter.ts create mode 100644 src/lib/bridge/markdown/feishu.ts diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index 2b145e2..aa7cf7a 100644 --- a/docs/handover/bridge-system.md +++ b/docs/handover/bridge-system.md @@ -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 命令菜单 diff --git a/package-lock.json b/package-lock.json index 3ad99a4..3879af4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5b6d4e3..0ad0a08 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts index 30e84f1..f85dcd5 100644 --- a/src/app/api/bridge/settings/route.ts +++ b/src/app/api/bridge/settings/route.ts @@ -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() { diff --git a/src/app/api/settings/feishu/route.ts b/src/app/api/settings/feishu/route.ts new file mode 100644 index 0000000..b0254e5 --- /dev/null +++ b/src/app/api/settings/feishu/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/settings/feishu/verify/route.ts b/src/app/api/settings/feishu/verify/route.ts new file mode 100644 index 0000000..dab04a5 --- /dev/null +++ b/src/app/api/settings/feishu/verify/route.ts @@ -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 }); + } +} diff --git a/src/components/bridge/BridgeLayout.tsx b/src/components/bridge/BridgeLayout.tsx index b20d806..78fe6ef 100644 --- a/src/components/bridge/BridgeLayout.tsx +++ b/src/components/bridge/BridgeLayout.tsx @@ -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 = { 'Bridge': 'bridge.title', 'Telegram': 'bridge.telegramSettings', + 'Feishu': 'bridge.feishuSettings', }; const handleSectionChange = useCallback((section: Section) => { @@ -86,6 +89,7 @@ export function BridgeLayout() {
{activeSection === "bridge" && } {activeSection === "telegram" && } + {activeSection === "feishu" && }
diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx index 73d357e..0116150 100644 --- a/src/components/bridge/BridgeSection.tsx +++ b/src/components/bridge/BridgeSection.tsx @@ -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() { /> +
+
+ +
+

{t("bridge.feishuChannel")}

+

+ {t("bridge.feishuChannelDesc")} +

+
+
+ +
+

{t("bridge.autoStart")}

diff --git a/src/components/bridge/FeishuBridgeSection.tsx b/src/components/bridge/FeishuBridgeSection.tsx new file mode 100644 index 0000000..8e623a0 --- /dev/null +++ b/src/components/bridge/FeishuBridgeSection.tsx @@ -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(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) => { + 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 = { + 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 ( +
+ {/* App Credentials */} +
+
+

{t("feishu.credentials")}

+

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

+
+ +
+
+ + setAppId(e.target.value)} + placeholder="cli_xxxxxxxxxx" + className="font-mono text-sm" + /> +
+ +
+ + setAppSecret(e.target.value)} + placeholder="xxxxxxxxxxxxxxxxxxxxxxxx" + className="font-mono text-sm" + /> +
+ +
+ + +

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

+
+
+ +
+ + +
+ + {verifyResult && ( +
+ + {verifyResult.message} +
+ )} +
+ + {/* Allowed Users */} +
+
+

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

+

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

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

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

+
+
+ + {/* Group Chat Settings */} +
+
+

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

+

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

+
+ +
+
+ + +
+ + {groupPolicy === "allowlist" && ( +
+ + setGroupAllowFrom(e.target.value)} + placeholder="oc_xxxxxxxxxx, oc_yyyyyyyyyy" + className="font-mono text-sm" + /> +

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

+
+ )} + +
+
+

{t("feishu.requireMention")}

+

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

+
+ +
+
+ + +
+ + {/* Setup Guide */} +
+

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

+
    +
  1. {t("feishu.step1")}
  2. +
  3. {t("feishu.step2")}
  4. +
  5. {t("feishu.step3")}
  6. +
  7. {t("feishu.step4")}
  8. +
  9. {t("feishu.step5")}
  10. +
  11. {t("feishu.step6")}
  12. +
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f305aab..e06ea75 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 848802e..dd6d0bd 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -488,10 +488,45 @@ const zh: Record = { '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 = { 'bridge.allowedUsers': '允许的用户', 'bridge.allowedUsersDesc': '允许使用桥接的 Telegram 用户 ID,逗号分隔', 'bridge.allowedUsersHint': '留空则仅允许上方配置的 Chat ID', + 'bridge.feishuChannel': '飞书', + 'bridge.feishuChannelDesc': '通过飞书机器人接收和回复消息', + 'bridge.feishuSettings': '飞书设置', + 'bridge.feishuSettingsDesc': '配置桥接使用的飞书应用凭据', }; export default zh; diff --git a/src/lib/bridge/adapters/feishu-adapter.ts b/src/lib/bridge/adapters/feishu-adapter.ts new file mode 100644 index 0000000..9f9fe6e --- /dev/null +++ b/src/lib/bridge/adapters/feishu-adapter.ts @@ -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 = { + 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++; + } + } + } +} + +// Self-register so bridge-manager can create FeishuAdapter via the registry. +registerAdapterFactory('feishu', () => new FeishuAdapter()); diff --git a/src/lib/bridge/adapters/index.ts b/src/lib/bridge/adapters/index.ts index e48bddb..a12f801 100644 --- a/src/lib/bridge/adapters/index.ts +++ b/src/lib/bridge/adapters/index.ts @@ -10,3 +10,4 @@ */ import './telegram-adapter'; +import './feishu-adapter'; diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index b935b84..5576fb8 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -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 <id> - 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 | /perm allow_session | /perm deny + 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 <permission_id>'; + 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 = [ 'CodePilot Bridge Commands', @@ -725,6 +754,7 @@ async function handleCommand( '/status - Show current status', '/sessions - List recent sessions', '/stop - Stop current session', + '/perm allow|allow_session|deny <id> - Respond to permission request', '/help - Show this help', ].join('\n'); break; diff --git a/src/lib/bridge/markdown/feishu.ts b/src/lib/bridge/markdown/feishu.ts new file mode 100644 index 0000000..b95af0e --- /dev/null +++ b/src/lib/bridge/markdown/feishu.ts @@ -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: , , ,
, entities. + */ +export function htmlToFeishuMarkdown(html: string): string { + return html + .replace(/(.*?)<\/b>/gi, '**$1**') + .replace(/(.*?)<\/strong>/gi, '**$1**') + .replace(/(.*?)<\/i>/gi, '*$1*') + .replace(/(.*?)<\/em>/gi, '*$1*') + .replace(/(.*?)<\/code>/gi, '`$1`') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/src/lib/bridge/permission-broker.ts b/src/lib/bridge/permission-broker.ts index c194ad2..e60be72 100644 --- a/src/lib/bridge/permission-broker.ts +++ b/src/lib/bridge/permission-broker.ts @@ -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(); + /** * 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 { + // 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 diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts index a009e24..5b67776 100644 --- a/src/lib/bridge/types.ts +++ b/src/lib/bridge/types.ts @@ -171,4 +171,5 @@ export const PLATFORM_LIMITS: Record = { telegram: 4096, discord: 2000, slack: 40000, + feishu: 30000, }; diff --git a/src/lib/telegram-bot.ts b/src/lib/telegram-bot.ts index d9fe5c6..c50e36f 100644 --- a/src/lib/telegram-bot.ts +++ b/src/lib/telegram-bot.ts @@ -149,6 +149,7 @@ async function sendMessage(text: string, parseMode: 'HTML' | 'Markdown' = 'HTML' export async function notifySessionStart(opts?: TelegramNotifyOptions): Promise { const config = getTelegramConfig(); if (!config.enabled || !config.notifyStart) return; + if (isBridgeModeActive()) return; const header = formatSessionHeader(opts); const msg = `▶️ Task Started\n${header}`.trim(); @@ -168,6 +169,7 @@ export async function notifySessionComplete( ): Promise { const config = getTelegramConfig(); if (!config.enabled || !config.notifyComplete) return; + if (isBridgeModeActive()) return; const header = formatSessionHeader(opts); let msg = `✅ Task Completed\n${header}`.trim(); @@ -193,6 +195,7 @@ export async function notifySessionError( ): Promise { 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 { 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);