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 <at> 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)
This commit is contained in:
aunknown 2026-03-20 18:30:02 +08:00
parent 57f1cf66ad
commit 54f2afd6a2
6 changed files with 237 additions and 6 deletions

View File

@ -182,6 +182,7 @@ const FeishuSharedConfigShape = {
reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(),
resolveSenderNames: z.boolean().optional(),
crossBotRelay: z.boolean().optional(),
};
/**

View File

@ -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<string, HistoryEntry[]>;
botOpenId?: string;
botName?: string;
};
const registeredAccounts = new Map<string, RegisteredAccount>();
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<string>();
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<void> {
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 <at user_id="xxx">name</at> tags in the text
const mentionedNames = new Set<string>();
const atPattern = /<at\s+user_id="[^"]+">([^<]+)<\/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);
}
}

View File

@ -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<string, HistoryEntry[]>();
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);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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<void>;
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) {