Merge cc50033593e4b29c6ce6e17f93841eb943af83ca into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
bfc2c4be4f
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user