Merge branch 'codex/feishu-remote-v2'
This commit is contained in:
commit
1420c05cd9
28 files changed
+3525
-1149
No files matched your search
+26
-1
@@ -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
@@ -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 清单` — 待后续实施
|
||||
Generated
+720
-1
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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': '远程桥接',
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
@@ -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 <session_id> - Bind to existing session',
|
||||
'/cwd /path - Change working directory',
|
||||
'/mode plan|code|ask - Change mode',
|
||||
'/status - Show current status',
|
||||
'/sessions - List recent sessions',
|
||||
'/stop - Stop current session',
|
||||
'/perm allow|allow_session|deny <id> - Respond to permission',
|
||||
'/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 <keyword>';
|
||||
break;
|
||||
}
|
||||
if (!(adapter instanceof ChannelPluginAdapter)) {
|
||||
response = 'Search is not supported for this channel type.';
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const { searchMessages } = await import('../channels/feishu/message-actions');
|
||||
const plugin = adapter.getPlugin();
|
||||
const restClient = (plugin as any).gateway?.getRestClient?.();
|
||||
if (!restClient) {
|
||||
response = 'Channel not connected.';
|
||||
break;
|
||||
}
|
||||
const realChatId = extractRealChatId(msg.address.chatId);
|
||||
const result = await searchMessages(restClient, realChatId, args, { pageSize: 10 });
|
||||
if (result.items.length === 0) {
|
||||
response = `No messages matching "<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 <session_id> - Bind to existing session',
|
||||
'/cwd /path - Change working directory',
|
||||
'/mode plan|code|ask - Change mode',
|
||||
'/status - Show current status',
|
||||
'/status - Show session / CWD / mode / model',
|
||||
'/sessions - List recent sessions',
|
||||
'/stop - Stop current session',
|
||||
'/perm allow|allow_session|deny <id> - Respond to permission request',
|
||||
'/help - Show this help',
|
||||
'/stop - Stop current task',
|
||||
'',
|
||||
'<b>Messages:</b>',
|
||||
'/history [count] - Show recent messages',
|
||||
'/search <keyword> - Search in current chat',
|
||||
'/perm allow|deny <id> - Permission response',
|
||||
'',
|
||||
'<b>Feishu:</b>',
|
||||
'/feishu doctor - Run diagnostics',
|
||||
'/feishu auth - View auth status',
|
||||
].join('\n');
|
||||
break;
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
Reference in new issue
Block a user