From 54f2afd6a27711b59868b2eddc337ccc62991594 Mon Sep 17 00:00:00 2001 From: aunknown Date: Fri, 20 Mar 2026 18:30:02 +0800 Subject: [PATCH 1/4] feat(feishu): add cross-bot relay for bot-to-bot messaging in groups Feishu's im.message.receive_v1 event does not deliver bot messages to other bots (by design, to prevent loops). This makes it impossible for multiple bots in the same group to collaborate by @-mentioning each other. This PR adds an opt-in cross-bot relay mechanism that: - Intercepts outbound bot messages after they are sent to Feishu - Parses mention tags to identify target bots by display name - Constructs synthetic FeishuMessageEvent objects and dispatches them to the target bot's handleFeishuMessage handler (in-process, no API) - Only relays to explicitly @-mentioned bots (no broadcast) Configuration: channels.feishu.crossBotRelay: true (default: false) When disabled (default), zero code paths are affected. Also includes: fix(feishu): use app_name from bot_info API (bot/v3/info returns app_name, not bot_name) --- extensions/feishu/src/config-schema.ts | 1 + extensions/feishu/src/cross-bot-relay.ts | 155 ++++++++++++++++++++++ extensions/feishu/src/monitor.account.ts | 15 +++ extensions/feishu/src/outbound.ts | 22 ++- extensions/feishu/src/probe.ts | 6 +- extensions/feishu/src/reply-dispatcher.ts | 44 +++++- 6 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 extensions/feishu/src/cross-bot-relay.ts diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index db1714f173f..6468e6236b2 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -182,6 +182,7 @@ const FeishuSharedConfigShape = { reactionNotifications: ReactionNotificationModeSchema, typingIndicator: z.boolean().optional(), resolveSenderNames: z.boolean().optional(), + crossBotRelay: z.boolean().optional(), }; /** diff --git a/extensions/feishu/src/cross-bot-relay.ts b/extensions/feishu/src/cross-bot-relay.ts new file mode 100644 index 00000000000..c9ee486210b --- /dev/null +++ b/extensions/feishu/src/cross-bot-relay.ts @@ -0,0 +1,155 @@ +/** + * Cross-bot relay: when one bot sends a message to a group chat, + * construct a synthetic inbound event and dispatch it to other bot + * accounts monitoring the same group. + * + * This works around Feishu's platform limitation where bot messages + * do not trigger im.message.receive_v1 events for other bots. + */ +import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk/feishu"; +import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; + +// --- Account registry --- +// Each monitoring account registers itself so the relay can dispatch to it. + +type RegisteredAccount = { + accountId: string; + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; + chatHistories: Map; + botOpenId?: string; + botName?: string; +}; + +const registeredAccounts = new Map(); + +export function registerRelayAccount(account: RegisteredAccount): void { + registeredAccounts.set(account.accountId, account); +} + +export function unregisterRelayAccount(accountId: string): void { + registeredAccounts.delete(accountId); +} + +// --- Relay dispatch --- + +// Track message IDs currently being relayed to prevent re-entry. +// When Bot A's outbound triggers relay → Bot B processes → Bot B replies → +// Bot B's outbound triggers relay → we must not re-relay to Bot A in the +// same chain. We use a Set of "synthetic message IDs" that are in-flight. +const activeRelayMessageIds = new Set(); + +export type RelayOutboundParams = { + /** Account ID of the bot that sent the message */ + senderAccountId: string; + /** Chat ID (group) where the message was sent */ + chatId: string; + /** The text content that was sent */ + text: string; + /** Message ID returned by Feishu API after sending */ + messageId?: string; + /** Bot's open_id (sender identity) */ + senderBotOpenId?: string; + /** Bot's display name */ + senderBotName?: string; +}; + +export async function relayOutboundToOtherBots(params: RelayOutboundParams): Promise { + const { senderAccountId, chatId, text, messageId, senderBotOpenId, senderBotName } = params; + + // Only relay to group chats + if (!chatId) return; + + // Use the real message ID for replies to work, but track with relay prefix for dedup + const syntheticMessageId = messageId + ? `relay:${messageId}` + : `relay:${senderAccountId}:${Date.now()}`; + // The actual message_id in the event uses the REAL id so reply_to works with Feishu API + const eventMessageId = messageId || `relay:${senderAccountId}:${Date.now()}`; + + // Prevent re-entry: if this message is already being relayed, skip + if (activeRelayMessageIds.has(syntheticMessageId)) return; + + // Extract mentioned names from name tags in the text + const mentionedNames = new Set(); + const atPattern = /([^<]+)<\/at>/g; + let match: RegExpExecArray | null; + while ((match = atPattern.exec(text)) !== null) { + mentionedNames.add(match[1].trim().toLowerCase()); + } + + // Filter targets: match mentioned names against registered bot names (case-insensitive) + const allOtherAccounts = Array.from(registeredAccounts.values()).filter( + (account) => account.accountId !== senderAccountId, + ); + // Only relay to explicitly mentioned bots — no fallback broadcast + if (mentionedNames.size === 0) return; + const targets = allOtherAccounts.filter((account) => { + const name = account.botName?.trim()?.toLowerCase(); + return name ? mentionedNames.has(name) : false; + }); + + if (targets.length === 0) return; + + activeRelayMessageIds.add(syntheticMessageId); + + try { + const dispatches = targets.map(async (target) => { + // Build synthetic event per target, including a mention of the target bot + // so it passes requireMention checks in group chats. + const targetBotOpenId = target.botOpenId?.trim(); + const mentions = targetBotOpenId + ? [ + { + key: `@_user_relay_${target.accountId}`, + id: { open_id: targetBotOpenId }, + name: target.botName ?? target.accountId, + }, + ] + : undefined; + + const syntheticEvent: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: senderBotOpenId || `bot:${senderAccountId}`, + }, + sender_type: "bot", + }, + message: { + message_id: eventMessageId, + chat_id: chatId, + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text }), + create_time: String(Date.now()), + mentions, + }, + }; + const log = target.runtime?.log ?? console.log; + try { + log( + `feishu[${target.accountId}]: cross-bot relay from ${senderAccountId}, ` + + `chat=${chatId}, msgId=${syntheticMessageId}`, + ); + await handleFeishuMessage({ + cfg: target.cfg, + event: syntheticEvent, + botOpenId: target.botOpenId, + botName: target.botName, + runtime: target.runtime, + chatHistories: target.chatHistories, + accountId: target.accountId, + }); + } catch (err) { + log(`feishu[${target.accountId}]: cross-bot relay dispatch failed: ${String(err)}`); + } + }); + + await Promise.allSettled(dispatches); + } finally { + // Clean up after a delay to handle any late re-entry attempts + setTimeout(() => { + activeRelayMessageIds.delete(syntheticMessageId); + }, 120_000); + } +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index ff3a0ba9dc9..1a707b5bad5 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -12,6 +12,7 @@ import { import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; import { createEventDispatcher } from "./client.js"; +import { registerRelayAccount, unregisterRelayAccount } from "./cross-bot-relay.js"; import { hasProcessedFeishuMessage, recordProcessedFeishuMessage, @@ -670,6 +671,19 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams): const chatHistories = new Map(); threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg }); + // Cross-bot relay: register this account if enabled + const feishuCfg = account.config; + if (feishuCfg.crossBotRelay) { + registerRelayAccount({ + accountId, + cfg, + runtime, + chatHistories, + botOpenId: botOpenId ?? undefined, + botName: botName ?? undefined, + }); + } + registerEventHandlers(eventDispatcher, { cfg, accountId, @@ -684,5 +698,6 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams): return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher }); } finally { threadBindingManager?.stop(); + unregisterRelayAccount(accountId); } } diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 0c449f82bd2..b937e81c46c 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -3,7 +3,9 @@ import path from "path"; import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; +import { relayOutboundToOtherBots } from "./cross-bot-relay.js"; import { sendMediaFeishu } from "./media.js"; +import { botOpenIds, botNames } from "./monitor.state.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js"; @@ -137,13 +139,31 @@ export const feishuOutbound: ChannelOutboundAdapter = { header: header?.title ? header : undefined, }); } - return await sendOutboundText({ + const result = await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); + + // Cross-bot relay: notify other bots in the same group + const feishuCfg = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }).config; + if (feishuCfg?.crossBotRelay && to.startsWith("oc_") && text?.trim()) { + const effectiveAccountId = accountId ?? undefined; + void relayOutboundToOtherBots({ + senderAccountId: effectiveAccountId ?? "default", + chatId: to, + text, + messageId: result.messageId, + senderBotOpenId: botOpenIds.get(effectiveAccountId ?? "default"), + senderBotName: botNames.get(effectiveAccountId ?? "default"), + }).catch((err) => { + console.error(`[feishu] cross-bot relay failed:`, err); + }); + } + + return result; }, sendMedia: async ({ cfg, diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts index e4b8d76f0c1..8318b7ebcf5 100644 --- a/extensions/feishu/src/probe.ts +++ b/extensions/feishu/src/probe.ts @@ -20,8 +20,8 @@ export type ProbeFeishuOptions = { type FeishuBotInfoResponse = { code: number; msg?: string; - bot?: { bot_name?: string; open_id?: string }; - data?: { bot?: { bot_name?: string; open_id?: string } }; + bot?: { bot_name?: string; app_name?: string; open_id?: string }; + data?: { bot?: { bot_name?: string; app_name?: string; open_id?: string } }; }; function setCachedProbeResult( @@ -132,7 +132,7 @@ export async function probeFeishu( { ok: true, appId: creds.appId, - botName: bot?.bot_name, + botName: bot?.app_name || bot?.bot_name, botOpenId: bot?.open_id, }, PROBE_SUCCESS_TTL_MS, diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 6ab7184c8e8..810f708934f 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -14,9 +14,11 @@ import { } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { relayOutboundToOtherBots } from "./cross-bot-relay.js"; import { sendMediaFeishu } from "./media.js"; import type { MentionTarget } from "./mention.js"; import { buildMentionedCardContent } from "./mention.js"; +import { botOpenIds, botNames } from "./monitor.state.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js"; import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; @@ -311,7 +313,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP text: string; useCard: boolean; infoKind?: string; - sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise; + sendChunk: (params: { + chunk: string; + isFirst: boolean; + }) => Promise<{ messageId?: string } | void>; }) => { const chunkSource = params.useCard ? params.text @@ -320,14 +325,32 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP chunkSource, core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode), ); + let lastMessageId: string | undefined; for (const [index, chunk] of chunks.entries()) { - await params.sendChunk({ + const result = await params.sendChunk({ chunk, isFirst: index === 0, }); + if (result?.messageId) lastMessageId = result.messageId; } if (params.infoKind === "final") { deliveredFinalTexts.add(params.text); + // Cross-bot relay: trigger after final reply + const feishuCfg = resolveFeishuAccount({ cfg, accountId }).config; + if (feishuCfg?.crossBotRelay && chatId?.startsWith("oc_") && params.text?.trim()) { + try { + await relayOutboundToOtherBots({ + senderAccountId: accountId ?? "default", + chatId, + text: params.text, + messageId: lastMessageId, + senderBotOpenId: botOpenIds.get(accountId ?? "default"), + senderBotName: botNames.get(accountId ?? "default"), + }); + } catch (err) { + console.error(`[feishu] cross-bot relay (reply-dispatcher) failed:`, err); + } + } } }; @@ -405,6 +428,23 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP streamText = mergeStreamingText(streamText, text); await closeStreaming(); deliveredFinalTexts.add(text); + // Cross-bot relay: trigger after streaming final + const feishuCfg = resolveFeishuAccount({ cfg, accountId }).config; + if (feishuCfg?.crossBotRelay && chatId?.startsWith("oc_") && text?.trim()) { + try { + const streamingMsgId = (streaming as any)?.state?.messageId; + await relayOutboundToOtherBots({ + senderAccountId: accountId ?? "default", + chatId, + text, + messageId: streamingMsgId, + senderBotOpenId: botOpenIds.get(accountId ?? "default"), + senderBotName: botNames.get(accountId ?? "default"), + }); + } catch (err) { + console.error(`[feishu] cross-bot relay (streaming final) failed:`, err); + } + } } // Send media even when streaming handled the text if (hasMedia) { From 9a8a66688026df38301aed852f98c812542d1fc2 Mon Sep 17 00:00:00 2001 From: aunknown Date: Sat, 21 Mar 2026 00:05:54 +0800 Subject: [PATCH 2/4] fix(feishu): use local api barrel for cross-bot-relay imports The channel-import-guardrails test requires extension files to import helpers via the local runtime-api barrel rather than directly from openclaw/plugin-sdk/feishu. Update cross-bot-relay.ts to import ClawdbotConfig, RuntimeEnv, and HistoryEntry from ../runtime-api.js, matching the pattern used by other files in the feishu extension. --- extensions/feishu/src/cross-bot-relay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/feishu/src/cross-bot-relay.ts b/extensions/feishu/src/cross-bot-relay.ts index c9ee486210b..c78aa6b2b1c 100644 --- a/extensions/feishu/src/cross-bot-relay.ts +++ b/extensions/feishu/src/cross-bot-relay.ts @@ -6,7 +6,7 @@ * This works around Feishu's platform limitation where bot messages * do not trigger im.message.receive_v1 events for other bots. */ -import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk/feishu"; +import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "../runtime-api.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; // --- Account registry --- From c0cead30632e9efbef96459778a3f9583de8e8a6 Mon Sep 17 00:00:00 2001 From: Claude Code Agent Date: Sat, 21 Mar 2026 10:39:11 +0800 Subject: [PATCH 3/4] fix: address 4 PR review issues in feishu cross-bot relay Issue 2: card path skipped relay in outbound.ts - Extract triggerRelay() helper to avoid duplication - Card branch now calls triggerRelay() before returning instead of early-returning without relay - Also passes threadId to relay so topic threads are preserved Issue 4: streaming messageId any cast + closeStreaming timing - Add getMessageId() typed accessor to FeishuStreamingSession - Save messageId via streaming.getMessageId() BEFORE calling closeStreaming() (closeStreaming sets streaming=null, making post-close access always undefined) - Replace (streaming as any)?.state?.messageId with the typed accessor Issue 5: sendChunk callbacks missing return value - sendChunkedTextReply callbacks for both card and text paths now use 'return sendStructuredCardFeishu(...)' / 'return sendMessageFeishu(...)' so result?.messageId is correctly captured in lastMessageId Issue 8: relay missing topic/thread metadata - Add threadId field to RelayOutboundParams - Populate root_id/thread_id in synthetic event from threadId - Pass rootId from reply-dispatcher relay calls - Pass threadId from outbound.ts sendText relay calls --- extensions/feishu/src/cross-bot-relay.ts | 7 +++- extensions/feishu/src/outbound.ts | 40 ++++++++++++++--------- extensions/feishu/src/reply-dispatcher.ts | 9 +++-- extensions/feishu/src/streaming-card.ts | 5 +++ 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/extensions/feishu/src/cross-bot-relay.ts b/extensions/feishu/src/cross-bot-relay.ts index c78aa6b2b1c..79e2eb903eb 100644 --- a/extensions/feishu/src/cross-bot-relay.ts +++ b/extensions/feishu/src/cross-bot-relay.ts @@ -48,6 +48,8 @@ export type RelayOutboundParams = { text: string; /** Message ID returned by Feishu API after sending */ messageId?: string; + /** Thread/topic ID for topic group messages (root_id in Feishu events) */ + threadId?: string; /** Bot's open_id (sender identity) */ senderBotOpenId?: string; /** Bot's display name */ @@ -55,7 +57,8 @@ export type RelayOutboundParams = { }; export async function relayOutboundToOtherBots(params: RelayOutboundParams): Promise { - const { senderAccountId, chatId, text, messageId, senderBotOpenId, senderBotName } = params; + const { senderAccountId, chatId, text, messageId, threadId, senderBotOpenId, senderBotName } = + params; // Only relay to group chats if (!chatId) return; @@ -123,6 +126,8 @@ export async function relayOutboundToOtherBots(params: RelayOutboundParams): Pro content: JSON.stringify({ text }), create_time: String(Date.now()), mentions, + // Preserve thread/topic metadata so relay messages stay in the correct topic + ...(threadId ? { root_id: threadId, thread_id: threadId } : {}), }, }; const log = target.runtime?.log ?? console.log; diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index b937e81c46c..66d11014ba7 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -120,6 +120,25 @@ export const feishuOutbound: ChannelOutboundAdapter = { const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); const renderMode = account.config?.renderMode ?? "auto"; const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + // Cross-bot relay helper — extracted to avoid duplication between card and text paths + const triggerRelay = (result: { messageId?: string }) => { + const feishuCfg = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }).config; + const effectiveAccountId = accountId ?? undefined; + if (feishuCfg?.crossBotRelay && to.startsWith("oc_") && text?.trim()) { + void relayOutboundToOtherBots({ + senderAccountId: effectiveAccountId ?? "default", + chatId: to, + text, + messageId: result.messageId, + threadId: threadId != null ? String(threadId) : undefined, + senderBotOpenId: botOpenIds.get(effectiveAccountId ?? "default"), + senderBotName: botNames.get(effectiveAccountId ?? "default"), + }).catch((err) => { + console.error(`[feishu] cross-bot relay failed:`, err); + }); + } + }; + if (useCard) { const header = identity ? { @@ -129,7 +148,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { template: "blue" as const, } : undefined; - return await sendStructuredCardFeishu({ + const cardResult = await sendStructuredCardFeishu({ cfg, to, text, @@ -138,6 +157,9 @@ export const feishuOutbound: ChannelOutboundAdapter = { accountId: accountId ?? undefined, header: header?.title ? header : undefined, }); + // Relay card messages too — previously skipped by early return + triggerRelay(cardResult); + return cardResult; } const result = await sendOutboundText({ cfg, @@ -147,21 +169,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { replyToMessageId, }); - // Cross-bot relay: notify other bots in the same group - const feishuCfg = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }).config; - if (feishuCfg?.crossBotRelay && to.startsWith("oc_") && text?.trim()) { - const effectiveAccountId = accountId ?? undefined; - void relayOutboundToOtherBots({ - senderAccountId: effectiveAccountId ?? "default", - chatId: to, - text, - messageId: result.messageId, - senderBotOpenId: botOpenIds.get(effectiveAccountId ?? "default"), - senderBotName: botNames.get(effectiveAccountId ?? "default"), - }).catch((err) => { - console.error(`[feishu] cross-bot relay failed:`, err); - }); - } + triggerRelay(result); return result; }, diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 810f708934f..4f2f1750d57 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -344,6 +344,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP chatId, text: params.text, messageId: lastMessageId, + threadId: rootId, senderBotOpenId: botOpenIds.get(accountId ?? "default"), senderBotName: botNames.get(accountId ?? "default"), }); @@ -426,18 +427,20 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } if (info?.kind === "final") { streamText = mergeStreamingText(streamText, text); + // Save messageId before closeStreaming() clears the streaming object + const streamingMsgId = streaming?.getMessageId(); await closeStreaming(); deliveredFinalTexts.add(text); // Cross-bot relay: trigger after streaming final const feishuCfg = resolveFeishuAccount({ cfg, accountId }).config; if (feishuCfg?.crossBotRelay && chatId?.startsWith("oc_") && text?.trim()) { try { - const streamingMsgId = (streaming as any)?.state?.messageId; await relayOutboundToOtherBots({ senderAccountId: accountId ?? "default", chatId, text, messageId: streamingMsgId, + threadId: rootId, senderBotOpenId: botOpenIds.get(accountId ?? "default"), senderBotName: botNames.get(accountId ?? "default"), }); @@ -461,7 +464,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP useCard: true, infoKind: info?.kind, sendChunk: async ({ chunk, isFirst }) => { - await sendStructuredCardFeishu({ + return sendStructuredCardFeishu({ cfg, to: chatId, text: chunk, @@ -480,7 +483,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP useCard: false, infoKind: info?.kind, sendChunk: async ({ chunk, isFirst }) => { - await sendMessageFeishu({ + return sendMessageFeishu({ cfg, to: chatId, text: chunk, diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index c7ca0c4a445..740ec25b7a0 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -447,4 +447,9 @@ export class FeishuStreamingSession { isActive(): boolean { return this.state !== null && !this.closed; } + + /** Returns the Feishu message_id of the streaming card, or undefined if not started. */ + getMessageId(): string | undefined { + return this.state?.messageId; + } } From 4bb09f972e76c755b0b419b57557278cd8865dd9 Mon Sep 17 00:00:00 2001 From: Claude Code Agent Date: Sat, 21 Mar 2026 10:58:30 +0800 Subject: [PATCH 4/4] fix(feishu): add getMessageId to streaming mock in tests --- extensions/feishu/src/reply-dispatcher.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index c7b2f9af28b..b97f5ae1397 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -41,6 +41,7 @@ vi.mock("./streaming-card.js", async () => { this.active = false; }); isActive = vi.fn(() => this.active); + getMessageId = vi.fn(() => undefined); constructor() { streamingInstances.push(this);