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:
1 parent
16d2f1af5d
commit
9ca9bce788
18 files changed
+1921
-51
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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': '未连接',
|
||||
|
||||
@@ -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 <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 = [
|
||||
'<b>CodePilot Bridge Commands</b>',
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user