From c0cead30632e9efbef96459778a3f9583de8e8a6 Mon Sep 17 00:00:00 2001 From: Claude Code Agent Date: Sat, 21 Mar 2026 10:39:11 +0800 Subject: [PATCH] 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; + } }