import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk"; import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { downloadMessageResourceFeishu } from "./media.js"; import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; import { resolveFeishuGroupConfig, resolveFeishuReplyPolicy, resolveFeishuAllowlistMatch, isFeishuGroupAllowed, } from "./policy.js"; import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu } from "./send.js"; // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. type PermissionError = { code: number; message: string; grantUrl?: string; }; 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; } // Extract the grant URL from the error message (contains the direct link) const msg = feishuErr.msg ?? ""; const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/); const grantUrl = urlMatch?.[0]; 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 type SenderNameResult = { name?: string; permissionError?: PermissionError; }; async function resolveFeishuSenderName(params: { account: ResolvedFeishuAccount; senderOpenId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type 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?.display_name || res?.data?.user?.nickname || res?.data?.user?.en_name; 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 {}; } } 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"; 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 parseMessageContent(content: string, messageType: string): string { try { const parsed = JSON.parse(content); if (messageType === "text") { return parsed.text || ""; } if (messageType === "post") { // Extract text content from rich text post const { textContent } = parsePostContent(content); return textContent; } return content; } catch { return content; } } function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean { const mentions = event.message.mentions ?? []; if (mentions.length === 0) { return false; } if (!botOpenId) { return mentions.length > 0; } return mentions.some((m) => m.id.open_id === botOpenId); } 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": // 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[]; } { try { const parsed = JSON.parse(content); const title = parsed.title || ""; const contentBlocks = parsed.content || []; let textContent = title ? `${title}\n\n` : ""; const imageKeys: 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 textContent += `@${element.user_name || element.user_id || ""}`; } else if (element.tag === "img" && element.image_key) { // Embedded image imageKeys.push(element.image_key); } } textContent += "\n"; } } return { textContent: textContent.trim() || "[富文本消息]", imageKeys, }; } catch { return { textContent: "[富文本消息]", imageKeys: [] }; } } /** * 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": 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; // Only process media message types (including post for embedded images) const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"]; if (!mediaTypes.includes(messageType)) { return []; } const out: FeishuMediaInfo[] = []; const core = getFeishuRuntime(); // Handle post (rich text) messages with embedded images if (messageType === "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) { contentType = await core.media.detectMime({ buffer: result.buffer }); } 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, messageType); 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 = mediaKeys.imageKey || mediaKeys.fileKey; if (!fileKey) { return []; } const resourceType = messageType === "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 if not provided if (!contentType) { contentType = await core.media.detectMime({ buffer }); } // 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(messageType), }); log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`); } catch (err) { log?.(`feishu: failed to download ${messageType} 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, }; } export function parseFeishuMessageEvent( event: FeishuMessageEvent, botOpenId?: string, ): FeishuMessageContext { const rawContent = parseMessageContent(event.message.content, event.message.message_type); const mentionedBot = checkBotMentioned(event, botOpenId); const content = stripBotMention(rawContent, 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, }; // 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; }): Promise { const { cfg, event, botOpenId, runtime, chatHistories, accountId } = 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; let ctx = parseFeishuMessageEvent(event, botOpenId); 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, ); if (isGroup) { const groupPolicy = feishuCfg?.groupPolicy ?? "open"; const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); // 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}]: sender ${ctx.senderOpenId} not in group allowlist`); 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 } = resolveFeishuReplyPolicy({ isDirectMessage: false, globalConfig: feishuCfg, groupConfig, }); if (requireMention && !ctx.mentionedBot) { log( `feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`, ); if (chatHistories) { recordPendingHistoryEntryIfEnabled({ historyMap: chatHistories, historyKey: ctx.chatId, limit: historyLimit, entry: { sender: ctx.senderOpenId, body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`, timestamp: Date.now(), messageId: ctx.messageId, }, }); } return; } } else { const dmPolicy = feishuCfg?.dmPolicy ?? "pairing"; const allowFrom = feishuCfg?.allowFrom ?? []; if (dmPolicy === "allowlist") { const match = resolveFeishuAllowlistMatch({ allowFrom, senderId: ctx.senderOpenId, }); if (!match.allowed) { log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`); return; } } } try { const core = getFeishuRuntime(); // 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 route = core.channel.routing.resolveAgentRoute({ cfg, channel: "feishu", accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: isGroup ? ctx.chatId : ctx.senderOpenId, }, }); 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}`; core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { 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: ${quotedContent?.slice(0, 100)}`, ); } } catch (err) { log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`); } } const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); // Build message body with quoted content if available let messageBody = ctx.content; if (quotedContent) { messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; } // 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; // 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: true, 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: ctx.messageId, accountId: account.accountId, }); log(`feishu[${account.accountId}]: dispatching permission error notification to agent`); await 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: ctx.content, CommandBody: ctx.content, 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: ctx.mentionedBot, CommandAuthorized: true, OriginatingChannel: "feishu" as const, OriginatingTo: feishuTo, ...mediaPayload, }); const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ cfg, agentId: route.agentId, runtime: runtime as RuntimeEnv, chatId: ctx.chatId, replyToMessageId: ctx.messageId, mentionTargets: ctx.mentionTargets, accountId: account.accountId, }); log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`); const { queuedFinal, counts } = await 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)}`); } }