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,