mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user