diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index a55ed6774e6..19b7e4627b1 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -1,8 +1,8 @@ import fs from "fs"; import path from "path"; import { Readable } from "stream"; -import { mediaKindFromMime } from "openclaw/plugin-sdk/media-runtime"; -import { withTempDownloadPath, type ClawdbotConfig } from "../runtime-api.js"; +import { spawnSync } from "child_process"; +import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; @@ -62,75 +62,6 @@ function extractFeishuUploadKey( return key; } -function readHeaderValue( - headers: Record | undefined, - name: string, -): string | undefined { - if (!headers) { - return undefined; - } - const target = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() !== target) { - continue; - } - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - if (Array.isArray(value)) { - const first = value.find((entry) => typeof entry === "string" && entry.trim()); - if (typeof first === "string") { - return first.trim(); - } - } - } - return undefined; -} - -function decodeDispositionFileName(value: string): string | undefined { - const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); - if (utf8Match?.[1]) { - try { - return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1")); - } catch { - return utf8Match[1].trim().replace(/^"(.*)"$/, "$1"); - } - } - - const plainMatch = value.match(/filename="?([^";]+)"?/i); - return plainMatch?.[1]?.trim(); -} - -function extractFeishuDownloadMetadata(response: unknown): { - contentType?: string; - fileName?: string; -} { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type - const responseAny = response as any; - const headers = - (responseAny.headers as Record | undefined) ?? - (responseAny.header as Record | undefined); - - const contentType = - readHeaderValue(headers, "content-type") ?? - (typeof responseAny.contentType === "string" ? responseAny.contentType : undefined) ?? - (typeof responseAny.mime_type === "string" ? responseAny.mime_type : undefined) ?? - (typeof responseAny.data?.contentType === "string" - ? responseAny.data.contentType - : undefined) ?? - (typeof responseAny.data?.mime_type === "string" ? responseAny.data.mime_type : undefined); - - const disposition = readHeaderValue(headers, "content-disposition"); - const fileName = - (disposition ? decodeDispositionFileName(disposition) : undefined) ?? - (typeof responseAny.file_name === "string" ? responseAny.file_name : undefined) ?? - (typeof responseAny.fileName === "string" ? responseAny.fileName : undefined) ?? - (typeof responseAny.data?.file_name === "string" ? responseAny.data.file_name : undefined) ?? - (typeof responseAny.data?.fileName === "string" ? responseAny.data.fileName : undefined); - - return { contentType, fileName }; -} - async function readFeishuResponseBuffer(params: { response: unknown; tmpDirPrefix: string; @@ -214,8 +145,7 @@ export async function downloadImageFeishu(params: { tmpDirPrefix: "openclaw-feishu-img-", errorPrefix: "Feishu image download failed", }); - const meta = extractFeishuDownloadMetadata(response); - return { buffer, contentType: meta.contentType }; + return { buffer }; } /** @@ -246,7 +176,19 @@ export async function downloadMessageResourceFeishu(params: { tmpDirPrefix: "openclaw-feishu-resource-", errorPrefix: "Feishu message resource download failed", }); - return { buffer, ...extractFeishuDownloadMetadata(response) }; + + // Extract metadata from response headers + const contentType = response.headers?.["content-type"] as string | undefined; + const contentDisposition = response.headers?.["content-disposition"] as string | undefined; + let extractedFileName: string | undefined; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^"]+)"?/); + if (match) { + extractedFileName = match[1]; + } + } + + return { buffer, contentType, fileName: extractedFileName }; } export type UploadImageResult = { @@ -472,53 +414,6 @@ export function detectFileType( } } -function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): { - fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; - msgType: "image" | "file" | "audio" | "media"; -} { - const { fileName, contentType } = params; - const ext = path.extname(fileName).toLowerCase(); - const mimeKind = mediaKindFromMime(contentType); - - const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes( - ext, - ); - if (isImageExt || mimeKind === "image") { - return { msgType: "image" }; - } - - if ( - ext === ".opus" || - ext === ".ogg" || - contentType === "audio/ogg" || - contentType === "audio/opus" - ) { - return { fileType: "opus", msgType: "audio" }; - } - - if ( - [".mp4", ".mov", ".avi"].includes(ext) || - contentType === "video/mp4" || - contentType === "video/quicktime" || - contentType === "video/x-msvideo" - ) { - return { fileType: "mp4", msgType: "media" }; - } - - const fileType = detectFileType(fileName); - return { - fileType, - msgType: - fileType === "stream" - ? "file" - : fileType === "opus" - ? "audio" - : fileType === "mp4" - ? "media" - : "file", - }; -} - /** * Upload and send media (image or file) from URL, local path, or buffer. * When mediaUrl is a local path, mediaLocalRoots (from core outbound context) @@ -555,42 +450,92 @@ export async function sendMediaFeishu(params: { let buffer: Buffer; let name: string; - let contentType: string | undefined; + + let loaded: { buffer: Buffer; fileName?: string; kind?: string; contentType?: string } | undefined; if (mediaBuffer) { buffer = mediaBuffer; name = fileName ?? "file"; } else if (mediaUrl) { - const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, { + loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, { maxBytes: mediaMaxBytes, optimizeImages: false, localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, }); buffer = loaded.buffer; name = fileName ?? loaded.fileName ?? "file"; - contentType = loaded.contentType; } else { throw new Error("Either mediaUrl or mediaBuffer must be provided"); } - const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType }); + // Determine if it's an image based on extension or loaded kind/contentType + const ext = path.extname(name).toLowerCase(); + const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext) || + loaded?.kind === "image" || + loaded?.contentType?.startsWith("image/"); - if (routing.msgType === "image") { + if (isImage) { const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId }); return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId }); } else { + // Use loaded kind/contentType to determine file type for remote media + let fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; + if (loaded?.kind === "video" || loaded?.contentType?.startsWith("video/")) { + fileType = "mp4"; + } else if (loaded?.kind === "audio" || loaded?.contentType?.startsWith("audio/")) { + // Only opus is supported for audio, others fall back to stream + fileType = ext === ".opus" || ext === ".ogg" ? "opus" : "stream"; + } else { + fileType = detectFileType(name); + } + + // Get duration for audio/video files (required by Feishu API) + let duration: number | undefined; + if ((fileType === "opus" || fileType === "mp4") && mediaUrl && !mediaUrl.startsWith("http")) { + try { + // Normalize file path (handle file:// URLs) + const normalizedPath = mediaUrl.startsWith("file://") ? mediaUrl.slice(7) : mediaUrl; + // Check if file exists and is a regular file (not a URL) + if (fs.existsSync(normalizedPath) && fs.statSync(normalizedPath).isFile()) { + // Try to run ffprobe directly - works on both POSIX and Windows + const result = spawnSync( + "ffprobe", + [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + normalizedPath, + ], + { encoding: "utf-8", timeout: 10000 } + ); + if (result.status === 0 && result.stdout) { + const durationSec = parseFloat(result.stdout.trim()); + if (!isNaN(durationSec)) { + duration = Math.round(durationSec * 1000); // Convert to milliseconds + } + } + } + } catch (e) { + // Silently ignore ffprobe errors - duration is optional + // This includes cases where ffprobe is not installed (ENOENT) + } + } + const { fileKey } = await uploadFileFeishu({ cfg, file: buffer, fileName: name, - fileType: routing.fileType ?? "stream", + fileType, + duration, accountId, }); + // Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file" + const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file"; return sendFileFeishu({ cfg, to, fileKey, - msgType: routing.msgType, + msgType, replyToMessageId, replyInThread, accountId,