Merge 4bb09f972e76c755b0b419b57557278cd8865dd9 into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
aunknown 2026-03-21 02:58:35 +00:00 committed by GitHub
commit cf1f2bcac0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 262 additions and 9 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,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<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;
/** 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<void> {
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 <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,
// 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);
}
}

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

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

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

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

View File

@ -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;
}
}