UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

528 lines (468 loc) 16.8 kB
import type { ClawdbotConfig } from "openclaw/plugin-sdk"; import fs from "fs"; import os from "os"; import path from "path"; import { Readable } from "stream"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; 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<DownloadImageResult> { 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 }, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type 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<DownloadMessageResourceResult> { 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 }, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response 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; }; /** * 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<UploadImageResult> { 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); // SDK expects a Readable stream, not a Buffer // Use type assertion since SDK actually accepts any Readable at runtime const imageStream = typeof image === "string" ? fs.createReadStream(image) : Readable.from(image); const response = await client.im.image.create({ data: { image_type: imageType, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type image: imageStream as any, }, }); // SDK v1.30+ returns data directly without code wrapper on success // On error, it throws or returns { code, msg } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type 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<UploadFileResult> { 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); // SDK expects a Readable stream, not a Buffer // Use type assertion since SDK actually accepts any Readable at runtime const fileStream = typeof file === "string" ? fs.createReadStream(file) : Readable.from(file); const response = await client.im.file.create({ data: { file_type: fileType, file_name: fileName, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type file: fileStream as any, ...(duration !== undefined && { duration }), }, }); // SDK v1.30+ returns data directly without code wrapper on success // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type 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<SendMediaResult> { 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; replyToMessageId?: string; accountId?: string; }): Promise<SendMediaResult> { const { cfg, to, fileKey, 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({ file_key: fileKey }); if (replyToMessageId) { const response = await client.im.message.reply({ path: { message_id: replyToMessageId }, data: { content, msg_type: "file", }, }); 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: "file", }, }); 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) { case ".opus": case ".ogg": return "opus"; case ".mp4": case ".mov": case ".avi": 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"; } } /** * Check if a string is a local file path (not a URL) */ function isLocalPath(urlOrPath: string): boolean { // Starts with / or ~ or drive letter (Windows) if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) { return true; } // Try to parse as URL - if it fails or has no protocol, it's likely a local path try { const url = new URL(urlOrPath); return url.protocol === "file:"; } catch { return true; // Not a valid URL, treat as local path } } /** * 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; accountId?: string; }): Promise<SendMediaResult> { const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params; let buffer: Buffer; let name: string; if (mediaBuffer) { buffer = mediaBuffer; name = fileName ?? "file"; } else if (mediaUrl) { if (isLocalPath(mediaUrl)) { // Local file path - read directly const filePath = mediaUrl.startsWith("~") ? mediaUrl.replace("~", process.env.HOME ?? "") : mediaUrl.replace("file://", ""); if (!fs.existsSync(filePath)) { throw new Error(`Local file not found: ${filePath}`); } buffer = fs.readFileSync(filePath); name = fileName ?? path.basename(filePath); } else { // Remote URL - fetch const response = await fetch(mediaUrl); if (!response.ok) { throw new Error(`Failed to fetch media from URL: ${response.status}`); } buffer = Buffer.from(await response.arrayBuffer()); name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file"); } } else { throw new Error("Either mediaUrl or mediaBuffer must be provided"); } // 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 { const fileType = detectFileType(name); const { fileKey } = await uploadFileFeishu({ cfg, file: buffer, fileName: name, fileType, accountId, }); return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId }); } }