Merge branch 'codex/feishu-remote-v2'

This commit is contained in:
7418 committed 2026-03-14 16:30:20 +08:00
commit 1420c05cd9
28 files changed
+3525 -1149

No files matched your search

+26 -1
View File
@@ -107,7 +107,32 @@ Schema 定义在 `src/lib/db.ts`,12 张表:
- `bridge-manager.ts` — 生命周期编排
- `markdown/` — Markdown → IR → 渠道特定格式(Telegram HTML / 飞书卡片)
- `adapters/telegram-adapter.ts` — Telegram 长轮询
- `adapters/feishu-adapter.ts` — 飞书 WebSocket + REST
- `adapters/feishu-adapter.ts` — 飞书薄代理(委托给 Channel Plugin)
### Channel Plugin 层
`src/lib/channels/` — 结构化渠道插件,提供 `ChannelPlugin<T>` 合约。
- `types.ts` — `ChannelPlugin`/`ChannelCapabilities`/`ProbeResult`/`CardStreamController` 接口
- `channel-plugin-adapter.ts` — Plugin → `BaseChannelAdapter` 桥接适配器
- `feishu/` — 飞书渠道插件(模块化拆分)
- `types.ts` — 飞书内部类型常量
- `config.ts` — `FeishuConfig` 结构化配置 + 校验
- `gateway.ts` — WSClient 生命周期 + 连接状态机 + probe
- `inbound.ts` — 入站消息处理 + 资源下载
- `outbound.ts` — 出站消息渲染(card/post/permission)
- `identity.ts` — Bot 身份解析 + @mention 检测
- `policy.ts` — 访问控制 + 群策略
- `card-controller.ts` — CardStreamController 接口 + 占位
- `index.ts` — `FeishuChannelPlugin` 入口
### Remote Core 层
`src/lib/remote/` — 远程 Host/Controller/Session/Lease 合约(接口 + 骨架)。
- `types.ts` — `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口
- `remote-manager.ts` — 轻量运行时骨架
- `index.ts` — 公开导出
详细文档:`docs/handover/bridge-system.md`
+115 -35
View File
@@ -19,42 +19,70 @@ src/lib/bridge/
│ ├── ir.ts # Markdown → IR 中间表示解析器(基于 markdown-it)
│ ├── render.ts # IR → 格式化输出的通用标记渲染器
│ └── telegram.ts # Telegram HTML 渲染 + 文件引用保护 + render-first 分片
├── markdown/
│ ├── ... # (见上)
│ └── feishu.ts # 飞书 Markdown 处理:hasComplexMarkdown / buildCardContent / buildPostContent / htmlToFeishuMarkdown
├── adapters/
│ ├── index.ts # Adapter 目录文件(side-effect import 自注册所有 adapter)
│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
│ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换
│ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
│ ├── feishu-adapter.ts # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册
│ ├── feishu-adapter.ts # 薄代理 → ChannelPluginAdapter(FeishuChannelPlugin)
│ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册
├── markdown/
│ └── discord.ts # Discord 消息分片(2000 字符限制)+ 代码围栏平衡
└── security/
├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟)
└── validators.ts # 路径/SessionID/危险输入校验
src/lib/channels/
├── types.ts # ChannelPlugin / ChannelCapabilities / CardStreamController / ToolCallInfo 接口
├── channel-plugin-adapter.ts # ChannelPlugin → BaseChannelAdapter 桥接
└── feishu/
├── index.ts # FeishuChannelPlugin 组合入口
├── types.ts # FeishuConfig / CardStreamConfig / FeishuBotInfo 等内部类型
├── config.ts # 从 settings DB 加载配置 + 校验
├── gateway.ts # WSClient 生命周期 + card.action.trigger monkey-patch + 超时保护
├── inbound.ts # 入站消息解析 + 内容提取 + 资源下载
├── outbound.ts # 出站消息渲染(post md / interactive card / reaction)+ Markdown 优化
├── policy.ts # 用户授权 + DM/群聊策略
├── identity.ts # Bot 身份解析 + @mention 检测
└── card-controller.ts # CardKit v2 流式卡片(create/update/finalize/thinking/toolCalls)
```
## 数据流
### 飞书
### 飞书(V2 — ChannelPlugin 架构 + 流式卡片)
```
飞书消息 → WSClient(WebSocket) → EventDispatcher
→ im.message.receive_v1 → handleIncomingEvent()
→ 去重(message_id LRU 1000) → 授权检查 → 群策略过滤 → @提及检查
→ text → parseTextContent() → enqueue()
→ image → downloadResource(stream/writeFile) → base64 FileAttachment → enqueue()
→ post → parsePostContent() 提取文本+图片 → enqueue()
→ /perm 文本命令 → 构造 callbackData → enqueue()
→ im.message.receive_v1 → FeishuGateway → messageHandler()
→ parseInboundMessage() → 去重(message_id) → 授权检查(policy.ts) → 群策略过滤 → @提及检查
→ text/image/post → enqueue()
→ card.action.trigger → FeishuGateway.safeCardActionHandler() (2.5s 超时保护)
→ FeishuChannelPlugin.cardActionHandler()
→ callback_data (perm:allow/deny) → enqueue(callbackMsg)
→ callback_data (cwd:/path) → enqueue(callbackMsg)
→ action/operation_id → enqueue(syntheticCallback)
→ 返回 toast 给飞书客户端
→ BridgeManager.runAdapterLoop() → handleMessage()
→ deliverResponse():
→ hasComplexMarkdown(代码块/表格)? → sendAsCard() [schema 2.0 markdown]
→ 纯文本? → sendAsPost() [msg_type: post, md tag]
→ 权限请求 → sendPermissionCard() [schema 2.0 卡片 + /perm 文本命令]
→ 普通消息 → processMessage():
→ CardStreamController.create() 创建流式卡片
→ consumeStream() 服务端消费 SSE:
→ text → onPartialText → CardStreamController.update() 流式推送
→ tool_use/tool_result → onToolEvent → cardToolCalls 追踪 → updateToolCalls() 渲染 🔄/✅/❌
→ permission_request → PermissionBroker 转发 → 内联按钮卡片(Schema V2 column_set)
→ CardStreamController.finalize() 最终渲染 + 页脚(状态+耗时)
→ 回调消息 → handlePermissionCallback() / handleCwdCallback()
→ 命令 → handleCommand():
→ /cwd 无参 → 项目选择器卡片(内联按钮, turquoise header)
→ /new → 继承当前 binding 的 workingDirectory
```
**飞书 V2 关键变化(相比初版 adapter):**
- **ChannelPlugin 架构**:`FeishuChannelPlugin` 实现 `ChannelPlugin<FeishuConfig>` 接口,通过 `ChannelPluginAdapter` 桥接为 `BaseChannelAdapter`,bridge-manager 无感知
- **流式卡片**:使用 CardKit v2 API(`cardkit.v2.card.create/streamContent/setStreamingMode/update`),替代旧的 card/post 分流渲染
- **WSClient 卡片回调**:通过 monkey-patch `handleEventData()` 将 `type:"card"` 重写为 `type:"event"`,使 SDK 的 EventDispatcher 能处理卡片交互事件
- **配置简化**:移除 encryptKey/verificationToken(WSClient 不需要)、renderMode/blockStreaming(流式始终开启)、footer 开关(始终显示)
- **卡片创建竞态保护**:`cardCreatePromise` 确保 finalize 路径不会在 create 完成前执行
### Discord
```
@@ -170,30 +198,31 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
- **降级**:`sendPreview` 返回 `'sent'|'skip'|'degrade'` 三态。400/404(API 不支持)→ 永久降级该 chatId;429/网络错误 → 仅跳过本次。`previewDegraded` Set 在 adapter `stop()` 时清空。
- **线程安全**:`processWithSessionLock` 保证同 session 串行 → 同时刻只有一个 `previewState`。多个 in-flight `sendMessageDraft` 安全:Telegram 对同 `draft_id` last-write-wins。
**13. 飞书适配器 — WSClient + 渲染分流**
飞书使用 `@larksuiteoapi/node-sdk` 的 `WSClient`(长连接 WebSocket)接收事件,`Client`(REST)发送消息和下载资源。与 Telegram 的 HTTP 长轮询不同,WSClient 由 SDK 管理重连。消息去重使用内存 Map LRU(上限 1000),无需持久化 offset。
**13. 飞书 ChannelPlugin 架构**
飞书从原 `BaseChannelAdapter` 子类迁移为 `ChannelPlugin<FeishuConfig>` 实现。`src/lib/channels/feishu/` 拆分为独立模块:`gateway.ts`(WSClient 生命周期)、`inbound.ts`(消息解析)、`outbound.ts`(消息发送 + Markdown 优化)、`policy.ts`(授权策略)、`card-controller.ts`(流式卡片)、`config.ts`(配置加载)。通过 `ChannelPluginAdapter` 桥接为 `BaseChannelAdapter`,bridge-manager 无需修改。WSClient 由 SDK 管理重连,消息去重使用内存 LRU。
**14. 飞书渲染策略 — Card vs Post**
Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- 含代码块(` ``` `)或表格 → `msg_type: 'interactive'`,schema 2.0 卡片(`{ tag: 'markdown', content }` 元素),代码高亮和表格正常渲染。
- 纯文本 → `msg_type: 'post'`,`{ tag: 'md', text }` 格式,渲染粗体、斜体、行内代码、链接。
- 每层发送失败自动降级:card → post → text。
**14. 飞书流式卡片渲染**
所有 Claude 回复通过 CardKit v2 流式卡片输出(替代旧的 card/post 分流)。流程:`cardController.create()` 创建卡片 → `update()` 节流推送文本(200ms)→ `finalize()` 停止流式 + 渲染最终内容 + 页脚。卡片支持:
- **Thinking 状态**:文本到达前显示 `💭 Thinking...`
- **Tool 进度**:`🔄 Running` / `✅ Complete` / `❌ Error` 实时显示
- **Markdown 优化**:标题降级(H1→H4, H2-6→H5)、表格间距、代码块填充、无效图片 key 剥离
- **页脚**:状态 emoji(✅/⚠️/❌)+ 耗时,始终显示
`markdown/feishu.ts` 的 `hasComplexMarkdown()` 负责路由判断,`buildCardContent()` / `buildPostContent()` 构建消息体。
非卡片消息(命令响应等)使用 `post` 格式 + `md` tag。注意:post md tag 不支持 HTML `<br>`(会渲染为字面文本),必须用空行 `\n\n` 代替。
**15. 飞书权限交互 — 无按钮,文本命令兜底**
**关键限制**:飞书卡片交互回调(card.action.trigger)需要 HTTP webhook 端点,不支持通过 WSClient 长连接接收。Openclaw 通过 `http.createServer()` + `Lark.adaptDefault()` 暴露公网 webhook 解决。CodePilot 是桌面应用无公网端点,因此:
- Schema 2.0 不支持 `action` 标签(错误码 200861)
- Schema 1.0 的 `action` 标签可渲染按钮,但点击报 200340(无 webhook 端点接收回调)
- **最终方案**:权限卡片使用 schema 2.0 markdown 展示信息 + `/perm` 文本命令。用户复制命令发送即可审批。`processIncomingEvent()` 检测 `/perm` 前缀并构造 `callbackData`,走 `permission-broker.handlePermissionCallback()` 标准流程。
**15. 飞书权限交互 — Schema V2 内联按钮**
通过 monkey-patch WSClient 的 `handleEventData()` 方法,将 `type:"card"` 事件重写为 `type:"event"`,使 SDK 的 EventDispatcher 能接收 `card.action.trigger` 回调。这解决了之前的 200340 错误(无 webhook 端点)。Schema V2 卡片不支持 `action` tag(错误码 200861),按钮使用 `column_set` + `column` + `button` 布局。按钮 value 中嵌入 `chatId` 作为兜底(WSClient 回调的 context 字段可能缺失)。Gateway 层提供 2.5s 超时保护,确保 3s 内必定返回 toast 响应。
**16. 飞书 Typing 指示器 — Emoji Reaction**
飞书无 typing indicator API。使用 Openclaw 方案:`onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastIncomingMessageId` Map 追踪每个 chat 的最新消息 ID。非关键路径,fire-and-forget。
`FeishuChannelPlugin.onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastMessageIdByChat` Map 追踪每个 chat 的最新消息 ID,`activeReactions` Map 追踪活跃 reaction ID。非关键路径,fire-and-forget。
**17. 飞书 @提及检测**
通过 `/bot/v3/info/` REST API 获取 bot 的 `open_id`/`bot_id`,存入 `botIds` Set。群聊消息检查 `event.message.mentions` 数组中是否有匹配的 ID。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。
`inbound.ts` 解析 `event.message.mentions` 数组检测 bot 是否被 @。bot 身份通过 `identity.ts` 的 `/bot/v3/info/` REST API 获取(`open_id`/`bot_id`)。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。
**18. Telegram 通知模式互斥**
**18. 飞书 Bridge 单操作者模型**
当前飞书 bridge 按「单操作者桌面应用」模型设计。虽然有 dmPolicy/groupPolicy/allowFrom 等多入口访问控制,但所有飞书聊天绑定共享同一操作者身份。`/cwd` 项目选择器展示同一 Feishu 渠道下所有活跃项目目录,作为「最近项目快捷切换」使用,不做 chat-level 隔离。如果未来需要多用户/多租户隔离,`/cwd` picker 应按 userId 或 chatId 进一步收窄数据源。
**19. Telegram 通知模式互斥**
`telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。
## 设置项(settings 表)
@@ -218,10 +247,12 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
| bridge_feishu_app_id | 飞书应用 App ID |
| bridge_feishu_app_secret | 飞书应用 App Secret(API 返回脱敏) |
| bridge_feishu_domain | 平台域名:`feishu`(默认)或 `lark` |
| bridge_feishu_allowed_users | 允许的 open_id/chat_id(逗号分隔,空=不限) |
| bridge_feishu_allow_from | 允许的 open_id(逗号分隔,`*`=不限) |
| bridge_feishu_dm_policy | 私信策略:`open`(默认)/ `pairing` / `allowlist` / `disabled` |
| bridge_feishu_thread_session | 每话题独立上下文(默认 false) |
| bridge_feishu_group_policy | 群消息策略:`open`(默认)/ `allowlist` / `disabled` |
| bridge_feishu_group_allow_from | 群聊白名单 chat_id(逗号分隔) |
| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 true) |
| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 false) |
## API 路由
@@ -253,9 +284,10 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/飞书通道开关
- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram + Feishu 入口)
- `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram)
- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书凭据 + 群聊策略 + 域名选择 UI(/bridge#feishu)
- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API
- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书设置 UI:凭据 + 访问与行为(2 卡片 2 保存按钮 + 脏状态追踪)
- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API(简化后 10 个 key)
- `src/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info)
- `src/lib/channels/` — V2 ChannelPlugin 架构(见目录结构)
- `electron/main.ts` — 窗口关闭时 bridge 活跃则保持后台运行;启动时通过 POST `auto-start` 触发桥接恢复
- `src/app/api/settings/telegram/verify/route.ts` — 支持 `register_commands` action 注册 Telegram 命令菜单
@@ -281,3 +313,51 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- Android App、桌面 Controller 和 IM 渠道都将共享同一套 Remote Core
- 飞书不再只是一个 adapter,而会逐步拆分成独立的渠道模块族
- 当前 Bridge 仍是实现基础,但不再是远程能力的最终抽象边界
### V2 实施状态(codex/feishu-remote-v2)
**已完成:**
1. **Channel Plugin 合约** (`src/lib/channels/types.ts`)
- `ChannelPlugin<T>` 接口:config/capabilities/lifecycle/inbound/outbound/policy
- `ChannelCapabilities`:streaming、threadReply、search、history、reactions 能力声明
- `CardStreamController`:流式卡片接口(create/update/finalize/setThinking/updateToolCalls)
- `ToolCallInfo`:工具调用进度追踪(id/name/status)
2. **ChannelPluginAdapter** (`src/lib/channels/channel-plugin-adapter.ts`)
- 将 `ChannelPlugin<T>` 桥接为 `BaseChannelAdapter`
- 自动代理 `getCardStreamController()`、`onMessageStart/End()` 等
- bridge-manager 无需修改即可使用新插件
3. **飞书模块拆分** (`src/lib/channels/feishu/`)
- `types.ts` — FeishuConfig、CardStreamConfig(简化后无 renderMode/blockStreaming/footer 开关)
- `config.ts` — 从 settings DB 加载配置,cardStreamConfig 始终启用(footer 始终显示)
- `gateway.ts` — WSClient 生命周期 + card.action.trigger monkey-patch + 2.5s 超时保护
- `inbound.ts` — 入站消息处理 + 内容解析 + 资源下载
- `outbound.ts` — 出站渲染(post md + interactive card)+ optimizeMarkdown() + 权限/CWD 卡片
- `identity.ts` — Bot 身份解析 + @mention 检测
- `policy.ts` — 用户授权 + DM/群聊策略
- `card-controller.ts` — CardKit v2 流式卡片(thinking/streaming/tool progress/footer)
- `index.ts` — FeishuChannelPlugin 组合入口 + Typing reaction 管理
4. **流式卡片 + 工具进度** (`bridge-manager.ts` + `card-controller.ts`)
- `onPartialText` 回调 → CardStreamController.update() 节流推送
- `onToolEvent` 回调 → cardToolCalls[] 追踪 → updateToolCalls() 实时渲染
- Tool-first 回合(无文本直接调工具):onToolEvent 自动 bootstrap 卡片
- `cardCreatePromise` 竞态保护:finalize 路径 await 创建完成后再执行
5. **权限内联按钮**
- Schema V2 `column_set` + `column` + `button` 布局(`action` tag 已废弃 → 200861)
- 权限卡片:蓝色 header + lock icon + Allow(primary)/Deny(danger) 按钮 + 5 分钟过期提示
- CWD 选择器卡片:turquoise header + folder icon + 垂直堆叠按钮 + 📍 当前项目高亮
- 按钮 value 嵌入 chatId 兜底(WSClient 回调 context 可能缺失)
6. **MCP 残留剥离**
- 移除 `.mcp.json` 中 feishu MCP 入口
- 移除 `@codepilot/feishu-mcp` workspace 依赖
- 原 feishu-adapter.ts 改为薄代理(~15 行)
7. **设置 UI 简化** (`FeishuBridgeSection.tsx`)
- 移除:encryptKey、verificationToken、renderMode、blockStreaming、footer 开关
- 合并为 2 个卡片:凭据 + 访问与行为
- 保存按钮脏状态追踪:修改后显示"保存",保存后显示"已保存"
@@ -516,7 +516,7 @@ OpenClaw 官方飞书插件最值得借的不是“功能数量”,而是能
如果继续推进,下一份文档最值得做的是:
- `远程方案 V2 执行计划`
- `Remote Contract / Channel Contract 草案`
- `Feishu V2 渠道模块拆分设计`
- `Remote App 信息架构 + API 清单`
- ~~`远程方案 V2 执行计划`~~ ✅ 已完成 → `docs/exec-plans/active/feishu-v2-channel-plugin.md`
- ~~`Remote Contract / Channel Contract 草案`~~ ✅ 已完成 → `src/lib/channels/types.ts` + `src/lib/remote/types.ts`
- ~~`Feishu V2 渠道模块拆分设计`~~ ✅ 已完成 → `src/lib/channels/feishu/`
- `Remote App 信息架构 + API 清单` — 待后续实施
+720 -1
View File
@@ -1479,6 +1479,56 @@
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
"integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -7955,6 +8005,356 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -9147,6 +9547,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -9891,6 +10301,18 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -9959,6 +10381,18 @@
"node": ">= 14"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/ahooks": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.6.tgz",
@@ -10221,6 +10655,13 @@
"react": ">=18"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/app-builder-bin": {
"version": "5.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz",
@@ -11137,6 +11578,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
"integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"load-tsconfig": "^0.2.3"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"peerDependencies": {
"esbuild": ">=0.18"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -11157,6 +11614,16 @@
"node": ">= 0.8"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cacache": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
@@ -11790,6 +12257,16 @@
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -13351,7 +13828,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -14287,6 +14763,15 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@@ -14813,6 +15298,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
"integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.17",
"mlly": "^1.7.4",
"rollup": "^4.34.8"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -14924,6 +15421,12 @@
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"license": "MIT"
},
"node_modules/format": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -14932,6 +15435,28 @@
"node": ">=0.4.x"
}
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/formdata-node/node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -16126,6 +16651,15 @@
"node": ">=18.18.0"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -17099,6 +17633,16 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -17651,6 +18195,19 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -17920,6 +18477,16 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/load-tsconfig": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
"integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -20092,6 +20659,18 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
@@ -21098,6 +21677,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -23078,6 +23667,51 @@
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
"node_modules/roughjs": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
@@ -24699,6 +25333,39 @@
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/sucrase/node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -24929,6 +25596,29 @@
"node": ">= 10.0.0"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -25110,6 +25800,12 @@
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -25172,6 +25868,13 @@
"node": ">=6.10"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/ts-md5": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-2.0.1.tgz",
@@ -26123,6 +26826,22 @@
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+5 -1
View File
@@ -5,7 +5,11 @@ export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/bridge — Return bridge status (pure query, no side effects)
* GET /api/bridge — Return bridge status (pure query, no side effects).
*
* Does NOT run probe — probe is an explicit action via the per-channel
* status endpoint (e.g. /api/channels/feishu/status?probe=true).
* This endpoint is polled every 5s by useBridgeStatus so it must stay cheap.
*/
export async function GET() {
try {
+16 -1
View File
@@ -14,7 +14,7 @@ import type { MCPServerConfig } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */
/** Read MCP server configs from ~/.claude.json, ~/.claude/settings.json, and project .mcp.json */
function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
try {
const readJson = (p: string): Record<string, unknown> => {
@@ -23,10 +23,25 @@ function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
};
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
// Also read project-level .mcp.json
const projectMcp = readJson(path.join(process.cwd(), '.mcp.json'));
const merged = {
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>),
};
// Resolve ${...} placeholders in env values against DB settings
for (const server of Object.values(merged)) {
if (server.env) {
for (const [key, value] of Object.entries(server.env)) {
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
const settingKey = value.slice(2, -1);
const resolved = getSetting(settingKey);
server.env[key] = resolved || '';
}
}
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
} catch {
return undefined;
+5 -3
View File
@@ -11,7 +11,9 @@ const FEISHU_KEYS = [
'bridge_feishu_app_id',
'bridge_feishu_app_secret',
'bridge_feishu_domain',
'bridge_feishu_allowed_users',
'bridge_feishu_allow_from',
'bridge_feishu_dm_policy',
'bridge_feishu_thread_session',
'bridge_feishu_group_policy',
'bridge_feishu_group_allow_from',
'bridge_feishu_require_mention',
@@ -23,7 +25,7 @@ export async function GET() {
for (const key of FEISHU_KEYS) {
const value = getSetting(key);
if (value !== undefined) {
// Mask app secret for security
// Mask sensitive fields for security
if (key === 'bridge_feishu_app_secret' && value.length > 8) {
result[key] = '***' + value.slice(-8);
} else {
@@ -52,7 +54,7 @@ export async function PUT(request: NextRequest) {
if (!FEISHU_KEYS.includes(key as typeof FEISHU_KEYS[number])) continue;
const strValue = String(value ?? '').trim();
// Don't overwrite secret if user sent the masked version back
// Don't overwrite secrets if user sent the masked version back
if (key === 'bridge_feishu_app_secret' && strValue.startsWith('***')) {
continue;
}
+223 -74
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -21,7 +21,9 @@ interface FeishuBridgeSettings {
bridge_feishu_app_id: string;
bridge_feishu_app_secret: string;
bridge_feishu_domain: string;
bridge_feishu_allowed_users: string;
bridge_feishu_allow_from: string;
bridge_feishu_dm_policy: string;
bridge_feishu_thread_session: string;
bridge_feishu_group_policy: string;
bridge_feishu_group_allow_from: string;
bridge_feishu_require_mention: string;
@@ -31,23 +33,68 @@ const DEFAULT_SETTINGS: FeishuBridgeSettings = {
bridge_feishu_app_id: "",
bridge_feishu_app_secret: "",
bridge_feishu_domain: "feishu",
bridge_feishu_allowed_users: "",
bridge_feishu_allow_from: "",
bridge_feishu_dm_policy: "open",
bridge_feishu_thread_session: "false",
bridge_feishu_group_policy: "open",
bridge_feishu_group_allow_from: "",
bridge_feishu_require_mention: "false",
};
/** SaveButton: shows Save / Saving / Saved based on dirty + saving state. */
function SaveButton({
dirty,
saving,
onClick,
label,
savedLabel,
}: {
dirty: boolean;
saving: boolean;
onClick: () => void;
label: string;
savedLabel: string;
}) {
return (
<Button size="sm" onClick={onClick} disabled={saving || !dirty}>
{saving ? (
<>
<SpinnerGap size={14} className="animate-spin mr-1.5" />
{label}
</>
) : dirty ? (
label
) : (
savedLabel
)}
</Button>
);
}
export function FeishuBridgeSection() {
const [, setSettings] =
useState<FeishuBridgeSettings>(DEFAULT_SETTINGS);
// ── Credentials state ──
const [appId, setAppId] = useState("");
const [appSecret, setAppSecret] = useState("");
const [domain, setDomain] = useState("feishu");
const [allowedUsers, setAllowedUsers] = useState("");
const [credentialsSaving, setCredentialsSaving] = useState(false);
const [credentialsDirty, setCredentialsDirty] = useState(false);
const savedCredentials = useRef({ appId: "", appSecret: "", domain: "feishu" });
// ── Access & Behavior state ──
const [allowFrom, setAllowFrom] = useState("");
const [dmPolicy, setDmPolicy] = useState("open");
const [threadSession, setThreadSession] = useState(false);
const [groupPolicy, setGroupPolicy] = useState("open");
const [groupAllowFrom, setGroupAllowFrom] = useState("");
const [requireMention, setRequireMention] = useState(false);
const [saving, setSaving] = useState(false);
const [behaviorSaving, setBehaviorSaving] = useState(false);
const [behaviorDirty, setBehaviorDirty] = useState(false);
const savedBehavior = useRef({
allowFrom: "", dmPolicy: "open", threadSession: false,
groupPolicy: "open", groupAllowFrom: "", requireMention: false,
});
// ── Verify state ──
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<{
ok: boolean;
@@ -55,20 +102,59 @@ export function FeishuBridgeSection() {
} | null>(null);
const { t } = useTranslation();
// ── Dirty tracking ──
useEffect(() => {
const s = savedCredentials.current;
setCredentialsDirty(
appId !== s.appId || appSecret !== s.appSecret || domain !== s.domain
);
}, [appId, appSecret, domain]);
useEffect(() => {
const s = savedBehavior.current;
setBehaviorDirty(
allowFrom !== s.allowFrom ||
dmPolicy !== s.dmPolicy ||
threadSession !== s.threadSession ||
groupPolicy !== s.groupPolicy ||
groupAllowFrom !== s.groupAllowFrom ||
requireMention !== s.requireMention
);
}, [allowFrom, dmPolicy, threadSession, groupPolicy, groupAllowFrom, requireMention]);
// ── Fetch ──
const fetchSettings = useCallback(async () => {
try {
const res = await fetch("/api/settings/feishu");
if (res.ok) {
const data = await res.json();
const s = { ...DEFAULT_SETTINGS, ...data.settings };
setSettings(s);
setAppId(s.bridge_feishu_app_id);
setAppSecret(s.bridge_feishu_app_secret);
setDomain(s.bridge_feishu_domain || "feishu");
setAllowedUsers(s.bridge_feishu_allowed_users);
setAllowFrom(s.bridge_feishu_allow_from);
setDmPolicy(s.bridge_feishu_dm_policy || "open");
setThreadSession(s.bridge_feishu_thread_session === "true");
setGroupPolicy(s.bridge_feishu_group_policy || "open");
setGroupAllowFrom(s.bridge_feishu_group_allow_from);
setRequireMention(s.bridge_feishu_require_mention === "true");
// Snapshot as "saved" baseline
savedCredentials.current = {
appId: s.bridge_feishu_app_id,
appSecret: s.bridge_feishu_app_secret,
domain: s.bridge_feishu_domain || "feishu",
};
savedBehavior.current = {
allowFrom: s.bridge_feishu_allow_from,
dmPolicy: s.bridge_feishu_dm_policy || "open",
threadSession: s.bridge_feishu_thread_session === "true",
groupPolicy: s.bridge_feishu_group_policy || "open",
groupAllowFrom: s.bridge_feishu_group_allow_from,
requireMention: s.bridge_feishu_require_mention === "true",
};
setCredentialsDirty(false);
setBehaviorDirty(false);
}
} catch {
// ignore
@@ -79,42 +165,60 @@ export function FeishuBridgeSection() {
fetchSettings();
}, [fetchSettings]);
const saveSettings = async (updates: Partial<FeishuBridgeSettings>) => {
setSaving(true);
// ── Save helpers ──
const saveToApi = async (updates: Partial<FeishuBridgeSettings>) => {
const res = await fetch("/api/settings/feishu", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: updates }),
});
return res.ok;
};
const handleSaveCredentials = async () => {
setCredentialsSaving(true);
try {
const res = await fetch("/api/settings/feishu", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: updates }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, ...updates }));
const updates: Partial<FeishuBridgeSettings> = {
bridge_feishu_app_id: appId,
bridge_feishu_domain: domain,
};
if (appSecret && !appSecret.startsWith("***")) {
updates.bridge_feishu_app_secret = appSecret;
}
if (await saveToApi(updates)) {
savedCredentials.current = { appId, appSecret, domain };
setCredentialsDirty(false);
}
} catch {
// ignore
} finally {
setSaving(false);
setCredentialsSaving(false);
}
};
const handleSaveCredentials = () => {
const updates: Partial<FeishuBridgeSettings> = {
bridge_feishu_app_id: appId,
bridge_feishu_domain: domain,
};
if (appSecret && !appSecret.startsWith("***")) {
updates.bridge_feishu_app_secret = appSecret;
const handleSaveBehavior = async () => {
setBehaviorSaving(true);
try {
const ok = await saveToApi({
bridge_feishu_allow_from: allowFrom,
bridge_feishu_dm_policy: dmPolicy,
bridge_feishu_thread_session: threadSession ? "true" : "false",
bridge_feishu_group_policy: groupPolicy,
bridge_feishu_group_allow_from: groupAllowFrom,
bridge_feishu_require_mention: requireMention ? "true" : "false",
});
if (ok) {
savedBehavior.current = {
allowFrom, dmPolicy, threadSession,
groupPolicy, groupAllowFrom, requireMention,
};
setBehaviorDirty(false);
}
} catch {
// ignore
} finally {
setBehaviorSaving(false);
}
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 () => {
@@ -162,7 +266,7 @@ export function FeishuBridgeSection() {
return (
<div className="max-w-3xl space-y-6">
{/* App Credentials */}
{/* ── App Credentials ── */}
<SettingsCard
title={t("feishu.credentials")}
description={t("feishu.credentialsDesc")}
@@ -217,9 +321,13 @@ export function FeishuBridgeSection() {
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSaveCredentials} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
<SaveButton
dirty={credentialsDirty}
saving={credentialsSaving}
onClick={handleSaveCredentials}
label={t("common.save")}
savedLabel={t("feishu.saved")}
/>
<Button
size="sm"
variant="outline"
@@ -246,32 +354,55 @@ export function FeishuBridgeSection() {
)}
</SettingsCard>
{/* Allowed Users */}
{/* ── Access & Behavior ── */}
<SettingsCard
title={t("feishu.allowedUsers")}
description={t("feishu.allowedUsersDesc")}
title={t("feishu.accessBehavior")}
description={t("feishu.accessBehaviorDesc")}
>
<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>
</SettingsCard>
<div className="space-y-4">
{/* DM Policy */}
<div className="space-y-2">
<label className="text-xs font-semibold text-foreground block">
{t("feishu.dmPolicy")}
</label>
<Select value={dmPolicy} onValueChange={setDmPolicy}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">
{t("feishu.dmPolicyOpen")}
</SelectItem>
<SelectItem value="pairing">
{t("feishu.dmPolicyPairing")}
</SelectItem>
<SelectItem value="allowlist">
{t("feishu.dmPolicyAllowlist")}
</SelectItem>
<SelectItem value="disabled">
{t("feishu.dmPolicyDisabled")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Group Chat Settings */}
<SettingsCard
title={t("feishu.groupSettings")}
description={t("feishu.groupSettingsDesc")}
>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.allowFrom")}
</label>
<Input
value={allowFrom}
onChange={(e) => setAllowFrom(e.target.value)}
placeholder="*, ou_xxxxxxxxxx, ou_yyyyyyyyyy"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.allowFromHint")}
</p>
</div>
<div className="border-t pt-3 space-y-2">
<label className="text-xs font-semibold text-foreground block">
{t("feishu.groupPolicy")}
</label>
<Select value={groupPolicy} onValueChange={setGroupPolicy}>
@@ -309,23 +440,41 @@ export function FeishuBridgeSection() {
</div>
)}
<FieldRow
label={t("feishu.requireMention")}
description={t("feishu.requireMentionDesc")}
>
<Switch
checked={requireMention}
onCheckedChange={setRequireMention}
/>
</FieldRow>
<div className="border-t pt-3">
<FieldRow
label={t("feishu.requireMention")}
description={t("feishu.requireMentionDesc")}
>
<Switch
checked={requireMention}
onCheckedChange={setRequireMention}
/>
</FieldRow>
</div>
<div className="border-t pt-3">
<FieldRow
label={t("feishu.threadSession")}
description={t("feishu.threadSessionDesc")}
>
<Switch
checked={threadSession}
onCheckedChange={setThreadSession}
/>
</FieldRow>
</div>
</div>
<Button size="sm" onClick={handleSaveGroupSettings} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
<SaveButton
dirty={behaviorDirty}
saving={behaviorSaving}
onClick={handleSaveBehavior}
label={t("common.save")}
savedLabel={t("feishu.saved")}
/>
</SettingsCard>
{/* Setup Guide */}
{/* ── Setup Guide ── */}
<SettingsCard title={t("feishu.setupGuide")}>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("feishu.step1")}</li>
+51 -3
View File
@@ -535,9 +535,17 @@ const en = {
'feishu.verifiedAs': 'Connected as {name}',
'feishu.verifyFailed': 'Connection failed',
'feishu.enterCredentialsFirst': 'Enter App ID and App Secret first',
'feishu.allowedUsers': 'Allowed Users',
'feishu.allowedUsersDesc': 'Comma-separated open_id or chat_id values allowed to use bridge',
'feishu.allowedUsersHint': 'Leave empty to allow all users',
'feishu.allowFrom': 'Allow From',
'feishu.allowFromDesc': 'Control which users can send DMs to the bot',
'feishu.allowFromHint': 'Comma-separated open_id values. Use * to allow all users.',
'feishu.dmPolicy': 'DM Policy',
'feishu.dmPolicyOpen': 'Open — accept DMs from all users',
'feishu.dmPolicyPairing': 'Pairing — require pairing handshake',
'feishu.dmPolicyAllowlist': 'Allowlist — only listed users',
'feishu.dmPolicyDisabled': 'Disabled — ignore all DMs',
'feishu.accessBehavior': 'Access & Behavior',
'feishu.accessBehaviorDesc': 'Configure who can use the bot and how it behaves',
'feishu.saved': 'Saved',
'feishu.groupSettings': 'Group Chat Settings',
'feishu.groupSettingsDesc': 'Control how the bot responds in group chats',
'feishu.groupPolicy': 'Group Policy',
@@ -555,6 +563,46 @@ const en = {
'feishu.step4': 'Add event subscription: im.message.receive_v1',
'feishu.step5': 'Publish the app version and approve it in the admin console',
'feishu.step6': 'Paste the credentials above and click "Test Connection" to verify',
'feishu.threadSession': 'Thread Sessions',
'feishu.threadSessionDesc': 'Enable per-thread independent context (parallel conversations in different threads)',
'feishu.connectionState': 'Connection State',
'feishu.connectionStateConnected': 'Connected',
'feishu.connectionStateDisconnected': 'Disconnected',
'feishu.connectionStateConnecting': 'Connecting...',
'feishu.connectionStateReconnecting': 'Reconnecting...',
'feishu.metrics': 'Metrics',
'feishu.metricsMessagesIn': 'Messages In',
'feishu.metricsMessagesOut': 'Messages Out',
'feishu.metricsErrors': 'Errors',
'feishu.probe': 'Probe',
'feishu.probeSuccess': 'Probe successful',
'feishu.probeFailed': 'Probe failed',
'feishu.capabilities': 'Platform Capabilities',
'feishu.capabilities.doc': 'Documents',
'feishu.capabilities.sheet': 'Spreadsheets',
'feishu.capabilities.calendar': 'Calendar',
'feishu.capabilities.task': 'Tasks',
'feishu.capabilities.bitable': 'Bitable (Tables)',
'feishu.diagnose': 'Diagnostics',
'feishu.diagnose.title': 'Feishu Connection Diagnostics',
'feishu.diagnose.config': 'Configuration',
'feishu.diagnose.connection': 'Connection',
'feishu.diagnose.permissions': 'Permissions',
'feishu.diagnose.running': 'Running diagnostics...',
// ── Channels (generic) ────────────────────────────────────
'channels.configSaved': 'Configuration saved',
'channels.configError': 'Configuration error',
// ── Remote ────────────────────────────────────────────────
'remote.title': 'Remote Control',
'remote.host': 'Host',
'remote.controllers': 'Controllers',
'remote.sessions': 'Remote Sessions',
'remote.lease': 'Session Lease',
'remote.leaseAcquired': 'Lease acquired',
'remote.leaseReleased': 'Lease released',
'remote.leaseExpired': 'Lease expired',
// ── Settings: Remote Bridge ────────────────────────────────
'settings.bridge': 'Remote Bridge',
+51 -3
View File
@@ -532,9 +532,17 @@ const zh: Record<TranslationKey, string> = {
'feishu.verifiedAs': '已连接为 {name}',
'feishu.verifyFailed': '连接失败',
'feishu.enterCredentialsFirst': '请先输入 App ID 和 App Secret',
'feishu.allowedUsers': '允许的用户',
'feishu.allowedUsersDesc': '允许使用桥接的 open_id 或 chat_id,逗号分隔',
'feishu.allowedUsersHint': '留空则允许所有用户',
'feishu.allowFrom': '允许来源',
'feishu.allowFromDesc': '控制哪些用户可以向机器人发送私信',
'feishu.allowFromHint': '逗号分隔的 open_id。使用 * 允许所有用户。',
'feishu.dmPolicy': '私信策略',
'feishu.dmPolicyOpen': '开放 — 接受所有用户私信',
'feishu.dmPolicyPairing': '配对 — 需要配对握手',
'feishu.dmPolicyAllowlist': '白名单 — 仅允许列表中的用户',
'feishu.dmPolicyDisabled': '禁用 — 忽略所有私信',
'feishu.accessBehavior': '访问与行为',
'feishu.accessBehaviorDesc': '配置谁可以使用机器人及其行为方式',
'feishu.saved': '已保存',
'feishu.groupSettings': '群聊设置',
'feishu.groupSettingsDesc': '控制机器人在群聊中的响应方式',
'feishu.groupPolicy': '群聊策略',
@@ -552,6 +560,46 @@ const zh: Record<TranslationKey, string> = {
'feishu.step4': '添加事件订阅:im.message.receive_v1',
'feishu.step5': '发布应用版本并在管理后台审批通过',
'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证',
'feishu.threadSession': '话题会话',
'feishu.threadSessionDesc': '启用每话题独立上下文(不同话题中的并行对话)',
'feishu.connectionState': '连接状态',
'feishu.connectionStateConnected': '已连接',
'feishu.connectionStateDisconnected': '未连接',
'feishu.connectionStateConnecting': '连接中...',
'feishu.connectionStateReconnecting': '重连中...',
'feishu.metrics': '指标',
'feishu.metricsMessagesIn': '接收消息',
'feishu.metricsMessagesOut': '发送消息',
'feishu.metricsErrors': '错误',
'feishu.probe': '探测',
'feishu.probeSuccess': '探测成功',
'feishu.probeFailed': '探测失败',
'feishu.capabilities': '平台能力',
'feishu.capabilities.doc': '云文档',
'feishu.capabilities.sheet': '电子表格',
'feishu.capabilities.calendar': '日历',
'feishu.capabilities.task': '任务',
'feishu.capabilities.bitable': '多维表格',
'feishu.diagnose': '诊断',
'feishu.diagnose.title': '飞书连接诊断',
'feishu.diagnose.config': '配置',
'feishu.diagnose.connection': '连接',
'feishu.diagnose.permissions': '权限',
'feishu.diagnose.running': '正在运行诊断...',
// ── 渠道(通用)────────────────────────────────────────────
'channels.configSaved': '配置已保存',
'channels.configError': '配置错误',
// ── 远程控制 ──────────────────────────────────────────────
'remote.title': '远程控制',
'remote.host': '主机',
'remote.controllers': '控制器',
'remote.sessions': '远程会话',
'remote.lease': '会话租约',
'remote.leaseAcquired': '租约已获取',
'remote.leaseReleased': '租约已释放',
'remote.leaseExpired': '租约已过期',
// ── Settings: Remote Bridge ────────────────────────────────
'settings.bridge': '远程桥接',
+10 -945
View File
@@ -1,951 +1,16 @@
/**
* Feishu (Lark) Adapter — implements BaseChannelAdapter for Feishu Bot API.
* Feishu adapter — thin proxy that delegates to FeishuChannelPlugin.
*
* Uses the official @larksuiteoapi/node-sdk WSClient for real-time event
* subscription and REST Client for message sending / resource downloading.
* Routes messages through an internal async queue (same pattern as Telegram).
*
* Rendering strategy (aligned with Openclaw):
* - Code blocks / tables → interactive card (schema 2.0 markdown)
* - Other text → post (msg_type: 'post') with md tag
* - Permission prompts → interactive card with action buttons
*
* card.action.trigger events are handled via EventDispatcher (Openclaw pattern):
* button clicks are converted to synthetic text messages and routed through
* the normal /perm command processing pipeline.
* The actual implementation lives in src/lib/channels/feishu/.
* This file exists only to maintain the existing self-registration pattern
* used by bridge-manager via adapters/index.ts.
*/
import crypto from 'crypto';
import * as lark from '@larksuiteoapi/node-sdk';
import type {
ChannelType,
InboundMessage,
OutboundMessage,
SendResult,
} from '../types';
import type { FileAttachment } from '@/types';
import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter';
import { insertAuditLog } from '../../db';
import { getSetting } from '../../db';
import {
htmlToFeishuMarkdown,
preprocessFeishuMarkdown,
hasComplexMarkdown,
buildCardContent,
buildPostContent,
} from '../markdown/feishu';
/** Max number of message_ids to keep for dedup. */
const DEDUP_MAX = 1000;
/** Max file download size (20 MB). */
const MAX_FILE_SIZE = 20 * 1024 * 1024;
/** Feishu emoji type for typing indicator (same as Openclaw). */
const TYPING_EMOJI = 'Typing';
/** Shape of the SDK's im.message.receive_v1 event data. */
type FeishuMessageEventData = {
sender: {
sender_id?: {
open_id?: string;
union_id?: string;
user_id?: string;
};
sender_type: string;
tenant_key?: string;
};
message: {
message_id: string;
chat_id: string;
chat_type: string;
message_type: string;
content: string;
create_time: string;
mentions?: Array<{
key: string;
id: { open_id?: string; union_id?: string; user_id?: string };
name: string;
}>;
};
};
/** MIME type guesses by message_type. */
const MIME_BY_TYPE: Record<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++;
}
}
}
}
import { registerAdapterFactory } from '../channel-adapter';
import { ChannelPluginAdapter } from '../../channels/channel-plugin-adapter';
import { FeishuChannelPlugin } from '../../channels/feishu';
// Self-register so bridge-manager can create FeishuAdapter via the registry.
registerAdapterFactory('feishu', () => new FeishuAdapter());
registerAdapterFactory('feishu', () =>
new ChannelPluginAdapter(new FeishuChannelPlugin())
);
+494 -73
View File
@@ -29,6 +29,16 @@ import {
sanitizeInput,
validateMode,
} from './security/validators';
import { ChannelPluginAdapter } from '../channels/channel-plugin-adapter';
/**
* Extract the real platform chat_id from a potentially synthetic thread-session address.
* Thread-session mode encodes addresses as `{real_chat_id}:thread:{root_id}`.
*/
function extractRealChatId(chatId: string): string {
const threadIdx = chatId.indexOf(':thread:');
return threadIdx >= 0 ? chatId.slice(0, threadIdx) : chatId;
}
const GLOBAL_KEY = '__bridge_manager__';
@@ -313,6 +323,8 @@ export async function stop(): Promise<void> {
state.adapters.clear();
state.adapterMeta.clear();
state.sessionLocks.clear();
state.activeTasks.clear();
state.startedAt = null;
// Re-enable notification bot polling
@@ -369,6 +381,15 @@ export function registerAdapter(adapter: BaseChannelAdapter): void {
state.adapters.set(adapter.channelType, adapter);
}
/**
* Get a running adapter by channel type.
* Returns null if the adapter is not registered.
*/
export function getAdapter(channelType: string): BaseChannelAdapter | null {
const state = getState();
return state.adapters.get(channelType) ?? null;
}
/**
* Run the event loop for a single adapter.
* Messages for different sessions are dispatched concurrently;
@@ -444,11 +465,29 @@ async function handleMessage(
}
};
// Handle callback queries (permission buttons)
// Handle callback queries
if (msg.callbackData) {
// CWD switch button callback
if (msg.callbackData.startsWith('cwd:')) {
const targetDir = msg.callbackData.slice(4);
const validated = validateWorkingDirectory(targetDir);
if (validated) {
const binding = router.resolve(msg.address);
router.updateBinding(binding.id, { workingDirectory: validated, sdkSessionId: '' });
await deliver(adapter, {
address: msg.address,
text: `Working directory switched to <code>${escapeHtml(validated)}</code>\n(Next message starts fresh context)`,
parseMode: 'HTML',
replyToMessageId: msg.messageId,
});
}
ack();
return;
}
// Permission buttons
const handled = broker.handlePermissionCallback(msg.callbackData, msg.address.chatId, msg.callbackMessageId);
if (handled) {
// Send confirmation
const confirmMsg: OutboundMessage = {
address: msg.address,
text: 'Permission response recorded.',
@@ -513,51 +552,131 @@ async function handleMessage(
};
}
// ── Card streaming setup (Feishu) ──────────────────────────────
let cardController: import('../channels/types').CardStreamController | null = null;
let cardMessageId: string | null = null;
let cardCreating = false;
let cardBufferedText = '';
let cardFinalized = false;
/** Promise that resolves when card creation completes — await before finalize. */
let cardCreatePromise: Promise<void> | null = null;
/** Track tool calls for card progress display */
const cardToolCalls: import('../channels/types').ToolCallInfo[] = [];
if (!previewState && adapter.getCardStreamController) {
cardController = adapter.getCardStreamController();
console.log('[bridge-manager] Card stream controller:', cardController ? 'available' : 'null');
}
const streamCfg = previewState ? getStreamConfig(adapter.channelType) : null;
// Build the onPartialText callback (or undefined if preview not supported)
const onPartialText = (previewState && streamCfg) ? (fullText: string) => {
const ps = previewState!;
const cfg = streamCfg!;
if (ps.degraded) return;
// Build the onPartialText callback — preview streaming OR card streaming
let onPartialText: ((fullText: string) => void) | undefined;
// Truncate to maxChars + ellipsis
ps.pendingText = fullText.length > cfg.maxChars
? fullText.slice(0, cfg.maxChars) + '...'
: fullText;
if (previewState && streamCfg) {
// Preview-based streaming (Telegram, etc.)
const ps = previewState;
const cfg = streamCfg;
onPartialText = (fullText: string) => {
if (ps.degraded) return;
const delta = ps.pendingText.length - ps.lastSentText.length;
const elapsed = Date.now() - ps.lastSentAt;
ps.pendingText = fullText.length > cfg.maxChars
? fullText.slice(0, cfg.maxChars) + '...'
: fullText;
if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) {
// Not enough new content — schedule trailing-edge timer if not already set
if (!ps.throttleTimer) {
ps.throttleTimer = setTimeout(() => {
ps.throttleTimer = null;
if (!ps.degraded) flushPreview(adapter, ps, cfg);
}, cfg.intervalMs);
const delta = ps.pendingText.length - ps.lastSentText.length;
const elapsed = Date.now() - ps.lastSentAt;
if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) {
if (!ps.throttleTimer) {
ps.throttleTimer = setTimeout(() => {
ps.throttleTimer = null;
if (!ps.degraded) flushPreview(adapter, ps, cfg);
}, cfg.intervalMs);
}
return;
}
return;
}
if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) {
// Too soon — schedule trailing-edge timer to ensure latest text is sent
if (!ps.throttleTimer) {
ps.throttleTimer = setTimeout(() => {
ps.throttleTimer = null;
if (!ps.degraded) flushPreview(adapter, ps, cfg);
}, cfg.intervalMs - elapsed);
if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) {
if (!ps.throttleTimer) {
ps.throttleTimer = setTimeout(() => {
ps.throttleTimer = null;
if (!ps.degraded) flushPreview(adapter, ps, cfg);
}, cfg.intervalMs - elapsed);
}
return;
}
return;
}
// Clear any pending trailing-edge timer and flush immediately
if (ps.throttleTimer) {
clearTimeout(ps.throttleTimer);
ps.throttleTimer = null;
}
flushPreview(adapter, ps, cfg);
} : undefined;
if (ps.throttleTimer) {
clearTimeout(ps.throttleTimer);
ps.throttleTimer = null;
}
flushPreview(adapter, ps, cfg);
};
} else if (cardController) {
// Card-based streaming (Feishu)
onPartialText = (fullText: string) => {
if (cardCreating) {
cardBufferedText = fullText;
return;
}
if (!cardMessageId) {
// First call — create the card
cardCreating = true;
cardBufferedText = fullText;
cardCreatePromise = cardController!.create(msg.address.chatId, fullText, msg.messageId).then((msgId) => {
cardCreating = false;
cardMessageId = msgId || null;
// Flush any buffered text that arrived during creation
if (cardMessageId && cardBufferedText && cardBufferedText !== fullText) {
cardController!.update(cardMessageId, cardBufferedText).catch(() => {});
}
}).catch(() => {
cardCreating = false;
});
return;
}
cardController!.update(cardMessageId, fullText).catch(() => {});
};
}
// Build onToolEvent callback for card tool progress
let onToolEvent: ((event: any) => void) | undefined;
if (cardController) {
onToolEvent = (event: any) => {
if (event.type === 'tool_use') {
cardToolCalls.push({ id: event.id, name: event.name, status: 'running' });
} else if (event.type === 'tool_result') {
const tc = cardToolCalls.find((t) => t.id === event.tool_use_id);
if (tc) tc.status = event.is_error ? 'error' : 'complete';
}
// Bootstrap card if tool event arrives before any text (tool-first turns).
// Without this, tool progress has nowhere to render.
if (!cardMessageId && !cardCreating) {
cardCreating = true;
cardCreatePromise = cardController!.create(msg.address.chatId, '', msg.messageId).then((msgId) => {
cardCreating = false;
cardMessageId = msgId || null;
if (cardMessageId && cardController?.updateToolCalls) {
cardController.updateToolCalls(cardMessageId, cardToolCalls);
}
// Flush any text that arrived while creating
if (cardMessageId && cardBufferedText) {
cardController!.update(cardMessageId, cardBufferedText).catch(() => {});
}
}).catch(() => { cardCreating = false; });
return;
}
// Update card display if we have a message ID
if (cardMessageId && cardController?.updateToolCalls) {
cardController.updateToolCalls(cardMessageId, cardToolCalls);
}
};
}
try {
// Pass permission callback so requests are forwarded to IM immediately
@@ -576,19 +695,37 @@ async function handleMessage(
perm.suggestions,
msg.messageId,
);
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText);
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText, onToolEvent);
// Await any in-flight card creation before checking cardMessageId,
// preventing race where processMessage() returns before create() resolves.
if (cardCreatePromise) {
await cardCreatePromise;
}
// Send response text — render via channel-appropriate format
if (result.responseText) {
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
if (cardController && cardMessageId) {
// Finalize streaming card with final content
const finalStatus = result.hasError ? 'error' : 'completed';
await cardController.finalize(cardMessageId, result.responseText, finalStatus);
cardFinalized = true;
} else {
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
}
} else if (result.hasError) {
const errorResponse: OutboundMessage = {
address: msg.address,
text: `<b>Error:</b> ${escapeHtml(result.errorMessage)}`,
parseMode: 'HTML',
replyToMessageId: msg.messageId,
};
await deliver(adapter, errorResponse);
if (cardController && cardMessageId) {
await cardController.finalize(cardMessageId, `❌ Error: ${result.errorMessage}`, 'error');
cardFinalized = true;
} else {
const errorResponse: OutboundMessage = {
address: msg.address,
text: `<b>Error:</b> ${escapeHtml(result.errorMessage)}`,
parseMode: 'HTML',
replyToMessageId: msg.messageId,
};
await deliver(adapter, errorResponse);
}
}
// Persist the actual SDK session ID for future resume.
@@ -614,6 +751,15 @@ async function handleMessage(
adapter.endPreview?.(msg.address.chatId, previewState.draftId);
}
// Clean up card streaming state — await creation if still in flight
if (cardController && !cardFinalized) {
const pending = cardCreatePromise as Promise<void> | null;
if (pending) await pending.catch(() => {});
if (cardMessageId) {
cardController.finalize(cardMessageId, '⚠️ Response interrupted.', 'interrupted').catch(() => {});
}
}
state.activeTasks.delete(binding.codepilotSessionId);
// Notify adapter that message processing ended
adapter.onMessageEnd?.(msg.address.chatId);
@@ -664,17 +810,7 @@ async function handleCommand(
'<b>CodePilot Bridge</b>',
'',
'Send any message to interact with Claude.',
'',
'<b>Commands:</b>',
'/new [path] - Start new session',
'/bind &lt;session_id&gt; - Bind to existing session',
'/cwd /path - Change working directory',
'/mode plan|code|ask - Change mode',
'/status - Show current status',
'/sessions - List recent sessions',
'/stop - Stop current session',
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission',
'/help - Show this help',
'Type /help for available commands.',
].join('\n');
break;
@@ -687,6 +823,12 @@ async function handleCommand(
break;
}
workDir = validated;
} else {
// No path specified — inherit CWD from current binding
const current = router.resolve(msg.address);
if (current.workingDirectory) {
workDir = current.workingDirectory;
}
}
const binding = router.createBinding(msg.address, workDir);
response = `New session created.\nSession: <code>${binding.codepilotSessionId.slice(0, 8)}...</code>\nCWD: <code>${escapeHtml(binding.workingDirectory || '~')}</code>`;
@@ -712,19 +854,60 @@ async function handleCommand(
}
case '/cwd': {
if (!args) {
response = 'Usage: /cwd /path/to/directory';
if (args) {
// Direct path specified
const validatedPath = validateWorkingDirectory(args);
if (!validatedPath) {
response = 'Invalid path. Must be an absolute path without traversal sequences or special characters.';
break;
}
const binding = router.resolve(msg.address);
router.updateBinding(binding.id, { workingDirectory: validatedPath, sdkSessionId: '' });
response = `Working directory set to <code>${escapeHtml(validatedPath)}</code>\n(SDK session reset — next message starts fresh context)`;
break;
}
const validatedPath = validateWorkingDirectory(args);
if (!validatedPath) {
response = 'Invalid path. Must be an absolute path without traversal sequences or special characters.';
// No args — show project selector card with buttons.
// Design decision: /cwd picker is a "recent projects quick-switch" for
// a single-operator desktop app. It intentionally shows all active
// directories across this channel type (not isolated per chat).
// If multi-user / chat-level isolation is needed in the future,
// this should be scoped by userId or chatId instead.
const bindings = router.listBindings(msg.address.channelType as any);
const uniqueDirs = [...new Set(
bindings
.filter((b) => b.active)
.map((b) => b.workingDirectory)
.filter((d): d is string => !!d && d !== '~')
)].slice(0, 8); // Max 8 options
if (uniqueDirs.length === 0) {
response = 'No project directories found.\nUsage: /cwd /path/to/directory';
break;
}
const binding = router.resolve(msg.address);
router.updateBinding(binding.id, { workingDirectory: validatedPath, sdkSessionId: '' });
response = `Working directory set to <code>${escapeHtml(validatedPath)}</code>\n(SDK session reset — next message starts fresh context)`;
break;
// Send as interactive card with buttons (Feishu) or text list (other channels)
const currentBinding = router.resolve(msg.address);
const currentCwd = currentBinding.workingDirectory || '~';
// Build inline buttons for project selection
const inlineButtons = uniqueDirs.map((dir) => {
const label = dir === currentCwd ? `📍 ${dir.split('/').pop() || dir}` : (dir.split('/').pop() || dir);
return [{
text: label,
callbackData: `cwd:${dir}`,
}];
});
const cardMsg: OutboundMessage = {
address: msg.address,
text: `<b>Switch Working Directory</b>\n\nCurrent: <code>${escapeHtml(currentCwd)}</code>\n\nSelect a project:`,
parseMode: 'HTML',
replyToMessageId,
inlineButtons,
};
await deliver(adapter, cardMsg);
return; // Don't send response — card is already sent
}
case '/mode': {
@@ -800,19 +983,257 @@ async function handleCommand(
break;
}
case '/history': {
// Fetch recent messages from the current chat (or thread)
if (!(adapter instanceof ChannelPluginAdapter)) {
response = 'History is not supported for this channel type.';
break;
}
const plugin = adapter.getPlugin();
if (!plugin.getCardStreamController && !(plugin as any).meta?.channelType) {
response = 'History is not available.';
break;
}
// Use message-actions if the plugin has Feishu-type capabilities
try {
const { readMessages, readThreadMessages } = await import('../channels/feishu/message-actions');
const restClient = (plugin as any).gateway?.getRestClient?.();
if (!restClient) {
response = 'Channel not connected.';
break;
}
const pageSize = parseInt(args, 10) || 10;
const chatIdRaw = msg.address.chatId;
const threadIdx = chatIdRaw.indexOf(':thread:');
let result;
if (threadIdx >= 0) {
// Thread history: extract thread ID and use readThreadMessages
const threadId = chatIdRaw.slice(threadIdx + ':thread:'.length);
result = await readThreadMessages(restClient, threadId, { pageSize });
} else {
const realChatId = extractRealChatId(chatIdRaw);
result = await readMessages(restClient, realChatId, { pageSize });
}
if (result.items.length === 0) {
response = 'No messages found.';
} else {
const lines = [`<b>Recent ${result.items.length} messages:</b>`, ''];
for (const item of result.items) {
const time = item.createTime ? new Date(parseInt(item.createTime, 10) * 1000).toLocaleString() : '?';
let content = '';
try {
const parsed = JSON.parse(item.content);
content = (parsed.text ?? '').slice(0, 80);
} catch {
content = item.content.slice(0, 80);
}
lines.push(`[${time}] ${escapeHtml(content)}`);
}
if (result.hasMore) lines.push('\n<i>(more messages available)</i>');
response = lines.join('\n');
}
} catch (err) {
response = `Failed to fetch history: ${err instanceof Error ? escapeHtml(err.message) : 'unknown error'}`;
}
break;
}
case '/search': {
// Simplified local search — lists recent messages and filters client-side.
// This is NOT equivalent to OpenClaw's server-side search (search.message.create API
// with user_access_token). Results are limited to recent messages in the current chat.
if (!args) {
response = 'Usage: /search &lt;keyword&gt;';
break;
}
if (!(adapter instanceof ChannelPluginAdapter)) {
response = 'Search is not supported for this channel type.';
break;
}
try {
const { searchMessages } = await import('../channels/feishu/message-actions');
const plugin = adapter.getPlugin();
const restClient = (plugin as any).gateway?.getRestClient?.();
if (!restClient) {
response = 'Channel not connected.';
break;
}
const realChatId = extractRealChatId(msg.address.chatId);
const result = await searchMessages(restClient, realChatId, args, { pageSize: 10 });
if (result.items.length === 0) {
response = `No messages matching "<b>${escapeHtml(args)}</b>".`;
} else {
const lines = [`<b>${result.items.length} result(s) for "${escapeHtml(args)}":</b>`, ''];
for (const item of result.items) {
const time = item.createTime ? new Date(parseInt(item.createTime, 10) * 1000).toLocaleString() : '?';
let content = '';
try {
const parsed = JSON.parse(item.content);
content = (parsed.text ?? '').slice(0, 100);
} catch {
content = item.content.slice(0, 100);
}
lines.push(`[${time}] ${escapeHtml(content)}`);
}
response = lines.join('\n');
}
} catch (err) {
response = `Search failed: ${err instanceof Error ? escapeHtml(err.message) : 'unknown error'}`;
}
break;
}
case '/feishu': {
const subArgs = args.split(/\s+/);
const subcommand = subArgs[0]?.toLowerCase() || 'help';
switch (subcommand) {
case 'start': {
// Validate Feishu config
if (!(adapter instanceof ChannelPluginAdapter)) {
response = 'This command is only available in Feishu channels.';
break;
}
const plugin = adapter.getPlugin();
const config = (plugin as any).getConfig?.();
if (!config) {
response = '❌ Feishu plugin not configured.\n\nPlease set App ID and App Secret in CodePilot settings, or use /feishu auth.';
break;
}
const validationError = plugin.validateConfig();
if (validationError) {
response = `❌ Configuration error: ${validationError}`;
break;
}
const capabilities = plugin.getCapabilities();
const lines = [
'✅ Feishu Bridge is running',
'',
`Streaming: ${capabilities.streaming ? '✅ Enabled' : '❌ Disabled'}`,
`Thread Reply: ${capabilities.threadReply ? '✅' : '❌'}`,
`Search: ${capabilities.search ? '✅' : '❌'}`,
`History: ${capabilities.history ? '✅' : '❌'}`,
];
response = lines.join('\n');
break;
}
case 'auth': {
// Show auth status and guidance
if (!(adapter instanceof ChannelPluginAdapter)) {
response = 'This command is only available in Feishu channels.';
break;
}
const plugin = adapter.getPlugin();
const config = (plugin as any).getConfig?.();
if (!config) {
response = '❌ App credentials not configured.\n\nPlease configure App ID and App Secret in CodePilot Settings → Bridge → Feishu.';
break;
}
// Note: CodePilot currently uses app-level bot tokens (no user OAuth)
// This is a simplified version compared to OpenClaw's full OAuth Device Flow
response = [
'🔐 Feishu Auth Status',
'',
`App ID: ${config.appId}`,
`DM Policy: ${config.dmPolicy}`,
`Allow From: ${(config.allowFrom || []).join(', ') || '(all)'}`,
'',
'ℹ️ CodePilot uses app-level bot tokens.',
'User-level OAuth (user_access_token) is not yet supported.',
'Some features requiring user identity (cross-chat search, sending as user) are unavailable.',
].join('\n');
break;
}
case 'doctor': {
// Run diagnostics
if (!(adapter instanceof ChannelPluginAdapter)) {
response = 'This command is only available in Feishu channels.';
break;
}
const plugin = adapter.getPlugin();
const config = (plugin as any).getConfig?.();
const lines = ['🔍 Feishu Doctor', ''];
// Config check
if (!config) {
lines.push('❌ Configuration: Not configured');
} else {
lines.push('✅ Configuration: OK');
lines.push(` App ID: ${config.appId}`);
lines.push(` DM Policy: ${config.dmPolicy}`);
lines.push(` Thread Session: ${config.threadSession ? 'Yes' : 'No'}`);
lines.push(` Streaming: Enabled`);
}
// Connection check
if (plugin.isRunning()) {
lines.push('✅ Connection: WebSocket connected');
} else {
lines.push('❌ Connection: Not running');
}
// Capabilities
const caps = plugin.getCapabilities();
lines.push('');
lines.push('Capabilities:');
lines.push(` Streaming Cards: ${caps.streaming ? '✅' : '❌'}`);
lines.push(` Thread Reply: ${caps.threadReply ? '✅' : '❌'}`);
lines.push(` Message Search: ${caps.search ? '✅' : '❌ (requires user_access_token)'}`);
lines.push(` Message History: ${caps.history ? '✅' : '❌'}`);
// Known limitations
lines.push('');
lines.push('Known Limitations (CodePilot vs OpenClaw):');
lines.push(' • No user_access_token / OAuth Device Flow');
lines.push(' • No cross-chat search (search.message.create requires UAT)');
lines.push(' • No "send as user" capability');
lines.push(' • Simplified card streaming (no reasoning phase display)');
response = lines.join('\n');
break;
}
default: {
// /feishu help or unknown subcommand
response = [
'Feishu Bridge Commands',
'',
'/feishu start — Check plugin status and configuration',
'/feishu auth — View auth status and guidance',
'/feishu doctor — Run diagnostics',
'/feishu help — Show this help',
].join('\n');
break;
}
}
break;
}
case '/help':
response = [
'<b>CodePilot Bridge Commands</b>',
'',
'/new [path] - Start new session',
'<b>Session:</b>',
'/new [path] - Create new session (optional: specify CWD)',
'/cwd /path - Change CWD, reset context',
'/bind &lt;session_id&gt; - Bind to existing session',
'/cwd /path - Change working directory',
'/mode plan|code|ask - Change mode',
'/status - Show current status',
'/status - Show session / CWD / mode / model',
'/sessions - List recent sessions',
'/stop - Stop current session',
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission request',
'/help - Show this help',
'/stop - Stop current task',
'',
'<b>Messages:</b>',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - Search in current chat',
'/perm allow|deny &lt;id&gt; - Permission response',
'',
'<b>Feishu:</b>',
'/feishu doctor - Run diagnostics',
'/feishu auth - View auth status',
].join('\n');
break;
+6
View File
@@ -96,6 +96,12 @@ export abstract class BaseChannelAdapter {
* normal delivery path, so this is typically a no-op.
*/
endPreview?(_chatId: string, _draftId: number): void;
/**
* Get a card stream controller for streaming card updates.
* Returns null if not supported by this adapter.
*/
getCardStreamController?(): import('../channels/types').CardStreamController | null;
}
// ── Adapter Registry ────────────────────────────────────────────
+30 -1
View File
@@ -37,10 +37,25 @@ function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
};
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
// Also read project-level .mcp.json
const projectMcp = readJson(path.join(process.cwd(), '.mcp.json'));
const merged = {
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
...((projectMcp.mcpServers || {}) as Record<string, MCPServerConfig>),
};
// Resolve ${...} placeholders in env values against DB settings
for (const server of Object.values(merged)) {
if (server.env) {
for (const [key, value] of Object.entries(server.env)) {
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
const settingKey = value.slice(2, -1);
const resolved = getSetting(settingKey);
server.env[key] = resolved || '';
}
}
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
} catch {
return undefined;
@@ -68,6 +83,12 @@ export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise<void>
*/
export type OnPartialText = (fullText: string) => void;
/**
* Callback invoked on tool_use / tool_result SSE events.
* Used by card streaming to show tool progress indicators.
*/
export type OnToolEvent = (event: { type: 'tool_use'; id: string; name: string } | { type: 'tool_result'; tool_use_id: string; is_error: boolean }) => void;
export interface ConversationResult {
responseText: string;
tokenUsage: TokenUsage | null;
@@ -90,6 +111,7 @@ export async function processMessage(
abortSignal?: AbortSignal,
files?: FileAttachment[],
onPartialText?: OnPartialText,
onToolEvent?: OnToolEvent,
): Promise<ConversationResult> {
const sessionId = binding.codepilotSessionId;
@@ -217,7 +239,7 @@ export async function processMessage(
// Consume the stream server-side (replicate collectStreamResponse pattern).
// Permission requests are forwarded immediately via the callback during streaming
// because the stream blocks until permission is resolved — we can't wait until after.
return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText);
return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText, onToolEvent);
} finally {
clearInterval(renewalInterval);
releaseSessionLock(sessionId, lockId);
@@ -234,6 +256,7 @@ async function consumeStream(
sessionId: string,
onPermissionRequest?: OnPermissionRequest,
onPartialText?: OnPartialText,
onToolEvent?: OnToolEvent,
): Promise<ConversationResult> {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
@@ -285,6 +308,9 @@ async function consumeStream(
name: toolData.name,
input: toolData.input,
});
if (onToolEvent) {
try { onToolEvent({ type: 'tool_use', id: toolData.id, name: toolData.name }); } catch { /* non-critical */ }
}
} catch { /* skip */ }
break;
}
@@ -307,6 +333,9 @@ async function consumeStream(
seenToolResultIds.add(resultData.tool_use_id);
contentBlocks.push(newBlock);
}
if (onToolEvent) {
try { onToolEvent({ type: 'tool_result', tool_use_id: resultData.tool_use_id, is_error: resultData.is_error || false }); } catch { /* non-critical */ }
}
} catch { /* skip */ }
break;
}
@@ -0,0 +1,73 @@
/**
* ChannelPluginAdapter — bridges a ChannelPlugin to BaseChannelAdapter.
*
* This allows any ChannelPlugin implementation to be used as a
* BaseChannelAdapter in the bridge system without modification.
*/
import { BaseChannelAdapter } from '../bridge/channel-adapter';
import type {
ChannelType,
InboundMessage,
OutboundMessage,
SendResult,
} from '../bridge/types';
import type { CardStreamController, ChannelPlugin } from './types';
export class ChannelPluginAdapter extends BaseChannelAdapter {
private plugin: ChannelPlugin;
constructor(plugin: ChannelPlugin) {
super();
this.plugin = plugin;
}
get channelType(): ChannelType {
return this.plugin.meta.channelType;
}
/** Expose the underlying plugin for direct access (e.g. message-actions). */
getPlugin(): ChannelPlugin {
return this.plugin;
}
async start(): Promise<void> {
return this.plugin.start();
}
async stop(): Promise<void> {
return this.plugin.stop();
}
isRunning(): boolean {
return this.plugin.isRunning();
}
async consumeOne(): Promise<InboundMessage | null> {
return this.plugin.consumeOne();
}
async send(message: OutboundMessage): Promise<SendResult> {
return this.plugin.send(message);
}
validateConfig(): string | null {
return this.plugin.validateConfig();
}
isAuthorized(userId: string, chatId: string): boolean {
return this.plugin.isAuthorized(userId, chatId);
}
onMessageStart(chatId: string): void {
this.plugin.onMessageStart?.(chatId);
}
onMessageEnd(chatId: string): void {
this.plugin.onMessageEnd?.(chatId);
}
getCardStreamController(): CardStreamController | null {
return this.plugin.getCardStreamController?.() ?? null;
}
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Feishu Card Streaming Controller
*
* Manages streaming card lifecycle via CardKit v2 API.
* State machine: idle → creating → streaming → completed | interrupted | error
*
* Features:
* - Thinking state display (💭 Thinking...)
* - Streaming text with throttled updates
* - Tool call progress indicators (🔄/✅/❌)
* - Final card with status footer and elapsed time
* - Markdown optimization for Feishu rendering
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { CardStreamController, ToolCallInfo } from '../types';
import type { CardStreamConfig } from './types';
import { optimizeMarkdown } from './outbound';
const LOG_TAG = '[card-controller]';
interface CardState {
cardId: string;
messageId: string;
sequence: number;
lastUpdateAt: number;
startTime: number;
throttleTimer: ReturnType<typeof setTimeout> | null;
pendingText: string | null;
/** Current tool calls being tracked */
toolCalls: ToolCallInfo[];
/** Whether we're in thinking state (before text starts flowing) */
thinking: boolean;
}
/** Format elapsed time for footer display */
function formatElapsed(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const sec = ms / 1000;
if (sec < 60) return `${sec.toFixed(1)}s`;
const min = Math.floor(sec / 60);
const remSec = Math.floor(sec % 60);
return `${min}m ${remSec}s`;
}
/** Build tool progress lines for card display */
function buildToolProgressMarkdown(tools: ToolCallInfo[]): string {
if (tools.length === 0) return '';
const lines = tools.map((tc) => {
const icon = tc.status === 'running' ? '🔄' : tc.status === 'complete' ? '✅' : '❌';
return `${icon} \`${tc.name}\``;
});
return lines.join('\n');
}
class FeishuCardStreamController implements CardStreamController {
private client: lark.Client;
private config: CardStreamConfig;
private cards = new Map<string, CardState>();
constructor(client: lark.Client, config: CardStreamConfig) {
this.client = client;
this.config = config;
}
async create(chatId: string, initialText: string, replyToMessageId?: string): Promise<string> {
try {
// 1. Create streaming card via CardKit v2
const cardBody = {
schema: '2.0',
config: {
streaming_mode: true,
wide_screen_mode: true,
summary: { content: '思考中...' },
},
body: {
elements: [{
tag: 'markdown',
content: initialText || '💭 Thinking...',
text_align: 'left',
text_size: 'normal',
element_id: 'streaming_content',
}],
},
};
const createResp = await (this.client as any).cardkit.v2.card.create({
data: { type: 'card_json', data: JSON.stringify(cardBody) },
});
const cardId = createResp?.data?.card_id;
if (!cardId) {
console.error(LOG_TAG, 'Card create returned no card_id');
return '';
}
// 2. Send card as IM message
const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } });
let msgResp: any;
if (replyToMessageId) {
msgResp = await this.client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content: cardContent, msg_type: 'interactive' },
});
} else {
msgResp = await this.client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' },
});
}
const messageId = msgResp?.data?.message_id || '';
this.cards.set(messageId, {
cardId,
messageId,
sequence: 0,
lastUpdateAt: Date.now(),
startTime: Date.now(),
throttleTimer: null,
pendingText: null,
toolCalls: [],
thinking: !initialText,
});
return messageId;
} catch (err: any) {
console.error(LOG_TAG, 'Card create failed:', err?.message || err);
return '';
}
}
async update(messageId: string, text: string): Promise<'ok' | 'fail'> {
const state = this.cards.get(messageId);
if (!state) return 'fail';
// Clear thinking state once text starts flowing
if (state.thinking && text.trim()) {
state.thinking = false;
}
state.pendingText = text;
const elapsed = Date.now() - state.lastUpdateAt;
if (elapsed < this.config.throttleMs) {
// Schedule trailing-edge flush
if (!state.throttleTimer) {
state.throttleTimer = setTimeout(() => {
state.throttleTimer = null;
if (state.pendingText) {
this.flushUpdate(state).catch(() => {});
}
}, this.config.throttleMs - elapsed);
}
return 'ok';
}
return this.flushUpdate(state);
}
private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> {
if (!state.pendingText && state.toolCalls.length === 0) return 'ok';
// Build content: main text + tool progress
let content = state.pendingText || '';
const toolMd = buildToolProgressMarkdown(state.toolCalls);
if (toolMd) {
content = content ? `${content}\n\n${toolMd}` : toolMd;
}
state.pendingText = null;
try {
state.sequence++;
await (this.client as any).cardkit.v2.card.streamContent({
path: { card_id: state.cardId },
data: { content, sequence: state.sequence },
});
state.lastUpdateAt = Date.now();
return 'ok';
} catch (err: any) {
console.error(LOG_TAG, 'Stream update failed:', err?.message || err);
return 'fail';
}
}
/** Update tool call progress — triggers a card update */
updateToolCalls(messageId: string, tools: ToolCallInfo[]): void {
const state = this.cards.get(messageId);
if (!state) return;
state.toolCalls = tools;
// Force a flush with current text + updated tool progress
const elapsed = Date.now() - state.lastUpdateAt;
if (elapsed >= this.config.throttleMs) {
this.flushUpdate(state).catch(() => {});
} else if (!state.throttleTimer) {
state.throttleTimer = setTimeout(() => {
state.throttleTimer = null;
this.flushUpdate(state).catch(() => {});
}, this.config.throttleMs - elapsed);
}
}
/** Set thinking state — shows 💭 Thinking... in card */
setThinking(messageId: string): void {
const state = this.cards.get(messageId);
if (!state) return;
state.thinking = true;
}
async finalize(
messageId: string,
finalText: string,
status: 'completed' | 'interrupted' | 'error' = 'completed',
): Promise<void> {
const state = this.cards.get(messageId);
if (!state) return;
// Clear any pending throttle
if (state.throttleTimer) {
clearTimeout(state.throttleTimer);
state.throttleTimer = null;
}
try {
// Close streaming mode
state.sequence++;
await (this.client as any).cardkit.v2.card.setStreamingMode({
path: { card_id: state.cardId },
data: { streaming_mode: false, sequence: state.sequence },
});
// Build final card elements
const elements: any[] = [];
// Main content (optimize markdown for Feishu rendering)
elements.push({
tag: 'markdown',
content: optimizeMarkdown(finalText),
text_size: 'normal',
element_id: 'streaming_content',
});
// Tool call summary (if any tools were used)
if (state.toolCalls.length > 0) {
const toolMd = buildToolProgressMarkdown(state.toolCalls);
if (toolMd) {
elements.push({
tag: 'markdown',
content: toolMd,
text_size: 'notation',
element_id: 'tool_summary',
});
}
}
// Footer
const footerCfg = this.config.footer;
const footerParts: string[] = [];
if (footerCfg?.status) {
const statusLabels: Record<string, string> = {
completed: '✅ Completed',
interrupted: '⚠️ Interrupted',
error: '❌ Error',
};
footerParts.push(statusLabels[status] || status);
}
if (footerCfg?.elapsed) {
const elapsedMs = Date.now() - state.startTime;
footerParts.push(formatElapsed(elapsedMs));
}
if (footerParts.length > 0) {
elements.push({ tag: 'hr' });
elements.push({
tag: 'markdown',
content: footerParts.join(' · '),
text_size: 'notation',
element_id: 'footer',
});
}
// Update final card
const finalCard = {
schema: '2.0',
config: { wide_screen_mode: true },
body: { elements },
};
state.sequence++;
await (this.client as any).cardkit.v2.card.update({
path: { card_id: state.cardId },
data: { type: 'card_json', data: JSON.stringify(finalCard), sequence: state.sequence },
});
} catch (err: any) {
console.error(LOG_TAG, 'Finalize failed:', err?.message || err);
}
this.cards.delete(messageId);
}
}
/**
* Factory function for creating a card stream controller.
*/
export function createCardStreamController(
client: lark.Client,
config: CardStreamConfig,
): CardStreamController {
return new FeishuCardStreamController(client, config);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Feishu configuration loading and validation.
*/
import { getSetting } from '../../db';
import type { FeishuConfig } from './types';
/** Parse a comma-separated string into a trimmed, non-empty string array. */
function parseList(raw: string | undefined): string[] {
if (!raw) return [];
return raw.split(',').map(s => s.trim()).filter(Boolean);
}
/** Load Feishu config from settings DB. Returns null if incomplete. */
export function loadFeishuConfig(): FeishuConfig | null {
const appId = getSetting('bridge_feishu_app_id');
const appSecret = getSetting('bridge_feishu_app_secret');
if (!appId || !appSecret) return null;
const domain = getSetting('bridge_feishu_domain') || 'feishu';
const allowFrom = parseList(getSetting('bridge_feishu_allow_from'));
const groupAllowFrom = parseList(getSetting('bridge_feishu_group_allow_from'));
const dmPolicy = (getSetting('bridge_feishu_dm_policy') || 'open') as FeishuConfig['dmPolicy'];
const groupPolicy = (getSetting('bridge_feishu_group_policy') || 'open') as FeishuConfig['groupPolicy'];
const requireMention = getSetting('bridge_feishu_require_mention') === 'true';
const threadSession = getSetting('bridge_feishu_thread_session') === 'true';
// Validation: when dmPolicy is 'open', allowFrom should include '*'
if (dmPolicy === 'open' && allowFrom.length > 0 && !allowFrom.includes('*')) {
allowFrom.push('*');
}
return {
appId,
appSecret,
domain,
allowFrom,
groupAllowFrom,
dmPolicy,
groupPolicy,
requireMention,
threadSession,
cardStreamConfig: {
throttleMs: 200,
footer: { status: true, elapsed: true },
},
};
}
/** Validate a Feishu config. Returns null if valid, error message otherwise. */
export function validateFeishuConfig(config: FeishuConfig | null): string | null {
if (!config) return 'Feishu App ID and App Secret are required.';
if (!config.appId) return 'Feishu App ID is required.';
if (!config.appSecret) return 'Feishu App Secret is required.';
return null;
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Feishu WebSocket gateway — manages lark SDK client and WS connection lifecycle.
*
* Key design decisions:
* - WSClient mode handles auth internally — no encryptKey/verificationToken needed.
* - Client and WSClient receive the resolved SDK domain (Feishu vs Lark).
* - card.action.trigger handler guarantees a valid response within 3 seconds,
* regardless of what the upper-layer handler does. Heavy logic is fire-and-forget.
*/
import * as lark from '@larksuiteoapi/node-sdk';
import type { FeishuConfig } from './types';
const LOG_TAG = '[feishu/gateway]';
/** Map brand string to SDK domain constant. */
function resolveDomain(brand: string): lark.Domain | string {
if (brand === 'lark') return lark.Domain.Lark;
if (brand === 'feishu') return lark.Domain.Feishu;
// Custom domain (e.g. self-hosted): pass through as-is
return brand.replace(/\/+$/, '');
}
/** Default toast returned when the upper-layer handler fails or times out. */
const FALLBACK_TOAST = {
toast: { type: 'info' as const, content: '已收到,正在处理...' },
};
/**
* Card action callback handler type.
* Receives the raw event data and returns a toast/card response object.
* May also return void/undefined — gateway will fill in a default toast.
*/
export type CardActionHandler = (data: unknown) => Promise<unknown>;
export class FeishuGateway {
private client: lark.Client | null = null;
private wsClient: lark.WSClient | null = null;
private running = false;
private config: FeishuConfig;
private eventDispatcher: lark.EventDispatcher;
private onMessage: ((data: unknown) => void) | null = null;
private cardActionHandler: CardActionHandler | null = null;
constructor(config: FeishuConfig) {
this.config = config;
// WSClient mode doesn't need encryptKey/verificationToken — pass empty strings.
this.eventDispatcher = new lark.EventDispatcher({
encryptKey: '',
verificationToken: '',
});
}
/** Get the REST client (for API calls). */
getRestClient(): lark.Client | null {
return this.client;
}
/** Get the event dispatcher for handler registration. */
getEventDispatcher(): lark.EventDispatcher {
return this.eventDispatcher;
}
/** Register the im.message.receive_v1 handler. */
registerMessageHandler(handler: (data: unknown) => void): void {
this.onMessage = handler;
this.eventDispatcher.register({
'im.message.receive_v1': ((data: unknown) => {
handler(data);
}) as any,
});
}
/**
* Register a card action handler.
*
* The gateway wraps this handler with a 3-second timeout guarantee:
* - If the handler resolves within 3s, its return value is used.
* - If it throws or times out, a safe fallback toast is returned.
* - The handler itself should keep synchronous work minimal and
* push heavy logic to fire-and-forget (setImmediate / queueMicrotask).
*
* Supports both button value formats:
* - value.callback_data (CodePilot permission buttons)
* - value.action / value.operation_id (OpenClaw-style buttons)
*/
registerCardActionHandler(handler: CardActionHandler): void {
this.cardActionHandler = handler;
// Register the safe wrapper on the EventDispatcher.
// The wrapper guarantees a response object is always returned.
this.eventDispatcher.register({
'card.action.trigger': ((data: unknown) => this.safeCardActionHandler(data)) as any,
});
}
/**
* Gateway-level card action handler that enforces the 3-second response contract.
*
* Flow:
* 1. Race the upper-layer handler against a 2.5s timeout (leaving 500ms margin).
* 2. If the handler returns a valid object, use it.
* 3. If it throws, times out, or returns nothing, return FALLBACK_TOAST.
*/
private async safeCardActionHandler(data: unknown): Promise<unknown> {
const handler = this.cardActionHandler;
if (!handler) return FALLBACK_TOAST;
const TIMEOUT_MS = 2500; // 2.5s — leave 500ms margin for SDK overhead
try {
const result = await Promise.race([
handler(data),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), TIMEOUT_MS)),
]);
// Handler resolved with a valid response
if (result && typeof result === 'object') return result;
// Handler returned void/undefined or timed out — use fallback
return FALLBACK_TOAST;
} catch (err) {
console.error(LOG_TAG, 'Card action handler error:', err);
return FALLBACK_TOAST;
}
}
/** Start the WebSocket connection. */
async start(): Promise<void> {
if (this.running) return;
const domain = resolveDomain(this.config.domain);
this.client = new lark.Client({
appId: this.config.appId,
appSecret: this.config.appSecret,
domain,
disableTokenCache: false,
});
this.wsClient = new lark.WSClient({
appId: this.config.appId,
appSecret: this.config.appSecret,
domain,
loggerLevel: lark.LoggerLevel.warn,
});
// Monkey-patch handleEventData to support card action events (type: "card").
// The SDK's WSClient only handles type="event" by default; card action
// callbacks arrive as type="card" and would be silently dropped.
// Patch: rewrite type header from "card" to "event" before dispatching.
const wsClientAny = this.wsClient as any;
if (typeof wsClientAny.handleEventData === 'function') {
const origHandleEventData = wsClientAny.handleEventData.bind(wsClientAny);
wsClientAny.handleEventData = (data: any) => {
const msgType = data.headers?.find?.((h: any) => h.key === 'type')?.value;
if (msgType !== 'event') console.log(LOG_TAG, 'handleEventData type:', msgType);
if (msgType === 'card') {
const patchedData = {
...data,
headers: data.headers.map((h: any) =>
h.key === 'type' ? { ...h, value: 'event' } : h,
),
};
return origHandleEventData(patchedData);
}
return origHandleEventData(data);
};
}
await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
this.running = true;
console.log(LOG_TAG, 'WebSocket connected');
}
/** Stop the WebSocket connection. */
async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
// WSClient doesn't expose a clean stop — set refs to null
this.wsClient = null;
console.log(LOG_TAG, 'Stopped');
}
isRunning(): boolean {
return this.running;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Feishu bot identity resolution.
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { FeishuBotInfo } from './types';
const LOG_TAG = '[feishu/identity]';
/** Fetch bot info from Feishu API. */
export async function getBotInfo(client: lark.Client): Promise<FeishuBotInfo | null> {
try {
const resp = await (client as any).bot.v3.botInfo.list();
const bot = resp?.data?.bot;
if (!bot) return null;
return {
appId: bot.app_id || '',
botName: bot.app_name || '',
openId: bot.open_id || '',
};
} catch (err) {
console.error(LOG_TAG, 'Failed to get bot info:', err);
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Feishu inbound message processing.
*
* Converts raw Feishu event data into InboundMessage for the bridge queue.
*/
import type { InboundMessage } from '../../bridge/types';
import type { FeishuConfig } from './types';
const LOG_TAG = '[feishu/inbound]';
/** Parse a raw Feishu im.message.receive_v1 event into an InboundMessage. */
export function parseInboundMessage(
eventData: any,
_config: FeishuConfig,
): InboundMessage | null {
try {
const event = eventData?.event ?? eventData;
const message = event?.message;
if (!message) return null;
const chatId = message.chat_id || '';
const messageId = message.message_id || '';
const sender = event.sender?.sender_id?.open_id || '';
const msgType = message.message_type;
// Only handle text messages for now
let text = '';
if (msgType === 'text') {
try {
const content = JSON.parse(message.content || '{}');
text = content.text || '';
} catch {
text = message.content || '';
}
} else {
// Non-text messages — skip silently
return null;
}
if (!text.trim()) return null;
// Build thread-session address if applicable
const rootId = message.root_id || '';
const effectiveChatId = rootId ? `${chatId}:thread:${rootId}` : chatId;
return {
messageId,
address: {
channelType: 'feishu',
chatId: effectiveChatId,
userId: sender,
},
text: text.trim(),
timestamp: parseInt(message.create_time, 10) || Date.now(),
};
} catch (err) {
console.error(LOG_TAG, 'Failed to parse inbound message:', err);
return null;
}
}
+242
View File
@@ -0,0 +1,242 @@
/**
* FeishuChannelPlugin — implements ChannelPlugin for Feishu/Lark.
*
* Composes: gateway (WS), inbound (parsing), outbound (sending),
* identity (bot info), policy (access control), card-controller (streaming).
*/
import type { InboundMessage, OutboundMessage, SendResult } from '../../bridge/types';
import type { ChannelPlugin, ChannelCapabilities, ChannelMeta, CardStreamController } from '../types';
import type { FeishuConfig } from './types';
import { loadFeishuConfig, validateFeishuConfig } from './config';
import { FeishuGateway } from './gateway';
import { parseInboundMessage } from './inbound';
import { sendMessage, addReaction, removeReaction } from './outbound';
import { isUserAuthorized } from './policy';
import { createCardStreamController } from './card-controller';
export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
readonly meta: ChannelMeta = {
channelType: 'feishu',
displayName: 'Feishu / Lark',
};
private config: FeishuConfig | null = null;
private gateway: FeishuGateway | null = null;
private messageQueue: InboundMessage[] = [];
private waitResolve: ((msg: InboundMessage | null) => void) | null = null;
/** Track last received messageId per chatId for reaction acknowledgment. */
private lastMessageIdByChat = new Map<string, string>();
/** Track active reaction IDs per chatId so we can remove them on completion. */
private activeReactions = new Map<string, { messageId: string; reactionId: string }>();
loadConfig(): FeishuConfig | null {
this.config = loadFeishuConfig();
return this.config;
}
getConfig(): FeishuConfig | null {
return this.config;
}
getCapabilities(): ChannelCapabilities {
return {
streaming: true,
threadReply: true,
search: false, // True server-side search requires user_access_token; we only have local filtering
history: true,
reactions: false,
};
}
validateConfig(): string | null {
if (!this.config) {
this.loadConfig();
}
return validateFeishuConfig(this.config);
}
async start(): Promise<void> {
if (!this.config) {
this.config = loadFeishuConfig();
}
if (!this.config) throw new Error('Feishu config not loaded');
this.gateway = new FeishuGateway(this.config);
// Register message handler — pushes to internal queue
this.gateway.registerMessageHandler((data: unknown) => {
const msg = parseInboundMessage(data, this.config!);
if (!msg) return;
this.enqueueMessage(msg);
});
// Register card action handler — converts button clicks to callback messages.
// Gateway guarantees 3-second response; this handler should stay lightweight.
// Supports two button value formats:
// 1. { callback_data: "perm:allow:xxx" } — CodePilot permission buttons
// 2. { action: "app_auth_done", operation_id: "xxx" } — OpenClaw-style buttons
this.gateway.registerCardActionHandler(async (data: unknown) => {
const event = data as any;
console.log('[feishu/plugin]', 'Card action raw event:', JSON.stringify(event).slice(0, 500));
const value = event?.action?.value ?? {};
// Feishu card.action.trigger v2 callback structure (per official docs):
// event.operator.open_id, event.context.open_chat_id, event.context.open_message_id
// SDK InteractiveCardActionEvent (older type) flattens to:
// event.open_id, event.open_message_id
// WSClient monkey-patch may deliver either format — try both paths.
// Additionally, we embed chatId in button value as ultimate fallback.
const chatId = event?.context?.open_chat_id || value.chatId || '';
const messageId = event?.context?.open_message_id || event?.open_message_id || '';
const userId = event?.operator?.open_id || event?.open_id || '';
// Format 1: callback_data (permission buttons)
const callbackData = value.callback_data;
if (callbackData && chatId) {
const callbackMsg: InboundMessage = {
messageId: messageId || `card_action_${Date.now()}`,
address: {
channelType: 'feishu',
chatId,
userId,
},
text: '',
timestamp: Date.now(),
callbackData,
callbackMessageId: messageId,
};
console.log('[feishu/plugin]', 'Card action (callback_data):', callbackData);
this.enqueueMessage(callbackMsg);
return {
toast: { type: 'info' as const, content: '已收到,正在处理...' },
};
}
// Format 2: action / operation_id (OpenClaw-style buttons)
const action = value.action;
const operationId = value.operation_id;
if (action) {
// Encode as callbackData so the existing bridge-manager callback path
// can handle it. Format: "action:{action}:{operation_id}"
const syntheticCallback = operationId
? `action:${action}:${operationId}`
: `action:${action}`;
const actionMsg: InboundMessage = {
messageId: messageId || `card_action_${Date.now()}`,
address: {
channelType: 'feishu',
chatId,
userId,
},
text: '',
timestamp: Date.now(),
callbackData: syntheticCallback,
callbackMessageId: messageId,
};
console.log('[feishu/plugin]', 'Card action (action):', action, operationId ?? '');
this.enqueueMessage(actionMsg);
return {
toast: { type: 'info' as const, content: '已收到,正在处理...' },
};
}
// Unknown button format — still return a valid toast to prevent 200340
console.warn('[feishu/plugin]', 'Unknown card action value:', JSON.stringify(value).slice(0, 200));
return {
toast: { type: 'info' as const, content: '已收到' },
};
});
await this.gateway.start();
}
async stop(): Promise<void> {
if (this.gateway) {
await this.gateway.stop();
this.gateway = null;
}
// Unblock any waiting consumer
if (this.waitResolve) {
this.waitResolve(null);
this.waitResolve = null;
}
}
isRunning(): boolean {
return this.gateway?.isRunning() ?? false;
}
private enqueueMessage(msg: InboundMessage): void {
// Track messageId for reaction acknowledgment (skip callback messages)
if (msg.messageId && !msg.callbackData) {
this.lastMessageIdByChat.set(msg.address.chatId, msg.messageId);
}
if (this.waitResolve) {
const resolve = this.waitResolve;
this.waitResolve = null;
resolve(msg);
} else {
this.messageQueue.push(msg);
}
}
async consumeOne(): Promise<InboundMessage | null> {
if (this.messageQueue.length > 0) {
return this.messageQueue.shift()!;
}
return new Promise<InboundMessage | null>((resolve) => {
this.waitResolve = resolve;
});
}
async send(message: OutboundMessage): Promise<SendResult> {
const client = this.gateway?.getRestClient();
if (!client) return { ok: false, error: 'Not connected' };
return sendMessage(client, message);
}
isAuthorized(userId: string, chatId: string): boolean {
if (!this.config) return false;
return isUserAuthorized(this.config, userId, chatId);
}
/** Add emoji reaction to acknowledge message receipt. */
onMessageStart(chatId: string): void {
const client = this.gateway?.getRestClient();
const messageId = this.lastMessageIdByChat.get(chatId);
if (!client || !messageId) return;
// Fire-and-forget — don't block message processing
addReaction(client, messageId, 'Typing').then((reactionId) => {
if (reactionId) {
this.activeReactions.set(chatId, { messageId, reactionId });
}
}).catch(() => {});
}
/** Remove the "processing" reaction after response is sent. */
onMessageEnd(chatId: string): void {
const client = this.gateway?.getRestClient();
const reaction = this.activeReactions.get(chatId);
if (!client || !reaction) return;
this.activeReactions.delete(chatId);
removeReaction(client, reaction.messageId, reaction.reactionId).catch(() => {});
}
getCardStreamController(): CardStreamController | null {
const client = this.gateway?.getRestClient();
if (!client) {
console.log('[feishu/plugin] getCardStreamController: no client');
return null;
}
if (!this.config) {
console.log('[feishu/plugin] getCardStreamController: no config');
return null;
}
return createCardStreamController(client, this.config.cardStreamConfig);
}
/** Expose gateway for direct access (e.g. message-actions need restClient). */
get _gateway(): FeishuGateway | null {
return this.gateway;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Feishu message-actions — readMessages, readThreadMessages, and searchMessagesLocal.
*
* These functions are dynamically imported by bridge-manager for /history and /search commands.
*
* Identity note: All functions here use bot identity (app_access_token) via the
* Lark SDK client. OpenClaw's equivalent (feishu_im_user_*) uses user_access_token
* for richer results and broader access.
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { MessageListResult, FeishuMessageItem } from './types';
interface ReadOptions {
pageSize?: number;
pageToken?: string;
}
interface SearchOptions {
pageSize?: number;
pageToken?: string;
}
/**
* Read recent messages from a Feishu chat using bot identity (app_access_token).
*
* OpenClaw's equivalent (feishu_im_user_get_chat_messages) uses user_access_token
* for richer results including messages from all participants.
*/
export async function readMessages(
client: lark.Client,
chatId: string,
options: ReadOptions = {},
): Promise<MessageListResult> {
const { pageSize = 10, pageToken } = options;
try {
const resp = await client.im.message.list({
params: {
container_id_type: 'chat',
container_id: chatId,
page_size: pageSize,
...(pageToken ? { page_token: pageToken } : {}),
},
});
const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({
messageId: item.message_id || '',
rootId: item.root_id || undefined,
parentId: item.parent_id || undefined,
msgType: item.msg_type || '',
createTime: item.create_time || '',
updateTime: item.update_time || undefined,
content: item.body?.content || '',
sender: {
id: item.sender?.id || '',
idType: item.sender?.id_type || '',
senderType: item.sender?.sender_type || '',
tenantKey: item.sender?.tenant_key || undefined,
},
}));
return {
items,
hasMore: resp?.data?.has_more || false,
pageToken: resp?.data?.page_token || undefined,
};
} catch (err: any) {
console.error('[feishu/message-actions] readMessages failed:', err?.message || err);
return { items: [], hasMore: false };
}
}
/**
* Read messages from a Feishu thread (reply chain).
*
* Uses `container_id_type: 'thread'` to fetch all messages in a specific thread.
* OpenClaw's equivalent is feishu_im_user_get_thread_messages.
*/
export async function readThreadMessages(
client: lark.Client,
threadId: string,
options: ReadOptions = {},
): Promise<MessageListResult> {
const { pageSize = 10, pageToken } = options;
try {
const resp = await client.im.message.list({
params: {
container_id_type: 'thread',
container_id: threadId,
page_size: pageSize,
...(pageToken ? { page_token: pageToken } : {}),
},
});
const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({
messageId: item.message_id || '',
rootId: item.root_id || undefined,
parentId: item.parent_id || undefined,
msgType: item.msg_type || '',
createTime: item.create_time || '',
updateTime: item.update_time || undefined,
content: item.body?.content || '',
sender: {
id: item.sender?.id || '',
idType: item.sender?.id_type || '',
senderType: item.sender?.sender_type || '',
tenantKey: item.sender?.tenant_key || undefined,
},
}));
return {
items,
hasMore: resp?.data?.has_more || false,
pageToken: resp?.data?.page_token || undefined,
};
} catch (err: any) {
console.error('[feishu/message-actions] readThreadMessages failed:', err?.message || err);
return { items: [], hasMore: false };
}
}
/**
* Simplified local search — lists recent messages and filters client-side.
*
* Unlike OpenClaw's feishu_im_user_search_messages which uses the search.message.create
* API with user_access_token for true cross-chat server-side search, this function
* only fetches recent messages from a single chat and applies a client-side keyword filter.
* Results are limited and pagination is not reliable.
*/
export async function searchMessagesLocal(
client: lark.Client,
chatId: string,
query: string,
options: SearchOptions = {},
): Promise<MessageListResult> {
const { pageSize = 10, pageToken } = options;
try {
// Use im.message.list and filter client-side.
// True server-side search requires user_access_token (search.message.create API).
const resp = await client.im.message.list({
params: {
container_id_type: 'chat',
container_id: chatId,
page_size: Math.min(pageSize * 5, 50), // fetch more to filter
sort_type: 'ByCreateTimeDesc' as any,
...(pageToken ? { page_token: pageToken } : {}),
},
});
const allItems: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({
messageId: item.message_id || '',
rootId: item.root_id || undefined,
parentId: item.parent_id || undefined,
msgType: item.msg_type || '',
createTime: item.create_time || '',
updateTime: item.update_time || undefined,
content: item.body?.content || '',
sender: {
id: item.sender?.id || '',
idType: item.sender?.id_type || '',
senderType: item.sender?.sender_type || '',
tenantKey: item.sender?.tenant_key || undefined,
},
}));
// Client-side keyword filter
const lowerQuery = query.toLowerCase();
const filtered = allItems.filter(item => {
try {
const parsed = JSON.parse(item.content);
return (parsed.text || '').toLowerCase().includes(lowerQuery);
} catch {
return item.content.toLowerCase().includes(lowerQuery);
}
}).slice(0, pageSize);
return {
items: filtered,
hasMore: false, // can't paginate client-side filter reliably
};
} catch (err: any) {
console.error('[feishu/message-actions] searchMessagesLocal failed:', err?.message || err);
return { items: [], hasMore: false };
}
}
/** Backward-compatible alias for searchMessagesLocal. */
export const searchMessages = searchMessagesLocal;
+365
View File
@@ -0,0 +1,365 @@
/**
* Feishu outbound message rendering and sending.
*
* Text messages use `post` format with `md` tag for markdown rendering.
* Card messages (buttons, permissions) use Schema V2 interactive cards.
*
* Markdown is optimized for Feishu: heading demotion, table spacing,
* code block padding, and invalid image key stripping.
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { OutboundMessage, SendResult } from '../../bridge/types';
const LOG_TAG = '[feishu/outbound]';
// ─── Markdown optimization for Feishu ────────────────────────────────────────
/**
* Optimize markdown for Feishu rendering compatibility.
*
* Based on OpenClaw's markdown-style.ts:
* - Demote headings: H1 → H4, H2-H6 → H5 (Feishu renders H1-H3 too large)
* - Add spacing around tables with <br> tags
* - Pad code blocks with <br> for visual separation
* - Strip invalid image keys (prevent CardKit error 200570)
* - Compress excessive newlines (3+ → 2)
*/
export function optimizeMarkdown(text: string): string {
try {
return _optimizeMarkdown(text);
} catch {
return text;
}
}
function _optimizeMarkdown(text: string): string {
// 1. Extract code blocks — protect from transformation
const MARK = '___CB_';
const codeBlocks: string[] = [];
let r = text.replace(/```[\s\S]*?```/g, (m) => {
return `${MARK}${codeBlocks.push(m) - 1}___`;
});
// 2. Heading demotion (only if H1-H3 exist)
// Feishu renders H1-H3 too large in post md tag
const hasLargeHeadings = /^#{1,3} /m.test(r);
if (hasLargeHeadings) {
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1'); // H2-H6 → H5
r = r.replace(/^# (.+)$/gm, '#### $1'); // H1 → H4
}
// 3. Spacing between consecutive headings
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n\n$2');
// 4. Table spacing — ensure blank line before/after table blocks
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2');
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '\n\n$1\n\n');
// 5. Restore code blocks with blank line padding
// Note: Feishu post md tag does NOT support <br> — use blank lines instead
codeBlocks.forEach((block, i) => {
r = r.replace(`${MARK}${i}___`, `\n\n${block}\n\n`);
});
// 6. Strip invalid image keys (only allow img_xxx and http(s) URLs)
if (r.includes('![')) {
r = r.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_full, _alt, value) => {
if (value.startsWith('img_') || value.startsWith('http://') || value.startsWith('https://')) {
return _full;
}
return value;
});
}
// 7. Compress excessive newlines (3+ → 2)
r = r.replace(/\n{3,}/g, '\n\n');
return r;
}
/**
* Convert HTML-formatted text (from permission-broker) to Feishu markdown.
*/
function htmlToFeishuMarkdown(text: string): string {
return text
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
.replace(/<\/?[^>]+>/g, '');
}
// ─── Message sending ─────────────────────────────────────────────────────────
/**
* Send a message to Feishu.
*
* - With inlineButtons → interactive card (Schema V2)
* - Without → post format with md tag (supports markdown rendering)
*/
export async function sendMessage(
client: lark.Client,
message: OutboundMessage,
): Promise<SendResult> {
try {
const chatId = message.address.chatId.split(':thread:')[0];
const replyId = message.replyToMessageId;
// Interactive card for messages with buttons
if (message.inlineButtons && message.inlineButtons.length > 0) {
return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId);
}
// Post format with md tag for markdown support
return sendAsPost(client, chatId, message.text, message.parseMode, replyId);
} catch (err: any) {
console.error(LOG_TAG, 'Send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}
/**
* Send a message using post format with md tag.
* This enables markdown rendering in Feishu (bold, code, lists, tables, etc).
*/
async function sendAsPost(
client: lark.Client,
chatId: string,
text: string,
parseMode?: string,
replyToMessageId?: string,
): Promise<SendResult> {
// Convert HTML to markdown if needed, then optimize for Feishu
let mdText = parseMode === 'HTML' ? htmlToFeishuMarkdown(text) : text;
mdText = optimizeMarkdown(mdText);
const content = JSON.stringify({
zh_cn: {
content: [[{ tag: 'md', text: mdText }]],
},
});
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'post' },
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, content, msg_type: 'post' },
});
}
const msgId = resp?.data?.message_id || '';
return { ok: true, messageId: msgId };
}
// ─── Interactive cards ───────────────────────────────────────────────────────
/**
* Build and send a Feishu interactive card with buttons.
*
* Uses Schema V2 with:
* - markdown element for text content
* - action element for buttons (Allow = primary, Deny = danger)
*/
async function sendAsInteractiveCard(
client: lark.Client,
chatId: string,
text: string,
inlineButtons: import('../../bridge/types').InlineButton[][],
replyToMessageId?: string,
): Promise<SendResult> {
try {
const mdText = htmlToFeishuMarkdown(text);
// Detect card type from button callback data
const firstCallback = inlineButtons[0]?.[0]?.callbackData || '';
const isPermission = firstCallback.startsWith('perm:');
const isCwd = firstCallback.startsWith('cwd:');
// Build button elements
const allButtons = inlineButtons.flat();
const buttonColumns = allButtons.map((btn) => {
let btnType: 'primary' | 'danger' | 'default' = 'default';
const lowerText = btn.text.toLowerCase();
if (lowerText.includes('deny') || lowerText.includes('拒绝')) {
btnType = 'danger';
} else if (lowerText.includes('allow') || lowerText.includes('允许')) {
btnType = 'primary';
} else if (btn.text.startsWith('📍')) {
btnType = 'primary'; // Current project highlighted
}
return {
tag: 'column' as const,
width: isCwd ? 'weighted' as const : 'auto' as const,
weight: isCwd ? 1 : undefined,
elements: [
{
tag: 'button' as const,
text: { tag: 'plain_text' as const, content: btn.text },
type: btnType,
size: 'medium' as const,
value: { callback_data: btn.callbackData, chatId },
},
],
};
});
// Card header based on type
const headerConfig = isPermission
? { title: 'Permission Required', template: 'blue' as const, icon: 'lock-chat_filled' }
: isCwd
? { title: 'Switch Project', template: 'turquoise' as const, icon: 'folder_outlined' }
: { title: 'Action Required', template: 'blue' as const, icon: 'info-circle_outlined' };
// Build body elements
const bodyElements: any[] = [
{
tag: 'markdown' as const,
content: mdText,
text_size: 'normal' as const,
},
];
if (isPermission) {
bodyElements.push({
tag: 'markdown' as const,
content: '⏱ This request will expire in 5 minutes',
text_size: 'notation' as const,
});
}
bodyElements.push({ tag: 'hr' as const });
// CWD card: stack buttons vertically (one per row)
if (isCwd) {
for (const col of buttonColumns) {
bodyElements.push({
tag: 'column_set' as const,
flex_mode: 'none' as const,
columns: [col],
});
}
} else {
bodyElements.push({
tag: 'column_set' as const,
flex_mode: 'none' as const,
horizontal_align: 'left' as const,
columns: buttonColumns,
});
}
const card = {
schema: '2.0',
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text' as const, content: headerConfig.title },
template: headerConfig.template,
icon: { tag: 'standard_icon' as const, token: headerConfig.icon },
padding: '12px 12px 12px 12px',
},
body: { elements: bodyElements },
};
const cardContent = JSON.stringify(card);
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content: cardContent, msg_type: 'interactive' },
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' },
});
}
const msgId = resp?.data?.message_id || '';
console.log(LOG_TAG, 'Sent interactive card:', msgId);
return { ok: true, messageId: msgId };
} catch (err: any) {
console.error(LOG_TAG, 'Interactive card send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}
// ─── Reactions ──────────────────────────────────────────────────────────────
/**
* Add an emoji reaction to a Feishu message.
* Used as a "message received" acknowledgment before processing starts.
*/
export async function addReaction(
client: lark.Client,
messageId: string,
emojiType: string = 'OnIt',
): Promise<string | null> {
try {
const resp = await client.im.messageReaction.create({
path: { message_id: messageId },
data: { reaction_type: { emoji_type: emojiType } },
});
return (resp?.data as any)?.reaction_id || null;
} catch (err: any) {
// Non-critical — log and swallow
console.warn(LOG_TAG, 'Failed to add reaction:', err?.message || err);
return null;
}
}
/**
* Remove an emoji reaction from a Feishu message.
* Used to clear the "processing" indicator after response is sent.
*/
export async function removeReaction(
client: lark.Client,
messageId: string,
reactionId: string,
): Promise<void> {
try {
await client.im.messageReaction.delete({
path: { message_id: messageId, reaction_id: reactionId },
});
} catch (err: any) {
console.warn(LOG_TAG, 'Failed to remove reaction:', err?.message || err);
}
}
/**
* Send a pre-built card (for permission cards or custom cards).
*/
export async function sendPermissionCard(
client: lark.Client,
chatId: string,
cardContent: string,
replyToMessageId?: string,
): Promise<SendResult> {
try {
const realChatId = chatId.split(':thread:')[0];
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content: cardContent, msg_type: 'interactive' },
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: realChatId, content: cardContent, msg_type: 'interactive' },
});
}
const msgId = resp?.data?.message_id || '';
return { ok: true, messageId: msgId };
} catch (err: any) {
console.error(LOG_TAG, 'Permission card send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Feishu access control and group policy.
*/
import type { FeishuConfig } from './types';
/**
* Check if a user is authorized based on the current config.
*
* DM policy logic:
* - disabled → false
* - open → allowFrom must include '*' or user's open_id
* - allowlist → allowFrom must include user's open_id
* - pairing → always true (pairing handled elsewhere)
*
* Group chat logic (chatId starts with 'oc_'):
* - groupPolicy === 'disabled' → false
* - groupPolicy === 'allowlist' → groupAllowFrom must include chatId
* - groupPolicy === 'open' → true
*/
export function isUserAuthorized(
config: FeishuConfig,
userId: string,
chatId: string,
): boolean {
// Group chat check
if (chatId.startsWith('oc_')) {
if (config.groupPolicy === 'disabled') return false;
if (config.groupPolicy === 'allowlist') {
if (!config.groupAllowFrom.includes(chatId)) return false;
}
// groupPolicy === 'open' → allowed
return true;
}
// DM policy check
if (config.dmPolicy === 'disabled') return false;
if (config.dmPolicy === 'pairing') return true;
if (config.dmPolicy === 'open') {
if (config.allowFrom.length === 0) return true;
return config.allowFrom.includes('*') || config.allowFrom.includes(userId);
}
if (config.dmPolicy === 'allowlist') {
return config.allowFrom.includes(userId);
}
return false;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Feishu channel internal types and constants.
*/
export interface FeishuConfig {
appId: string;
appSecret: string;
/** SDK domain: 'feishu' | 'lark' or a custom base URL. */
domain: string;
/** Allowed user IDs (open_id). ['*'] means all users. */
allowFrom: string[];
/** Allowed group chat IDs. */
groupAllowFrom: string[];
/** DM policy: open / pairing / allowlist / disabled */
dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled';
/** Group policy: open / allowlist / disabled */
groupPolicy: 'open' | 'allowlist' | 'disabled';
/** Whether to require @mention in group chats */
requireMention: boolean;
/** Whether to use per-thread sessions */
threadSession: boolean;
/** Card streaming config (always enabled) */
cardStreamConfig: CardStreamConfig;
}
export interface CardStreamConfig {
/** Throttle interval for stream updates (ms) */
throttleMs: number;
/** Footer display options (status line and elapsed time) */
footer?: {
status: boolean;
elapsed: boolean;
};
}
export interface FeishuBotInfo {
appId: string;
botName: string;
openId: string;
}
/** Feishu message item from list/search API */
export interface FeishuMessageItem {
messageId: string;
rootId?: string;
parentId?: string;
msgType: string;
createTime: string;
updateTime?: string;
content: string;
sender: {
id: string;
idType: string;
senderType: string;
tenantKey?: string;
};
}
/** Result from readMessages / searchMessages */
export interface MessageListResult {
items: FeishuMessageItem[];
hasMore: boolean;
pageToken?: string;
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Channel plugin system types.
*
* These interfaces define the contract between channel plugins (Feishu, etc.)
* and the bridge system via ChannelPluginAdapter.
*/
import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '../bridge/types';
// ── Card Stream Controller ──────────────────────────────────────
/** Controls a streaming card lifecycle (create → update → finalize). */
/** Tool call info for card display */
export interface ToolCallInfo {
id: string;
name: string;
status: 'running' | 'complete' | 'error';
}
export interface CardStreamController {
/**
* Create a streaming card in the given chat.
* @param chatId Target chat ID
* @param initialText Initial markdown content
* @param replyToMessageId Optional message to reply to
* @returns The platform message ID, or empty string if creation failed (triggers fallback)
*/
create(chatId: string, initialText: string, replyToMessageId?: string): Promise<string>;
/**
* Stream-update the card content (throttled internally).
* @returns 'ok' on success, 'fail' on error
*/
update(messageId: string, text: string): Promise<'ok' | 'fail'>;
/**
* Finalize the card with the complete content and close streaming mode.
* @param status 'completed' | 'interrupted' | 'error'
*/
finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise<void>;
/** Optional: update tool call progress display */
updateToolCalls?(messageId: string, tools: ToolCallInfo[]): void;
/** Optional: set thinking state before text starts flowing */
setThinking?(messageId: string): void;
}
// ── Channel Capabilities ────────────────────────────────────────
export interface ChannelCapabilities {
/** Whether the channel supports streaming card updates */
streaming: boolean;
/** Whether the channel supports thread replies */
threadReply: boolean;
/** Whether the channel supports message search */
search: boolean;
/** Whether the channel supports reading message history */
history: boolean;
/** Whether the channel supports reactions */
reactions: boolean;
}
// ── Channel Meta ────────────────────────────────────────────────
export interface ChannelMeta {
channelType: ChannelType;
displayName: string;
}
// ── Probe Result ────────────────────────────────────────────────
export interface ProbeResult {
ok: boolean;
error?: string;
botName?: string;
botId?: string;
}
// ── Channel Plugin ──────────────────────────────────────────────
/**
* Plugin interface for a channel implementation.
* Generic parameter T is the plugin's config type.
*/
export interface ChannelPlugin<T = unknown> {
readonly meta: ChannelMeta;
/** Load and validate configuration. */
loadConfig(): T | null;
/** Get current capabilities (may depend on config). */
getCapabilities(): ChannelCapabilities;
/** Start the plugin (connect WS, start polling, etc.). */
start(): Promise<void>;
/** Stop the plugin gracefully. */
stop(): Promise<void>;
/** Whether the plugin is currently running. */
isRunning(): boolean;
/** Consume the next inbound message. Blocks until available or stopped. */
consumeOne(): Promise<InboundMessage | null>;
/** Send an outbound message. */
send(message: OutboundMessage): Promise<SendResult>;
/** Validate config completeness. Returns null if valid, error message otherwise. */
validateConfig(): string | null;
/** Check user authorization. */
isAuthorized(userId: string, chatId: string): boolean;
/** Optional: get a card stream controller. */
getCardStreamController?(): CardStreamController | null;
/** Optional: probe connectivity. */
probe?(): Promise<ProbeResult>;
/** Optional: lifecycle hooks. */
onMessageStart?(chatId: string): void;
onMessageEnd?(chatId: string): void;
}
+4 -4
View File
@@ -693,7 +693,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
})();
const contentBlocks: Array<
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
| { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } }
| { type: 'text'; text: string }
> = [];
@@ -702,7 +702,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
type: 'image',
source: {
type: 'base64',
media_type: img.type || 'image/png',
media_type: (img.type || 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
data: img.data,
},
});
@@ -844,8 +844,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
? block.content
: Array.isArray(block.content)
? block.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join('\n')
: String(block.content ?? '');
controller.enqueue(formatSSE({
+13
View File
@@ -698,6 +698,19 @@ function migrateDb(db: Database.Database): void {
if (permLinkColNames.length > 0 && !permLinkColNames.includes('resolved')) {
db.exec("ALTER TABLE channel_permission_links ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0");
}
// Channel configs table (structured config for channel plugins)
db.exec(`
CREATE TABLE IF NOT EXISTS channel_configs (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
account_id TEXT NOT NULL DEFAULT 'default',
config_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(channel_type, account_id)
);
`);
}
// ==========================================