From 9ca9bce78815b6afb49e7045d8ee1f7943d20464 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Sat, 14 Mar 2026 09:55:03 +0800 Subject: [PATCH] fix: feishu card action 200340 and bridge session lock cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu card button clicks: - Add encryptKey/verificationToken to FeishuConfig for EventDispatcher - Gateway monkey-patch: rewrite type="card" → "event" for WSClient - Gateway safeCardActionHandler: 2.5s timeout guarantee with fallback toast - Embed chatId in button value (Feishu card.action.trigger has no open_chat_id) - Support both callback_data and action/operation_id button value formats - Domain resolution: 'feishu' → Domain.Feishu, 'lark' → Domain.Lark Bridge reliability: - Clear sessionLocks and activeTasks on bridge stop() to prevent stuck sessions after restart (processWithSessionLock chains to unresolved promises from previous bridge lifecycle) Settings UI: - Add encryptKey and verificationToken fields to Feishu settings - Mask encryptKey in GET, skip masked values in PUT - Add i18n strings (marked as optional for WSClient mode) Co-Authored-By: Claude Opus 4.6 --- src/app/api/settings/feishu/route.ts | 18 +- src/components/bridge/FeishuBridgeSection.tsx | 230 ++++++++++++++++-- src/i18n/en.ts | 34 ++- src/i18n/zh.ts | 34 ++- src/lib/bridge/bridge-manager.ts | 155 +++++++++++- src/lib/channels/channel-plugin-adapter.ts | 73 ++++++ src/lib/channels/feishu/card-controller.ts | 228 +++++++++++++++++ src/lib/channels/feishu/config.ts | 90 +++++++ src/lib/channels/feishu/gateway.ts | 190 +++++++++++++++ src/lib/channels/feishu/identity.ts | 25 ++ src/lib/channels/feishu/inbound.ts | 61 +++++ src/lib/channels/feishu/index.ts | 205 ++++++++++++++++ src/lib/channels/feishu/message-actions.ts | 191 +++++++++++++++ src/lib/channels/feishu/outbound.ts | 183 ++++++++++++++ src/lib/channels/feishu/policy.ts | 50 ++++ src/lib/channels/feishu/types.ts | 85 +++++++ src/lib/channels/types.ts | 112 +++++++++ src/lib/claude-client.ts | 8 +- 18 files changed, 1921 insertions(+), 51 deletions(-) create mode 100644 src/lib/channels/channel-plugin-adapter.ts create mode 100644 src/lib/channels/feishu/card-controller.ts create mode 100644 src/lib/channels/feishu/config.ts create mode 100644 src/lib/channels/feishu/gateway.ts create mode 100644 src/lib/channels/feishu/identity.ts create mode 100644 src/lib/channels/feishu/inbound.ts create mode 100644 src/lib/channels/feishu/index.ts create mode 100644 src/lib/channels/feishu/message-actions.ts create mode 100644 src/lib/channels/feishu/outbound.ts create mode 100644 src/lib/channels/feishu/policy.ts create mode 100644 src/lib/channels/feishu/types.ts create mode 100644 src/lib/channels/types.ts diff --git a/src/app/api/settings/feishu/route.ts b/src/app/api/settings/feishu/route.ts index b0254e5..985fd15 100644 --- a/src/app/api/settings/feishu/route.ts +++ b/src/app/api/settings/feishu/route.ts @@ -11,7 +11,15 @@ const FEISHU_KEYS = [ 'bridge_feishu_app_id', 'bridge_feishu_app_secret', 'bridge_feishu_domain', - 'bridge_feishu_allowed_users', + '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', @@ -23,8 +31,8 @@ export async function GET() { for (const key of FEISHU_KEYS) { const value = getSetting(key); if (value !== undefined) { - // Mask app secret for security - if (key === 'bridge_feishu_app_secret' && value.length > 8) { + // Mask sensitive fields for security + if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && value.length > 8) { result[key] = '***' + value.slice(-8); } else { result[key] = value; @@ -52,8 +60,8 @@ export async function PUT(request: NextRequest) { if (!FEISHU_KEYS.includes(key as typeof FEISHU_KEYS[number])) continue; const strValue = String(value ?? '').trim(); - // Don't overwrite secret if user sent the masked version back - if (key === 'bridge_feishu_app_secret' && strValue.startsWith('***')) { + // Don't overwrite secrets if user sent the masked version back + if ((key === 'bridge_feishu_app_secret' || key === 'bridge_feishu_encrypt_key') && strValue.startsWith('***')) { continue; } diff --git a/src/components/bridge/FeishuBridgeSection.tsx b/src/components/bridge/FeishuBridgeSection.tsx index 7cdd170..bea1928 100644 --- a/src/components/bridge/FeishuBridgeSection.tsx +++ b/src/components/bridge/FeishuBridgeSection.tsx @@ -21,7 +21,15 @@ interface FeishuBridgeSettings { bridge_feishu_app_id: string; bridge_feishu_app_secret: string; bridge_feishu_domain: string; - bridge_feishu_allowed_users: 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; @@ -31,7 +39,15 @@ const DEFAULT_SETTINGS: FeishuBridgeSettings = { bridge_feishu_app_id: "", bridge_feishu_app_secret: "", bridge_feishu_domain: "feishu", - bridge_feishu_allowed_users: "", + 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", @@ -43,7 +59,15 @@ export function FeishuBridgeSection() { const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); const [domain, setDomain] = useState("feishu"); - const [allowedUsers, setAllowedUsers] = useState(""); + const [encryptKey, setEncryptKey] = useState(""); + const [verificationToken, setVerificationToken] = useState(""); + 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); @@ -65,7 +89,15 @@ export function FeishuBridgeSection() { setAppId(s.bridge_feishu_app_id); setAppSecret(s.bridge_feishu_app_secret); setDomain(s.bridge_feishu_domain || "feishu"); - setAllowedUsers(s.bridge_feishu_allowed_users); + 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"); @@ -101,16 +133,36 @@ export function FeishuBridgeSection() { const updates: Partial = { bridge_feishu_app_id: appId, bridge_feishu_domain: domain, + bridge_feishu_verification_token: verificationToken, }; if (appSecret && !appSecret.startsWith("***")) { updates.bridge_feishu_app_secret = appSecret; } + 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_allowed_users: allowedUsers, bridge_feishu_group_policy: groupPolicy, bridge_feishu_group_allow_from: groupAllowFrom, bridge_feishu_require_mention: requireMention ? "true" : "false", @@ -214,6 +266,37 @@ export function FeishuBridgeSection() { {t("feishu.domainHint")}

+ +
+ + setEncryptKey(e.target.value)} + placeholder={t("feishu.encryptKeyPlaceholder")} + className="font-mono text-sm" + /> +

+ {t("feishu.encryptKeyHint")} +

+
+ +
+ + setVerificationToken(e.target.value)} + placeholder={t("feishu.verificationTokenPlaceholder")} + className="font-mono text-sm" + /> +

+ {t("feishu.verificationTokenHint")} +

+
@@ -246,22 +329,133 @@ export function FeishuBridgeSection() { )} - {/* Allowed Users */} + {/* DM Policy & Allow From */} -
- setAllowedUsers(e.target.value)} - placeholder="ou_xxxxxxxxxx, ou_yyyyyyyyyy" - className="font-mono text-sm" - /> -

- {t("feishu.allowedUsersHint")} -

+
+
+ + +
+ +
+ setAllowFrom(e.target.value)} + placeholder="*, ou_xxxxxxxxxx, ou_yyyyyyyyyy" + className="font-mono text-sm" + /> +

+ {t("feishu.allowFromHint")} +

