From 0b6278a89de0b053385775c7a4a62715a11e5c6a Mon Sep 17 00:00:00 2001 From: Tang QI Date: Thu, 19 Mar 2026 19:42:28 +0800 Subject: [PATCH] fix(feishu): add duration parameter for audio/video files --- extensions/feishu/src/media.ts | 162 +++++++-------------------------- 1 file changed, 35 insertions(+), 127 deletions(-) diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index a55ed6774e6..f83d7e9442f 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 { execSync } 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,7 @@ export async function downloadMessageResourceFeishu(params: { tmpDirPrefix: "openclaw-feishu-resource-", errorPrefix: "Feishu message resource download failed", }); - return { buffer, ...extractFeishuDownloadMetadata(response) }; + return { buffer }; } export type UploadImageResult = { @@ -472,53 +402,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,7 +438,6 @@ export async function sendMediaFeishu(params: { let buffer: Buffer; let name: string; - let contentType: string | undefined; if (mediaBuffer) { buffer = mediaBuffer; @@ -568,29 +450,55 @@ export async function sendMediaFeishu(params: { }); 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 + const ext = path.extname(name).toLowerCase(); + const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext); - if (routing.msgType === "image") { + if (isImage) { const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId }); return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId }); } else { + const fileType = detectFileType(name); + + // Get duration for audio/video files (required by Feishu API) + let duration: number | undefined; + if (fileType === "opus" || fileType === "mp4") { + try { + // Try to get duration from the original file path if available + const filePath = mediaUrl && !mediaUrl.startsWith("http") ? mediaUrl : undefined; + if (filePath && fs.existsSync(filePath)) { + const durationSec = parseFloat( + execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, { + encoding: "utf-8", + timeout: 10000, + }).trim() + ); + duration = Math.round(durationSec * 1000); // Convert to milliseconds + } + } catch (e) { + // Silently ignore ffprobe errors + } + } + 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,