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
This commit is contained in:
Clawd
2026-01-22 19:36:04 -08:00
committed by Peter Steinberger
parent 5d0d9e6323
commit 02b5f403db
3 changed files with 91 additions and 2 deletions

View File

@@ -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,
});

View File

@@ -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<void>((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<SendBlueBubblesAttachmentResult> {
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);

View File

@@ -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,