fix(feishu): add duration parameter for audio/video files

This commit is contained in:
Tang QI 2026-03-19 19:42:28 +08:00
parent 009a10bce2
commit 0b6278a89d

View File

@ -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<string, unknown> | 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<string, unknown> | undefined) ??
(responseAny.header as Record<string, unknown> | 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,