import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
resolveRuntimeConversationBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime"; import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history"; import {
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import {
checkBotMentioned,
normalizeFeishuCommandProbeBody,
normalizeMentions,
parseMergeForwardContent,
parseMessageContent,
resolveFeishuGroupSession,
resolveFeishuMediaList,
toMessageResourceType,
} from "./bot-content.js"; import {
buildAgentMediaPayload,
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
normalizeAgentId,
resolveChannelContextVisibilityMode,
} from "./bot-runtime-api.js"; import type { ClawdbotConfig, RuntimeEnv } from "./bot-runtime-api.js"; import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js"; import { createFeishuClient } from "./client.js"; import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { extractMentionTargets, isMentionForwardRequest } from "./mention.js"; import {
resolveFeishuGroupConfig,
resolveFeishuReplyPolicy,
resolveFeishuAllowlistMatch,
isFeishuGroupAllowed,
} from "./policy.js"; import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js"; import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js"; import type { FeishuMessageEvent } from "./event-types.js"; import {
isFeishuGroupChatType,
type FeishuMessageContext,
type FeishuMessageInfo,
} from "./types.js"; import type { DynamicAgentCreationConfig } from "./types.js";
export { toMessageResourceType } from "./bot-content.js";
// Cache permission errors to avoid spamming the user with repeated notifications. // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map<string, number>(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
// --- Broadcast support --- // Resolve broadcast agent list for a given peer (group) ID. // Returns null if no broadcast config exists or the peer is not in the broadcast list.
export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null { const broadcast = (cfg as Record<string, unknown>).broadcast; if (!broadcast || typeof broadcast !== "object") { returnnull;
} const agents = (broadcast as Record<string, unknown>)[peerId]; if (!Array.isArray(agents) || agents.length === 0) { returnnull;
} return agents as string[];
}
// Build a session key for a broadcast target agent by replacing the agent ID prefix. // Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
export function buildBroadcastSessionKey(
baseSessionKey: string,
originalAgentId: string,
targetAgentId: string,
): string { const prefix = `agent:${originalAgentId}:`; if (baseSessionKey.startsWith(prefix)) { return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
} return baseSessionKey;
}
/** * Build media payload for inbound context. * Similar to Discord's buildDiscordMediaPayload().
*/
export function parseFeishuMessageEvent(
event: FeishuMessageEvent,
botOpenId?: string,
_botName?: string,
): FeishuMessageContext { const rawContent = parseMessageContent(event.message.content, event.message.message_type); const mentionedBot = checkBotMentioned(event, botOpenId); const hasAnyMention = (event.message.mentions?.length ?? 0) > 0; // Strip the bot's own mention so slash commands like @Bot /help retain // the leading /. This applies in both p2p *and* group contexts — the // mentionedBot flag already captures whether the bot was addressed, so // keeping the mention tag in content only breaks command detection (#35994). // Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags. const content = normalizeMentions(rawContent, event.message.mentions, botOpenId); const senderOpenId = event.sender.sender_id.open_id?.trim(); const senderUserId = event.sender.sender_id.user_id?.trim(); const senderFallbackId = senderOpenId || senderUserId || "";
const ctx: FeishuMessageContext = {
chatId: event.message.chat_id,
messageId: event.message.message_id,
senderId: senderUserId || senderOpenId || "", // Keep the historical field name, but fall back to user_id when open_id is unavailable // (common in some mobile app deliveries).
senderOpenId: senderFallbackId,
chatType: event.message.chat_type,
mentionedBot,
hasAnyMention,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
threadId: event.message.thread_id || undefined,
content,
contentType: event.message.message_type,
};
// Detect mention forward request: message mentions bot + at least one other user if (isMentionForwardRequest(event, botOpenId)) { const mentionTargets = extractMentionTargets(event, botOpenId); if (mentionTargets.length > 0) {
ctx.mentionTargets = mentionTargets;
}
}
// DMs already have per-sender sessions, but this label still improves attribution. const speaker = ctx.senderName ?? ctx.senderOpenId;
messageBody = `${speaker}: ${messageBody}`;
if (ctx.hasAnyMention) { const botIdHint = botOpenId?.trim();
messageBody +=
`\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
`Treat these as real mentions of Feishu entities (users or bots).]`; if (botIdHint) {
messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
}
}
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
}
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
if (permissionErrorForAgent) { const grantUrl = permissionErrorForAgent.grantUrl ?? "";
messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
}
// Handle merge_forward messages: fetch full message via API then expand sub-messages if (event.message.message_type === "merge_forward") {
log(
`feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
); try { // Websocket event doesn't include sub-messages, need to fetch via API // The API returns all sub-messages in the items array const client = createFeishuClient(account); const response = (await client.im.message.get({
path: { message_id: event.message.message_id },
})) as { code?: number; data?: { items?: unknown[] } };
if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
log(
`feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
); const expandedContent = parseMergeForwardContent({
content: JSON.stringify(response.data.items),
log,
});
ctx = { ...ctx, content: expandedContent };
} else {
log(`feishu[${account.accountId}]: merge_forward API returned no items`);
ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
}
} catch (err) {
log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
}
}
// Resolve sender display name (best-effort) so the agent can attribute messages correctly. // Optimization: skip if disabled to save API quota (Feishu free tier limit).
let permissionErrorForAgent: FeishuPermissionError | undefined; if (feishuCfg?.resolveSenderNames ?? true) { const senderResult = await resolveFeishuSenderName({
account,
senderId: ctx.senderOpenId,
log,
}); if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
}
// Track permission error to inform agent later (with cooldown to avoid repetition) if (senderResult.permissionError) { const appKey = account.appId ?? "default"; const now = Date.now(); const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
// Parse message create_time early so every downstream consumer (pending // history, inbound payload, etc.) uses the original authoring timestamp // instead of the delivery/processing time. Feishu uses a millisecond // epoch string; fall back to Date.now() only when the field is absent. const messageCreateTimeMs = event.message.create_time
? Number.parseInt(event.message.create_time, 10)
: Date.now();
let requireMention = false; // DMs never require mention; groups may override below if (isGroup) { if (groupConfig?.enabled === false) {
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`); return;
} const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: feishuCfg?.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "feishu",
accountId: account.accountId,
log,
}); const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs) const groupAllowed = isFeishuGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
senderId: ctx.chatId, // Check group ID, not sender ID
senderName: undefined,
});
if (!groupAllowed) {
log(
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
); return;
}
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom if (effectiveGroupSenderAllowFrom.length > 0) { const senderAllowed = isFeishuGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveGroupSenderAllowFrom,
senderId: ctx.senderOpenId,
senderIds: [senderUserId],
senderName: ctx.senderName,
}); if (!senderAllowed) {
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`); return;
}
}
if (requireMention && !ctx.mentionedBot) {
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`); // Record to pending history for non-broadcast groups only. For broadcast groups, // the mentioned handler's broadcast dispatch writes the turn directly into all // agent sessions — buffering here would cause duplicate replay when this account // later becomes active via buildPendingHistoryContextFromMap. if (!broadcastAgents && chatHistories && groupHistoryKey) {
recordPendingHistoryEntryIfEnabled({
historyMap: chatHistories,
historyKey: groupHistoryKey,
limit: historyLimit,
entry: {
sender: ctx.senderOpenId,
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
timestamp: messageCreateTimeMs,
messageId: ctx.messageId,
},
});
} return;
}
}
// In group chats, the session is scoped to the group, but the *speaker* is the sender. // Using a group-scoped From causes the agent to treat different users as the same person. const feishuFrom = `feishu:${ctx.senderOpenId}`; const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`; const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId; const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null; const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false; const feishuAcpConversationSupported =
!isGroup ||
groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender";
if (isGroup && groupSession) {
log(
`feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
);
}
// Do not enqueue inbound user previews as system events. // System events are prepended to future prompts and can be misread as // authoritative transcript turns.
log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
// Determine reply target based on group session mode: // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic // root so the bot stays in the same thread. // - Groups with explicit replyInThread config: reply to the root so the bot // stays in the thread the user expects. // - Normal groups (auto-detected threadReply from root_id): reply to the // triggering message itself. Using rootId here would silently push the // reply into a topic thread invisible in the main chat view (#32980). const isTopicSession =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender"); const configReplyInThread =
isGroup &&
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled"; const replyTargetMessageId =
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId; const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
if (broadcastAgents) { // Cross-account dedup: in multi-account setups, Feishu delivers the same // event to every bot account in the group. Only one account should handle // broadcast dispatch to avoid duplicate agent sessions and race conditions. // Uses a shared "broadcast" namespace (not per-account) so the first handler // to reach this point claims the message; subsequent accounts skip. if (!(await tryRecordMessagePersistent(ctx.messageId, "broadcast", log))) {
log(
`feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
); return;
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.