Files
clawdbot/extensions/bluebubbles/src/media-send.ts

166 lines
4.8 KiB
TypeScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles } from "./send.js";
const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024;
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
if (typeof maxBytes !== "number" || maxBytes <= 0) return;
if (sizeBytes <= maxBytes) return;
const maxLabel = (maxBytes / MB).toFixed(0);
const sizeLabel = (sizeBytes / MB).toFixed(2);
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
}
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source;
try {
return fileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined;
if (source.startsWith("file://")) {
try {
return path.basename(fileURLToPath(source)) || undefined;
} catch {
return undefined;
}
}
if (HTTP_URL_RE.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
const base = path.basename(source);
return base || undefined;
}
export async function sendBlueBubblesMedia(params: {
cfg: ClawdbotConfig;
to: string;
mediaUrl?: string;
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
replyToId?: string | null;
accountId?: string;
}) {
const {
cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption,
replyToId,
accountId,
} = params;
const core = getBlueBubblesRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
let resolvedFilename = filename ?? undefined;
if (mediaBuffer) {
assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
buffer = mediaBuffer;
if (!resolvedContentType) {
const hint = mediaPath ?? mediaUrl;
const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
}
} else {
const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({
url: source,
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
});
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = resolveLocalMediaPath(source);
const fs = await import("node:fs/promises");
if (typeof maxBytes === "number" && maxBytes > 0) {
const stats = await fs.stat(localPath);
assertMediaWithinLimit(stats.size, maxBytes);
}
const data = await fs.readFile(localPath);
assertMediaWithinLimit(data.byteLength, maxBytes);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
filePath: localPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(localPath);
}
}
}
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = replyToId?.trim()
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
: undefined;
const attachmentResult = await sendBlueBubblesAttachment({
to,
buffer,
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
replyToMessageGuid,
opts: {
cfg,
accountId,
},
});
const trimmedCaption = caption?.trim();
if (trimmedCaption) {
await sendMessageBlueBubbles(to, trimmedCaption, {
cfg,
accountId,
replyToMessageGuid,
});
}
return attachmentResult;
}