Merge cc50033593e4b29c6ce6e17f93841eb943af83ca into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Tang QI 2026-03-20 23:15:55 -04:00 committed by GitHub
commit bfc2c4be4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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 { 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<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,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,