refactor: simplify Feishu settings, fix card race conditions, update docs
Settings cleanup: - Remove encryptKey/verificationToken (WSClient handles auth internally) - Remove renderMode/blockStreaming toggles (streaming always enabled) - Remove footer toggles (always show status + elapsed) - Simplify FeishuConfig type, always-on cardStreamConfig - Merge UI into 2 cards (credentials + access/behavior) with dirty-state save buttons Bug fixes: - Fix card creation race: cardCreatePromise awaited before finalize/interrupt - Fix tool-first turns: onToolEvent bootstraps card when no text precedes tools - Fix /cwd picker: source from channel_bindings (not chat_sessions), filter active only - Remove stale renderMode reference in bridge-manager /status Docs: - Update bridge-system.md: Feishu data flow, design decisions 13-18, V2 status, settings table - Add design decision 18: single-operator model for /cwd picker scope Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
cdb4155771
commit
7cc9c90242
11 files changed
+485
-450
No files matched your search
@@ -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 命令菜单
|
||||
|
||||
@@ -288,34 +320,44 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
|
||||
|
||||
1. **Channel Plugin 合约** (`src/lib/channels/types.ts`)
|
||||
- `ChannelPlugin<T>` 接口:config/capabilities/lifecycle/inbound/outbound/policy
|
||||
- `ChannelCapabilities`:streaming、inlineButtons、reactions 等能力声明
|
||||
- `ProbeResult`:健康检查结果
|
||||
- `CardStreamController`:流式卡片接口(占位)
|
||||
- `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` — 内部类型 + 常量
|
||||
- `config.ts` — `FeishuConfig` 结构化配置 + 校验 + `configFromSettings()`
|
||||
- `gateway.ts` — WSClient 生命周期 + 连接状态机 + 指标 + probe
|
||||
- `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` — 出站消息渲染(card/post/text/permission)
|
||||
- `outbound.ts` — 出站渲染(post md + interactive card)+ optimizeMarkdown() + 权限/CWD 卡片
|
||||
- `identity.ts` — Bot 身份解析 + @mention 检测
|
||||
- `policy.ts` — 用户授权 + 群聊策略
|
||||
- `card-controller.ts` — CardStreamController 占位
|
||||
- `index.ts` — `FeishuChannelPlugin` 组合入口
|
||||
- `policy.ts` — 用户授权 + DM/群聊策略
|
||||
- `card-controller.ts` — CardKit v2 流式卡片(thinking/streaming/tool progress/footer)
|
||||
- `index.ts` — FeishuChannelPlugin 组合入口 + Typing reaction 管理
|
||||
|
||||
4. **结构化配置** (`channel_configs` DB 表 + API 路由)
|
||||
- `GET/PUT /api/channels/feishu/config` — 读写结构化配置
|
||||
- 向后兼容:读 channel_configs → 回退到 settings
|
||||
4. **流式卡片 + 工具进度** (`bridge-manager.ts` + `card-controller.ts`)
|
||||
- `onPartialText` 回调 → CardStreamController.update() 节流推送
|
||||
- `onToolEvent` 回调 → cardToolCalls[] 追踪 → updateToolCalls() 实时渲染
|
||||
- Tool-first 回合(无文本直接调工具):onToolEvent 自动 bootstrap 卡片
|
||||
- `cardCreatePromise` 竞态保护:finalize 路径 await 创建完成后再执行
|
||||
|
||||
5. **Status / Probe** (`/api/channels/feishu/status`)
|
||||
- 连接状态、Bot 身份、指标、可选 probe
|
||||
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. **Remote Core 合约** (`src/lib/remote/`)
|
||||
- `RemoteHost`/`RemoteController`/`SessionLease`/`RemoteEvent` 接口
|
||||
- `RemoteManager` 轻量骨架
|
||||
6. **MCP 残留剥离**
|
||||
- 移除 `.mcp.json` 中 feishu MCP 入口
|
||||
- 移除 `@codepilot/feishu-mcp` workspace 依赖
|
||||
- 原 feishu-adapter.ts 改为薄代理(~15 行)
|
||||
|
||||
7. **原 feishu-adapter.ts** 改为薄代理(~15 行)
|
||||
7. **设置 UI 简化** (`FeishuBridgeSection.tsx`)
|
||||
- 移除:encryptKey、verificationToken、renderMode、blockStreaming、footer 开关
|
||||
- 合并为 2 个卡片:凭据 + 访问与行为
|
||||
- 保存按钮脏状态追踪:修改后显示"保存",保存后显示"已保存"
|
||||
@@ -11,15 +11,9 @@ const FEISHU_KEYS = [
|
||||
'bridge_feishu_app_id',
|
||||
'bridge_feishu_app_secret',
|
||||
'bridge_feishu_domain',
|
||||
'bridge_feishu_encrypt_key',
|
||||
'bridge_feishu_verification_token',
|
||||
'bridge_feishu_allow_from',
|
||||
'bridge_feishu_dm_policy',
|
||||
'bridge_feishu_render_mode',
|
||||
'bridge_feishu_thread_session',
|
||||
'bridge_feishu_block_streaming',
|
||||
'bridge_feishu_footer_status',
|
||||
'bridge_feishu_footer_elapsed',
|
||||
'bridge_feishu_group_policy',
|
||||
'bridge_feishu_group_allow_from',
|
||||
'bridge_feishu_require_mention',
|
||||
@@ -32,7 +26,7 @@ export async function GET() {
|
||||
const value = getSetting(key);
|
||||
if (value !== undefined) {
|
||||
// Mask sensitive fields for security
|
||||
if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && value.length > 8) {
|
||||
if (key === 'bridge_feishu_app_secret' && value.length > 8) {
|
||||
result[key] = '***' + value.slice(-8);
|
||||
} else {
|
||||
result[key] = value;
|
||||
@@ -61,7 +55,7 @@ export async function PUT(request: NextRequest) {
|
||||
const strValue = String(value ?? '').trim();
|
||||
|
||||
// Don't overwrite secrets if user sent the masked version back
|
||||
if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && strValue.startsWith('***')) {
|
||||
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,15 +21,9 @@ interface FeishuBridgeSettings {
|
||||
bridge_feishu_app_id: string;
|
||||
bridge_feishu_app_secret: string;
|
||||
bridge_feishu_domain: string;
|
||||
bridge_feishu_encrypt_key: string;
|
||||
bridge_feishu_verification_token: string;
|
||||
bridge_feishu_allow_from: string;
|
||||
bridge_feishu_dm_policy: string;
|
||||
bridge_feishu_render_mode: string;
|
||||
bridge_feishu_thread_session: string;
|
||||
bridge_feishu_block_streaming: string;
|
||||
bridge_feishu_footer_status: string;
|
||||
bridge_feishu_footer_elapsed: string;
|
||||
bridge_feishu_group_policy: string;
|
||||
bridge_feishu_group_allow_from: string;
|
||||
bridge_feishu_require_mention: string;
|
||||
@@ -39,39 +33,68 @@ const DEFAULT_SETTINGS: FeishuBridgeSettings = {
|
||||
bridge_feishu_app_id: "",
|
||||
bridge_feishu_app_secret: "",
|
||||
bridge_feishu_domain: "feishu",
|
||||
bridge_feishu_encrypt_key: "",
|
||||
bridge_feishu_verification_token: "",
|
||||
bridge_feishu_allow_from: "",
|
||||
bridge_feishu_dm_policy: "open",
|
||||
bridge_feishu_render_mode: "auto",
|
||||
bridge_feishu_thread_session: "false",
|
||||
bridge_feishu_block_streaming: "false",
|
||||
bridge_feishu_footer_status: "false",
|
||||
bridge_feishu_footer_elapsed: "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 [encryptKey, setEncryptKey] = useState("");
|
||||
const [verificationToken, setVerificationToken] = 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 [renderMode, setRenderMode] = useState("auto");
|
||||
const [threadSession, setThreadSession] = useState(false);
|
||||
const [blockStreaming, setBlockStreaming] = useState(false);
|
||||
const [footerStatus, setFooterStatus] = useState(false);
|
||||
const [footerElapsed, setFooterElapsed] = 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;
|
||||
@@ -79,28 +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");
|
||||
setEncryptKey(s.bridge_feishu_encrypt_key || "");
|
||||
setVerificationToken(s.bridge_feishu_verification_token || "");
|
||||
setAllowFrom(s.bridge_feishu_allow_from);
|
||||
setDmPolicy(s.bridge_feishu_dm_policy || "open");
|
||||
setRenderMode(s.bridge_feishu_render_mode || "auto");
|
||||
setThreadSession(s.bridge_feishu_thread_session === "true");
|
||||
setBlockStreaming(s.bridge_feishu_block_streaming === "true");
|
||||
setFooterStatus(s.bridge_feishu_footer_status === "true");
|
||||
setFooterElapsed(s.bridge_feishu_footer_elapsed === "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
|
||||
@@ -111,62 +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,
|
||||
bridge_feishu_verification_token: verificationToken,
|
||||
};
|
||||
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);
|
||||
}
|
||||
if (encryptKey && !encryptKey.startsWith("***")) {
|
||||
updates.bridge_feishu_encrypt_key = encryptKey;
|
||||
}
|
||||
saveSettings(updates);
|
||||
};
|
||||
|
||||
const handleSaveDmSettings = () => {
|
||||
saveSettings({
|
||||
bridge_feishu_allow_from: allowFrom,
|
||||
bridge_feishu_dm_policy: dmPolicy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBehaviorSettings = () => {
|
||||
saveSettings({
|
||||
bridge_feishu_render_mode: renderMode,
|
||||
bridge_feishu_thread_session: threadSession ? "true" : "false",
|
||||
bridge_feishu_block_streaming: blockStreaming ? "true" : "false",
|
||||
bridge_feishu_footer_status: footerStatus ? "true" : "false",
|
||||
bridge_feishu_footer_elapsed: footerElapsed ? "true" : "false",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveGroupSettings = () => {
|
||||
saveSettings({
|
||||
bridge_feishu_group_policy: groupPolicy,
|
||||
bridge_feishu_group_allow_from: groupAllowFrom,
|
||||
bridge_feishu_require_mention: requireMention ? "true" : "false",
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
@@ -214,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")}
|
||||
@@ -266,43 +318,16 @@ export function FeishuBridgeSection() {
|
||||
{t("feishu.domainHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("feishu.encryptKey")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={encryptKey}
|
||||
onChange={(e) => setEncryptKey(e.target.value)}
|
||||
placeholder={t("feishu.encryptKeyPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("feishu.encryptKeyHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("feishu.verificationToken")}
|
||||
</label>
|
||||
<Input
|
||||
value={verificationToken}
|
||||
onChange={(e) => setVerificationToken(e.target.value)}
|
||||
placeholder={t("feishu.verificationTokenPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("feishu.verificationTokenHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleSaveCredentials} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
<SaveButton
|
||||
dirty={credentialsDirty}
|
||||
saving={credentialsSaving}
|
||||
onClick={handleSaveCredentials}
|
||||
label={t("common.save")}
|
||||
savedLabel={t("feishu.saved")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -329,14 +354,15 @@ export function FeishuBridgeSection() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* DM Policy & Allow From */}
|
||||
{/* ── Access & Behavior ── */}
|
||||
<SettingsCard
|
||||
title={t("feishu.allowFrom")}
|
||||
description={t("feishu.allowFromDesc")}
|
||||
title={t("feishu.accessBehavior")}
|
||||
description={t("feishu.accessBehaviorDesc")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
<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}>
|
||||
@@ -361,6 +387,9 @@ export function FeishuBridgeSection() {
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
@@ -371,101 +400,9 @@ export function FeishuBridgeSection() {
|
||||
{t("feishu.allowFromHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={handleSaveDmSettings} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Behavior Settings: Render Mode, Thread Session, Block Streaming, Footer */}
|
||||
<SettingsCard
|
||||
title={t("feishu.renderMode")}
|
||||
description={t("feishu.renderModeDesc")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("feishu.renderMode")}
|
||||
</label>
|
||||
<Select value={renderMode} onValueChange={setRenderMode}>
|
||||
<SelectTrigger className="w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("feishu.renderModeAuto")}
|
||||
</SelectItem>
|
||||
<SelectItem value="static">
|
||||
{t("feishu.renderModeStatic")}
|
||||
</SelectItem>
|
||||
<SelectItem value="streaming">
|
||||
{t("feishu.renderModeStreaming")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<FieldRow
|
||||
label={t("feishu.threadSession")}
|
||||
description={t("feishu.threadSessionDesc")}
|
||||
>
|
||||
<Switch
|
||||
checked={threadSession}
|
||||
onCheckedChange={setThreadSession}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow
|
||||
label={t("feishu.blockStreaming")}
|
||||
description={t("feishu.blockStreamingDesc")}
|
||||
>
|
||||
<Switch
|
||||
checked={blockStreaming}
|
||||
onCheckedChange={setBlockStreaming}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow
|
||||
label={t("feishu.footer")}
|
||||
description=""
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FieldRow
|
||||
label={t("feishu.footerStatus")}
|
||||
description=""
|
||||
>
|
||||
<Switch
|
||||
checked={footerStatus}
|
||||
onCheckedChange={setFooterStatus}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={t("feishu.footerElapsed")}
|
||||
description=""
|
||||
>
|
||||
<Switch
|
||||
checked={footerElapsed}
|
||||
onCheckedChange={setFooterElapsed}
|
||||
/>
|
||||
</FieldRow>
|
||||
</div>
|
||||
</FieldRow>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={handleSaveBehaviorSettings} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
|
||||
{/* 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">
|
||||
<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}>
|
||||
@@ -503,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>
|
||||
|
||||
+3
-16
@@ -530,12 +530,6 @@ const en = {
|
||||
'feishu.domainFeishu': 'Feishu (feishu.cn)',
|
||||
'feishu.domainLark': 'Lark (larksuite.com)',
|
||||
'feishu.domainHint': 'Choose Feishu for China mainland or Lark for international',
|
||||
'feishu.encryptKey': 'Encrypt Key',
|
||||
'feishu.encryptKeyPlaceholder': 'Encrypt Key from Feishu event subscription page',
|
||||
'feishu.encryptKeyHint': 'Optional, needed for HTTP Webhook mode (Feishu Console → Events & Callbacks → Encrypt Key)',
|
||||
'feishu.verificationToken': 'Verification Token',
|
||||
'feishu.verificationTokenPlaceholder': 'Verification Token from Feishu console',
|
||||
'feishu.verificationTokenHint': 'Optional, needed for HTTP Webhook mode (Feishu Console → Events & Callbacks → Verification Token)',
|
||||
'feishu.verify': 'Test Connection',
|
||||
'feishu.verified': 'Connection verified',
|
||||
'feishu.verifiedAs': 'Connected as {name}',
|
||||
@@ -549,11 +543,9 @@ const en = {
|
||||
'feishu.dmPolicyPairing': 'Pairing — require pairing handshake',
|
||||
'feishu.dmPolicyAllowlist': 'Allowlist — only listed users',
|
||||
'feishu.dmPolicyDisabled': 'Disabled — ignore all DMs',
|
||||
'feishu.renderMode': 'Render Mode',
|
||||
'feishu.renderModeDesc': 'Control how the bot renders responses',
|
||||
'feishu.renderModeAuto': 'Auto — choose best rendering',
|
||||
'feishu.renderModeStatic': 'Static — send as plain text',
|
||||
'feishu.renderModeStreaming': 'Streaming — stream via cards',
|
||||
'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',
|
||||
@@ -571,13 +563,8 @@ 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.blockStreaming': 'Block Streaming',
|
||||
'feishu.blockStreamingDesc': 'Stream responses at block level with coalesce buffering',
|
||||
'feishu.threadSession': 'Thread Sessions',
|
||||
'feishu.threadSessionDesc': 'Enable per-thread independent context (parallel conversations in different threads)',
|
||||
'feishu.footer': 'Footer',
|
||||
'feishu.footerStatus': 'Show Status',
|
||||
'feishu.footerElapsed': 'Show Elapsed Time',
|
||||
'feishu.connectionState': 'Connection State',
|
||||
'feishu.connectionStateConnected': 'Connected',
|
||||
'feishu.connectionStateDisconnected': 'Disconnected',
|
||||
|
||||
+3
-16
@@ -527,12 +527,6 @@ const zh: Record<TranslationKey, string> = {
|
||||
'feishu.domainFeishu': '飞书 (feishu.cn)',
|
||||
'feishu.domainLark': 'Lark (larksuite.com)',
|
||||
'feishu.domainHint': '中国大陆选择飞书,海外选择 Lark',
|
||||
'feishu.encryptKey': 'Encrypt Key',
|
||||
'feishu.encryptKeyPlaceholder': '飞书后台「事件订阅」页面的 Encrypt Key',
|
||||
'feishu.encryptKeyHint': '可选,HTTP Webhook 模式需要(飞书后台 → 事件与回调 → Encrypt Key)',
|
||||
'feishu.verificationToken': 'Verification Token',
|
||||
'feishu.verificationTokenPlaceholder': '飞书后台的 Verification Token',
|
||||
'feishu.verificationTokenHint': '可选,HTTP Webhook 模式需要(飞书后台 → 事件与回调 → Verification Token)',
|
||||
'feishu.verify': '测试连接',
|
||||
'feishu.verified': '连接验证成功',
|
||||
'feishu.verifiedAs': '已连接为 {name}',
|
||||
@@ -546,11 +540,9 @@ const zh: Record<TranslationKey, string> = {
|
||||
'feishu.dmPolicyPairing': '配对 — 需要配对握手',
|
||||
'feishu.dmPolicyAllowlist': '白名单 — 仅允许列表中的用户',
|
||||
'feishu.dmPolicyDisabled': '禁用 — 忽略所有私信',
|
||||
'feishu.renderMode': '渲染模式',
|
||||
'feishu.renderModeDesc': '控制机器人如何渲染响应',
|
||||
'feishu.renderModeAuto': '自动 — 选择最佳渲染方式',
|
||||
'feishu.renderModeStatic': '静态 — 以纯文本发送',
|
||||
'feishu.renderModeStreaming': '流式 — 通过卡片流式输出',
|
||||
'feishu.accessBehavior': '访问与行为',
|
||||
'feishu.accessBehaviorDesc': '配置谁可以使用机器人及其行为方式',
|
||||
'feishu.saved': '已保存',
|
||||
'feishu.groupSettings': '群聊设置',
|
||||
'feishu.groupSettingsDesc': '控制机器人在群聊中的响应方式',
|
||||
'feishu.groupPolicy': '群聊策略',
|
||||
@@ -568,13 +560,8 @@ const zh: Record<TranslationKey, string> = {
|
||||
'feishu.step4': '添加事件订阅:im.message.receive_v1',
|
||||
'feishu.step5': '发布应用版本并在管理后台审批通过',
|
||||
'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证',
|
||||
'feishu.blockStreaming': 'Block 流式',
|
||||
'feishu.blockStreamingDesc': '按 Block 级别流式输出响应(配合合并缓冲配置)',
|
||||
'feishu.threadSession': '话题会话',
|
||||
'feishu.threadSessionDesc': '启用每话题独立上下文(不同话题中的并行对话)',
|
||||
'feishu.footer': '页脚',
|
||||
'feishu.footerStatus': '显示状态',
|
||||
'feishu.footerElapsed': '显示耗时',
|
||||
'feishu.connectionState': '连接状态',
|
||||
'feishu.connectionStateConnected': '已连接',
|
||||
'feishu.connectionStateDisconnected': '未连接',
|
||||
|
||||
@@ -465,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.',
|
||||
@@ -540,11 +558,14 @@ async function handleMessage(
|
||||
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;
|
||||
@@ -604,7 +625,7 @@ async function handleMessage(
|
||||
// First call — create the card
|
||||
cardCreating = true;
|
||||
cardBufferedText = fullText;
|
||||
cardController!.create(msg.address.chatId, fullText, msg.messageId).then((msgId) => {
|
||||
cardCreatePromise = cardController!.create(msg.address.chatId, fullText, msg.messageId).then((msgId) => {
|
||||
cardCreating = false;
|
||||
cardMessageId = msgId || null;
|
||||
// Flush any buffered text that arrived during creation
|
||||
@@ -631,6 +652,25 @@ async function handleMessage(
|
||||
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);
|
||||
@@ -657,6 +697,12 @@ async function handleMessage(
|
||||
);
|
||||
}, 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) {
|
||||
if (cardController && cardMessageId) {
|
||||
@@ -705,9 +751,13 @@ async function handleMessage(
|
||||
adapter.endPreview?.(msg.address.chatId, previewState.draftId);
|
||||
}
|
||||
|
||||
// Clean up card streaming state
|
||||
if (cardController && cardMessageId && !cardFinalized) {
|
||||
cardController.finalize(cardMessageId, '⚠️ Response interrupted.', 'interrupted').catch(() => {});
|
||||
// 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);
|
||||
@@ -773,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>`;
|
||||
@@ -798,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': {
|
||||
@@ -1068,9 +1165,8 @@ async function handleCommand(
|
||||
lines.push('✅ Configuration: OK');
|
||||
lines.push(` App ID: ${config.appId}`);
|
||||
lines.push(` DM Policy: ${config.dmPolicy}`);
|
||||
lines.push(` Render Mode: ${config.renderMode}`);
|
||||
lines.push(` Thread Session: ${config.threadSession ? 'Yes' : 'No'}`);
|
||||
lines.push(` Streaming: ${config.cardStreamConfig ? 'Enabled' : 'Disabled'}`);
|
||||
lines.push(` Streaming: Enabled`);
|
||||
}
|
||||
|
||||
// Connection check
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { getSetting } from '../../db';
|
||||
import type { FeishuConfig, CardStreamConfig, BlockStreamingCoalesce, FooterConfig } from './types';
|
||||
import type { FeishuConfig } from './types';
|
||||
|
||||
/** Parse a comma-separated string into a trimmed, non-empty string array. */
|
||||
function parseList(raw: string | undefined): string[] {
|
||||
@@ -18,66 +18,33 @@ export function loadFeishuConfig(): FeishuConfig | null {
|
||||
if (!appId || !appSecret) return null;
|
||||
|
||||
const domain = getSetting('bridge_feishu_domain') || 'feishu';
|
||||
const encryptKey = getSetting('bridge_feishu_encrypt_key') || '';
|
||||
const verificationToken = getSetting('bridge_feishu_verification_token') || '';
|
||||
|
||||
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 renderMode = (getSetting('bridge_feishu_render_mode') || 'auto') as FeishuConfig['renderMode'];
|
||||
const requireMention = getSetting('bridge_feishu_require_mention') === 'true';
|
||||
const threadSession = getSetting('bridge_feishu_thread_session') === 'true';
|
||||
const blockStreaming = getSetting('bridge_feishu_block_streaming') === 'true';
|
||||
|
||||
// Validation: when dmPolicy is 'open', allowFrom should include '*'
|
||||
if (dmPolicy === 'open' && allowFrom.length > 0 && !allowFrom.includes('*')) {
|
||||
allowFrom.push('*');
|
||||
}
|
||||
|
||||
let cardStreamConfig: CardStreamConfig | null = null;
|
||||
if (!blockStreaming && renderMode !== 'static') {
|
||||
cardStreamConfig = {
|
||||
throttleMs: 200,
|
||||
};
|
||||
}
|
||||
|
||||
let blockStreamingCoalesce: BlockStreamingCoalesce | null = null;
|
||||
if (blockStreaming) {
|
||||
blockStreamingCoalesce = {
|
||||
minChars: 20,
|
||||
maxChars: 4096,
|
||||
idleMs: 300,
|
||||
};
|
||||
}
|
||||
|
||||
// Footer config
|
||||
const footerStatus = getSetting('bridge_feishu_footer_status');
|
||||
const footerElapsed = getSetting('bridge_feishu_footer_elapsed');
|
||||
let footer: FooterConfig | null = null;
|
||||
if (footerStatus !== undefined || footerElapsed !== undefined) {
|
||||
footer = {
|
||||
status: footerStatus === 'true',
|
||||
elapsed: footerElapsed === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
encryptKey,
|
||||
verificationToken,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
renderMode,
|
||||
requireMention,
|
||||
threadSession,
|
||||
cardStreamConfig,
|
||||
blockStreamingCoalesce,
|
||||
footer,
|
||||
cardStreamConfig: {
|
||||
throttleMs: 200,
|
||||
footer: { status: true, elapsed: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* Feishu WebSocket gateway — manages lark SDK client and WS connection lifecycle.
|
||||
*
|
||||
* Key design decisions:
|
||||
* - EventDispatcher receives encryptKey / verificationToken so card.action.trigger
|
||||
* callbacks can be decrypted and verified by the SDK.
|
||||
* - 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.
|
||||
@@ -45,11 +44,10 @@ export class FeishuGateway {
|
||||
|
||||
constructor(config: FeishuConfig) {
|
||||
this.config = config;
|
||||
// Pass encryptKey and verificationToken so the SDK can decrypt/verify
|
||||
// card.action.trigger callbacks delivered over WebSocket.
|
||||
// WSClient mode doesn't need encryptKey/verificationToken — pass empty strings.
|
||||
this.eventDispatcher = new lark.EventDispatcher({
|
||||
encryptKey: config.encryptKey || '',
|
||||
verificationToken: config.verificationToken || '',
|
||||
encryptKey: '',
|
||||
verificationToken: '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
|
||||
|
||||
getCapabilities(): ChannelCapabilities {
|
||||
return {
|
||||
streaming: !!(this.config?.cardStreamConfig),
|
||||
streaming: true,
|
||||
threadReply: true,
|
||||
search: false, // True server-side search requires user_access_token; we only have local filtering
|
||||
history: true,
|
||||
@@ -224,7 +224,14 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
|
||||
|
||||
getCardStreamController(): CardStreamController | null {
|
||||
const client = this.gateway?.getRestClient();
|
||||
if (!client || !this.config?.cardStreamConfig) return null;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function _optimizeMarkdown(text: string): string {
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -49,16 +50,16 @@ function _optimizeMarkdown(text: string): string {
|
||||
}
|
||||
|
||||
// 3. Spacing between consecutive headings
|
||||
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2');
|
||||
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n\n$2');
|
||||
|
||||
// 4. Table spacing — add <br> before/after table blocks
|
||||
// 4. Table spacing — ensure blank line before/after table blocks
|
||||
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2');
|
||||
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1');
|
||||
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '$1\n<br>\n');
|
||||
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '\n\n$1\n\n');
|
||||
|
||||
// 5. Restore code blocks with <br> padding
|
||||
// 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<br>\n${block}\n<br>\n`);
|
||||
r = r.replace(`${MARK}${i}___`, `\n\n${block}\n\n`);
|
||||
});
|
||||
|
||||
// 6. Strip invalid image keys (only allow img_xxx and http(s) URLs)
|
||||
@@ -71,7 +72,7 @@ function _optimizeMarkdown(text: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Compress excessive newlines
|
||||
// 7. Compress excessive newlines (3+ → 2)
|
||||
r = r.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return r;
|
||||
@@ -174,20 +175,28 @@ async function sendAsInteractiveCard(
|
||||
try {
|
||||
const mdText = htmlToFeishuMarkdown(text);
|
||||
|
||||
// Build button columns — Schema V2 doesn't support 'action' tag,
|
||||
// use column_set with individual button elements instead
|
||||
const buttonColumns = inlineButtons.flat().map((btn) => {
|
||||
// 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: 'auto' as const,
|
||||
width: isCwd ? 'weighted' as const : 'auto' as const,
|
||||
weight: isCwd ? 1 : undefined,
|
||||
elements: [
|
||||
{
|
||||
tag: 'button' as const,
|
||||
@@ -200,46 +209,60 @@ async function sendAsInteractiveCard(
|
||||
};
|
||||
});
|
||||
|
||||
// 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,
|
||||
style: {
|
||||
color: {
|
||||
'light-orange-bg': {
|
||||
light_mode: 'rgba(255, 214, 102, 0.12)',
|
||||
dark_mode: 'rgba(255, 214, 102, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
title: { tag: 'plain_text' as const, content: 'Permission Required' },
|
||||
template: 'blue' as const,
|
||||
icon: { tag: 'standard_icon' as const, token: 'lock-chat_filled' },
|
||||
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: [
|
||||
{
|
||||
tag: 'markdown' as const,
|
||||
content: mdText,
|
||||
text_size: 'normal' as const,
|
||||
},
|
||||
{
|
||||
tag: 'markdown' as const,
|
||||
content: '⏱ This request will expire in 5 minutes',
|
||||
text_size: 'notation' as const,
|
||||
},
|
||||
{ tag: 'hr' as const },
|
||||
{
|
||||
tag: 'column_set' as const,
|
||||
flex_mode: 'none' as const,
|
||||
horizontal_align: 'left' as const,
|
||||
columns: buttonColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
body: { elements: bodyElements },
|
||||
};
|
||||
|
||||
const cardContent = JSON.stringify(card);
|
||||
|
||||
@@ -7,10 +7,6 @@ export interface FeishuConfig {
|
||||
appSecret: string;
|
||||
/** SDK domain: 'feishu' | 'lark' or a custom base URL. */
|
||||
domain: string;
|
||||
/** Encrypt key from Feishu app (used by EventDispatcher for card callbacks). */
|
||||
encryptKey: string;
|
||||
/** Verification token from Feishu app (used by EventDispatcher for card callbacks). */
|
||||
verificationToken: string;
|
||||
/** Allowed user IDs (open_id). ['*'] means all users. */
|
||||
allowFrom: string[];
|
||||
/** Allowed group chat IDs. */
|
||||
@@ -19,18 +15,12 @@ export interface FeishuConfig {
|
||||
dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled';
|
||||
/** Group policy: open / allowlist / disabled */
|
||||
groupPolicy: 'open' | 'allowlist' | 'disabled';
|
||||
/** Rendering mode: auto / static / streaming */
|
||||
renderMode: 'auto' | 'static' | 'streaming';
|
||||
/** Whether to require @mention in group chats */
|
||||
requireMention: boolean;
|
||||
/** Whether to use per-thread sessions */
|
||||
threadSession: boolean;
|
||||
/** Card streaming config (null = disabled) */
|
||||
cardStreamConfig: CardStreamConfig | null;
|
||||
/** Block streaming coalesce config (null = disabled) */
|
||||
blockStreamingCoalesce: BlockStreamingCoalesce | null;
|
||||
/** Footer config (null = disabled) */
|
||||
footer: FooterConfig | null;
|
||||
/** Card streaming config (always enabled) */
|
||||
cardStreamConfig: CardStreamConfig;
|
||||
}
|
||||
|
||||
export interface CardStreamConfig {
|
||||
@@ -43,17 +33,6 @@ export interface CardStreamConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlockStreamingCoalesce {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
idleMs: number;
|
||||
}
|
||||
|
||||
export interface FooterConfig {
|
||||
status: boolean;
|
||||
elapsed: boolean;
|
||||
}
|
||||
|
||||
export interface FeishuBotInfo {
|
||||
appId: string;
|
||||
botName: string;
|
||||
|
||||
Reference in new issue
Block a user