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..79e2eb903eb --- /dev/null +++ b/extensions/feishu/src/cross-bot-relay.ts @@ -0,0 +1,160 @@ +/** + * 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 "../runtime-api.js"; +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; + /** 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 */ + senderBotName?: string; +}; + +export async function relayOutboundToOtherBots(params: RelayOutboundParams): Promise { + const { senderAccountId, chatId, text, messageId, threadId, 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, + // 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; + 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..66d11014ba7 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"; @@ -118,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 ? { @@ -127,7 +148,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { template: "blue" as const, } : undefined; - return await sendStructuredCardFeishu({ + const cardResult = await sendStructuredCardFeishu({ cfg, to, text, @@ -136,14 +157,21 @@ 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; } - return await sendOutboundText({ + const result = await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); + + triggerRelay(result); + + 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.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); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 6ab7184c8e8..4f2f1750d57 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,33 @@ 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, + threadId: rootId, + senderBotOpenId: botOpenIds.get(accountId ?? "default"), + senderBotName: botNames.get(accountId ?? "default"), + }); + } catch (err) { + console.error(`[feishu] cross-bot relay (reply-dispatcher) failed:`, err); + } + } } }; @@ -403,8 +427,27 @@ 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 { + await relayOutboundToOtherBots({ + senderAccountId: accountId ?? "default", + chatId, + text, + messageId: streamingMsgId, + threadId: rootId, + 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) { @@ -421,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, @@ -440,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; + } }