Merge 4bb09f972e76c755b0b419b57557278cd8865dd9 into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
cf1f2bcac0
@ -182,6 +182,7 @@ const FeishuSharedConfigShape = {
|
||||
reactionNotifications: ReactionNotificationModeSchema,
|
||||
typingIndicator: z.boolean().optional(),
|
||||
resolveSenderNames: z.boolean().optional(),
|
||||
crossBotRelay: z.boolean().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
160
extensions/feishu/src/cross-bot-relay.ts
Normal file
160
extensions/feishu/src/cross-bot-relay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user