From 02b5f403db4096ea30e7657394a70259b19ba7f1 Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 22 Jan 2026 19:36:04 -0800 Subject: [PATCH] feat(bluebubbles): add asVoice support for voice memos Add asVoice parameter to sendBlueBubblesAttachment that converts audio to iMessage voice memo format (Opus CAF at 48kHz) and sets isAudioMessage flag in the BlueBubbles API. This follows the existing asVoice pattern used by Telegram. - Convert audio to Opus CAF format using ffmpeg when asVoice=true - Set isAudioMessage=true in BlueBubbles attachment API - Pass asVoice through action handler and media-send --- extensions/bluebubbles/src/actions.ts | 2 + extensions/bluebubbles/src/attachments.ts | 88 ++++++++++++++++++++++- extensions/bluebubbles/src/media-send.ts | 3 + 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 8097add2c..23365e4ac 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -356,6 +356,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const caption = readStringParam(params, "caption"); const contentType = readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); + const asVoice = readBooleanParam(params, "asVoice"); // Buffer can come from params.buffer (base64) or params.path (file path) const base64Buffer = readStringParam(params, "buffer"); @@ -380,6 +381,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { filename, contentType: contentType ?? undefined, caption: caption ?? undefined, + asVoice: asVoice ?? undefined, opts, }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index b264ca04d..720f357b4 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -1,4 +1,8 @@ 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 type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { resolveChatGuidForTarget } from "./send.js"; @@ -64,6 +68,65 @@ 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") { @@ -104,6 +167,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). */ export async function sendBlueBubblesAttachment(params: { to: string; @@ -113,12 +177,27 @@ export async function sendBlueBubblesAttachment(params: { caption?: string; replyToMessageGuid?: string; replyToPartIndex?: number; + asVoice?: boolean; opts?: BlueBubblesAttachmentOpts; }): Promise { - const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } = - params; + const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params; + let { buffer, filename, contentType } = params; const { baseUrl, password } = resolveAccount(opts); + // Convert to voice memo format if requested + const isAudioMessage = asVoice === true; + 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 target = resolveSendTarget(to); const chatGuid = await resolveChatGuidForTarget({ baseUrl, @@ -170,6 +249,11 @@ export async function sendBlueBubblesAttachment(params: { addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); addField("method", "private-api"); + // Add isAudioMessage flag for voice memos + if (isAudioMessage) { + addField("isAudioMessage", "true"); + } + const trimmedReplyTo = replyToMessageGuid?.trim(); if (trimmedReplyTo) { addField("selectedMessageGuid", trimmedReplyTo); diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index 5fc2225cc..eff82afd8 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -59,6 +59,7 @@ export async function sendBlueBubblesMedia(params: { caption?: string; replyToId?: string | null; accountId?: string; + asVoice?: boolean; }) { const { cfg, @@ -71,6 +72,7 @@ export async function sendBlueBubblesMedia(params: { caption, replyToId, accountId, + asVoice, } = params; const core = getBlueBubblesRuntime(); const maxBytes = resolveChannelMediaMaxBytes({ @@ -146,6 +148,7 @@ export async function sendBlueBubblesMedia(params: { filename: resolvedFilename ?? "attachment", contentType: resolvedContentType ?? undefined, replyToMessageGuid, + asVoice, opts: { cfg, accountId,