Spaces:
Sleeping
Sleeping
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/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: OpenClawConfig; | |
| to: string; | |
| mediaUrl?: string; | |
| mediaPath?: string; | |
| mediaBuffer?: Uint8Array; | |
| contentType?: string; | |
| filename?: string; | |
| caption?: string; | |
| replyToId?: string | null; | |
| accountId?: string; | |
| asVoice?: boolean; | |
| }) { | |
| const { | |
| cfg, | |
| to, | |
| mediaUrl, | |
| mediaPath, | |
| mediaBuffer, | |
| contentType, | |
| filename, | |
| caption, | |
| replyToId, | |
| accountId, | |
| asVoice, | |
| } = 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, | |
| asVoice, | |
| opts: { | |
| cfg, | |
| accountId, | |
| }, | |
| }); | |
| const trimmedCaption = caption?.trim(); | |
| if (trimmedCaption) { | |
| await sendMessageBlueBubbles(to, trimmedCaption, { | |
| cfg, | |
| accountId, | |
| replyToMessageGuid, | |
| }); | |
| } | |
| return attachmentResult; | |
| } | |