import fs from "fs"; import path from "path"; import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { parseFeishuCommentTarget } from "./comment-target.js"; import { replyComment } from "./drive.js"; import { sendMediaFeishu } from "./media.js"; import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js"; import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js"; function normalizePossibleLocalImagePath(text: string | undefined): string | null { const raw = text?.trim(); if (!raw) { return null; } // Only auto-convert when the message is a pure path-like payload. // Avoid converting regular sentences that merely contain a path. const hasWhitespace = /\s/.test(raw); if (hasWhitespace) { return null; } // Ignore links/data URLs; those should stay in normal mediaUrl/text paths. if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) { return null; } const ext = normalizeLowercaseStringOrEmpty(path.extname(raw)); const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes( ext, ); if (!isImageExt) { return null; } if (!path.isAbsolute(raw)) { return null; } if (!fs.existsSync(raw)) { return null; } // Fix race condition: wrap statSync in try-catch to handle file deletion // between existsSync and statSync try { if (!fs.statSync(raw).isFile()) { return null; } } catch { // File may have been deleted or became inaccessible between checks return null; } return raw; } function shouldUseCard(text: string): boolean { return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text); } function resolveReplyToMessageId(params: { replyToId?: string | null; threadId?: string | number | null; }): string | undefined { const replyToId = params.replyToId?.trim(); if (replyToId) { return replyToId; } if (params.threadId == null) { return undefined; } const trimmed = String(params.threadId).trim(); return trimmed || undefined; } async function sendCommentThreadReply(params: { cfg: Parameters[0]["cfg"]; to: string; text: string; accountId?: string; }) { const target = parseFeishuCommentTarget(params.to); if (!target) { return null; } const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); const client = createFeishuClient(account); const result = await replyComment(client, { file_token: target.fileToken, file_type: target.fileType, comment_id: target.commentId, content: params.text, }); return { messageId: typeof result.reply_id === "string" ? result.reply_id : "", chatId: target.commentId, result, }; } async function sendOutboundText(params: { cfg: Parameters[0]["cfg"]; to: string; text: string; replyToMessageId?: string; accountId?: string; }) { const { cfg, to, text, accountId, replyToMessageId } = params; const commentResult = await sendCommentThreadReply({ cfg, to, text, accountId, }); if (commentResult) { return commentResult; } const account = resolveFeishuAccount({ cfg, accountId }); const renderMode = account.config?.renderMode ?? "auto"; if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) { return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId }); } return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId }); } export const feishuOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkTextForOutbound, chunkerMode: "markdown", textChunkLimit: 4000, ...createAttachedChannelResultAdapter({ channel: "feishu", sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots, identity, }) => { const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); // Scheme A compatibility shim: // when upstream accidentally returns a local image path as plain text, // auto-upload and send as Feishu image message instead of leaking path text. const localImagePath = normalizePossibleLocalImagePath(text); if (localImagePath) { try { return await sendMediaFeishu({ cfg, to, mediaUrl: localImagePath, accountId: accountId ?? undefined, replyToMessageId, mediaLocalRoots, }); } catch (err) { console.error(`[feishu] local image path auto-send failed:`, err); // fall through to plain text as last resort } } if (parseFeishuCommentTarget(to)) { return await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); } const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); const renderMode = account.config?.renderMode ?? "auto"; const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); if (useCard) { const header = identity ? { title: identity.emoji ? `${identity.emoji} ${identity.name ?? ""}`.trim() : (identity.name ?? ""), template: "blue" as const, } : undefined; return await sendStructuredCardFeishu({ cfg, to, text, replyToMessageId, replyInThread: threadId != null && !replyToId, accountId: accountId ?? undefined, header: header?.title ? header : undefined, }); } return await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots, replyToId, threadId, }) => { const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); const commentTarget = parseFeishuCommentTarget(to); if (commentTarget) { const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n"); return await sendOutboundText({ cfg, to, text: commentText || mediaUrl || text || "", accountId: accountId ?? undefined, replyToMessageId, }); } // Send text first if provided if (text?.trim()) { await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); } // Upload and send media if URL or local path provided if (mediaUrl) { try { return await sendMediaFeishu({ cfg, to, mediaUrl, accountId: accountId ?? undefined, mediaLocalRoots, replyToMessageId, }); } catch (err) { // Log the error for debugging console.error(`[feishu] sendMediaFeishu failed:`, err); // Fallback to URL link if upload fails return await sendOutboundText({ cfg, to, text: `📎 ${mediaUrl}`, accountId: accountId ?? undefined, replyToMessageId, }); } } // No media URL, just return text result return await sendOutboundText({ cfg, to, text: text ?? "", accountId: accountId ?? undefined, replyToMessageId, }); }, }), };