fix: feishu card action 200340 and bridge session lock cleanup

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 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-14 09:55:03 +08:00
1 parent 16d2f1af5d
commit 9ca9bce788
18 files changed
+1921 -51

No files matched your search

+13 -5
View File
@@ -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;
}
+212 -18
View File
@@ -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<FeishuBridgeSettings> = {
bridge_feishu_app_id: appId,
bridge_feishu_domain: domain,
bridge_feishu_verification_token: verificationToken,
};
if (appSecret && !appSecret.startsWith("***")) {
updates.bridge_feishu_app_secret = appSecret;
}
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")}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.encryptKey")}
</label>
<Input
type="password"
value={encryptKey}
onChange={(e) => setEncryptKey(e.target.value)}
placeholder={t("feishu.encryptKeyPlaceholder")}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.encryptKeyHint")}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.verificationToken")}
</label>
<Input
value={verificationToken}
onChange={(e) => setVerificationToken(e.target.value)}
placeholder={t("feishu.verificationTokenPlaceholder")}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.verificationTokenHint")}
</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -246,22 +329,133 @@ export function FeishuBridgeSection() {
)}
</SettingsCard>
{/* Allowed Users */}
{/* DM Policy & Allow From */}
<SettingsCard
title={t("feishu.allowedUsers")}
description={t("feishu.allowedUsersDesc")}
title={t("feishu.allowFrom")}
description={t("feishu.allowFromDesc")}
>
<div>
<Input
value={allowedUsers}
onChange={(e) => setAllowedUsers(e.target.value)}
placeholder="ou_xxxxxxxxxx, ou_yyyyyyyyyy"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.allowedUsersHint")}
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.dmPolicy")}
</label>
<Select value={dmPolicy} onValueChange={setDmPolicy}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">
{t("feishu.dmPolicyOpen")}
</SelectItem>
<SelectItem value="pairing">
{t("feishu.dmPolicyPairing")}
</SelectItem>
<SelectItem value="allowlist">
{t("feishu.dmPolicyAllowlist")}
</SelectItem>
<SelectItem value="disabled">
{t("feishu.dmPolicyDisabled")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Input
value={allowFrom}
onChange={(e) => setAllowFrom(e.target.value)}
placeholder="*, ou_xxxxxxxxxx, ou_yyyyyyyyyy"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.allowFromHint")}
</p>
</div>
</div>
<Button size="sm" onClick={handleSaveDmSettings} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</SettingsCard>
{/* Behavior Settings: Render Mode, Thread Session, Block Streaming, Footer */}
<SettingsCard
title={t("feishu.renderMode")}
description={t("feishu.renderModeDesc")}
>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.renderMode")}
</label>
<Select value={renderMode} onValueChange={setRenderMode}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t("feishu.renderModeAuto")}
</SelectItem>
<SelectItem value="static">
{t("feishu.renderModeStatic")}
</SelectItem>
<SelectItem value="streaming">
{t("feishu.renderModeStreaming")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FieldRow
label={t("feishu.threadSession")}
description={t("feishu.threadSessionDesc")}
>
<Switch
checked={threadSession}
onCheckedChange={setThreadSession}
/>
</FieldRow>
<FieldRow
label={t("feishu.blockStreaming")}
description={t("feishu.blockStreamingDesc")}
>
<Switch
checked={blockStreaming}
onCheckedChange={setBlockStreaming}
/>
</FieldRow>
<FieldRow
label={t("feishu.footer")}
description=""
>
<div className="flex flex-col gap-2">
<FieldRow
label={t("feishu.footerStatus")}
description=""
>
<Switch
checked={footerStatus}
onCheckedChange={setFooterStatus}
/>
</FieldRow>
<FieldRow
label={t("feishu.footerElapsed")}
description=""
>
<Switch
checked={footerElapsed}
onCheckedChange={setFooterElapsed}
/>
</FieldRow>
</div>
</FieldRow>
</div>
<Button size="sm" onClick={handleSaveBehaviorSettings} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</SettingsCard>
{/* Group Chat Settings */}
+24 -10
View File
@@ -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',
+24 -10
View File
@@ -527,14 +527,30 @@ const zh: Record<TranslationKey, string> = {
'feishu.domainFeishu': '飞书 (feishu.cn)',
'feishu.domainLark': 'Lark (larksuite.com)',
'feishu.domainHint': '中国大陆选择飞书,海外选择 Lark',
'feishu.encryptKey': 'Encrypt Key',
'feishu.encryptKeyPlaceholder': '飞书后台「事件订阅」页面的 Encrypt Key',
'feishu.encryptKeyHint': '可选,HTTP Webhook 模式需要(飞书后台 → 事件与回调 → Encrypt Key)',
'feishu.verificationToken': 'Verification Token',
'feishu.verificationTokenPlaceholder': '飞书后台的 Verification Token',
'feishu.verificationTokenHint': '可选,HTTP Webhook 模式需要(飞书后台 → 事件与回调 → Verification Token)',
'feishu.verify': '测试连接',
'feishu.verified': '连接验证成功',
'feishu.verifiedAs': '已连接为 {name}',
'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<TranslationKey, string> = {
'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': '未连接',
+151 -4
View File
@@ -323,6 +323,8 @@ export async function stop(): Promise<void> {
state.adapters.clear();
state.adapterMeta.clear();
state.sessionLocks.clear();
state.activeTasks.clear();
state.startedAt = null;
// Re-enable notification bot polling
@@ -745,6 +747,7 @@ async function handleCommand(
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - Search messages',
'/feishu &lt;subcommand&gt; - 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 &lt;keyword&gt;';
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 = [
'<b>CodePilot Bridge Commands</b>',
@@ -973,6 +1119,7 @@ async function handleCommand(
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission request',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - Search messages',
'/feishu &lt;subcommand&gt; - Feishu bridge management',
'/help - Show this help',
].join('\n');
break;
@@ -0,0 +1,73 @@
/**
* ChannelPluginAdapter — bridges a ChannelPlugin to BaseChannelAdapter.
*
* This allows any ChannelPlugin implementation to be used as a
* BaseChannelAdapter in the bridge system without modification.
*/
import { BaseChannelAdapter } from '../bridge/channel-adapter';
import type {
ChannelType,
InboundMessage,
OutboundMessage,
SendResult,
} from '../bridge/types';
import type { CardStreamController, ChannelPlugin } from './types';
export class ChannelPluginAdapter extends BaseChannelAdapter {
private plugin: ChannelPlugin;
constructor(plugin: ChannelPlugin) {
super();
this.plugin = plugin;
}
get channelType(): ChannelType {
return this.plugin.meta.channelType;
}
/** Expose the underlying plugin for direct access (e.g. message-actions). */
getPlugin(): ChannelPlugin {
return this.plugin;
}
async start(): Promise<void> {
return this.plugin.start();
}
async stop(): Promise<void> {
return this.plugin.stop();
}
isRunning(): boolean {
return this.plugin.isRunning();
}
async consumeOne(): Promise<InboundMessage | null> {
return this.plugin.consumeOne();
}
async send(message: OutboundMessage): Promise<SendResult> {
return this.plugin.send(message);
}
validateConfig(): string | null {
return this.plugin.validateConfig();
}
isAuthorized(userId: string, chatId: string): boolean {
return this.plugin.isAuthorized(userId, chatId);
}
onMessageStart(chatId: string): void {
this.plugin.onMessageStart?.(chatId);
}
onMessageEnd(chatId: string): void {
this.plugin.onMessageEnd?.(chatId);
}
getCardStreamController(): CardStreamController | null {
return this.plugin.getCardStreamController?.() ?? null;
}
}
+228
View File
@@ -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<typeof setTimeout> | null;
pendingText: string | null;
}
class FeishuCardStreamController implements CardStreamController {
private client: lark.Client;
private config: CardStreamConfig;
private cards = new Map<string, CardState>();
constructor(client: lark.Client, config: CardStreamConfig) {
this.client = client;
this.config = config;
}
async create(chatId: string, initialText: string, replyToMessageId?: string): Promise<string> {
try {
// 1. Create streaming card via CardKit v2
const cardBody = {
schema: '2.0',
config: { streaming_mode: true, wide_screen_mode: true },
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<void> {
const state = this.cards.get(messageId);
if (!state) return;
// Clear any pending throttle
if (state.throttleTimer) {
clearTimeout(state.throttleTimer);
state.throttleTimer = null;
}
try {
// Close streaming mode
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<string, string> = {
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);
}
+90
View File
@@ -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;
}
+190
View File
@@ -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<unknown>;
export class FeishuGateway {
private client: lark.Client | null = null;
private wsClient: lark.WSClient | null = null;
private running = false;
private config: FeishuConfig;
private eventDispatcher: lark.EventDispatcher;
private onMessage: ((data: unknown) => void) | null = null;
private cardActionHandler: CardActionHandler | null = null;
constructor(config: FeishuConfig) {
this.config = config;
// 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<unknown> {
const handler = this.cardActionHandler;
if (!handler) return FALLBACK_TOAST;
const TIMEOUT_MS = 2500; // 2.5s — leave 500ms margin for SDK overhead
try {
const result = await Promise.race([
handler(data),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), TIMEOUT_MS)),
]);
// Handler resolved with a valid response
if (result && typeof result === 'object') return result;
// Handler returned void/undefined or timed out — use fallback
return FALLBACK_TOAST;
} catch (err) {
console.error(LOG_TAG, 'Card action handler error:', err);
return FALLBACK_TOAST;
}
}
/** Start the WebSocket connection. */
async start(): Promise<void> {
if (this.running) return;
const domain = resolveDomain(this.config.domain);
this.client = new lark.Client({
appId: this.config.appId,
appSecret: this.config.appSecret,
domain,
disableTokenCache: false,
});
this.wsClient = new lark.WSClient({
appId: this.config.appId,
appSecret: this.config.appSecret,
domain,
loggerLevel: lark.LoggerLevel.warn,
});
// Monkey-patch handleEventData to support card action events (type: "card").
// The SDK's WSClient only handles type="event" by default; card action
// callbacks arrive as type="card" and would be silently dropped.
// Patch: rewrite type header from "card" to "event" before dispatching.
const wsClientAny = this.wsClient as any;
if (typeof wsClientAny.handleEventData === 'function') {
const origHandleEventData = wsClientAny.handleEventData.bind(wsClientAny);
wsClientAny.handleEventData = (data: any) => {
const msgType = data.headers?.find?.((h: any) => h.key === 'type')?.value;
if (msgType !== 'event') console.log(LOG_TAG, 'handleEventData type:', msgType);
if (msgType === 'card') {
const patchedData = {
...data,
headers: data.headers.map((h: any) =>
h.key === 'type' ? { ...h, value: 'event' } : h,
),
};
return origHandleEventData(patchedData);
}
return origHandleEventData(data);
};
}
await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
this.running = true;
console.log(LOG_TAG, 'WebSocket connected');
}
/** Stop the WebSocket connection. */
async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
// WSClient doesn't expose a clean stop — set refs to null
this.wsClient = null;
console.log(LOG_TAG, 'Stopped');
}
isRunning(): boolean {
return this.running;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Feishu bot identity resolution.
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { FeishuBotInfo } from './types';
const LOG_TAG = '[feishu/identity]';
/** Fetch bot info from Feishu API. */
export async function getBotInfo(client: lark.Client): Promise<FeishuBotInfo | null> {
try {
const resp = await (client as any).bot.v3.botInfo.list();
const bot = resp?.data?.bot;
if (!bot) return null;
return {
appId: bot.app_id || '',
botName: bot.app_name || '',
openId: bot.open_id || '',
};
} catch (err) {
console.error(LOG_TAG, 'Failed to get bot info:', err);
return null;
}
}
+61
View File
@@ -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;
}
}
+205
View File
@@ -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<FeishuConfig> {
readonly meta: ChannelMeta = {
channelType: 'feishu',
displayName: 'Feishu / Lark',
};
private config: FeishuConfig | null = null;
private gateway: FeishuGateway | null = null;
private messageQueue: InboundMessage[] = [];
private waitResolve: ((msg: InboundMessage | null) => void) | null = null;
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<void> {
if (!this.config) {
this.config = loadFeishuConfig();
}
if (!this.config) throw new Error('Feishu config not loaded');
this.gateway = new FeishuGateway(this.config);
// Register message handler — pushes to internal queue
this.gateway.registerMessageHandler((data: unknown) => {
const msg = parseInboundMessage(data, this.config!);
if (!msg) return;
this.enqueueMessage(msg);
});
// Register card action handler — converts button clicks to callback messages.
// Gateway guarantees 3-second response; this handler should stay lightweight.
// Supports two button value formats:
// 1. { callback_data: "perm:allow:xxx" } — CodePilot permission buttons
// 2. { action: "app_auth_done", operation_id: "xxx" } — OpenClaw-style buttons
this.gateway.registerCardActionHandler(async (data: unknown) => {
const event = data as any;
console.log('[feishu/plugin]', 'Card action raw event:', JSON.stringify(event).slice(0, 500));
const value = event?.action?.value ?? {};
// Feishu card.action.trigger v2 callback structure (per official docs):
// event.operator.open_id, event.context.open_chat_id, event.context.open_message_id
// SDK InteractiveCardActionEvent (older type) flattens to:
// event.open_id, event.open_message_id
// WSClient monkey-patch may deliver either format — try both paths.
// Additionally, we embed chatId in button value as ultimate fallback.
const chatId = event?.context?.open_chat_id || value.chatId || '';
const messageId = event?.context?.open_message_id || event?.open_message_id || '';
const userId = event?.operator?.open_id || event?.open_id || '';
// Format 1: callback_data (permission buttons)
const callbackData = value.callback_data;
if (callbackData && chatId) {
const callbackMsg: InboundMessage = {
messageId: messageId || `card_action_${Date.now()}`,
address: {
channelType: 'feishu',
chatId,
userId,
},
text: '',
timestamp: Date.now(),
callbackData,
callbackMessageId: messageId,
};
console.log('[feishu/plugin]', 'Card action (callback_data):', callbackData);
this.enqueueMessage(callbackMsg);
return {
toast: { type: 'info' as const, content: '已收到,正在处理...' },
};
}
// Format 2: action / operation_id (OpenClaw-style buttons)
const action = value.action;
const operationId = value.operation_id;
if (action) {
// Encode as callbackData so the existing bridge-manager callback path
// can handle it. Format: "action:{action}:{operation_id}"
const syntheticCallback = operationId
? `action:${action}:${operationId}`
: `action:${action}`;
const actionMsg: InboundMessage = {
messageId: messageId || `card_action_${Date.now()}`,
address: {
channelType: 'feishu',
chatId,
userId,
},
text: '',
timestamp: Date.now(),
callbackData: syntheticCallback,
callbackMessageId: messageId,
};
console.log('[feishu/plugin]', 'Card action (action):', action, operationId ?? '');
this.enqueueMessage(actionMsg);
return {
toast: { type: 'info' as const, content: '已收到,正在处理...' },
};
}
// Unknown button format — still return a valid toast to prevent 200340
console.warn('[feishu/plugin]', 'Unknown card action value:', JSON.stringify(value).slice(0, 200));
return {
toast: { type: 'info' as const, content: '已收到' },
};
});
await this.gateway.start();
}
async stop(): Promise<void> {
if (this.gateway) {
await this.gateway.stop();
this.gateway = null;
}
// Unblock any waiting consumer
if (this.waitResolve) {
this.waitResolve(null);
this.waitResolve = null;
}
}
isRunning(): boolean {
return this.gateway?.isRunning() ?? false;
}
private enqueueMessage(msg: InboundMessage): void {
if (this.waitResolve) {
const resolve = this.waitResolve;
this.waitResolve = null;
resolve(msg);
} else {
this.messageQueue.push(msg);
}
}
async consumeOne(): Promise<InboundMessage | null> {
if (this.messageQueue.length > 0) {
return this.messageQueue.shift()!;
}
return new Promise<InboundMessage | null>((resolve) => {
this.waitResolve = resolve;
});
}
async send(message: OutboundMessage): Promise<SendResult> {
const client = this.gateway?.getRestClient();
if (!client) return { ok: false, error: 'Not connected' };
return sendMessage(client, message);
}
isAuthorized(userId: string, chatId: string): boolean {
if (!this.config) return false;
return isUserAuthorized(this.config, userId, chatId);
}
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;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Feishu message-actions — readMessages, readThreadMessages, and searchMessagesLocal.
*
* These functions are dynamically imported by bridge-manager for /history and /search commands.
*
* Identity note: All functions here use bot identity (app_access_token) via the
* Lark SDK client. OpenClaw's equivalent (feishu_im_user_*) uses user_access_token
* for richer results and broader access.
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { MessageListResult, FeishuMessageItem } from './types';
interface ReadOptions {
pageSize?: number;
pageToken?: string;
}
interface SearchOptions {
pageSize?: number;
pageToken?: string;
}
/**
* Read recent messages from a Feishu chat using bot identity (app_access_token).
*
* OpenClaw's equivalent (feishu_im_user_get_chat_messages) uses user_access_token
* for richer results including messages from all participants.
*/
export async function readMessages(
client: lark.Client,
chatId: string,
options: ReadOptions = {},
): Promise<MessageListResult> {
const { pageSize = 10, pageToken } = options;
try {
const resp = await client.im.message.list({
params: {
container_id_type: 'chat',
container_id: chatId,
page_size: pageSize,
...(pageToken ? { page_token: pageToken } : {}),
},
});
const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({
messageId: item.message_id || '',
rootId: item.root_id || undefined,
parentId: item.parent_id || undefined,
msgType: item.msg_type || '',
createTime: item.create_time || '',
updateTime: item.update_time || undefined,
content: item.body?.content || '',
sender: {
id: item.sender?.id || '',
idType: item.sender?.id_type || '',
senderType: item.sender?.sender_type || '',
tenantKey: item.sender?.tenant_key || undefined,
},
}));
return {
items,
hasMore: resp?.data?.has_more || false,
pageToken: resp?.data?.page_token || undefined,
};
} catch (err: any) {
console.error('[feishu/message-actions] readMessages failed:', err?.message || err);
return { items: [], hasMore: false };
}
}
/**
* Read messages from a Feishu thread (reply chain).
*
* Uses `container_id_type: 'thread'` to fetch all messages in a specific thread.
* OpenClaw's equivalent is feishu_im_user_get_thread_messages.
*/
export async function readThreadMessages(
client: lark.Client,
threadId: string,
options: ReadOptions = {},
): Promise<MessageListResult> {
const { pageSize = 10, pageToken } = options;
try {
const resp = await client.im.message.list({
params: {
container_id_type: 'thread',
container_id: threadId,
page_size: pageSize,
...(pageToken ? { page_token: pageToken } : {}),
},
});
const items: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({
messageId: item.message_id || '',
rootId: item.root_id || undefined,
parentId: item.parent_id || undefined,
msgType: item.msg_type || '',
createTime: item.create_time || '',
updateTime: item.update_time || undefined,
content: item.body?.content || '',
sender: {
id: item.sender?.id || '',
idType: item.sender?.id_type || '',
senderType: item.sender?.sender_type || '',
tenantKey: item.sender?.tenant_key || undefined,
},
}));
return {
items,
hasMore: resp?.data?.has_more || false,
pageToken: resp?.data?.page_token || undefined,
};
} catch (err: any) {
console.error('[feishu/message-actions] readThreadMessages failed:', err?.message || err);
return { items: [], hasMore: false };
}
}
/**
* Simplified local search — lists recent messages and filters client-side.
*
* Unlike OpenClaw's feishu_im_user_search_messages which uses the search.message.create
* API with user_access_token for true cross-chat server-side search, this function
* only fetches recent messages from a single chat and applies a client-side keyword filter.
* Results are limited and pagination is not reliable.
*/
export async function searchMessagesLocal(
client: lark.Client,
chatId: string,
query: string,
options: SearchOptions = {},
): Promise<MessageListResult> {
const { pageSize = 10, pageToken } = options;
try {
// Use im.message.list and filter client-side.
// True server-side search requires user_access_token (search.message.create API).
const resp = await client.im.message.list({
params: {
container_id_type: 'chat',
container_id: chatId,
page_size: Math.min(pageSize * 5, 50), // fetch more to filter
sort_type: 'ByCreateTimeDesc' as any,
...(pageToken ? { page_token: pageToken } : {}),
},
});
const allItems: FeishuMessageItem[] = (resp?.data?.items || []).map((item: any) => ({
messageId: item.message_id || '',
rootId: item.root_id || undefined,
parentId: item.parent_id || undefined,
msgType: item.msg_type || '',
createTime: item.create_time || '',
updateTime: item.update_time || undefined,
content: item.body?.content || '',
sender: {
id: item.sender?.id || '',
idType: item.sender?.id_type || '',
senderType: item.sender?.sender_type || '',
tenantKey: item.sender?.tenant_key || undefined,
},
}));
// Client-side keyword filter
const lowerQuery = query.toLowerCase();
const filtered = allItems.filter(item => {
try {
const parsed = JSON.parse(item.content);
return (parsed.text || '').toLowerCase().includes(lowerQuery);
} catch {
return item.content.toLowerCase().includes(lowerQuery);
}
}).slice(0, pageSize);
return {
items: filtered,
hasMore: false, // can't paginate client-side filter reliably
};
} catch (err: any) {
console.error('[feishu/message-actions] searchMessagesLocal failed:', err?.message || err);
return { items: [], hasMore: false };
}
}
/** Backward-compatible alias for searchMessagesLocal. */
export const searchMessages = searchMessagesLocal;
+183
View File
@@ -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<SendResult> {
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<SendResult> {
try {
// Strip HTML tags for Feishu (convert <b> to **, <code> to `, <pre> to ```)
const mdText = text
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<pre>([\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<SendResult> {
try {
const realChatId = chatId.split(':thread:')[0];
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content: cardContent,
msg_type: 'interactive',
},
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: realChatId,
content: cardContent,
msg_type: 'interactive',
},
});
}
const msgId = resp?.data?.message_id || '';
return { ok: true, messageId: msgId };
} catch (err: any) {
console.error(LOG_TAG, 'Permission card send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}
+50
View File
@@ -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;
}
+85
View File
@@ -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;
}
+112
View File
@@ -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<string>;
/**
* Stream-update the card content (throttled internally).
* @returns 'ok' on success, 'fail' on error
*/
update(messageId: string, text: string): Promise<'ok' | 'fail'>;
/**
* Finalize the card with the complete content and close streaming mode.
* @param status 'completed' | 'interrupted' | 'error'
*/
finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise<void>;
}
// ── Channel Capabilities ────────────────────────────────────────
export interface ChannelCapabilities {
/** Whether the channel supports streaming card updates */
streaming: boolean;
/** Whether the channel supports thread replies */
threadReply: boolean;
/** Whether the channel supports message search */
search: boolean;
/** Whether the channel supports reading message history */
history: boolean;
/** Whether the channel supports reactions */
reactions: boolean;
}
// ── Channel Meta ────────────────────────────────────────────────
export interface ChannelMeta {
channelType: ChannelType;
displayName: string;
}
// ── Probe Result ────────────────────────────────────────────────
export interface ProbeResult {
ok: boolean;
error?: string;
botName?: string;
botId?: string;
}
// ── Channel Plugin ──────────────────────────────────────────────
/**
* Plugin interface for a channel implementation.
* Generic parameter T is the plugin's config type.
*/
export interface ChannelPlugin<T = unknown> {
readonly meta: ChannelMeta;
/** Load and validate configuration. */
loadConfig(): T | null;
/** Get current capabilities (may depend on config). */
getCapabilities(): ChannelCapabilities;
/** Start the plugin (connect WS, start polling, etc.). */
start(): Promise<void>;
/** Stop the plugin gracefully. */
stop(): Promise<void>;
/** Whether the plugin is currently running. */
isRunning(): boolean;
/** Consume the next inbound message. Blocks until available or stopped. */
consumeOne(): Promise<InboundMessage | null>;
/** Send an outbound message. */
send(message: OutboundMessage): Promise<SendResult>;
/** Validate config completeness. Returns null if valid, error message otherwise. */
validateConfig(): string | null;
/** Check user authorization. */
isAuthorized(userId: string, chatId: string): boolean;
/** Optional: get a card stream controller. */
getCardStreamController?(): CardStreamController | null;
/** Optional: probe connectivity. */
probe?(): Promise<ProbeResult>;
/** Optional: lifecycle hooks. */
onMessageStart?(chatId: string): void;
onMessageEnd?(chatId: string): void;
}
+4 -4
View File
@@ -693,7 +693,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
})();
const contentBlocks: Array<
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
| { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } }
| { type: 'text'; text: string }
> = [];
@@ -702,7 +702,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
type: 'image',
source: {
type: 'base64',
media_type: img.type || 'image/png',
media_type: (img.type || 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
data: img.data,
},
});
@@ -844,8 +844,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
? block.content
: Array.isArray(block.content)
? block.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join('\n')
: String(block.content ?? '');
controller.enqueue(formatSSE({