From 784468d6c3669deda97735ce70759c2a9db50be4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 04:33:36 +0000 Subject: [PATCH] fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell) --- CHANGELOG.md | 1 + docs/channels/bluebubbles.md | 3 +- extensions/bluebubbles/src/actions.test.ts | 36 ++++++ extensions/bluebubbles/src/actions.ts | 12 +- .../bluebubbles/src/attachments.test.ts | 108 +++++++++++++++- extensions/bluebubbles/src/attachments.ts | 120 +++++++----------- 6 files changed, 203 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0cc4b88c..bcbff27f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot - Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. +- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477) Thanks @Nicell. - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - Docs: add /model allowlist troubleshooting note. (#1405) - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index 2336f3609..cf5faee1d 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -147,7 +147,8 @@ Available actions: - **addParticipant**: Add someone to a group (`chatGuid`, `address`) - **removeParticipant**: Remove someone from a group (`chatGuid`, `address`) - **leaveGroup**: Leave a group chat (`chatGuid`) -- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`) +- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`) + - Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos. ### Message IDs (short vs full) Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens. diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 157776b1c..12ae97d05 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -521,6 +521,42 @@ describe("bluebubblesMessageActions", () => { }); }); + it("passes asVoice through sendAttachment", async () => { + const { sendBlueBubblesAttachment } = await import("./attachments.js"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + const base64Buffer = Buffer.from("voice").toString("base64"); + + await bluebubblesMessageActions.handleAction({ + action: "sendAttachment", + params: { + to: "+15551234567", + filename: "voice.mp3", + buffer: base64Buffer, + contentType: "audio/mpeg", + asVoice: true, + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + filename: "voice.mp3", + contentType: "audio/mpeg", + asVoice: true, + }), + ); + }); + it("throws when buffer is missing for setGroupIcon", async () => { const cfg: ClawdbotConfig = { channels: { diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 23365e4ac..1effde626 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -3,7 +3,6 @@ import { BLUEBUBBLES_ACTIONS, createActionGate, jsonResult, - readBooleanParam, readNumberParam, readReactionParams, readStringParam, @@ -51,6 +50,17 @@ function readMessageText(params: Record): string | undefined { return readStringParam(params, "text") ?? readStringParam(params, "message"); } +function readBooleanParam(params: Record, key: string): boolean | undefined { + const raw = params[key]; + if (typeof raw === "boolean") return raw; + if (typeof raw === "string") { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + } + return undefined; +} + /** Supported action names for BlueBubbles */ const SUPPORTED_ACTIONS = new Set(BLUEBUBBLES_ACTION_NAMES); diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index c1b45af52..9611f09fd 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { downloadBlueBubblesAttachment } from "./attachments.js"; +import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import type { BlueBubblesAttachment } from "./types.js"; vi.mock("./accounts.js", () => ({ @@ -238,3 +238,109 @@ describe("downloadBlueBubblesAttachment", () => { expect(result.buffer).toEqual(new Uint8Array([1])); }); }); + +describe("sendBlueBubblesAttachment", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function decodeBody(body: Uint8Array) { + return Buffer.from(body).toString("utf8"); + } + + it("marks voice memos when asVoice is true and mp3 is provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "voice.mp3", + contentType: "audio/mpeg", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('name="isAudioMessage"'); + expect(bodyText).toContain("true"); + expect(bodyText).toContain('filename="voice.mp3"'); + }); + + it("normalizes mp3 filenames for voice memos", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "voice", + contentType: "audio/mpeg", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('filename="voice.mp3"'); + expect(bodyText).toContain('name="voice.mp3"'); + }); + + it("throws when asVoice is true but media is not audio", async () => { + await expect( + sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "image.png", + contentType: "image/png", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }), + ).rejects.toThrow("voice messages require audio"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws when asVoice is true but audio is not mp3 or caf", async () => { + await expect( + sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "voice.wav", + contentType: "audio/wav", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }), + ).rejects.toThrow("require mp3 or caf"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("sanitizes filenames before sending", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "../evil.mp3", + contentType: "audio/mpeg", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('filename="evil.mp3"'); + expect(bodyText).toContain('name="evil.mp3"'); + }); +}); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 720f357b4..24eefba96 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -1,8 +1,5 @@ import crypto from "node:crypto"; -import { spawn } from "node:child_process"; -import { writeFile, unlink, mkdtemp, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import path from "node:path"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { resolveChatGuidForTarget } from "./send.js"; @@ -23,6 +20,30 @@ export type BlueBubblesAttachmentOpts = { }; const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; +const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]); +const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]); + +function sanitizeFilename(input: string | undefined, fallback: string): string { + const trimmed = input?.trim() ?? ""; + const base = trimmed ? path.basename(trimmed) : ""; + return base || fallback; +} + +function ensureExtension(filename: string, extension: string, fallbackBase: string): string { + const currentExt = path.extname(filename); + if (currentExt.toLowerCase() === extension) return filename; + const base = currentExt ? filename.slice(0, -currentExt.length) : filename; + return `${base || fallbackBase}${extension}`; +} + +function resolveVoiceInfo(filename: string, contentType?: string) { + const normalizedType = contentType?.trim().toLowerCase(); + const extension = path.extname(filename).toLowerCase(); + const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false); + const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false); + const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/")); + return { isAudio, isMp3, isCaf }; +} function resolveAccount(params: BlueBubblesAttachmentOpts) { const account = resolveBlueBubblesAccount({ @@ -68,65 +89,6 @@ export type SendBlueBubblesAttachmentResult = { messageId: string; }; -/** - * Convert audio to Opus CAF format for iMessage voice messages. - * iMessage voice memos use Opus codec at 48kHz in CAF container. - */ -async function convertToVoiceFormat( - inputBuffer: Uint8Array, - inputFilename: string, -): Promise<{ buffer: Uint8Array; filename: string; contentType: string }> { - const tempDir = await mkdtemp(join(tmpdir(), "bb-voice-")); - const inputPath = join(tempDir, inputFilename); - const outputPath = join(tempDir, "Audio Message.caf"); - - try { - await writeFile(inputPath, inputBuffer); - - // Convert to Opus CAF (iMessage voice memo format) - await new Promise((resolve, reject) => { - const ffmpeg = spawn("ffmpeg", [ - "-y", - "-i", inputPath, - "-ar", "48000", - "-c:a", "libopus", - "-b:a", "32k", - "-f", "caf", - outputPath, - ]); - - let stderr = ""; - ffmpeg.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - ffmpeg.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`ffmpeg conversion failed (code ${code}): ${stderr.slice(-500)}`)); - } - }); - - ffmpeg.on("error", (err) => { - reject(new Error(`ffmpeg spawn error: ${err.message}`)); - }); - }); - - const outputBuffer = await readFile(outputPath); - return { - buffer: new Uint8Array(outputBuffer), - filename: "Audio Message.caf", - contentType: "audio/x-caf", - }; - } finally { - // Cleanup temp files - await unlink(inputPath).catch(() => {}); - await unlink(outputPath).catch(() => {}); - await unlink(tempDir).catch(() => {}); - } -} - function resolveSendTarget(raw: string): BlueBubblesSendTarget { const parsed = parseBlueBubblesTarget(raw); if (parsed.kind === "handle") { @@ -167,7 +129,7 @@ function extractMessageId(payload: unknown): string { /** * Send an attachment via BlueBubbles API. * Supports sending media files (images, videos, audio, documents) to a chat. - * When asVoice is true, converts audio to iMessage voice memo format (Opus CAF). + * When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo. */ export async function sendBlueBubblesAttachment(params: { to: string; @@ -182,19 +144,29 @@ export async function sendBlueBubblesAttachment(params: { }): Promise { const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params; let { buffer, filename, contentType } = params; + const wantsVoice = asVoice === true; + const fallbackName = wantsVoice ? "Audio Message" : "attachment"; + filename = sanitizeFilename(filename, fallbackName); + contentType = contentType?.trim() || undefined; const { baseUrl, password } = resolveAccount(opts); - // Convert to voice memo format if requested - const isAudioMessage = asVoice === true; + // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). + const isAudioMessage = wantsVoice; if (isAudioMessage) { - try { - const converted = await convertToVoiceFormat(buffer, filename); - buffer = converted.buffer; - filename = converted.filename; - contentType = converted.contentType; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to convert audio to voice format: ${msg}`); + const voiceInfo = resolveVoiceInfo(filename, contentType); + if (!voiceInfo.isAudio) { + throw new Error("BlueBubbles voice messages require audio media (mp3 or caf)."); + } + if (voiceInfo.isMp3) { + filename = ensureExtension(filename, ".mp3", fallbackName); + contentType = contentType ?? "audio/mpeg"; + } else if (voiceInfo.isCaf) { + filename = ensureExtension(filename, ".caf", fallbackName); + contentType = contentType ?? "audio/x-caf"; + } else { + throw new Error( + "BlueBubbles voice messages require mp3 or caf audio (convert before sending).", + ); } }