+
+ + + + + {/* Behavior Settings: Render Mode, Thread Session, Block Streaming, Footer */} + +
+
+ + +
+ + + + + + + + + + +
+ + + + + + +
+
+
+ +
{/* Group Chat Settings */} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b6838df..d5ff9ff 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -530,14 +530,30 @@ 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}', 'feishu.verifyFailed': 'Connection failed', 'feishu.enterCredentialsFirst': 'Enter App ID and App Secret first', - 'feishu.allowedUsers': 'Allowed Users', - 'feishu.allowedUsersDesc': 'Comma-separated open_id or chat_id values allowed to use bridge', - 'feishu.allowedUsersHint': 'Leave empty to allow all users', + 'feishu.allowFrom': 'Allow From', + 'feishu.allowFromDesc': 'Control which users can send DMs to the bot', + 'feishu.allowFromHint': 'Comma-separated open_id values. Use * to allow all users.', + 'feishu.dmPolicy': 'DM Policy', + 'feishu.dmPolicyOpen': 'Open — accept DMs from all users', + 'feishu.dmPolicyPairing': 'Pairing — require pairing handshake', + 'feishu.dmPolicyAllowlist': 'Allowlist — only listed users', + 'feishu.dmPolicyDisabled': 'Disabled — ignore all DMs', + 'feishu.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.groupSettings': 'Group Chat Settings', 'feishu.groupSettingsDesc': 'Control how the bot responds in group chats', 'feishu.groupPolicy': 'Group Policy', @@ -555,15 +571,13 @@ 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.replyMode': 'Reply Mode', - 'feishu.replyModeInline': 'Inline — reply directly in chat', - 'feishu.replyModeThread': 'Thread — reply in message thread', - 'feishu.streaming': 'Streaming Cards', - 'feishu.streamingDesc': 'Stream responses via updateable cards (requires CardKit v2)', 'feishu.blockStreaming': 'Block Streaming', - 'feishu.blockStreamingDesc': 'Stream responses at block level', + 'feishu.blockStreamingDesc': 'Stream responses at block level with coalesce buffering', 'feishu.threadSession': 'Thread Sessions', - 'feishu.threadSessionDesc': 'Use message threads as separate conversation 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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ce4ec79..3920ea3 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -527,14 +527,30 @@ const zh: Record = { '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}', 'feishu.verifyFailed': '连接失败', 'feishu.enterCredentialsFirst': '请先输入 App ID 和 App Secret', - 'feishu.allowedUsers': '允许的用户', - 'feishu.allowedUsersDesc': '允许使用桥接的 open_id 或 chat_id,逗号分隔', - 'feishu.allowedUsersHint': '留空则允许所有用户', + 'feishu.allowFrom': '允许来源', + 'feishu.allowFromDesc': '控制哪些用户可以向机器人发送私信', + 'feishu.allowFromHint': '逗号分隔的 open_id。使用 * 允许所有用户。', + 'feishu.dmPolicy': '私信策略', + 'feishu.dmPolicyOpen': '开放 — 接受所有用户私信', + 'feishu.dmPolicyPairing': '配对 — 需要配对握手', + 'feishu.dmPolicyAllowlist': '白名单 — 仅允许列表中的用户', + 'feishu.dmPolicyDisabled': '禁用 — 忽略所有私信', + 'feishu.renderMode': '渲染模式', + 'feishu.renderModeDesc': '控制机器人如何渲染响应', + 'feishu.renderModeAuto': '自动 — 选择最佳渲染方式', + 'feishu.renderModeStatic': '静态 — 以纯文本发送', + 'feishu.renderModeStreaming': '流式 — 通过卡片流式输出', 'feishu.groupSettings': '群聊设置', 'feishu.groupSettingsDesc': '控制机器人在群聊中的响应方式', 'feishu.groupPolicy': '群聊策略', @@ -552,15 +568,13 @@ const zh: Record = { 'feishu.step4': '添加事件订阅:im.message.receive_v1', 'feishu.step5': '发布应用版本并在管理后台审批通过', 'feishu.step6': '将凭据粘贴到上方,点击「测试连接」验证', - 'feishu.replyMode': '回复模式', - 'feishu.replyModeInline': '内联 — 直接在聊天中回复', - 'feishu.replyModeThread': '话题 — 在消息话题中回复', - 'feishu.streaming': '流式卡片', - 'feishu.streamingDesc': '通过可更新卡片流式输出响应(需要 CardKit v2)', 'feishu.blockStreaming': 'Block 流式', - 'feishu.blockStreamingDesc': '按 Block 级别流式输出响应', + 'feishu.blockStreamingDesc': '按 Block 级别流式输出响应(配合合并缓冲配置)', 'feishu.threadSession': '话题会话', - 'feishu.threadSessionDesc': '将消息话题作为独立的对话会话', + 'feishu.threadSessionDesc': '启用每话题独立上下文(不同话题中的并行对话)', + 'feishu.footer': '页脚', + 'feishu.footerStatus': '显示状态', + 'feishu.footerElapsed': '显示耗时', 'feishu.connectionState': '连接状态', 'feishu.connectionStateConnected': '已连接', 'feishu.connectionStateDisconnected': '未连接', diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index dbeb673..5d632c2 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -323,6 +323,8 @@ export async function stop(): Promise { state.adapters.clear(); state.adapterMeta.clear(); + state.sessionLocks.clear(); + state.activeTasks.clear(); state.startedAt = null; // Re-enable notification bot polling @@ -745,6 +747,7 @@ async function handleCommand( '/perm allow|allow_session|deny <id> - Respond to permission', '/history [count] - Show recent messages', '/search <keyword> - Search messages', + '/feishu <subcommand> - Feishu bridge management', '/help - Show this help', ].join('\n'); break; @@ -872,7 +875,7 @@ async function handleCommand( } case '/history': { - // Fetch recent messages from the current chat + // Fetch recent messages from the current chat (or thread) if (!(adapter instanceof ChannelPluginAdapter)) { response = 'History is not supported for this channel type.'; break; @@ -884,15 +887,26 @@ async function handleCommand( } // Use message-actions if the plugin has Feishu-type capabilities try { - const { readMessages } = await import('../channels/feishu/message-actions'); + const { readMessages, readThreadMessages } = await import('../channels/feishu/message-actions'); const restClient = (plugin as any).gateway?.getRestClient?.(); if (!restClient) { response = 'Channel not connected.'; break; } const pageSize = parseInt(args, 10) || 10; - const realChatId = extractRealChatId(msg.address.chatId); - const result = await readMessages(restClient, realChatId, { pageSize }); + const chatIdRaw = msg.address.chatId; + const threadIdx = chatIdRaw.indexOf(':thread:'); + + let result; + if (threadIdx >= 0) { + // Thread history: extract thread ID and use readThreadMessages + const threadId = chatIdRaw.slice(threadIdx + ':thread:'.length); + result = await readThreadMessages(restClient, threadId, { pageSize }); + } else { + const realChatId = extractRealChatId(chatIdRaw); + result = await readMessages(restClient, realChatId, { pageSize }); + } + if (result.items.length === 0) { response = 'No messages found.'; } else { @@ -918,6 +932,9 @@ async function handleCommand( } case '/search': { + // Simplified local search — lists recent messages and filters client-side. + // This is NOT equivalent to OpenClaw's server-side search (search.message.create API + // with user_access_token). Results are limited to recent messages in the current chat. if (!args) { response = 'Usage: /search <keyword>'; break; @@ -959,6 +976,135 @@ async function handleCommand( break; } + case '/feishu': { + const subArgs = args.split(/\s+/); + const subcommand = subArgs[0]?.toLowerCase() || 'help'; + + switch (subcommand) { + case 'start': { + // Validate Feishu config + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'This command is only available in Feishu channels.'; + break; + } + const plugin = adapter.getPlugin(); + const config = (plugin as any).getConfig?.(); + if (!config) { + response = '❌ Feishu plugin not configured.\n\nPlease set App ID and App Secret in CodePilot settings, or use /feishu auth.'; + break; + } + const validationError = plugin.validateConfig(); + if (validationError) { + response = `❌ Configuration error: ${validationError}`; + break; + } + const capabilities = plugin.getCapabilities(); + const lines = [ + '✅ Feishu Bridge is running', + '', + `Streaming: ${capabilities.streaming ? '✅ Enabled' : '❌ Disabled'}`, + `Thread Reply: ${capabilities.threadReply ? '✅' : '❌'}`, + `Search: ${capabilities.search ? '✅' : '❌'}`, + `History: ${capabilities.history ? '✅' : '❌'}`, + ]; + response = lines.join('\n'); + break; + } + + case 'auth': { + // Show auth status and guidance + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'This command is only available in Feishu channels.'; + break; + } + const plugin = adapter.getPlugin(); + const config = (plugin as any).getConfig?.(); + if (!config) { + response = '❌ App credentials not configured.\n\nPlease configure App ID and App Secret in CodePilot Settings → Bridge → Feishu.'; + break; + } + // Note: CodePilot currently uses app-level bot tokens (no user OAuth) + // This is a simplified version compared to OpenClaw's full OAuth Device Flow + response = [ + '🔐 Feishu Auth Status', + '', + `App ID: ${config.appId}`, + `DM Policy: ${config.dmPolicy}`, + `Allow From: ${(config.allowFrom || []).join(', ') || '(all)'}`, + '', + 'ℹ️ CodePilot uses app-level bot tokens.', + 'User-level OAuth (user_access_token) is not yet supported.', + 'Some features requiring user identity (cross-chat search, sending as user) are unavailable.', + ].join('\n'); + break; + } + + case 'doctor': { + // Run diagnostics + if (!(adapter instanceof ChannelPluginAdapter)) { + response = 'This command is only available in Feishu channels.'; + break; + } + const plugin = adapter.getPlugin(); + const config = (plugin as any).getConfig?.(); + const lines = ['🔍 Feishu Doctor', '']; + + // Config check + if (!config) { + lines.push('❌ Configuration: Not configured'); + } else { + lines.push('✅ Configuration: OK'); + lines.push(` App ID: ${config.appId}`); + lines.push(` DM Policy: ${config.dmPolicy}`); + lines.push(` Render Mode: ${config.renderMode}`); + lines.push(` Thread Session: ${config.threadSession ? 'Yes' : 'No'}`); + lines.push(` Streaming: ${config.cardStreamConfig ? 'Enabled' : 'Disabled'}`); + } + + // Connection check + if (plugin.isRunning()) { + lines.push('✅ Connection: WebSocket connected'); + } else { + lines.push('❌ Connection: Not running'); + } + + // Capabilities + const caps = plugin.getCapabilities(); + lines.push(''); + lines.push('Capabilities:'); + lines.push(` Streaming Cards: ${caps.streaming ? '✅' : '❌'}`); + lines.push(` Thread Reply: ${caps.threadReply ? '✅' : '❌'}`); + lines.push(` Message Search: ${caps.search ? '✅' : '❌ (requires user_access_token)'}`); + lines.push(` Message History: ${caps.history ? '✅' : '❌'}`); + + // Known limitations + lines.push(''); + lines.push('Known Limitations (CodePilot vs OpenClaw):'); + lines.push(' • No user_access_token / OAuth Device Flow'); + lines.push(' • No cross-chat search (search.message.create requires UAT)'); + lines.push(' • No "send as user" capability'); + lines.push(' • Simplified card streaming (no reasoning phase display)'); + + response = lines.join('\n'); + break; + } + + default: { + // /feishu help or unknown subcommand + response = [ + 'Feishu Bridge Commands', + '', + '/feishu start — Check plugin status and configuration', + '/feishu auth — View auth status and guidance', + '/feishu doctor — Run diagnostics', + '/feishu help — Show this help', + ].join('\n'); + break; + } + } + break; + } + case '/help': response = [ 'CodePilot Bridge Commands', @@ -973,6 +1119,7 @@ async function handleCommand( '/perm allow|allow_session|deny <id> - Respond to permission request', '/history [count] - Show recent messages', '/search <keyword> - Search messages', + '/feishu <subcommand> - Feishu bridge management', '/help - Show this help', ].join('\n'); break; diff --git a/src/lib/channels/channel-plugin-adapter.ts b/src/lib/channels/channel-plugin-adapter.ts new file mode 100644 index 0000000..7fa687e --- /dev/null +++ b/src/lib/channels/channel-plugin-adapter.ts @@ -0,0 +1,73 @@ +/** + * ChannelPluginAdapter — bridges a ChannelPlugin to BaseChannelAdapter. + * + * This allows any ChannelPlugin implementation to be used as a + * BaseChannelAdapter in the bridge system without modification. + */ + +import { BaseChannelAdapter } from '../bridge/channel-adapter'; +import type { + ChannelType, + InboundMessage, + OutboundMessage, + SendResult, +} from '../bridge/types'; +import type { CardStreamController, ChannelPlugin } from './types'; + +export class ChannelPluginAdapter extends BaseChannelAdapter { + private plugin: ChannelPlugin; + + constructor(plugin: ChannelPlugin) { + super(); + this.plugin = plugin; + } + + get channelType(): ChannelType { + return this.plugin.meta.channelType; + } + + /** Expose the underlying plugin for direct access (e.g. message-actions). */ + getPlugin(): ChannelPlugin { + return this.plugin; + } + + async start(): Promise { + return this.plugin.start(); + } + + async stop(): Promise { + return this.plugin.stop(); + } + + isRunning(): boolean { + return this.plugin.isRunning(); + } + + async consumeOne(): Promise { + return this.plugin.consumeOne(); + } + + async send(message: OutboundMessage): Promise { + return this.plugin.send(message); + } + + validateConfig(): string | null { + return this.plugin.validateConfig(); + } + + isAuthorized(userId: string, chatId: string): boolean { + return this.plugin.isAuthorized(userId, chatId); + } + + onMessageStart(chatId: string): void { + this.plugin.onMessageStart?.(chatId); + } + + onMessageEnd(chatId: string): void { + this.plugin.onMessageEnd?.(chatId); + } + + getCardStreamController(): CardStreamController | null { + return this.plugin.getCardStreamController?.() ?? null; + } +} diff --git a/src/lib/channels/feishu/card-controller.ts b/src/lib/channels/feishu/card-controller.ts new file mode 100644 index 0000000..9cedb8d --- /dev/null +++ b/src/lib/channels/feishu/card-controller.ts @@ -0,0 +1,228 @@ +/** + * CodePilot Simplified Card Streaming + * + * Manages streaming card lifecycle via CardKit v2 API. + * State machine: idle → creating → streaming → completed | error + * + * Differences from OpenClaw's StreamingCardController: + * - No reasoning phase streaming (thinking indicator) + * - No FlushController / UnavailableGuard + * - No IM patch fallback (if CardKit fails, falls back to static text) + * - No reply boundary detection + * - Footer is simplified (just elapsed time and status text, no animated status) + * + * These are intentional simplifications for CodePilot's use case. + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { CardStreamController } from '../types'; +import type { CardStreamConfig } from './types'; + +const LOG_TAG = '[card-controller]'; + +interface CardState { + cardId: string; + messageId: string; + sequence: number; + lastUpdateAt: number; + startTime: number; + throttleTimer: ReturnType | null; + pendingText: string | null; +} + +class FeishuCardStreamController implements CardStreamController { + private client: lark.Client; + private config: CardStreamConfig; + private cards = new Map(); + + constructor(client: lark.Client, config: CardStreamConfig) { + this.client = client; + this.config = config; + } + + async create(chatId: string, initialText: string, replyToMessageId?: string): Promise { + try { + // 1. Create streaming card via CardKit v2 + const cardBody = { + schema: '2.0', + config: { streaming_mode: true, wide_screen_mode: true }, + body: { + elements: [{ + tag: 'markdown', + content: initialText || '...', + element_id: 'streaming_content', + }], + }, + }; + + const createResp = await (this.client as any).cardkit.v2.card.create({ + data: { type: 'card_json', data: JSON.stringify(cardBody) }, + }); + const cardId = createResp?.data?.card_id; + if (!cardId) { + console.error(LOG_TAG, 'Card create returned no card_id'); + return ''; + } + console.log(LOG_TAG, 'Created card:', cardId); + + // 2. Send card as IM message + const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } }); + let msgResp: any; + if (replyToMessageId) { + msgResp = await this.client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { content: cardContent, msg_type: 'interactive' }, + }); + } else { + msgResp = await this.client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' }, + }); + } + const messageId = msgResp?.data?.message_id || ''; + console.log(LOG_TAG, 'Sent card message:', messageId); + + this.cards.set(messageId, { + cardId, + messageId, + sequence: 0, + lastUpdateAt: Date.now(), + startTime: Date.now(), + throttleTimer: null, + pendingText: null, + }); + + return messageId; + } catch (err: any) { + console.error(LOG_TAG, 'Card create failed:', err?.message || err); + return ''; + } + } + + async update(messageId: string, text: string): Promise<'ok' | 'fail'> { + const state = this.cards.get(messageId); + if (!state) return 'fail'; + + state.pendingText = text; + + const elapsed = Date.now() - state.lastUpdateAt; + if (elapsed < this.config.throttleMs) { + // Schedule trailing-edge flush + if (!state.throttleTimer) { + state.throttleTimer = setTimeout(() => { + state.throttleTimer = null; + if (state.pendingText) { + this.flushUpdate(state).catch(() => {}); + } + }, this.config.throttleMs - elapsed); + } + return 'ok'; + } + + return this.flushUpdate(state); + } + + private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> { + if (!state.pendingText) return 'ok'; + const text = state.pendingText; + state.pendingText = null; + + try { + state.sequence++; + await (this.client as any).cardkit.v2.card.streamContent({ + path: { card_id: state.cardId }, + data: { content: text, sequence: state.sequence }, + }); + state.lastUpdateAt = Date.now(); + return 'ok'; + } catch (err: any) { + console.error(LOG_TAG, 'Stream update failed:', err?.message || err); + return 'fail'; + } + } + + async finalize( + messageId: string, + finalText: string, + status: 'completed' | 'interrupted' | 'error' = 'completed', + ): Promise { + const state = this.cards.get(messageId); + if (!state) return; + + // Clear any pending throttle + if (state.throttleTimer) { + clearTimeout(state.throttleTimer); + state.throttleTimer = null; + } + + try { + // Close streaming mode + await (this.client as any).cardkit.v2.card.setStreamingMode({ + path: { card_id: state.cardId }, + params: { streaming_mode: false }, + }); + + // Build footer elements if configured + const footerElements: Array<{ tag: string; content: string; element_id: string }> = []; + const footerCfg = this.config.footer; + + if (footerCfg?.elapsed) { + const elapsedMs = Date.now() - state.startTime; + const elapsedSec = (elapsedMs / 1000).toFixed(1); + footerElements.push({ + tag: 'markdown', + content: `*${elapsedSec}s*`, + element_id: 'footer_elapsed', + }); + } + + if (footerCfg?.status) { + const statusLabels: Record = { + completed: 'Completed', + interrupted: 'Interrupted', + error: 'Error', + }; + footerElements.push({ + tag: 'markdown', + content: `**${statusLabels[status] || status}**`, + element_id: 'footer_status', + }); + } + + // Update final card content + const finalCard = { + schema: '2.0', + config: { wide_screen_mode: true }, + body: { + elements: [ + { + tag: 'markdown', + content: finalText, + element_id: 'streaming_content', + }, + ...footerElements, + ], + }, + }; + await (this.client as any).cardkit.v2.card.update({ + path: { card_id: state.cardId }, + data: { type: 'card_json', data: JSON.stringify(finalCard) }, + }); + } catch (err: any) { + console.error(LOG_TAG, 'Finalize failed:', err?.message || err); + } + + console.log(LOG_TAG, `Finalized card ${state.cardId} as ${status}`); + this.cards.delete(messageId); + } +} + +/** + * Factory function for creating a card stream controller. + */ +export function createCardStreamController( + client: lark.Client, + config: CardStreamConfig, +): CardStreamController { + return new FeishuCardStreamController(client, config); +} diff --git a/src/lib/channels/feishu/config.ts b/src/lib/channels/feishu/config.ts new file mode 100644 index 0000000..d872a9c --- /dev/null +++ b/src/lib/channels/feishu/config.ts @@ -0,0 +1,90 @@ +/** + * Feishu configuration loading and validation. + */ + +import { getSetting } from '../../db'; +import type { FeishuConfig, CardStreamConfig, BlockStreamingCoalesce, FooterConfig } from './types'; + +/** Parse a comma-separated string into a trimmed, non-empty string array. */ +function parseList(raw: string | undefined): string[] { + if (!raw) return []; + return raw.split(',').map(s => s.trim()).filter(Boolean); +} + +/** Load Feishu config from settings DB. Returns null if incomplete. */ +export function loadFeishuConfig(): FeishuConfig | null { + const appId = getSetting('bridge_feishu_app_id'); + const appSecret = getSetting('bridge_feishu_app_secret'); + if (!appId || !appSecret) return null; + + const domain = getSetting('bridge_feishu_domain') || 'feishu'; + const 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, + }; +} + +/** Validate a Feishu config. Returns null if valid, error message otherwise. */ +export function validateFeishuConfig(config: FeishuConfig | null): string | null { + if (!config) return 'Feishu App ID and App Secret are required.'; + if (!config.appId) return 'Feishu App ID is required.'; + if (!config.appSecret) return 'Feishu App Secret is required.'; + return null; +} diff --git a/src/lib/channels/feishu/gateway.ts b/src/lib/channels/feishu/gateway.ts new file mode 100644 index 0000000..99ae8a4 --- /dev/null +++ b/src/lib/channels/feishu/gateway.ts @@ -0,0 +1,190 @@ +/** + * 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. + * - Client and WSClient receive the resolved SDK domain (Feishu vs Lark). + * - card.action.trigger handler guarantees a valid response within 3 seconds, + * regardless of what the upper-layer handler does. Heavy logic is fire-and-forget. + */ + +import * as lark from '@larksuiteoapi/node-sdk'; +import type { FeishuConfig } from './types'; + +const LOG_TAG = '[feishu/gateway]'; + +/** Map brand string to SDK domain constant. */ +function resolveDomain(brand: string): lark.Domain | string { + if (brand === 'lark') return lark.Domain.Lark; + if (brand === 'feishu') return lark.Domain.Feishu; + // Custom domain (e.g. self-hosted): pass through as-is + return brand.replace(/\/+$/, ''); +} + +/** Default toast returned when the upper-layer handler fails or times out. */ +const FALLBACK_TOAST = { + toast: { type: 'info' as const, content: '已收到,正在处理...' }, +}; + +/** + * Card action callback handler type. + * Receives the raw event data and returns a toast/card response object. + * May also return void/undefined — gateway will fill in a default toast. + */ +export type CardActionHandler = (data: unknown) => Promise; + +export class FeishuGateway { + private client: lark.Client | null = null; + private wsClient: lark.WSClient | null = null; + private running = false; + private config: FeishuConfig; + private eventDispatcher: lark.EventDispatcher; + private onMessage: ((data: unknown) => void) | null = null; + private cardActionHandler: CardActionHandler | null = null; + + constructor(config: FeishuConfig) { + this.config = config; + // Pass encryptKey and verificationToken so the SDK can decrypt/verify + // card.action.trigger callbacks delivered over WebSocket. + this.eventDispatcher = new lark.EventDispatcher({ + encryptKey: config.encryptKey || '', + verificationToken: config.verificationToken || '', + }); + } + + /** Get the REST client (for API calls). */ + getRestClient(): lark.Client | null { + return this.client; + } + + /** Get the event dispatcher for handler registration. */ + getEventDispatcher(): lark.EventDispatcher { + return this.eventDispatcher; + } + + /** Register the im.message.receive_v1 handler. */ + registerMessageHandler(handler: (data: unknown) => void): void { + this.onMessage = handler; + this.eventDispatcher.register({ + 'im.message.receive_v1': ((data: unknown) => { + handler(data); + }) as any, + }); + } + + /** + * Register a card action handler. + * + * The gateway wraps this handler with a 3-second timeout guarantee: + * - If the handler resolves within 3s, its return value is used. + * - If it throws or times out, a safe fallback toast is returned. + * - The handler itself should keep synchronous work minimal and + * push heavy logic to fire-and-forget (setImmediate / queueMicrotask). + * + * Supports both button value formats: + * - value.callback_data (CodePilot permission buttons) + * - value.action / value.operation_id (OpenClaw-style buttons) + */ + registerCardActionHandler(handler: CardActionHandler): void { + this.cardActionHandler = handler; + + // Register the safe wrapper on the EventDispatcher. + // The wrapper guarantees a response object is always returned. + this.eventDispatcher.register({ + 'card.action.trigger': ((data: unknown) => this.safeCardActionHandler(data)) as any, + }); + } + + /** + * Gateway-level card action handler that enforces the 3-second response contract. + * + * Flow: + * 1. Race the upper-layer handler against a 2.5s timeout (leaving 500ms margin). + * 2. If the handler returns a valid object, use it. + * 3. If it throws, times out, or returns nothing, return FALLBACK_TOAST. + */ + private async safeCardActionHandler(data: unknown): Promise { + const handler = this.cardActionHandler; + if (!handler) return FALLBACK_TOAST; + + const TIMEOUT_MS = 2500; // 2.5s — leave 500ms margin for SDK overhead + + try { + const result = await Promise.race([ + handler(data), + new Promise((resolve) => setTimeout(() => resolve(undefined), TIMEOUT_MS)), + ]); + + // Handler resolved with a valid response + if (result && typeof result === 'object') return result; + + // Handler returned void/undefined or timed out — use fallback + return FALLBACK_TOAST; + } catch (err) { + console.error(LOG_TAG, 'Card action handler error:', err); + return FALLBACK_TOAST; + } + } + + /** Start the WebSocket connection. */ + async start(): Promise { + if (this.running) return; + + const domain = resolveDomain(this.config.domain); + + this.client = new lark.Client({ + appId: this.config.appId, + appSecret: this.config.appSecret, + domain, + disableTokenCache: false, + }); + + this.wsClient = new lark.WSClient({ + appId: this.config.appId, + appSecret: this.config.appSecret, + domain, + loggerLevel: lark.LoggerLevel.warn, + }); + + // Monkey-patch handleEventData to support card action events (type: "card"). + // The SDK's WSClient only handles type="event" by default; card action + // callbacks arrive as type="card" and would be silently dropped. + // Patch: rewrite type header from "card" to "event" before dispatching. + const wsClientAny = this.wsClient as any; + if (typeof wsClientAny.handleEventData === 'function') { + const origHandleEventData = wsClientAny.handleEventData.bind(wsClientAny); + wsClientAny.handleEventData = (data: any) => { + const msgType = data.headers?.find?.((h: any) => h.key === 'type')?.value; + if (msgType !== 'event') console.log(LOG_TAG, 'handleEventData type:', msgType); + if (msgType === 'card') { + const patchedData = { + ...data, + headers: data.headers.map((h: any) => + h.key === 'type' ? { ...h, value: 'event' } : h, + ), + }; + return origHandleEventData(patchedData); + } + return origHandleEventData(data); + }; + } + + await this.wsClient.start({ eventDispatcher: this.eventDispatcher }); + this.running = true; + console.log(LOG_TAG, 'WebSocket connected'); + } + + /** Stop the WebSocket connection. */ + async stop(): Promise { + if (!this.running) return; + this.running = false; + // WSClient doesn't expose a clean stop — set refs to null + this.wsClient = null; + console.log(LOG_TAG, 'Stopped'); + } + + isRunning(): boolean { + return this.running; + } +} diff --git a/src/lib/channels/feishu/identity.ts b/src/lib/channels/feishu/identity.ts new file mode 100644 index 0000000..e8408c2 --- /dev/null +++ b/src/lib/channels/feishu/identity.ts @@ -0,0 +1,25 @@ +/** + * Feishu bot identity resolution. + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { FeishuBotInfo } from './types'; + +const LOG_TAG = '[feishu/identity]'; + +/** Fetch bot info from Feishu API. */ +export async function getBotInfo(client: lark.Client): Promise { + try { + const resp = await (client as any).bot.v3.botInfo.list(); + const bot = resp?.data?.bot; + if (!bot) return null; + return { + appId: bot.app_id || '', + botName: bot.app_name || '', + openId: bot.open_id || '', + }; + } catch (err) { + console.error(LOG_TAG, 'Failed to get bot info:', err); + return null; + } +} diff --git a/src/lib/channels/feishu/inbound.ts b/src/lib/channels/feishu/inbound.ts new file mode 100644 index 0000000..590f5ae --- /dev/null +++ b/src/lib/channels/feishu/inbound.ts @@ -0,0 +1,61 @@ +/** + * Feishu inbound message processing. + * + * Converts raw Feishu event data into InboundMessage for the bridge queue. + */ + +import type { InboundMessage } from '../../bridge/types'; +import type { FeishuConfig } from './types'; + +const LOG_TAG = '[feishu/inbound]'; + +/** Parse a raw Feishu im.message.receive_v1 event into an InboundMessage. */ +export function parseInboundMessage( + eventData: any, + _config: FeishuConfig, +): InboundMessage | null { + try { + const event = eventData?.event ?? eventData; + const message = event?.message; + if (!message) return null; + + const chatId = message.chat_id || ''; + const messageId = message.message_id || ''; + const sender = event.sender?.sender_id?.open_id || ''; + const msgType = message.message_type; + + // Only handle text messages for now + let text = ''; + if (msgType === 'text') { + try { + const content = JSON.parse(message.content || '{}'); + text = content.text || ''; + } catch { + text = message.content || ''; + } + } else { + // Non-text messages — skip silently + return null; + } + + if (!text.trim()) return null; + + // Build thread-session address if applicable + const rootId = message.root_id || ''; + const effectiveChatId = rootId ? `${chatId}:thread:${rootId}` : chatId; + + return { + messageId, + address: { + channelType: 'feishu', + chatId: effectiveChatId, + userId: sender, + }, + text: text.trim(), + timestamp: parseInt(message.create_time, 10) || Date.now(), + }; + } catch (err) { + console.error(LOG_TAG, 'Failed to parse inbound message:', err); + return null; + } +} diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts new file mode 100644 index 0000000..522c412 --- /dev/null +++ b/src/lib/channels/feishu/index.ts @@ -0,0 +1,205 @@ +/** + * FeishuChannelPlugin — implements ChannelPlugin for Feishu/Lark. + * + * Composes: gateway (WS), inbound (parsing), outbound (sending), + * identity (bot info), policy (access control), card-controller (streaming). + */ + +import type { InboundMessage, OutboundMessage, SendResult } from '../../bridge/types'; +import type { ChannelPlugin, ChannelCapabilities, ChannelMeta, CardStreamController } from '../types'; +import type { FeishuConfig } from './types'; +import { loadFeishuConfig, validateFeishuConfig } from './config'; +import { FeishuGateway } from './gateway'; +import { parseInboundMessage } from './inbound'; +import { sendMessage } from './outbound'; +import { isUserAuthorized } from './policy'; +import { createCardStreamController } from './card-controller'; + +export class FeishuChannelPlugin implements ChannelPlugin { + readonly meta: ChannelMeta = { + channelType: 'feishu', + displayName: 'Feishu / Lark', + }; + + private config: FeishuConfig | null = null; + private gateway: FeishuGateway | null = null; + private messageQueue: InboundMessage[] = []; + private waitResolve: ((msg: InboundMessage | null) => void) | null = null; + + loadConfig(): FeishuConfig | null { + this.config = loadFeishuConfig(); + return this.config; + } + + getConfig(): FeishuConfig | null { + return this.config; + } + + getCapabilities(): ChannelCapabilities { + return { + streaming: !!(this.config?.cardStreamConfig), + threadReply: true, + search: false, // True server-side search requires user_access_token; we only have local filtering + history: true, + reactions: false, + }; + } + + validateConfig(): string | null { + if (!this.config) { + this.loadConfig(); + } + return validateFeishuConfig(this.config); + } + + async start(): Promise { + if (!this.config) { + this.config = loadFeishuConfig(); + } + if (!this.config) throw new Error('Feishu config not loaded'); + + this.gateway = new FeishuGateway(this.config); + + // Register message handler — pushes to internal queue + this.gateway.registerMessageHandler((data: unknown) => { + const msg = parseInboundMessage(data, this.config!); + if (!msg) return; + this.enqueueMessage(msg); + }); + + // Register card action handler — converts button clicks to callback messages. + // Gateway guarantees 3-second response; this handler should stay lightweight. + // Supports two button value formats: + // 1. { callback_data: "perm:allow:xxx" } — CodePilot permission buttons + // 2. { action: "app_auth_done", operation_id: "xxx" } — OpenClaw-style buttons + this.gateway.registerCardActionHandler(async (data: unknown) => { + const event = data as any; + console.log('[feishu/plugin]', 'Card action raw event:', JSON.stringify(event).slice(0, 500)); + const value = event?.action?.value ?? {}; + // Feishu card.action.trigger v2 callback structure (per official docs): + // event.operator.open_id, event.context.open_chat_id, event.context.open_message_id + // SDK InteractiveCardActionEvent (older type) flattens to: + // event.open_id, event.open_message_id + // WSClient monkey-patch may deliver either format — try both paths. + // Additionally, we embed chatId in button value as ultimate fallback. + const chatId = event?.context?.open_chat_id || value.chatId || ''; + const messageId = event?.context?.open_message_id || event?.open_message_id || ''; + const userId = event?.operator?.open_id || event?.open_id || ''; + + // Format 1: callback_data (permission buttons) + const callbackData = value.callback_data; + if (callbackData && chatId) { + const callbackMsg: InboundMessage = { + messageId: messageId || `card_action_${Date.now()}`, + address: { + channelType: 'feishu', + chatId, + userId, + }, + text: '', + timestamp: Date.now(), + callbackData, + callbackMessageId: messageId, + }; + console.log('[feishu/plugin]', 'Card action (callback_data):', callbackData); + this.enqueueMessage(callbackMsg); + return { + toast: { type: 'info' as const, content: '已收到,正在处理...' }, + }; + } + + // Format 2: action / operation_id (OpenClaw-style buttons) + const action = value.action; + const operationId = value.operation_id; + if (action) { + // Encode as callbackData so the existing bridge-manager callback path + // can handle it. Format: "action:{action}:{operation_id}" + const syntheticCallback = operationId + ? `action:${action}:${operationId}` + : `action:${action}`; + const actionMsg: InboundMessage = { + messageId: messageId || `card_action_${Date.now()}`, + address: { + channelType: 'feishu', + chatId, + userId, + }, + text: '', + timestamp: Date.now(), + callbackData: syntheticCallback, + callbackMessageId: messageId, + }; + console.log('[feishu/plugin]', 'Card action (action):', action, operationId ?? ''); + this.enqueueMessage(actionMsg); + return { + toast: { type: 'info' as const, content: '已收到,正在处理...' }, + }; + } + + // Unknown button format — still return a valid toast to prevent 200340 + console.warn('[feishu/plugin]', 'Unknown card action value:', JSON.stringify(value).slice(0, 200)); + return { + toast: { type: 'info' as const, content: '已收到' }, + }; + }); + + await this.gateway.start(); + } + + async stop(): Promise { + if (this.gateway) { + await this.gateway.stop(); + this.gateway = null; + } + // Unblock any waiting consumer + if (this.waitResolve) { + this.waitResolve(null); + this.waitResolve = null; + } + } + + isRunning(): boolean { + return this.gateway?.isRunning() ?? false; + } + + private enqueueMessage(msg: InboundMessage): void { + if (this.waitResolve) { + const resolve = this.waitResolve; + this.waitResolve = null; + resolve(msg); + } else { + this.messageQueue.push(msg); + } + } + + async consumeOne(): Promise { + if (this.messageQueue.length > 0) { + return this.messageQueue.shift()!; + } + return new Promise((resolve) => { + this.waitResolve = resolve; + }); + } + + async send(message: OutboundMessage): Promise { + const client = this.gateway?.getRestClient(); + if (!client) return { ok: false, error: 'Not connected' }; + return sendMessage(client, message); + } + + isAuthorized(userId: string, chatId: string): boolean { + if (!this.config) return false; + return isUserAuthorized(this.config, userId, chatId); + } + + getCardStreamController(): CardStreamController | null { + const client = this.gateway?.getRestClient(); + if (!client || !this.config?.cardStreamConfig) return null; + return createCardStreamController(client, this.config.cardStreamConfig); + } + + /** Expose gateway for direct access (e.g. message-actions need restClient). */ + get _gateway(): FeishuGateway | null { + return this.gateway; + } +} diff --git a/src/lib/channels/feishu/message-actions.ts b/src/lib/channels/feishu/message-actions.ts new file mode 100644 index 0000000..a839bc5 --- /dev/null +++ b/src/lib/channels/feishu/message-actions.ts @@ -0,0 +1,191 @@ +/** + * Feishu message-actions — readMessages, readThreadMessages, and searchMessagesLocal. + * + * These functions are dynamically imported by bridge-manager for /history and /search commands. + * + * Identity note: All functions here use bot identity (app_access_token) via the + * Lark SDK client. OpenClaw's equivalent (feishu_im_user_*) uses user_access_token + * for richer results and broader access. + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { MessageListResult, FeishuMessageItem } from './types'; + +interface ReadOptions { + pageSize?: number; + pageToken?: string; +} + +interface SearchOptions { + pageSize?: number; + pageToken?: string; +} + +/** + * Read recent messages from a Feishu chat using bot identity (app_access_token). + * + * OpenClaw's equivalent (feishu_im_user_get_chat_messages) uses user_access_token + * for richer results including messages from all participants. + */ +export async function readMessages( + client: lark.Client, + chatId: string, + options: ReadOptions = {}, +): Promise { + const { pageSize = 10, pageToken } = options; + + try { + const resp = await client.im.message.list({ + params: { + container_id_type: 'chat', + container_id: chatId, + page_size: pageSize, + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + + const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({ + messageId: item.message_id || '', + rootId: item.root_id || undefined, + parentId: item.parent_id || undefined, + msgType: item.msg_type || '', + createTime: item.create_time || '', + updateTime: item.update_time || undefined, + content: item.body?.content || '', + sender: { + id: item.sender?.id || '', + idType: item.sender?.id_type || '', + senderType: item.sender?.sender_type || '', + tenantKey: item.sender?.tenant_key || undefined, + }, + })); + + return { + items, + hasMore: resp?.data?.has_more || false, + pageToken: resp?.data?.page_token || undefined, + }; + } catch (err: any) { + console.error('[feishu/message-actions] readMessages failed:', err?.message || err); + return { items: [], hasMore: false }; + } +} + +/** + * Read messages from a Feishu thread (reply chain). + * + * Uses `container_id_type: 'thread'` to fetch all messages in a specific thread. + * OpenClaw's equivalent is feishu_im_user_get_thread_messages. + */ +export async function readThreadMessages( + client: lark.Client, + threadId: string, + options: ReadOptions = {}, +): Promise { + const { pageSize = 10, pageToken } = options; + + try { + const resp = await client.im.message.list({ + params: { + container_id_type: 'thread', + container_id: threadId, + page_size: pageSize, + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + + const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({ + messageId: item.message_id || '', + rootId: item.root_id || undefined, + parentId: item.parent_id || undefined, + msgType: item.msg_type || '', + createTime: item.create_time || '', + updateTime: item.update_time || undefined, + content: item.body?.content || '', + sender: { + id: item.sender?.id || '', + idType: item.sender?.id_type || '', + senderType: item.sender?.sender_type || '', + tenantKey: item.sender?.tenant_key || undefined, + }, + })); + + return { + items, + hasMore: resp?.data?.has_more || false, + pageToken: resp?.data?.page_token || undefined, + }; + } catch (err: any) { + console.error('[feishu/message-actions] readThreadMessages failed:', err?.message || err); + return { items: [], hasMore: false }; + } +} + +/** + * Simplified local search — lists recent messages and filters client-side. + * + * Unlike OpenClaw's feishu_im_user_search_messages which uses the search.message.create + * API with user_access_token for true cross-chat server-side search, this function + * only fetches recent messages from a single chat and applies a client-side keyword filter. + * Results are limited and pagination is not reliable. + */ +export async function searchMessagesLocal( + client: lark.Client, + chatId: string, + query: string, + options: SearchOptions = {}, +): Promise { + const { pageSize = 10, pageToken } = options; + + try { + // Use im.message.list and filter client-side. + // True server-side search requires user_access_token (search.message.create API). + const resp = await client.im.message.list({ + params: { + container_id_type: 'chat', + container_id: chatId, + page_size: Math.min(pageSize * 5, 50), // fetch more to filter + sort_type: 'ByCreateTimeDesc' as any, + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + + const allItems: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({ + messageId: item.message_id || '', + rootId: item.root_id || undefined, + parentId: item.parent_id || undefined, + msgType: item.msg_type || '', + createTime: item.create_time || '', + updateTime: item.update_time || undefined, + content: item.body?.content || '', + sender: { + id: item.sender?.id || '', + idType: item.sender?.id_type || '', + senderType: item.sender?.sender_type || '', + tenantKey: item.sender?.tenant_key || undefined, + }, + })); + + // Client-side keyword filter + const lowerQuery = query.toLowerCase(); + const filtered = allItems.filter(item => { + try { + const parsed = JSON.parse(item.content); + return (parsed.text || '').toLowerCase().includes(lowerQuery); + } catch { + return item.content.toLowerCase().includes(lowerQuery); + } + }).slice(0, pageSize); + + return { + items: filtered, + hasMore: false, // can't paginate client-side filter reliably + }; + } catch (err: any) { + console.error('[feishu/message-actions] searchMessagesLocal failed:', err?.message || err); + return { items: [], hasMore: false }; + } +} + +/** Backward-compatible alias for searchMessagesLocal. */ +export const searchMessages = searchMessagesLocal; diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts new file mode 100644 index 0000000..99a72ae --- /dev/null +++ b/src/lib/channels/feishu/outbound.ts @@ -0,0 +1,183 @@ +/** + * Feishu outbound message rendering and sending. + * + * Missing capabilities compared to OpenClaw's ChannelMessageActionAdapter: + * - No react/unreact support (add/remove emoji reactions) + * - No message delete/unsend support + * - No user identity (user_access_token) sending — bot identity only + */ + +import type * as lark from '@larksuiteoapi/node-sdk'; +import type { OutboundMessage, SendResult } from '../../bridge/types'; + +const LOG_TAG = '[feishu/outbound]'; + +/** + * Send a message to Feishu using bot identity (app_access_token). + * + * If the message contains inlineButtons, it is rendered as an interactive card + * (Schema V2 with column_set + button layout). Otherwise it's sent as plain text. + * + * OpenClaw supports both bot and user identity via its ChannelMessageActionAdapter, + * allowing messages to be sent as the authenticated user when user_access_token is available. + * + * This handles static message delivery — card streaming is handled upstream by bridge-manager. + */ +export async function sendMessage( + client: lark.Client, + message: OutboundMessage, +): Promise { + try { + // Extract real chat ID (strip thread prefix if present) + const chatId = message.address.chatId.split(':thread:')[0]; + const replyId = message.replyToMessageId; + + // If message has inline buttons, send as interactive card + if (message.inlineButtons && message.inlineButtons.length > 0) { + return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId); + } + + const content = JSON.stringify({ text: message.text }); + + let resp: any; + if (replyId) { + resp = await client.im.message.reply({ + path: { message_id: replyId }, + data: { + content, + msg_type: 'text', + }, + }); + } else { + resp = await client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + content, + msg_type: 'text', + }, + }); + } + + const msgId = resp?.data?.message_id || ''; + return { ok: true, messageId: msgId }; + } catch (err: any) { + console.error(LOG_TAG, 'Send failed:', err?.message || err); + return { ok: false, error: err?.message || 'Unknown error' }; + } +} + +/** + * Build and send a Feishu interactive card with buttons. + * + * Uses Schema V2 with column_set + column + button layout because + * Schema V2 does NOT support the `action` tag directly. + */ +async function sendAsInteractiveCard( + client: lark.Client, + chatId: string, + text: string, + inlineButtons: import('../../bridge/types').InlineButton[][], + replyToMessageId?: string, +): Promise { + try { + // Strip HTML tags for Feishu (convert to **, to `,
 to ```)
+    const mdText = text
+      .replace(/(.*?)<\/b>/gi, '**$1**')
+      .replace(/(.*?)<\/code>/gi, '`$1`')
+      .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+      .replace(/<\/?[^>]+>/g, '');
+
+    // Build button elements using column_set layout (Schema V2 compatible)
+    const buttonRows = inlineButtons.map((row) => ({
+      tag: 'column_set' as const,
+      flex_mode: 'stretch' as const,
+      columns: row.map((btn) => ({
+        tag: 'column' as const,
+        width: 'weighted' as const,
+        weight: 1,
+        elements: [{
+          tag: 'button' as const,
+          text: { tag: 'plain_text' as const, content: btn.text },
+          type: 'primary' as const,
+          value: { callback_data: btn.callbackData, chatId },
+        }],
+      })),
+    }));
+
+    const card = {
+      schema: '2.0',
+      config: { wide_screen_mode: true },
+      body: {
+        elements: [
+          { tag: 'markdown', content: mdText },
+          ...buttonRows,
+        ],
+      },
+    };
+
+    const cardContent = JSON.stringify(card);
+
+    let resp: any;
+    if (replyToMessageId) {
+      resp = await client.im.message.reply({
+        path: { message_id: replyToMessageId },
+        data: { content: cardContent, msg_type: 'interactive' },
+      });
+    } else {
+      resp = await client.im.message.create({
+        params: { receive_id_type: 'chat_id' },
+        data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' },
+      });
+    }
+
+    const msgId = resp?.data?.message_id || '';
+    console.log(LOG_TAG, 'Sent interactive card:', msgId);
+    return { ok: true, messageId: msgId };
+  } catch (err: any) {
+    console.error(LOG_TAG, 'Interactive card send failed:', err?.message || err);
+    return { ok: false, error: err?.message || 'Unknown error' };
+  }
+}
+
+/**
+ * Send a permission card with interactive buttons using bot identity (app_access_token).
+ *
+ * OpenClaw supports both bot and user identity for card delivery.
+ */
+export async function sendPermissionCard(
+  client: lark.Client,
+  chatId: string,
+  cardContent: string,
+  replyToMessageId?: string,
+): Promise {
+  try {
+    const realChatId = chatId.split(':thread:')[0];
+
+    let resp: any;
+    if (replyToMessageId) {
+      resp = await client.im.message.reply({
+        path: { message_id: replyToMessageId },
+        data: {
+          content: cardContent,
+          msg_type: 'interactive',
+        },
+      });
+    } else {
+      resp = await client.im.message.create({
+        params: { receive_id_type: 'chat_id' },
+        data: {
+          receive_id: realChatId,
+          content: cardContent,
+          msg_type: 'interactive',
+        },
+      });
+    }
+
+    const msgId = resp?.data?.message_id || '';
+    return { ok: true, messageId: msgId };
+  } catch (err: any) {
+    console.error(LOG_TAG, 'Permission card send failed:', err?.message || err);
+    return { ok: false, error: err?.message || 'Unknown error' };
+  }
+}
diff --git a/src/lib/channels/feishu/policy.ts b/src/lib/channels/feishu/policy.ts
new file mode 100644
index 0000000..68900ad
--- /dev/null
+++ b/src/lib/channels/feishu/policy.ts
@@ -0,0 +1,50 @@
+/**
+ * Feishu access control and group policy.
+ */
+
+import type { FeishuConfig } from './types';
+
+/**
+ * Check if a user is authorized based on the current config.
+ *
+ * DM policy logic:
+ * - disabled → false
+ * - open → allowFrom must include '*' or user's open_id
+ * - allowlist → allowFrom must include user's open_id
+ * - pairing → always true (pairing handled elsewhere)
+ *
+ * Group chat logic (chatId starts with 'oc_'):
+ * - groupPolicy === 'disabled' → false
+ * - groupPolicy === 'allowlist' → groupAllowFrom must include chatId
+ * - groupPolicy === 'open' → true
+ */
+export function isUserAuthorized(
+  config: FeishuConfig,
+  userId: string,
+  chatId: string,
+): boolean {
+  // Group chat check
+  if (chatId.startsWith('oc_')) {
+    if (config.groupPolicy === 'disabled') return false;
+    if (config.groupPolicy === 'allowlist') {
+      if (!config.groupAllowFrom.includes(chatId)) return false;
+    }
+    // groupPolicy === 'open' → allowed
+    return true;
+  }
+
+  // DM policy check
+  if (config.dmPolicy === 'disabled') return false;
+  if (config.dmPolicy === 'pairing') return true;
+
+  if (config.dmPolicy === 'open') {
+    if (config.allowFrom.length === 0) return true;
+    return config.allowFrom.includes('*') || config.allowFrom.includes(userId);
+  }
+
+  if (config.dmPolicy === 'allowlist') {
+    return config.allowFrom.includes(userId);
+  }
+
+  return false;
+}
diff --git a/src/lib/channels/feishu/types.ts b/src/lib/channels/feishu/types.ts
new file mode 100644
index 0000000..d3ce4b0
--- /dev/null
+++ b/src/lib/channels/feishu/types.ts
@@ -0,0 +1,85 @@
+/**
+ * Feishu channel internal types and constants.
+ */
+
+export interface FeishuConfig {
+  appId: string;
+  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. */
+  groupAllowFrom: string[];
+  /** DM policy: open / pairing / allowlist / disabled */
+  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;
+}
+
+export interface CardStreamConfig {
+  /** Throttle interval for stream updates (ms) */
+  throttleMs: number;
+  /** Footer display options (status line and elapsed time) */
+  footer?: {
+    status: boolean;
+    elapsed: boolean;
+  };
+}
+
+export interface BlockStreamingCoalesce {
+  minChars: number;
+  maxChars: number;
+  idleMs: number;
+}
+
+export interface FooterConfig {
+  status: boolean;
+  elapsed: boolean;
+}
+
+export interface FeishuBotInfo {
+  appId: string;
+  botName: string;
+  openId: string;
+}
+
+/** Feishu message item from list/search API */
+export interface FeishuMessageItem {
+  messageId: string;
+  rootId?: string;
+  parentId?: string;
+  msgType: string;
+  createTime: string;
+  updateTime?: string;
+  content: string;
+  sender: {
+    id: string;
+    idType: string;
+    senderType: string;
+    tenantKey?: string;
+  };
+}
+
+/** Result from readMessages / searchMessages */
+export interface MessageListResult {
+  items: FeishuMessageItem[];
+  hasMore: boolean;
+  pageToken?: string;
+}
diff --git a/src/lib/channels/types.ts b/src/lib/channels/types.ts
new file mode 100644
index 0000000..5473f48
--- /dev/null
+++ b/src/lib/channels/types.ts
@@ -0,0 +1,112 @@
+/**
+ * Channel plugin system types.
+ *
+ * These interfaces define the contract between channel plugins (Feishu, etc.)
+ * and the bridge system via ChannelPluginAdapter.
+ */
+
+import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '../bridge/types';
+
+// ── Card Stream Controller ──────────────────────────────────────
+
+/** Controls a streaming card lifecycle (create → update → finalize). */
+export interface CardStreamController {
+  /**
+   * Create a streaming card in the given chat.
+   * @param chatId Target chat ID
+   * @param initialText Initial markdown content
+   * @param replyToMessageId Optional message to reply to
+   * @returns The platform message ID, or empty string if creation failed (triggers fallback)
+   */
+  create(chatId: string, initialText: string, replyToMessageId?: string): Promise;
+
+  /**
+   * Stream-update the card content (throttled internally).
+   * @returns 'ok' on success, 'fail' on error
+   */
+  update(messageId: string, text: string): Promise<'ok' | 'fail'>;
+
+  /**
+   * Finalize the card with the complete content and close streaming mode.
+   * @param status 'completed' | 'interrupted' | 'error'
+   */
+  finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise;
+}
+
+// ── Channel Capabilities ────────────────────────────────────────
+
+export interface ChannelCapabilities {
+  /** Whether the channel supports streaming card updates */
+  streaming: boolean;
+  /** Whether the channel supports thread replies */
+  threadReply: boolean;
+  /** Whether the channel supports message search */
+  search: boolean;
+  /** Whether the channel supports reading message history */
+  history: boolean;
+  /** Whether the channel supports reactions */
+  reactions: boolean;
+}
+
+// ── Channel Meta ────────────────────────────────────────────────
+
+export interface ChannelMeta {
+  channelType: ChannelType;
+  displayName: string;
+}
+
+// ── Probe Result ────────────────────────────────────────────────
+
+export interface ProbeResult {
+  ok: boolean;
+  error?: string;
+  botName?: string;
+  botId?: string;
+}
+
+// ── Channel Plugin ──────────────────────────────────────────────
+
+/**
+ * Plugin interface for a channel implementation.
+ * Generic parameter T is the plugin's config type.
+ */
+export interface ChannelPlugin {
+  readonly meta: ChannelMeta;
+
+  /** Load and validate configuration. */
+  loadConfig(): T | null;
+
+  /** Get current capabilities (may depend on config). */
+  getCapabilities(): ChannelCapabilities;
+
+  /** Start the plugin (connect WS, start polling, etc.). */
+  start(): Promise;
+
+  /** Stop the plugin gracefully. */
+  stop(): Promise;
+
+  /** Whether the plugin is currently running. */
+  isRunning(): boolean;
+
+  /** Consume the next inbound message. Blocks until available or stopped. */
+  consumeOne(): Promise;
+
+  /** Send an outbound message. */
+  send(message: OutboundMessage): Promise;
+
+  /** Validate config completeness. Returns null if valid, error message otherwise. */
+  validateConfig(): string | null;
+
+  /** Check user authorization. */
+  isAuthorized(userId: string, chatId: string): boolean;
+
+  /** Optional: get a card stream controller. */
+  getCardStreamController?(): CardStreamController | null;
+
+  /** Optional: probe connectivity. */
+  probe?(): Promise;
+
+  /** Optional: lifecycle hooks. */
+  onMessageStart?(chatId: string): void;
+  onMessageEnd?(chatId: string): void;
+}
diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts
index 57345f2..a0c21db 100644
--- a/src/lib/claude-client.ts
+++ b/src/lib/claude-client.ts
@@ -693,7 +693,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream = [];
 
@@ -702,7 +702,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream c.type === 'text')
-                            .map((c: { text: string }) => c.text)
+                            .filter((c: any) => c.type === 'text')
+                            .map((c: any) => c.text)
                             .join('\n')
                         : String(block.content ?? '');
                     controller.enqueue(formatSSE({