fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell)
This commit is contained in:
@@ -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
|
- 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.
|
- 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.
|
- 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 troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ Available actions:
|
|||||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **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)
|
### Message IDs (short vs full)
|
||||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||||
|
|||||||
@@ -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 () => {
|
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BLUEBUBBLES_ACTIONS,
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readBooleanParam,
|
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readReactionParams,
|
readReactionParams,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
@@ -51,6 +50,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBooleanParam(params: Record<string, unknown>, 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 */
|
/** Supported action names for BlueBubbles */
|
||||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
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";
|
import type { BlueBubblesAttachment } from "./types.js";
|
||||||
|
|
||||||
vi.mock("./accounts.js", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
@@ -238,3 +238,109 @@ describe("downloadBlueBubblesAttachment", () => {
|
|||||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
import path from "node:path";
|
||||||
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 type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import { resolveChatGuidForTarget } from "./send.js";
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
@@ -23,6 +20,30 @@ export type BlueBubblesAttachmentOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
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) {
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
@@ -68,65 +89,6 @@ export type SendBlueBubblesAttachmentResult = {
|
|||||||
messageId: string;
|
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 {
|
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
||||||
const parsed = parseBlueBubblesTarget(raw);
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
if (parsed.kind === "handle") {
|
if (parsed.kind === "handle") {
|
||||||
@@ -167,7 +129,7 @@ function extractMessageId(payload: unknown): string {
|
|||||||
/**
|
/**
|
||||||
* Send an attachment via BlueBubbles API.
|
* Send an attachment via BlueBubbles API.
|
||||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
* 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: {
|
export async function sendBlueBubblesAttachment(params: {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -182,19 +144,29 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||||
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
||||||
let { buffer, filename, contentType } = 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);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
|
||||||
// Convert to voice memo format if requested
|
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
||||||
const isAudioMessage = asVoice === true;
|
const isAudioMessage = wantsVoice;
|
||||||
if (isAudioMessage) {
|
if (isAudioMessage) {
|
||||||
try {
|
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
||||||
const converted = await convertToVoiceFormat(buffer, filename);
|
if (!voiceInfo.isAudio) {
|
||||||
buffer = converted.buffer;
|
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
||||||
filename = converted.filename;
|
}
|
||||||
contentType = converted.contentType;
|
if (voiceInfo.isMp3) {
|
||||||
} catch (err) {
|
filename = ensureExtension(filename, ".mp3", fallbackName);
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
contentType = contentType ?? "audio/mpeg";
|
||||||
throw new Error(`Failed to convert audio to voice format: ${msg}`);
|
} 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).",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user