mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
fix(bluebubbles): UTI-aware audio attachment detection (#75488)
Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
import {
|
||||
buildMessagePlaceholder,
|
||||
isBlueBubblesAudioAttachment,
|
||||
normalizeWebhookMessage,
|
||||
normalizeWebhookReaction,
|
||||
} from "./monitor-normalize.js";
|
||||
|
||||
function createFallbackDmPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -140,3 +145,62 @@ describe("normalizeWebhookReaction", () => {
|
||||
expect(result?.action).toBe("added");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBlueBubblesAudioAttachment", () => {
|
||||
it("detects audio by `audio/*` MIME type", () => {
|
||||
expect(isBlueBubblesAudioAttachment({ mimeType: "audio/x-m4a" })).toBe(true);
|
||||
expect(isBlueBubblesAudioAttachment({ mimeType: "audio/mp4" })).toBe(true);
|
||||
});
|
||||
|
||||
it("detects audio by Apple UTI even when MIME is missing", () => {
|
||||
expect(isBlueBubblesAudioAttachment({ uti: "public.audio" })).toBe(true);
|
||||
expect(isBlueBubblesAudioAttachment({ uti: "public.mpeg-4-audio" })).toBe(true);
|
||||
expect(isBlueBubblesAudioAttachment({ uti: "com.apple.m4a-audio" })).toBe(true);
|
||||
expect(isBlueBubblesAudioAttachment({ uti: "com.apple.coreaudio-format" })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats UTI matching as case-insensitive", () => {
|
||||
expect(isBlueBubblesAudioAttachment({ uti: "Public.Audio" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for image / video / unknown attachments", () => {
|
||||
expect(isBlueBubblesAudioAttachment({ mimeType: "image/jpeg" })).toBe(false);
|
||||
expect(isBlueBubblesAudioAttachment({ mimeType: "video/quicktime" })).toBe(false);
|
||||
expect(isBlueBubblesAudioAttachment({ uti: "public.jpeg" })).toBe(false);
|
||||
expect(isBlueBubblesAudioAttachment({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMessagePlaceholder audio detection", () => {
|
||||
function makeMsg(attachments: Array<{ mimeType?: string; uti?: string }>) {
|
||||
return {
|
||||
text: "",
|
||||
senderId: "+15551234567",
|
||||
senderIdExplicit: false,
|
||||
isGroup: false,
|
||||
attachments,
|
||||
} as Parameters<typeof buildMessagePlaceholder>[0];
|
||||
}
|
||||
|
||||
it("emits <media:audio> for `audio/*` MIME (existing behavior)", () => {
|
||||
expect(buildMessagePlaceholder(makeMsg([{ mimeType: "audio/x-m4a" }]))).toContain(
|
||||
"<media:audio>",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits <media:audio> for Apple `public.audio` UTI when MIME is missing", () => {
|
||||
expect(buildMessagePlaceholder(makeMsg([{ uti: "public.audio" }]))).toContain("<media:audio>");
|
||||
});
|
||||
|
||||
it("emits <media:audio> for Apple `com.apple.m4a-audio` UTI", () => {
|
||||
expect(buildMessagePlaceholder(makeMsg([{ uti: "com.apple.m4a-audio" }]))).toContain(
|
||||
"<media:audio>",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to <media:attachment> for non-audio mixes", () => {
|
||||
expect(
|
||||
buildMessagePlaceholder(makeMsg([{ uti: "public.audio" }, { mimeType: "image/jpeg" }])),
|
||||
).toContain("<media:attachment>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,32 @@ export function extractAttachments(message: Record<string, unknown>): BlueBubble
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apple UTIs used by BlueBubbles for voice notes / audio attachments. Webhook
|
||||
// payloads sometimes carry only a UTI without a normalized `audio/*` MIME
|
||||
// (notably iMessage voice notes recorded on macOS 26 Tahoe), so audio
|
||||
// detection must consult both. Intentionally narrow: covers what BB emits for
|
||||
// iMessage voice notes today (m4a/MPEG-4 audio). Broader UTIs like
|
||||
// `public.aiff-audio`, `public.wav`, `public.mp3` are not iMessage voice-note
|
||||
// formats and pull in `audio/*` MIME paths anyway.
|
||||
const APPLE_AUDIO_UTIS = new Set<string>([
|
||||
"public.audio",
|
||||
"public.mpeg-4-audio",
|
||||
"com.apple.m4a-audio",
|
||||
"com.apple.coreaudio-format",
|
||||
]);
|
||||
|
||||
export function isBlueBubblesAudioAttachment(attachment: BlueBubblesAttachment): boolean {
|
||||
const mime = attachment.mimeType?.trim().toLowerCase();
|
||||
if (mime && mime.startsWith("audio/")) {
|
||||
return true;
|
||||
}
|
||||
const uti = attachment.uti?.trim().toLowerCase();
|
||||
if (uti && APPLE_AUDIO_UTIS.has(uti)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
|
||||
if (attachments.length === 0) {
|
||||
return "";
|
||||
@@ -66,7 +92,7 @@ function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): strin
|
||||
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
|
||||
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
|
||||
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
|
||||
const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
|
||||
const allAudio = attachments.every(isBlueBubblesAudioAttachment);
|
||||
const tag = allImages
|
||||
? "<media:image>"
|
||||
: allVideos
|
||||
|
||||
Reference in New Issue
Block a user