import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import type { HistoryEntry } from "openclaw/plugin-sdk/feishu"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, resolveMentionGatingWithBypass, } from "./sdk-compat.js"; import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; import { getFeishuRuntime } from "./runtime.js"; import { createFeishuClient } from "./client.js"; import { resolveFeishuAccount } from "./accounts.js"; import { tryRecordMessage } from "./dedup.js"; import { resolveFeishuGroupConfig, resolveFeishuReplyPolicy, resolveFeishuGroupCommandMentionBypass, resolveFeishuAllowlistMatch, isFeishuGroupAllowed, } from "./policy.js"; import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getMessageFeishu, sendMessageFeishu } from "./send.js"; import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js"; import { extractMentionTargets, extractMessageBody, isMentionForwardRequest, } from "./mention.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { runWithFeishuToolContext } from "./tools-common/tool-context.js"; import type { DynamicAgentCreationConfig } from "./types.js"; // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. type PermissionError = { code: number; message: string; grantUrl?: string; }; function decodeHtmlEntities(raw: string): string { return raw .replace(/&/gi, "&") .replace(/'|'/gi, "'") .replace(/"/gi, '"'); } function extractFirstUrl(raw: string): string | undefined { if (!raw) return undefined; const decoded = decodeHtmlEntities(raw); const urlMatch = decoded.match(/https?:\/\/[^\s"'<>]+/i); return urlMatch?.[0]; } function extractPermissionError(err: unknown): PermissionError | null { if (!err || typeof err !== "object") return null; // Axios error structure: err.response.data contains the Feishu error const axiosErr = err as { response?: { data?: unknown } }; const data = axiosErr.response?.data; if (!data || typeof data !== "object") return null; const feishuErr = data as { code?: number; msg?: string; error?: { permission_violations?: Array<{ uri?: string }> }; }; // Feishu permission error code: 99991672 if (feishuErr.code !== 99991672) return null; const msg = feishuErr.msg ?? ""; const grantUrlFromMsg = extractFirstUrl(msg); const grantUrlFromViolations = feishuErr.error?.permission_violations ?.map((item) => extractFirstUrl(item.uri ?? "")) .find((url): url is string => Boolean(url)); const grantUrl = grantUrlFromMsg ?? grantUrlFromViolations; return { code: feishuErr.code, message: msg, grantUrl, }; } // --- Sender name resolution (so the agent can distinguish who is speaking in group chats) --- // Cache display names by open_id to avoid an API call on every message. const SENDER_NAME_TTL_MS = 10 * 60 * 1000; const senderNameCache = new Map(); // Cache permission errors to avoid spamming the user with repeated notifications. // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes const DEFAULT_MAX_MESSAGE_AGE_MS = 300_000; // 5 minutes type SenderNameResult = { name?: string; permissionError?: PermissionError; }; type FeishuPairingApiMode = "legacy" | "scoped"; let detectedFeishuPairingApiMode: FeishuPairingApiMode | null = null; async function resolveFeishuPairingApiMode(params: { readAllowFromStore: (...args: any[]) => Promise>; }): Promise { if (detectedFeishuPairingApiMode) return detectedFeishuPairingApiMode; let channelObserved = false; const probe = new Proxy>( {}, { get(_target, prop) { if (prop === "channel") { channelObserved = true; return "__feishu_pairing_probe__"; } if (prop === "accountId") return "__feishu_pairing_probe__"; if (prop === "env") return undefined; if (prop === Symbol.toPrimitive) return () => "__feishu_pairing_probe__"; if (prop === "toString") return () => "__feishu_pairing_probe__"; return undefined; }, has(_target, prop) { if (prop === "channel") { channelObserved = true; return true; } return false; }, }, ); try { // Probe once to detect whether runtime reads object-style params (scoped API) // or positional params (legacy API). await params.readAllowFromStore(probe as any, undefined, "__feishu_pairing_probe__"); } catch { // Ignore probe errors; fallback below still keeps behavior safe. } detectedFeishuPairingApiMode = channelObserved ? "scoped" : "legacy"; return detectedFeishuPairingApiMode; } async function readFeishuPairingAllowFrom(params: { core: ReturnType; accountId: string; }): Promise> { const pairing = params.core.channel.pairing as { readAllowFromStore: (...args: any[]) => Promise>; }; const pairingMode = await resolveFeishuPairingApiMode({ readAllowFromStore: pairing.readAllowFromStore, }); if (pairingMode === "scoped") { try { return await pairing.readAllowFromStore({ channel: "feishu", accountId: params.accountId, }); } catch { // Compatibility fallback for older OpenClaw implementations. return await pairing.readAllowFromStore("feishu", undefined, params.accountId); } } try { // OpenClaw <= 2026.2.19 signature: readAllowFromStore(channel, env?, accountId?) return await pairing.readAllowFromStore("feishu", undefined, params.accountId); } catch { // Compatibility fallback for newer OpenClaw implementations. return await pairing.readAllowFromStore({ channel: "feishu", accountId: params.accountId, }); } } async function upsertFeishuPairingRequest(params: { core: ReturnType; accountId: string; senderId: string; senderName?: string; }): Promise<{ code: string; created: boolean }> { const pairing = params.core.channel.pairing as { upsertPairingRequest: (...args: any[]) => Promise<{ code: string; created: boolean }>; }; const meta = params.senderName ? { name: params.senderName } : undefined; const pairingMode = await resolveFeishuPairingApiMode({ readAllowFromStore: params.core.channel.pairing.readAllowFromStore as (...args: any[]) => Promise< Array >, }); if (pairingMode === "scoped") { try { return await pairing.upsertPairingRequest({ channel: "feishu", id: params.senderId, accountId: params.accountId, meta, }); } catch { // Compatibility fallback for older OpenClaw implementations. return await pairing.upsertPairingRequest({ channel: "feishu", id: params.senderId, meta, }); } } try { return await pairing.upsertPairingRequest({ channel: "feishu", id: params.senderId, meta, }); } catch { // Compatibility fallback for newer OpenClaw implementations. return await pairing.upsertPairingRequest({ channel: "feishu", id: params.senderId, accountId: params.accountId, meta, }); } } async function resolveFeishuSenderName(params: { account: ResolvedFeishuAccount; senderOpenId: string; log: (...args: any[]) => void; }): Promise { const { account, senderOpenId, log } = params; if (!account.configured) return {}; if (!senderOpenId) return {}; const cached = senderNameCache.get(senderOpenId); const now = Date.now(); if (cached && cached.expireAt > now) return { name: cached.name }; try { const client = createFeishuClient(account); // contact/v3/users/:user_id?user_id_type=open_id const res: any = await client.contact.user.get({ path: { user_id: senderOpenId }, params: { user_id_type: "open_id" }, }); const name: string | undefined = res?.data?.user?.name || res?.data?.user?.nickname; if (name && typeof name === "string") { senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS }); return { name }; } return {}; } catch (err) { // Check if this is a permission error const permErr = extractPermissionError(err); if (permErr) { log(`feishu: permission error resolving sender name: code=${permErr.code}`); return { permissionError: permErr }; } // Best-effort. Don't fail message handling if name lookup fails. log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`); return {}; } } // Cache group bot counts for command mention bypass policy checks. const GROUP_BOT_COUNT_TTL_MS = 10 * 60 * 1000; const groupBotCountCache = new Map(); // Fallback flush only for standalone forwarded messages. // Quote-reply merging should rely on parent_id relation instead of time windows. const FORWARDED_COALESCE_WINDOW_MS = 1500; const FORWARDED_COMPANION_TTL_MS = 20 * 1000; type PendingForwardedDispatchEntry = { token: string; timer: ReturnType; content: string; messageKeys: Set; }; const pendingForwardedDispatch = new Map(); const pendingForwardedDispatchByMessageId = new Map(); const recentForwardedCompanionReplies = new Map(); function getForwardedKey(params: { accountId: string; chatId: string; forwardedMessageId: string; }): string { return `${params.accountId}:${params.chatId}:${params.forwardedMessageId}`; } function clearPendingForwardedDispatchEntry(dispatchKey: string, entry: PendingForwardedDispatchEntry): void { clearTimeout(entry.timer); pendingForwardedDispatch.delete(dispatchKey); for (const key of entry.messageKeys) { pendingForwardedDispatchByMessageId.delete(key); } } function markForwardedCompanionReply(params: { accountId: string; chatId: string; forwardedMessageId: string; }): void { const now = Date.now(); for (const [key, expireAt] of recentForwardedCompanionReplies) { if (expireAt <= now) recentForwardedCompanionReplies.delete(key); } recentForwardedCompanionReplies.set( getForwardedKey(params), now + FORWARDED_COMPANION_TTL_MS, ); } function consumeForwardedCompanionReply(params: { accountId: string; chatId: string; forwardedMessageId: string; }): boolean { const key = getForwardedKey(params); const now = Date.now(); const expireAt = recentForwardedCompanionReplies.get(key); if (!expireAt) return false; if (expireAt <= now) { recentForwardedCompanionReplies.delete(key); return false; } recentForwardedCompanionReplies.delete(key); return true; } async function resolveFeishuGroupBotCount(params: { account: ResolvedFeishuAccount; chatId: string; log: (...args: any[]) => void; }): Promise { const { account, chatId, log } = params; if (!account.configured || !chatId) return undefined; const cacheKey = `${account.accountId}:${chatId}`; const now = Date.now(); const cached = groupBotCountCache.get(cacheKey); if (cached && cached.expireAt > now) return cached.count; try { const client = createFeishuClient(account); const res: any = await client.im.chat.get({ path: { chat_id: chatId }, }); const parsed = Number.parseInt(String(res?.data?.bot_count ?? ""), 10); if (Number.isFinite(parsed) && parsed >= 0) { groupBotCountCache.set(cacheKey, { count: parsed, expireAt: now + GROUP_BOT_COUNT_TTL_MS }); return parsed; } return undefined; } catch (err) { log(`feishu[${account.accountId}]: failed to resolve bot_count for ${chatId}: ${String(err)}`); return undefined; } } export type FeishuMessageEvent = { sender: { sender_id: { open_id?: string; user_id?: string; union_id?: string; }; sender_type?: string; tenant_key?: string; }; message: { message_id: string; root_id?: string; parent_id?: string; chat_id: string; chat_type: "p2p" | "group" | "private"; create_time?: string; message_type: string; content: string; mentions?: Array<{ key: string; id: { open_id?: string; user_id?: string; union_id?: string; }; name: string; tenant_key?: string; }>; }; }; export type FeishuBotAddedEvent = { chat_id: string; operator_id: { open_id?: string; user_id?: string; union_id?: string; }; external: boolean; operator_tenant_key?: string; }; function isForwardedMessageType(messageType: string): boolean { return messageType === "forwarded" || messageType === "merged_forwarded" || messageType === "merge_forward"; } type ParsedFeishuMessageContent = { text: string; placeholder?: string; rawPayload?: string; }; function parseMessageContent(content: string, messageType: string): ParsedFeishuMessageContent { try { const parsed = JSON.parse(content); const normalizedMessageType = normalizeFeishuInboundMessageType(messageType); if (normalizedMessageType === "text") { return { text: parsed.text || "", rawPayload: content }; } if (normalizedMessageType === "post") { // Extract text content from rich text post const { textContent } = parsePostContent(content); return { text: textContent, rawPayload: content }; } if (["image", "file", "audio", "video", "sticker"].includes(normalizedMessageType)) { const ph = inferPlaceholder(normalizedMessageType); return { text: ph, placeholder: ph, rawPayload: content }; } // Handle forwarded messages (single message forwarded) if (messageType === "forwarded") { const senderName = parsed.sender_name || parsed.sender?.name || "unknown"; const text = parsed.text || parsed.title || ""; const forwarded = text ? `[Forwarded from ${senderName}]: ${text.substring(0, 100)}` : `[Forwarded from ${senderName}]`; return { text: forwarded, rawPayload: content }; } // Handle merged_forwarded messages (multiple messages merged) // Note: Feishu uses "merge_forward" for merged forwarded messages if (messageType === "merged_forwarded" || messageType === "merge_forward") { const messageCount = parsed.message_count || parsed.messages?.length || 0; const senderNames = parsed.messages ?.map((m: any) => m.sender_name || m.sender?.name) .filter(Boolean) .join(", ") || "unknown"; // Extract preview of first few messages let preview = ""; if (parsed.messages && parsed.messages.length > 0) { const previews = parsed.messages .slice(0, 3) .map((m: any) => m.text || m.title || "") .filter(Boolean) .map((t: string) => t.substring(0, 50)); if (previews.length > 0) { preview = `\nā”” ${previews.join("\nā”” ")}`; } } return { text: `[Merged forward (${messageCount} messages), from: ${senderNames}]${preview}`, rawPayload: content }; } return { text: content, rawPayload: content }; } catch { return { text: content, rawPayload: content }; } } /** * Feishu may use "media" for inbound video messages. * Normalize to "video" so downstream media handling is consistent. */ export function normalizeFeishuInboundMessageType(messageType: string): string { return messageType === "media" ? "video" : messageType; } /** * Select the correct resource key for messageResource download. * - image message: prefer image_key * - other media (video/audio/file/sticker): prefer file_key */ export function selectFeishuMessageResourceKey( messageType: string, keys: { imageKey?: string; fileKey?: string }, ): string | undefined { const normalized = normalizeFeishuInboundMessageType(messageType); if (normalized === "image") { return keys.imageKey ?? keys.fileKey; } return keys.fileKey ?? keys.imageKey; } function checkBotMentioned( event: FeishuMessageEvent, botOpenId?: string, postMentionIds: string[] = [], ): boolean { const normalizedBotOpenId = botOpenId?.trim(); // Keep explicit bot mention semantics: without a resolved botOpenId, do not trigger. if (!normalizedBotOpenId) return false; const mentions = event.message.mentions ?? []; return ( mentions.some( (m) => m.id.open_id === normalizedBotOpenId || m.id.user_id === normalizedBotOpenId, ) || postMentionIds.some((id) => id === normalizedBotOpenId) ); } function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string { if (!mentions || mentions.length === 0) return text; let result = text; for (const mention of mentions) { result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim(); result = result.replace(new RegExp(mention.key, "g"), "").trim(); } return result; } /** * Parse media keys from message content based on message type. */ function parseMediaKeys( content: string, messageType: string, ): { imageKey?: string; fileKey?: string; fileName?: string; } { try { const parsed = JSON.parse(content); switch (messageType) { case "image": return { imageKey: parsed.image_key }; case "file": return { fileKey: parsed.file_key, fileName: parsed.file_name }; case "audio": return { fileKey: parsed.file_key }; case "video": case "media": // Video has both file_key (video) and image_key (thumbnail) return { fileKey: parsed.file_key, imageKey: parsed.image_key }; case "sticker": return { fileKey: parsed.file_key }; default: return {}; } } catch { return {}; } } /** * Parse post (rich text) content and extract embedded image keys. * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] } */ function parsePostContent(content: string): { textContent: string; imageKeys: string[]; mentionIds: string[]; } { try { const parsed = JSON.parse(content); const title = parsed.title || ""; const contentBlocks = parsed.content || []; let textContent = title ? `${title}\n\n` : ""; const imageKeys: string[] = []; const mentionIds: string[] = []; for (const paragraph of contentBlocks) { if (Array.isArray(paragraph)) { for (const element of paragraph) { if (element.tag === "text") { textContent += element.text || ""; } else if (element.tag === "a") { // Link: show text or href textContent += element.text || element.href || ""; } else if (element.tag === "at") { // Mention: @username const mentionId = String(element.open_id ?? element.user_id ?? element.union_id ?? "").trim() || undefined; if (mentionId) mentionIds.push(mentionId); textContent += `@${element.user_name || mentionId || ""}`; } else if (element.tag === "img" && element.image_key) { // Embedded image imageKeys.push(element.image_key); } } textContent += "\n"; } } return { textContent: textContent.trim() || "[Rich text message]", imageKeys, mentionIds, }; } catch { return { textContent: "[Rich text message]", imageKeys: [], mentionIds: [] }; } } function isGenericMimeType(mime?: string): boolean { if (!mime) return true; const normalized = mime.split(";")[0]?.trim().toLowerCase(); return !normalized || normalized === "application/octet-stream"; } function resolveFallbackMimeType(messageType: string): string | undefined { switch (messageType) { case "audio": // Feishu voice messages are typically opus in ogg container. return "audio/ogg; codecs=opus"; case "video": return "video/mp4"; case "image": case "post": return "image/jpeg"; default: return undefined; } } /** * Infer placeholder text based on message type. */ function inferPlaceholder(messageType: string): string { switch (messageType) { case "image": return ""; case "file": return ""; case "audio": return ""; case "video": case "media": return ""; case "sticker": return ""; default: return ""; } } /** * Resolve media from a Feishu message, downloading and saving to disk. * Similar to Discord's resolveMediaList(). */ async function resolveFeishuMediaList(params: { cfg: ClawdbotConfig; messageId: string; messageType: string; content: string; maxBytes: number; log?: (msg: string) => void; accountId?: string; }): Promise { const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params; const normalizedMessageType = normalizeFeishuInboundMessageType(messageType); // Only process media message types (including post for embedded images) const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"]; if (!mediaTypes.includes(normalizedMessageType)) { return []; } const out: FeishuMediaInfo[] = []; const core = getFeishuRuntime(); // Handle post (rich text) messages with embedded images if (normalizedMessageType === "post") { const { imageKeys } = parsePostContent(content); if (imageKeys.length === 0) { return []; } log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`); for (const imageKey of imageKeys) { try { // Embedded images in post use messageResource API with image_key as file_key const result = await downloadMessageResourceFeishu({ cfg, messageId, fileKey: imageKey, type: "image", accountId, }); let contentType = result.contentType; if (!contentType || isGenericMimeType(contentType)) { contentType = (await core.media.detectMime({ buffer: result.buffer, headerMime: contentType, })) ?? resolveFallbackMimeType("image"); } const saved = await core.channel.media.saveMediaBuffer( result.buffer, contentType, "inbound", maxBytes, ); out.push({ path: saved.path, contentType: saved.contentType, placeholder: "", }); log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`); } catch (err) { log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`); } } return out; } // Handle other media types const mediaKeys = parseMediaKeys(content, normalizedMessageType); if (!mediaKeys.imageKey && !mediaKeys.fileKey) { return []; } try { let buffer: Buffer; let contentType: string | undefined; let fileName: string | undefined; // For message media, always use messageResource API // The image.get API is only for images uploaded via im/v1/images, not for message attachments const fileKey = selectFeishuMessageResourceKey(normalizedMessageType, mediaKeys); if (!fileKey) { return []; } const resourceType = normalizedMessageType === "image" ? "image" : "file"; const result = await downloadMessageResourceFeishu({ cfg, messageId, fileKey, type: resourceType, accountId, }); buffer = result.buffer; contentType = result.contentType; fileName = result.fileName || mediaKeys.fileName; // Detect MIME type and apply message-type fallback when needed. if (!contentType || isGenericMimeType(contentType)) { contentType = (await core.media.detectMime({ buffer, headerMime: contentType, filePath: fileName, })) ?? resolveFallbackMimeType(normalizedMessageType); } if (isGenericMimeType(contentType)) { contentType = resolveFallbackMimeType(normalizedMessageType) ?? contentType; } // Save to disk using core's saveMediaBuffer const saved = await core.channel.media.saveMediaBuffer( buffer, contentType, "inbound", maxBytes, fileName, ); out.push({ path: saved.path, contentType: saved.contentType, placeholder: inferPlaceholder(normalizedMessageType), }); log?.(`feishu: downloaded ${normalizedMessageType} media, saved to ${saved.path}`); } catch (err) { log?.(`feishu: failed to download ${normalizedMessageType} media: ${String(err)}`); } return out; } /** * Build media payload for inbound context. * Similar to Discord's buildDiscordMediaPayload(). */ function buildFeishuMediaPayload( mediaList: FeishuMediaInfo[], ): { MediaPath?: string; MediaType?: string; MediaUrl?: string; MediaPaths?: string[]; MediaUrls?: string[]; MediaTypes?: string[]; } { const first = mediaList[0]; const mediaPaths = mediaList.map((media) => media.path); const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; return { MediaPath: first?.path, MediaType: first?.contentType, MediaUrl: first?.path, MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, }; } function skipGroupMessageForMentionGate(params: { accountId: string; chatId: string; senderOpenId: string; senderName?: string; content: string; messageId: string; historyLimit: number; chatHistories?: Map; log: (...args: any[]) => void; }): void { params.log(`feishu[${params.accountId}]: message in group ${params.chatId} skipped (mention required)`); if (params.chatHistories) { recordPendingHistoryEntryIfEnabled({ historyMap: params.chatHistories, historyKey: params.chatId, limit: params.historyLimit, entry: { sender: params.senderOpenId, body: `${params.senderName ?? params.senderOpenId}: ${params.content}`, timestamp: Date.now(), messageId: params.messageId, }, }); } } export function parseFeishuMessageEvent( event: FeishuMessageEvent, botOpenId?: string, ): FeishuMessageContext { const parsedPost = event.message.message_type === "post" ? parsePostContent(event.message.content) : undefined; const parsedContent = parseMessageContent(event.message.content, event.message.message_type); const mentionedBot = checkBotMentioned(event, botOpenId, parsedPost?.mentionIds ?? []); const hasAnyMention = (event.message.mentions?.length ?? 0) > 0 || (parsedPost?.mentionIds.length ?? 0) > 0; const content = stripBotMention(parsedContent.text, event.message.mentions); const ctx: FeishuMessageContext = { chatId: event.message.chat_id, messageId: event.message.message_id, senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "", senderOpenId: event.sender.sender_id.open_id || "", chatType: event.message.chat_type, mentionedBot, rootId: event.message.root_id || undefined, parentId: event.message.parent_id || undefined, content, contentType: event.message.message_type, placeholder: parsedContent.placeholder, rawPayload: parsedContent.rawPayload, hasAnyMention, }; // 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; // Extract message body (remove all @ placeholders) const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key); ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys); } } return ctx; } export async function handleFeishuMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent; botOpenId?: string; runtime?: RuntimeEnv; chatHistories?: Map; accountId?: string; replyToMessageIdOverride?: string; }): Promise { const { cfg, event, botOpenId, runtime, chatHistories, accountId, replyToMessageIdOverride } = params; // Resolve account with merged config const account = resolveFeishuAccount({ cfg, accountId }); const feishuCfg = account.config; const log = runtime?.log ?? console.log; const error = runtime?.error ?? console.error; // Dedup check: skip if this message was already processed const messageId = event.message.message_id; const dedupAccountId = accountId || "default"; if (!tryRecordMessage(messageId, dedupAccountId)) { log(`feishu: skipping duplicate message ${messageId}`); return; } // Parse message create_time (Feishu uses millisecond epoch string). const messageCreateTimeMs = event.message.create_time ? parseInt(event.message.create_time, 10) : undefined; // Discard stale messages (e.g. replayed after gateway restart). const maxAgeMs = feishuCfg.maxMessageAgeMs ?? DEFAULT_MAX_MESSAGE_AGE_MS; if (messageCreateTimeMs && Date.now() - messageCreateTimeMs > maxAgeMs) { log(`feishu[${account.accountId}]: discarding stale message ${messageId} (age: ${Math.round((Date.now() - messageCreateTimeMs) / 1000)}s, max: ${maxAgeMs / 1000}s)`); return; } let ctx = parseFeishuMessageEvent(event, botOpenId); const messageType = event.message.message_type; const isForwardedInbound = isForwardedMessageType(messageType); const forwardedDispatchKey = getForwardedKey({ accountId: account.accountId, chatId: ctx.chatId, forwardedMessageId: ctx.messageId, }); if (!isForwardedInbound && ctx.parentId) { markForwardedCompanionReply({ accountId: account.accountId, chatId: ctx.chatId, forwardedMessageId: ctx.parentId, }); } // For forwarded/merged_forwarded messages, fetch full message details via API // Note: Feishu uses "merge_forward" for merged forwarded messages if (isForwardedInbound) { if ( consumeForwardedCompanionReply({ accountId: account.accountId, chatId: ctx.chatId, forwardedMessageId: ctx.messageId, }) ) { log( `feishu[${account.accountId}]: skipping companion forwarded message ${ctx.messageId} (covered by parent reply)`, ); return; } try { const fullMessage = await getMessageFeishu({ cfg, messageId, accountId: account.accountId, }); if (fullMessage?.content) { // Use the already-parsed content from getMessageFeishu (includes all child messages for merge_forward) ctx = { ...ctx, content: fullMessage.content }; } } catch (err) { log(`feishu: failed to fetch forwarded message details: ${err}`); } // Handle race: parent-reply may arrive and be processed while we're awaiting getMessageFeishu above. // In that case, do not enqueue deferred forwarded flush again. if ( consumeForwardedCompanionReply({ accountId: account.accountId, chatId: ctx.chatId, forwardedMessageId: ctx.messageId, }) ) { log( `feishu[${account.accountId}]: skipping companion forwarded message ${ctx.messageId} (covered during fetch)`, ); return; } const existing = pendingForwardedDispatch.get(forwardedDispatchKey); if (existing) clearPendingForwardedDispatchEntry(forwardedDispatchKey, existing); const mergedMessageKeys = new Set([forwardedDispatchKey]); const token = `${ctx.messageId}:${Date.now()}`; const deferredMessageIdBase = ctx.messageId; const timer = setTimeout(() => { const current = pendingForwardedDispatch.get(forwardedDispatchKey); if (!current || current.token !== token) return; clearPendingForwardedDispatchEntry(forwardedDispatchKey, current); const syntheticEvent: FeishuMessageEvent = { ...event, message: { ...event.message, message_id: `${deferredMessageIdBase}:forwarded-flush:${Date.now()}`, message_type: "text", content: JSON.stringify({ text: current.content }), }, }; void handleFeishuMessage({ cfg, event: syntheticEvent, botOpenId, runtime, chatHistories, accountId: account.accountId, replyToMessageIdOverride: deferredMessageIdBase, }).catch((err) => { error(`feishu[${account.accountId}]: failed deferred forwarded dispatch: ${String(err)}`); }); }, FORWARDED_COALESCE_WINDOW_MS); pendingForwardedDispatch.set(forwardedDispatchKey, { token, timer, content: ctx.content, messageKeys: mergedMessageKeys, }); for (const messageKey of mergedMessageKeys) { pendingForwardedDispatchByMessageId.set(messageKey, forwardedDispatchKey); } log( `feishu[${account.accountId}]: queued forwarded message ${ctx.messageId} for deferred dispatch`, ); return; } else { let pending: PendingForwardedDispatchEntry | undefined; let pendingDispatchKey: string | undefined; if (ctx.parentId) { const parentMessageKey = getForwardedKey({ accountId: account.accountId, chatId: ctx.chatId, forwardedMessageId: ctx.parentId, }); const parentDispatchKey = pendingForwardedDispatchByMessageId.get(parentMessageKey); if (parentDispatchKey) { pendingDispatchKey = parentDispatchKey; pending = pendingForwardedDispatch.get(parentDispatchKey); } else { pendingForwardedDispatchByMessageId.delete(parentMessageKey); } } if (pending && pendingDispatchKey) { clearPendingForwardedDispatchEntry(pendingDispatchKey, pending); ctx = { ...ctx, content: `${pending.content}\n\n---\n\n${ctx.content}`, }; log( `feishu[${account.accountId}]: merged pending forwarded context into follow-up ${ctx.messageId}`, ); } } const isGroup = ctx.chatType === "group"; // Resolve sender display name (best-effort) so the agent can attribute messages correctly. const senderResult = await resolveFeishuSenderName({ account, senderOpenId: ctx.senderOpenId, log, }); if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name }; // Track permission error to inform agent later (with cooldown to avoid repetition) let permissionErrorForAgent: PermissionError | undefined; if (senderResult.permissionError) { const appKey = account.appId ?? "default"; const now = Date.now(); const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0; if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) { permissionErrorNotifiedAt.set(appKey, now); permissionErrorForAgent = senderResult.permissionError; } } log(`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`); // Log mention targets if detected if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { const names = ctx.mentionTargets.map((t) => t.name).join(", "); log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`); } const historyLimit = Math.max( 0, feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, ); const groupConfig = isGroup ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }) : undefined; const dmPolicy = feishuCfg?.dmPolicy ?? "pairing"; const configAllowFrom = feishuCfg?.allowFrom ?? []; const useAccessGroups = cfg.commands?.useAccessGroups !== false; try { const core = getFeishuRuntime(); const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized( ctx.content, cfg, ); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "feishu", }); const hasControlCommand = core.channel.text.hasControlCommand(ctx.content, cfg); const storeAllowFrom = !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized) ? await readFeishuPairingAllowFrom({ core, accountId: account.accountId, }).catch(() => []) : []; const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom]; const dmAllowed = resolveFeishuAllowlistMatch({ allowFrom: effectiveDmAllowFrom, senderId: ctx.senderOpenId, senderName: ctx.senderName, }).allowed; if (!isGroup && dmPolicy !== "open" && !dmAllowed) { if (dmPolicy === "pairing") { const { code, created } = await upsertFeishuPairingRequest({ core, accountId: account.accountId, senderId: ctx.senderOpenId, senderName: ctx.senderName, }); if (created) { log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`); try { await sendMessageFeishu({ cfg, to: `chat:${ctx.chatId}`, text: core.channel.pairing.buildPairingReply({ channel: "feishu", idLine: `Your Feishu user id: ${ctx.senderOpenId}`, code, }), accountId: account.accountId, }); } catch (err) { log( `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`, ); } } } else { log( `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`, ); } return; } const commandAllowFrom = isGroup ? groupConfig?.allowFrom && groupConfig.allowFrom.length > 0 ? groupConfig.allowFrom : configAllowFrom : effectiveDmAllowFrom; const senderAllowedForCommands = resolveFeishuAllowlistMatch({ allowFrom: commandAllowFrom, senderId: ctx.senderOpenId, senderName: ctx.senderName, }).allowed; const commandAuthorized = shouldComputeCommandAuthorized ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, ], }) : undefined; let effectiveWasMentioned = ctx.mentionedBot; if (isGroup) { if (groupConfig?.enabled === false) { log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`); return; } const groupPolicy = feishuCfg?.groupPolicy ?? "open"; const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // 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; } // Additional sender-level allowlist check if group has specific allowFrom config const senderAllowFrom = groupConfig?.allowFrom ?? []; if (senderAllowFrom.length > 0) { const senderAllowed = isFeishuGroupAllowed({ groupPolicy: "allowlist", allowFrom: senderAllowFrom, senderId: ctx.senderOpenId, senderName: ctx.senderName, }); if (!senderAllowed) { log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`); return; } } const { requireMention, allowMentionlessInMultiBotGroup } = resolveFeishuReplyPolicy({ isDirectMessage: false, globalConfig: feishuCfg, groupConfig, }); if (requireMention) { const bypassPolicy = resolveFeishuGroupCommandMentionBypass({ globalConfig: feishuCfg, groupConfig, }); let bypassAllowedByPolicy = bypassPolicy === "always"; if (!bypassAllowedByPolicy && bypassPolicy === "single_bot" && hasControlCommand) { const botCount = await resolveFeishuGroupBotCount({ account, chatId: ctx.chatId, log, }); bypassAllowedByPolicy = botCount !== undefined && botCount <= 1; if (botCount === undefined) { log( `feishu[${account.accountId}]: unable to resolve bot count for ${ctx.chatId}, command mention bypass disabled`, ); } } const mentionGate = resolveMentionGatingWithBypass({ isGroup: true, requireMention, canDetectMention: true, wasMentioned: ctx.mentionedBot, hasAnyMention: ctx.hasAnyMention, allowTextCommands: allowTextCommands && bypassAllowedByPolicy, hasControlCommand, commandAuthorized: commandAuthorized === true, }); effectiveWasMentioned = mentionGate.effectiveWasMentioned; if (mentionGate.shouldSkip) { skipGroupMessageForMentionGate({ accountId: account.accountId, chatId: ctx.chatId, senderOpenId: ctx.senderOpenId, senderName: ctx.senderName, content: ctx.content, messageId: ctx.messageId, historyLimit, chatHistories, log, }); return; } } else if (!ctx.mentionedBot && !allowMentionlessInMultiBotGroup) { // Safety default for mention-free mode: only allow non-@ traffic when the group has <= 1 bot. // In multi-bot groups, explicit @ is required unless allowMentionlessInMultiBotGroup=true. const botCount = await resolveFeishuGroupBotCount({ account, chatId: ctx.chatId, log, }); const singleBotGroup = botCount !== undefined && botCount <= 1; if (!singleBotGroup) { if (botCount === undefined) { log( `feishu[${account.accountId}]: unable to resolve bot_count for ${ctx.chatId}, mention-free mode disabled for safety`, ); } else { log( `feishu[${account.accountId}]: group ${ctx.chatId} has ${botCount} bots, explicit @ required unless allowMentionlessInMultiBotGroup=true`, ); } skipGroupMessageForMentionGate({ accountId: account.accountId, chatId: ctx.chatId, senderOpenId: ctx.senderOpenId, senderName: ctx.senderName, content: ctx.content, messageId: ctx.messageId, historyLimit, chatHistories, log, }); return; } } } // In group chats, From must be group-scoped so the core's resolveGroupSessionKey can // detect group context and route replies back to the group — not to a user's DM session. // We include senderOpenId to preserve per-speaker identity within the group. const feishuFrom = isGroup ? `feishu:${ctx.chatId}:${ctx.senderOpenId}` : `feishu:${ctx.senderOpenId}`; const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`; // Resolve peer ID for session routing // When topicSessionMode is enabled, messages within a topic (identified by root_id) // get a separate session from the main group chat. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; if (isGroup && ctx.rootId) { const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); const topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; if (topicSessionMode === "enabled") { // Use chatId:topic:rootId as peer ID for topic-scoped sessions peerId = `${ctx.chatId}:topic:${ctx.rootId}`; log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`); } } let route = core.channel.routing.resolveAgentRoute({ cfg, channel: "feishu", accountId: account.accountId, peer: { kind: isGroup ? "group" : "direct", id: peerId, }, }); // Dynamic agent creation for DM users // When enabled, creates a unique agent instance with its own workspace for each DM user. let effectiveCfg = cfg; if (!isGroup && route.matchedBy === "default") { const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined; if (dynamicCfg?.enabled) { const runtime = getFeishuRuntime(); const result = await maybeCreateDynamicAgent({ cfg, runtime, senderOpenId: ctx.senderOpenId, dynamicCfg, accountId: account.accountId, log: (msg) => log(msg), }); if (result.created) { effectiveCfg = result.updatedCfg; // Re-resolve route with updated config route = core.channel.routing.resolveAgentRoute({ cfg: result.updatedCfg, channel: "feishu", accountId: account.accountId, peer: { kind: "direct", id: ctx.senderOpenId }, }); log(`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`); } } } const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160); const inboundLabel = isGroup ? `Feishu[${account.accountId}] message in group ${ctx.chatId}` : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`; const systemEventText = permissionErrorForAgent ? inboundLabel : `${inboundLabel}: ${preview}`; core.system.enqueueSystemEvent(systemEventText, { sessionKey: route.sessionKey, contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`, }); // Resolve media from message const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default const mediaList = await resolveFeishuMediaList({ cfg, messageId: ctx.messageId, messageType: event.message.message_type, content: event.message.content, maxBytes: mediaMaxBytes, log, accountId: account.accountId, }); const mediaPayload = buildFeishuMediaPayload(mediaList); // Fetch quoted/replied message content if parentId exists let quotedContent: string | undefined; if (ctx.parentId) { try { const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId, accountId: account.accountId }); if (quotedMsg) { quotedContent = quotedMsg.content; log(`feishu[${account.accountId}]: fetched quoted message ${ctx.parentId}`); } } catch (err) { log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`); } } const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); // Build core message content with quoted content if available. // Keep this separate from speaker/system prefixes so RawBody preserves quote context. let rawBody = ctx.content; if (quotedContent) { const normalizedQuoted = quotedContent.replace(/\s+/g, " ").trim(); const normalizedBody = ctx.content.replace(/\s+/g, " ").trim(); const quoteAlreadyInBody = normalizedQuoted.length > 16 && normalizedBody.length > 0 && normalizedBody.includes(normalizedQuoted); if (quoteAlreadyInBody) { log(`feishu[${account.accountId}]: skip duplicate quote merge for message ${ctx.messageId}`); } else { rawBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; } } let messageBody = rawBody; // Include a readable speaker label so the model can attribute instructions. // (DMs already have per-sender sessions, but the prefix is still useful for clarity.) const speaker = ctx.senderName ?? ctx.senderOpenId; messageBody = `${speaker}: ${messageBody}`; // If there are mention targets, inform the agent that replies will auto-mention them 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.]`; } const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId; const replyToMessageId = replyToMessageIdOverride ?? ctx.rootId ?? ctx.messageId; // If there's a permission error, dispatch a separate notification first if (permissionErrorForAgent) { const grantUrl = permissionErrorForAgent.grantUrl ?? ""; const permissionNotifyBody = `[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}]`; const permissionBody = core.channel.reply.formatAgentEnvelope({ channel: "Feishu", from: envelopeFrom, timestamp: new Date(), envelope: envelopeOptions, body: permissionNotifyBody, }); const permissionCtx = core.channel.reply.finalizeInboundContext({ Body: permissionBody, RawBody: permissionNotifyBody, CommandBody: permissionNotifyBody, From: feishuFrom, To: feishuTo, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? ctx.chatId : undefined, SenderName: "system", SenderId: "system", Provider: "feishu" as const, Surface: "feishu" as const, MessageSid: `${ctx.messageId}:permission-error`, Timestamp: Date.now(), WasMentioned: false, CommandAuthorized: commandAuthorized, OriginatingChannel: "feishu" as const, OriginatingTo: feishuTo, }); const { dispatcher: permDispatcher, replyOptions: permReplyOptions, markDispatchIdle: markPermIdle } = createFeishuReplyDispatcher({ cfg, agentId: route.agentId, runtime: runtime as RuntimeEnv, chatId: ctx.chatId, replyToMessageId, accountId: account.accountId, messageCreateTimeMs, }); log(`feishu[${account.accountId}]: dispatching permission error notification to agent`); await runWithFeishuToolContext( { channel: "feishu", accountId: account.accountId, sessionKey: route.sessionKey, senderOpenId: ctx.senderOpenId, chatId: ctx.chatId, }, // Keep account context available while the agent executes plugin tools. () => core.channel.reply.dispatchReplyFromConfig({ ctx: permissionCtx, cfg, dispatcher: permDispatcher, replyOptions: permReplyOptions, }), ); markPermIdle(); } const body = core.channel.reply.formatAgentEnvelope({ channel: "Feishu", from: envelopeFrom, timestamp: new Date(), envelope: envelopeOptions, body: messageBody, }); let combinedBody = body; const historyKey = isGroup ? ctx.chatId : undefined; if (isGroup && historyKey && chatHistories) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: chatHistories, historyKey, limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => core.channel.reply.formatAgentEnvelope({ channel: "Feishu", // Preserve speaker identity in group history as well. from: `${ctx.chatId}:${entry.sender}`, timestamp: entry.timestamp, body: entry.body, envelope: envelopeOptions, }), }); } const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, RawBody: rawBody, CommandBody: rawBody, From: feishuFrom, To: feishuTo, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? ctx.chatId : undefined, SenderName: ctx.senderName ?? ctx.senderOpenId, SenderId: ctx.senderOpenId, Provider: "feishu" as const, Surface: "feishu" as const, MessageSid: ctx.messageId, Timestamp: Date.now(), WasMentioned: effectiveWasMentioned, CommandAuthorized: commandAuthorized, OriginatingChannel: "feishu" as const, OriginatingTo: feishuTo, GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined, ReplyToBody: quotedContent, ...mediaPayload, }); const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ cfg, agentId: route.agentId, runtime: runtime as RuntimeEnv, chatId: ctx.chatId, replyToMessageId, mentionTargets: ctx.mentionTargets, accountId: account.accountId, messageCreateTimeMs, }); log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`); const { queuedFinal, counts } = await runWithFeishuToolContext( { channel: "feishu", accountId: account.accountId, sessionKey: route.sessionKey, senderOpenId: ctx.senderOpenId, chatId: ctx.chatId, }, // Tool calls produced by this turn should resolve to the same inbound account. () => core.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions, }), ); markDispatchIdle(); if (isGroup && historyKey && chatHistories) { clearHistoryEntriesIfEnabled({ historyMap: chatHistories, historyKey, limit: historyLimit, }); } log(`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`); } catch (err) { error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`); } }