From 7bc399845183f0c18b6478791e483304fc141219 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 20 Jan 2026 01:57:02 -0800 Subject: [PATCH] feat: add media size validation to BlueBubbles media handling, ensuring compliance with channel limits and improving error handling for oversized media --- extensions/bluebubbles/src/media-send.ts | 29 ++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index c86ab21c74..66a67e5d25 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -1,13 +1,22 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk"; import { sendBlueBubblesAttachment } from "./attachments.js"; import { sendMessageBlueBubbles } from "./send.js"; import { getBlueBubblesRuntime } from "./runtime.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; @@ -63,12 +72,20 @@ export async function sendBlueBubblesMedia(params: { 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; @@ -87,14 +104,22 @@ export async function sendBlueBubblesMedia(params: { 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 }); + 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({