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:
7418andClaude Opus 4.6 committed 2026-03-14 16:30:15 +08:00
1 parent cdb4155771
commit 7cc9c90242
11 files changed
+485 -450

No files matched your search

+96 -54
View File
@@ -19,42 +19,70 @@ src/lib/bridge/
│ ├── ir.ts # Markdown → IR 中间表示解析器(基于 markdown-it)
│ ├── render.ts # IR → 格式化输出的通用标记渲染器
│ └── telegram.ts # Telegram HTML 渲染 + 文件引用保护 + render-first 分片
├── markdown/
│ ├── ... # (见上)
│ └── feishu.ts # 飞书 Markdown 处理:hasComplexMarkdown / buildCardContent / buildPostContent / htmlToFeishuMarkdown
├── adapters/
│ ├── index.ts # Adapter 目录文件(side-effect import 自注册所有 adapter)
│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
│ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换
│ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
│ ├── feishu-adapter.ts # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册
│ ├── feishu-adapter.ts # 薄代理 → ChannelPluginAdapter(FeishuChannelPlugin)
│ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册
├── markdown/
│ └── discord.ts # Discord 消息分片(2000 字符限制)+ 代码围栏平衡
└── security/
├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟)
└── validators.ts # 路径/SessionID/危险输入校验
src/lib/channels/
├── types.ts # ChannelPlugin / ChannelCapabilities / CardStreamController / ToolCallInfo 接口
├── channel-plugin-adapter.ts # ChannelPlugin → BaseChannelAdapter 桥接
└── feishu/
├── index.ts # FeishuChannelPlugin 组合入口
├── types.ts # FeishuConfig / CardStreamConfig / FeishuBotInfo 等内部类型
├── config.ts # 从 settings DB 加载配置 + 校验
├── gateway.ts # WSClient 生命周期 + card.action.trigger monkey-patch + 超时保护
├── inbound.ts # 入站消息解析 + 内容提取 + 资源下载
├── outbound.ts # 出站消息渲染(post md / interactive card / reaction)+ Markdown 优化
├── policy.ts # 用户授权 + DM/群聊策略
├── identity.ts # Bot 身份解析 + @mention 检测
└── card-controller.ts # CardKit v2 流式卡片(create/update/finalize/thinking/toolCalls)
```
## 数据流
### 飞书
### 飞书(V2 — ChannelPlugin 架构 + 流式卡片)
```
飞书消息 → WSClient(WebSocket) → EventDispatcher
→ im.message.receive_v1 → handleIncomingEvent()
→ 去重(message_id LRU 1000) → 授权检查 → 群策略过滤 → @提及检查
→ text → parseTextContent() → enqueue()
→ image → downloadResource(stream/writeFile) → base64 FileAttachment → enqueue()
→ post → parsePostContent() 提取文本+图片 → enqueue()
→ /perm 文本命令 → 构造 callbackData → enqueue()
→ im.message.receive_v1 → FeishuGateway → messageHandler()
→ parseInboundMessage() → 去重(message_id) → 授权检查(policy.ts) → 群策略过滤 → @提及检查
→ text/image/post → enqueue()
→ card.action.trigger → FeishuGateway.safeCardActionHandler() (2.5s 超时保护)
→ FeishuChannelPlugin.cardActionHandler()
→ callback_data (perm:allow/deny) → enqueue(callbackMsg)
→ callback_data (cwd:/path) → enqueue(callbackMsg)
→ action/operation_id → enqueue(syntheticCallback)
→ 返回 toast 给飞书客户端
→ BridgeManager.runAdapterLoop() → handleMessage()
→ deliverResponse():
→ hasComplexMarkdown(代码块/表格)? → sendAsCard() [schema 2.0 markdown]
→ 纯文本? → sendAsPost() [msg_type: post, md tag]
→ 权限请求 → sendPermissionCard() [schema 2.0 卡片 + /perm 文本命令]
→ 普通消息 → processMessage():
→ CardStreamController.create() 创建流式卡片
→ consumeStream() 服务端消费 SSE:
→ text → onPartialText → CardStreamController.update() 流式推送
→ tool_use/tool_result → onToolEvent → cardToolCalls 追踪 → updateToolCalls() 渲染 🔄/✅/❌
→ permission_request → PermissionBroker 转发 → 内联按钮卡片(Schema V2 column_set)
→ CardStreamController.finalize() 最终渲染 + 页脚(状态+耗时)
→ 回调消息 → handlePermissionCallback() / handleCwdCallback()
→ 命令 → handleCommand():
→ /cwd 无参 → 项目选择器卡片(内联按钮, turquoise header)
→ /new → 继承当前 binding 的 workingDirectory
```
**飞书 V2 关键变化(相比初版 adapter):**
- **ChannelPlugin 架构**:`FeishuChannelPlugin` 实现 `ChannelPlugin<FeishuConfig>` 接口,通过 `ChannelPluginAdapter` 桥接为 `BaseChannelAdapter`,bridge-manager 无感知
- **流式卡片**:使用 CardKit v2 API(`cardkit.v2.card.create/streamContent/setStreamingMode/update`),替代旧的 card/post 分流渲染
- **WSClient 卡片回调**:通过 monkey-patch `handleEventData()` 将 `type:"card"` 重写为 `type:"event"`,使 SDK 的 EventDispatcher 能处理卡片交互事件
- **配置简化**:移除 encryptKey/verificationToken(WSClient 不需要)、renderMode/blockStreaming(流式始终开启)、footer 开关(始终显示)
- **卡片创建竞态保护**:`cardCreatePromise` 确保 finalize 路径不会在 create 完成前执行
### Discord
```
@@ -170,30 +198,31 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
- **降级**:`sendPreview` 返回 `'sent'|'skip'|'degrade'` 三态。400/404(API 不支持)→ 永久降级该 chatId;429/网络错误 → 仅跳过本次。`previewDegraded` Set 在 adapter `stop()` 时清空。
- **线程安全**:`processWithSessionLock` 保证同 session 串行 → 同时刻只有一个 `previewState`。多个 in-flight `sendMessageDraft` 安全:Telegram 对同 `draft_id` last-write-wins。
**13. 飞书适配器 — WSClient + 渲染分流**
飞书使用 `@larksuiteoapi/node-sdk` 的 `WSClient`(长连接 WebSocket)接收事件,`Client`(REST)发送消息和下载资源。与 Telegram 的 HTTP 长轮询不同,WSClient 由 SDK 管理重连。消息去重使用内存 Map LRU(上限 1000),无需持久化 offset。
**13. 飞书 ChannelPlugin 架构**
飞书从原 `BaseChannelAdapter` 子类迁移为 `ChannelPlugin<FeishuConfig>` 实现。`src/lib/channels/feishu/` 拆分为独立模块:`gateway.ts`(WSClient 生命周期)、`inbound.ts`(消息解析)、`outbound.ts`(消息发送 + Markdown 优化)、`policy.ts`(授权策略)、`card-controller.ts`(流式卡片)、`config.ts`(配置加载)。通过 `ChannelPluginAdapter` 桥接为 `BaseChannelAdapter`,bridge-manager 无需修改。WSClient 由 SDK 管理重连,消息去重使用内存 LRU。
**14. 飞书渲染策略 — Card vs Post**
Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- 含代码块(` ``` `)或表格 → `msg_type: 'interactive'`,schema 2.0 卡片(`{ tag: 'markdown', content }` 元素),代码高亮和表格正常渲染。
- 纯文本 → `msg_type: 'post'`,`{ tag: 'md', text }` 格式,渲染粗体、斜体、行内代码、链接。
- 每层发送失败自动降级:card → post → text。
**14. 飞书流式卡片渲染**
所有 Claude 回复通过 CardKit v2 流式卡片输出(替代旧的 card/post 分流)。流程:`cardController.create()` 创建卡片 → `update()` 节流推送文本(200ms)→ `finalize()` 停止流式 + 渲染最终内容 + 页脚。卡片支持:
- **Thinking 状态**:文本到达前显示 `💭 Thinking...`
- **Tool 进度**:`🔄 Running` / `✅ Complete` / `❌ Error` 实时显示
- **Markdown 优化**:标题降级(H1→H4, H2-6→H5)、表格间距、代码块填充、无效图片 key 剥离
- **页脚**:状态 emoji(✅/⚠️/❌)+ 耗时,始终显示
`markdown/feishu.ts` 的 `hasComplexMarkdown()` 负责路由判断,`buildCardContent()` / `buildPostContent()` 构建消息体。
非卡片消息(命令响应等)使用 `post` 格式 + `md` tag。注意:post md tag 不支持 HTML `<br>`(会渲染为字面文本),必须用空行 `\n\n` 代替。
**15. 飞书权限交互 — 无按钮,文本命令兜底**
**关键限制**:飞书卡片交互回调(card.action.trigger)需要 HTTP webhook 端点,不支持通过 WSClient 长连接接收。Openclaw 通过 `http.createServer()` + `Lark.adaptDefault()` 暴露公网 webhook 解决。CodePilot 是桌面应用无公网端点,因此:
- Schema 2.0 不支持 `action` 标签(错误码 200861)
- Schema 1.0 的 `action` 标签可渲染按钮,但点击报 200340(无 webhook 端点接收回调)
- **最终方案**:权限卡片使用 schema 2.0 markdown 展示信息 + `/perm` 文本命令。用户复制命令发送即可审批。`processIncomingEvent()` 检测 `/perm` 前缀并构造 `callbackData`,走 `permission-broker.handlePermissionCallback()` 标准流程。
**15. 飞书权限交互 — Schema V2 内联按钮**
通过 monkey-patch WSClient 的 `handleEventData()` 方法,将 `type:"card"` 事件重写为 `type:"event"`,使 SDK 的 EventDispatcher 能接收 `card.action.trigger` 回调。这解决了之前的 200340 错误(无 webhook 端点)。Schema V2 卡片不支持 `action` tag(错误码 200861),按钮使用 `column_set` + `column` + `button` 布局。按钮 value 中嵌入 `chatId` 作为兜底(WSClient 回调的 context 字段可能缺失)。Gateway 层提供 2.5s 超时保护,确保 3s 内必定返回 toast 响应。
**16. 飞书 Typing 指示器 — Emoji Reaction**
飞书无 typing indicator API。使用 Openclaw 方案:`onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastIncomingMessageId` Map 追踪每个 chat 的最新消息 ID。非关键路径,fire-and-forget。
`FeishuChannelPlugin.onMessageStart()` 在用户消息上添加 "Typing" emoji reaction(`im.messageReaction.create`),`onMessageEnd()` 删除。`lastMessageIdByChat` Map 追踪每个 chat 的最新消息 ID,`activeReactions` Map 追踪活跃 reaction ID。非关键路径,fire-and-forget。
**17. 飞书 @提及检测**
通过 `/bot/v3/info/` REST API 获取 bot 的 `open_id`/`bot_id`,存入 `botIds` Set。群聊消息检查 `event.message.mentions` 数组中是否有匹配的 ID。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。
`inbound.ts` 解析 `event.message.mentions` 数组检测 bot 是否被 @。bot 身份通过 `identity.ts` 的 `/bot/v3/info/` REST API 获取(`open_id`/`bot_id`)。文本中的 `@_user_N` 占位符由 `stripMentionMarkers()` 清理。
**18. Telegram 通知模式互斥**
**18. 飞书 Bridge 单操作者模型**
当前飞书 bridge 按「单操作者桌面应用」模型设计。虽然有 dmPolicy/groupPolicy/allowFrom 等多入口访问控制,但所有飞书聊天绑定共享同一操作者身份。`/cwd` 项目选择器展示同一 Feishu 渠道下所有活跃项目目录,作为「最近项目快捷切换」使用,不做 chat-level 隔离。如果未来需要多用户/多租户隔离,`/cwd` picker 应按 userId 或 chatId 进一步收窄数据源。
**19. Telegram 通知模式互斥**
`telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。
## 设置项(settings 表)
@@ -218,10 +247,12 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
| bridge_feishu_app_id | 飞书应用 App ID |
| bridge_feishu_app_secret | 飞书应用 App Secret(API 返回脱敏) |
| bridge_feishu_domain | 平台域名:`feishu`(默认)或 `lark` |
| bridge_feishu_allowed_users | 允许的 open_id/chat_id(逗号分隔,空=不限) |
| bridge_feishu_allow_from | 允许的 open_id(逗号分隔,`*`=不限) |
| bridge_feishu_dm_policy | 私信策略:`open`(默认)/ `pairing` / `allowlist` / `disabled` |
| bridge_feishu_thread_session | 每话题独立上下文(默认 false) |
| bridge_feishu_group_policy | 群消息策略:`open`(默认)/ `allowlist` / `disabled` |
| bridge_feishu_group_allow_from | 群聊白名单 chat_id(逗号分隔) |
| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 true) |
| bridge_feishu_require_mention | 群聊需要 @bot 才触发(默认 false) |
## API 路由
@@ -253,9 +284,10 @@ Claude 回复按内容分流渲染(对齐 Openclaw 方案):
- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/飞书通道开关
- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram + Feishu 入口)
- `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram)
- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书凭据 + 群聊策略 + 域名选择 UI(/bridge#feishu)
- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API
- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书设置 UI:凭据 + 访问与行为(2 卡片 2 保存按钮 + 脏状态追踪)
- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API(简化后 10 个 key)
- `src/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info)
- `src/lib/channels/` — V2 ChannelPlugin 架构(见目录结构)
- `electron/main.ts` — 窗口关闭时 bridge 活跃则保持后台运行;启动时通过 POST `auto-start` 触发桥接恢复
- `src/app/api/settings/telegram/verify/route.ts` — 支持 `register_commands` action 注册 Telegram 命令菜单
@@ -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 个卡片:凭据 + 访问与行为
- 保存按钮脏状态追踪:修改后显示"保存",保存后显示"已保存"
+2 -8
View File
@@ -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;
}
+179 -224
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -21,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
View File
@@ -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
View File
@@ -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': '未连接',
+113 -17
View File
@@ -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
+5 -38
View File
@@ -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 },
},
};
}
+4 -6
View File
@@ -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: '',
});
}
+9 -2
View File
@@ -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);
}
+69 -46
View File
@@ -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);
+2 -23
View File
@@ -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;