- Sync latest changes from clawdbot-feishu including multi-account support - Add eslint-disable comments for SDK-related any types - Remove unused imports - Fix no-floating-promises in monitor.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
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 });
|
|
}
|
|
}
|