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({