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:
1 parent
b1a043a995
commit
243724a765
18 files changed
+1844
-38
No files matched your search
@@ -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 命令菜单
|
||||
Generated
+45
-30
@@ -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
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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());
|
||||
@@ -10,3 +10,4 @@
|
||||
*/
|
||||
|
||||
import './telegram-adapter';
|
||||
import './feishu-adapter';
|
||||
@@ -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 <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 <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 = [
|
||||
'<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 <id> - Respond to permission request',
|
||||
'/help - Show this help',
|
||||
].join('\n');
|
||||
break;
|
||||
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -171,4 +171,5 @@ export const PLATFORM_LIMITS: Record<string, number> = {
|
||||
telegram: 4096,
|
||||
discord: 2000,
|
||||
slack: 40000,
|
||||
feishu: 30000,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user