import type { ClawdbotConfig } from "openclaw/plugin-sdk"; import { createFeishuClient } from "./client.js"; import { resolveFeishuAccount } from "./accounts.js"; import { getFeishuRuntime } from "./runtime.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; import { parseFeishuMediaDurationMs } from "./media-duration.js"; import fs from "fs"; import path from "path"; import os from "os"; import { Readable } from "stream"; export type DownloadImageResult = { buffer: Buffer; contentType?: string; }; export type DownloadMessageResourceResult = { buffer: Buffer; contentType?: string; fileName?: string; }; /** * Download an image from Feishu using image_key. * Used for downloading images sent in messages. */ export async function downloadImageFeishu(params: { cfg: ClawdbotConfig; imageKey: string; accountId?: string; }): Promise { const { cfg, imageKey, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const client = createFeishuClient(account); const response = await client.im.image.get({ path: { image_key: imageKey }, }); const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error(`Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`); } // Handle various response formats from Feishu SDK let buffer: Buffer; if (Buffer.isBuffer(response)) { buffer = response; } else if (response instanceof ArrayBuffer) { buffer = Buffer.from(response); } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) { buffer = responseAny.data; } else if (responseAny.data instanceof ArrayBuffer) { buffer = Buffer.from(responseAny.data); } else if (typeof responseAny.getReadableStream === "function") { // SDK provides getReadableStream method const stream = responseAny.getReadableStream(); const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } buffer = Buffer.concat(chunks); } else if (typeof responseAny.writeFile === "function") { // SDK provides writeFile method - use a temp file const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`); await responseAny.writeFile(tmpPath); buffer = await fs.promises.readFile(tmpPath); await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup } else if (typeof responseAny[Symbol.asyncIterator] === "function") { // Response is an async iterable const chunks: Buffer[] = []; for await (const chunk of responseAny) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } buffer = Buffer.concat(chunks); } else if (typeof responseAny.read === "function") { // Response is a Readable stream const chunks: Buffer[] = []; for await (const chunk of responseAny as Readable) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } buffer = Buffer.concat(chunks); } else { // Debug: log what we actually received const keys = Object.keys(responseAny); const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", "); throw new Error( `Feishu image download failed: unexpected response format. Keys: [${types}]`, ); } return { buffer }; } /** * Download a message resource (file/image/audio/video) from Feishu. * Used for downloading files, audio, and video from messages. */ export async function downloadMessageResourceFeishu(params: { cfg: ClawdbotConfig; messageId: string; fileKey: string; type: "image" | "file"; accountId?: string; }): Promise { const { cfg, messageId, fileKey, type, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const client = createFeishuClient(account); const response = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type }, }); const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error( `Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`, ); } // Handle various response formats from Feishu SDK let buffer: Buffer; if (Buffer.isBuffer(response)) { buffer = response; } else if (response instanceof ArrayBuffer) { buffer = Buffer.from(response); } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) { buffer = responseAny.data; } else if (responseAny.data instanceof ArrayBuffer) { buffer = Buffer.from(responseAny.data); } else if (typeof responseAny.getReadableStream === "function") { // SDK provides getReadableStream method const stream = responseAny.getReadableStream(); const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } buffer = Buffer.concat(chunks); } else if (typeof responseAny.writeFile === "function") { // SDK provides writeFile method - use a temp file const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`); await responseAny.writeFile(tmpPath); buffer = await fs.promises.readFile(tmpPath); await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup } else if (typeof responseAny[Symbol.asyncIterator] === "function") { // Response is an async iterable const chunks: Buffer[] = []; for await (const chunk of responseAny) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } buffer = Buffer.concat(chunks); } else if (typeof responseAny.read === "function") { // Response is a Readable stream const chunks: Buffer[] = []; for await (const chunk of responseAny as Readable) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } buffer = Buffer.concat(chunks); } else { // Debug: log what we actually received const keys = Object.keys(responseAny); const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", "); throw new Error( `Feishu message resource download failed: unexpected response format. Keys: [${types}]`, ); } return { buffer }; } export type UploadImageResult = { imageKey: string; }; export type UploadFileResult = { fileKey: string; }; export type SendMediaResult = { messageId: string; chatId: string; }; function assertNonEmptyMediaBuffer(buffer: Buffer, name: string): void { if (buffer.length === 0) { throw new Error(`Feishu media upload failed: "${name}" is empty (0 bytes)`); } } /** * Upload an image to Feishu and get an image_key for sending. * Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO */ export async function uploadImageFeishu(params: { cfg: ClawdbotConfig; image: Buffer | string; // Buffer or file path imageType?: "message" | "avatar"; accountId?: string; }): Promise { const { cfg, image, imageType = "message", accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const client = createFeishuClient(account); if (Buffer.isBuffer(image)) { assertNonEmptyMediaBuffer(image, "image"); } const imagePayload = typeof image === "string" ? fs.createReadStream(image) : image; const response = await client.im.image.create({ data: { image_type: imageType, image: imagePayload as any, }, }); // SDK v1.30+ returns data directly without code wrapper on success // On error, it throws or returns { code, msg } const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); } const imageKey = responseAny.image_key ?? responseAny.data?.image_key; if (!imageKey) { throw new Error("Feishu image upload failed: no image_key returned"); } return { imageKey }; } /** * Upload a file to Feishu and get a file_key for sending. * Max file size: 30MB */ export async function uploadFileFeishu(params: { cfg: ClawdbotConfig; file: Buffer | string; // Buffer or file path fileName: string; fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; duration?: number; // Required for audio/video files, in milliseconds accountId?: string; }): Promise { const { cfg, file, fileName, fileType, duration, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const client = createFeishuClient(account); if (Buffer.isBuffer(file)) { assertNonEmptyMediaBuffer(file, fileName); } const filePayload = typeof file === "string" ? fs.createReadStream(file) : file; const response = await client.im.file.create({ data: { file_type: fileType, file_name: fileName, file: filePayload as any, ...(duration !== undefined && { duration }), }, }); // SDK v1.30+ returns data directly without code wrapper on success const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); } const fileKey = responseAny.file_key ?? responseAny.data?.file_key; if (!fileKey) { throw new Error("Feishu file upload failed: no file_key returned"); } return { fileKey }; } /** * Send an image message using an image_key */ export async function sendImageFeishu(params: { cfg: ClawdbotConfig; to: string; imageKey: string; replyToMessageId?: string; accountId?: string; }): Promise { const { cfg, to, imageKey, replyToMessageId, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const client = createFeishuClient(account); const receiveId = normalizeFeishuTarget(to); if (!receiveId) { throw new Error(`Invalid Feishu target: ${to}`); } const receiveIdType = resolveReceiveIdType(receiveId); const content = JSON.stringify({ image_key: imageKey }); if (replyToMessageId) { const response = await client.im.message.reply({ path: { message_id: replyToMessageId }, data: { content, msg_type: "image", }, }); if (response.code !== 0) { throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`); } return { messageId: response.data?.message_id ?? "unknown", chatId: receiveId, }; } const response = await client.im.message.create({ params: { receive_id_type: receiveIdType }, data: { receive_id: receiveId, content, msg_type: "image", }, }); if (response.code !== 0) { throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`); } return { messageId: response.data?.message_id ?? "unknown", chatId: receiveId, }; } /** * Send a file message using a file_key */ export async function sendFileFeishu(params: { cfg: ClawdbotConfig; to: string; fileKey: string; /** Use "audio" for audio, "media" for video, "file" for documents */ msgType?: "file" | "audio" | "media"; /** Optional cover image key for video (msg_type "media") messages */ imageKey?: string; replyToMessageId?: string; accountId?: string; }): Promise { const { cfg, to, fileKey, imageKey, replyToMessageId, accountId } = params; const msgType = params.msgType ?? "file"; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const client = createFeishuClient(account); const receiveId = normalizeFeishuTarget(to); if (!receiveId) { throw new Error(`Invalid Feishu target: ${to}`); } const receiveIdType = resolveReceiveIdType(receiveId); const content = JSON.stringify({ file_key: fileKey, ...(imageKey && { image_key: imageKey }), }); if (replyToMessageId) { const response = await client.im.message.reply({ path: { message_id: replyToMessageId }, data: { content, msg_type: msgType, }, }); if (response.code !== 0) { throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`); } return { messageId: response.data?.message_id ?? "unknown", chatId: receiveId, }; } const response = await client.im.message.create({ params: { receive_id_type: receiveIdType }, data: { receive_id: receiveId, content, msg_type: msgType, }, }); if (response.code !== 0) { throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`); } return { messageId: response.data?.message_id ?? "unknown", chatId: receiveId, }; } /** * Helper to detect file type from extension */ export function detectFileType( fileName: string, ): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" { const ext = path.extname(fileName).toLowerCase(); switch (ext) { // Audio formats → Feishu "opus" category case ".opus": case ".ogg": case ".mp3": case ".m4a": case ".aac": case ".wav": case ".flac": case ".wma": case ".amr": return "opus"; // Video formats → Feishu "mp4" category case ".mp4": case ".mov": case ".avi": case ".mkv": case ".webm": case ".flv": case ".wmv": case ".m4v": case ".3gp": return "mp4"; case ".pdf": return "pdf"; case ".doc": case ".docx": return "doc"; case ".xls": case ".xlsx": return "xls"; case ".ppt": case ".pptx": return "ppt"; default: return "stream"; } } /** * Upload and send media (image or file) from URL, local path, or buffer */ export async function sendMediaFeishu(params: { cfg: ClawdbotConfig; to: string; mediaUrl?: string; mediaBuffer?: Buffer; fileName?: string; replyToMessageId?: string; mediaLocalRoots?: readonly string[]; accountId?: string; }): Promise { const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, mediaLocalRoots, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); } const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024; let buffer: Buffer; let name: string; let contentType: string | undefined; if (mediaBuffer) { buffer = mediaBuffer; name = fileName ?? "file"; } else if (mediaUrl) { const configLocalRoots = (account.config?.mediaLocalRoots ?? []) .map((root) => root.trim()) .filter((root) => root.length > 0); // Merge context-provided roots (includes agent workspace) with config roots. const mergedLocalRoots = [...(mediaLocalRoots ?? []), ...configLocalRoots]; const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, { maxBytes: mediaMaxBytes, optimizeImages: false, ...(mergedLocalRoots.length > 0 ? { localRoots: mergedLocalRoots } : {}), }); buffer = loaded.buffer; const loadedFileName = loaded.fileName ?? "file"; let decoded: string; try { decoded = decodeURIComponent(loadedFileName); } catch { decoded = loadedFileName; } name = fileName ?? decoded; contentType = loaded.contentType; } else { throw new Error("Either mediaUrl or mediaBuffer must be provided"); } assertNonEmptyMediaBuffer(buffer, name); // 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 (isImage) { const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId }); return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId }); } else { // Determine file type from extension; fall back to MIME type for files without // a recognized extension (e.g. URLs with no filename, or buffers without fileName). let fileType = detectFileType(name); if (fileType === "stream" && contentType) { if (contentType.startsWith("video/")) fileType = "mp4"; else if (contentType.startsWith("audio/")) fileType = "opus"; } const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file"; const duration = fileType === "opus" || fileType === "mp4" ? await parseFeishuMediaDurationMs(buffer, fileType, { fileName: name, contentType }) : undefined; const { fileKey } = await uploadFileFeishu({ cfg, file: buffer, fileName: name, fileType, duration, accountId, }); // Feishu requires msg_type "audio" for audio, "media" for video, "file" for documents return sendFileFeishu({ cfg, to, fileKey, msgType, replyToMessageId, accountId, }); } }