Skip to content

Commit 364d202

Browse files
committed
fix(bluebubbles): UTI-aware audio attachment detection
1 parent c38d946 commit 364d202

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai
7676
- Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc.
7777
- Gateway/sessions: yield during bulk transcript title/preview hydration and copy compaction checkpoints asynchronously, keeping the Gateway event loop responsive for large session stores and large transcripts. Refs #75330 and #75414. Thanks @amknight.
7878
- Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc.
79+
- BlueBubbles: detect audio attachments by Apple UTIs (`public.audio`, `public.mpeg-4-audio`, `com.apple.m4a-audio`, `com.apple.coreaudio-format`) in addition to `audio/*` MIME, so iMessage voice notes whose webhook payload only carries the UTI are now classified as audio in the inbound `<media:audio>` placeholder instead of falling through to the generic `<media:attachment>` tag. Thanks @omarshahine.
7980
- Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP.
8081
- Docs/sandboxing: clarify that sandbox setup scripts (`sandbox-setup.sh`, `sandbox-common-setup.sh`, `sandbox-browser-setup.sh`) are only available from a source checkout, and add inline `docker build` commands for npm-installed users so sandbox image setup works without cloning the repo. Fixes #75485. Thanks @amknight.
8182
- Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP.

extensions/bluebubbles/src/monitor-normalize.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from "vitest";
2-
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
2+
import {
3+
buildMessagePlaceholder,
4+
isBlueBubblesAudioAttachment,
5+
normalizeWebhookMessage,
6+
normalizeWebhookReaction,
7+
} from "./monitor-normalize.js";
38

49
function createFallbackDmPayload(overrides: Record<string, unknown> = {}) {
510
return {
@@ -140,3 +145,62 @@ describe("normalizeWebhookReaction", () => {
140145
expect(result?.action).toBe("added");
141146
});
142147
});
148+
149+
describe("isBlueBubblesAudioAttachment", () => {
150+
it("detects audio by `audio/*` MIME type", () => {
151+
expect(isBlueBubblesAudioAttachment({ mimeType: "audio/x-m4a" })).toBe(true);
152+
expect(isBlueBubblesAudioAttachment({ mimeType: "audio/mp4" })).toBe(true);
153+
});
154+
155+
it("detects audio by Apple UTI even when MIME is missing", () => {
156+
expect(isBlueBubblesAudioAttachment({ uti: "public.audio" })).toBe(true);
157+
expect(isBlueBubblesAudioAttachment({ uti: "public.mpeg-4-audio" })).toBe(true);
158+
expect(isBlueBubblesAudioAttachment({ uti: "com.apple.m4a-audio" })).toBe(true);
159+
expect(isBlueBubblesAudioAttachment({ uti: "com.apple.coreaudio-format" })).toBe(true);
160+
});
161+
162+
it("treats UTI matching as case-insensitive", () => {
163+
expect(isBlueBubblesAudioAttachment({ uti: "Public.Audio" })).toBe(true);
164+
});
165+
166+
it("returns false for image / video / unknown attachments", () => {
167+
expect(isBlueBubblesAudioAttachment({ mimeType: "image/jpeg" })).toBe(false);
168+
expect(isBlueBubblesAudioAttachment({ mimeType: "video/quicktime" })).toBe(false);
169+
expect(isBlueBubblesAudioAttachment({ uti: "public.jpeg" })).toBe(false);
170+
expect(isBlueBubblesAudioAttachment({})).toBe(false);
171+
});
172+
});
173+
174+
describe("buildMessagePlaceholder audio detection", () => {
175+
function makeMsg(attachments: Array<{ mimeType?: string; uti?: string }>) {
176+
return {
177+
text: "",
178+
senderId: "+15551234567",
179+
senderIdExplicit: false,
180+
isGroup: false,
181+
attachments,
182+
} as Parameters<typeof buildMessagePlaceholder>[0];
183+
}
184+
185+
it("emits <media:audio> for `audio/*` MIME (existing behavior)", () => {
186+
expect(buildMessagePlaceholder(makeMsg([{ mimeType: "audio/x-m4a" }]))).toContain(
187+
"<media:audio>",
188+
);
189+
});
190+
191+
it("emits <media:audio> for Apple `public.audio` UTI when MIME is missing", () => {
192+
expect(buildMessagePlaceholder(makeMsg([{ uti: "public.audio" }]))).toContain("<media:audio>");
193+
});
194+
195+
it("emits <media:audio> for Apple `com.apple.m4a-audio` UTI", () => {
196+
expect(buildMessagePlaceholder(makeMsg([{ uti: "com.apple.m4a-audio" }]))).toContain(
197+
"<media:audio>",
198+
);
199+
});
200+
201+
it("falls back to <media:attachment> for non-audio mixes", () => {
202+
expect(
203+
buildMessagePlaceholder(makeMsg([{ uti: "public.audio" }, { mimeType: "image/jpeg" }])),
204+
).toContain("<media:attachment>");
205+
});
206+
});

extensions/bluebubbles/src/monitor-normalize.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,40 @@ export function extractAttachments(message: Record<string, unknown>): BlueBubble
5959
return out;
6060
}
6161

62+
// Apple UTIs used by BlueBubbles for voice notes / audio attachments. Webhook
63+
// payloads sometimes carry only a UTI without a normalized `audio/*` MIME
64+
// (notably iMessage voice notes recorded on macOS 26 Tahoe), so audio
65+
// detection must consult both. Intentionally narrow: covers what BB emits for
66+
// iMessage voice notes today (m4a/MPEG-4 audio). Broader UTIs like
67+
// `public.aiff-audio`, `public.wav`, `public.mp3` are not iMessage voice-note
68+
// formats and pull in `audio/*` MIME paths anyway.
69+
const APPLE_AUDIO_UTIS = new Set<string>([
70+
"public.audio",
71+
"public.mpeg-4-audio",
72+
"com.apple.m4a-audio",
73+
"com.apple.coreaudio-format",
74+
]);
75+
76+
export function isBlueBubblesAudioAttachment(attachment: BlueBubblesAttachment): boolean {
77+
const mime = attachment.mimeType?.trim().toLowerCase();
78+
if (mime && mime.startsWith("audio/")) {
79+
return true;
80+
}
81+
const uti = attachment.uti?.trim().toLowerCase();
82+
if (uti && APPLE_AUDIO_UTIS.has(uti)) {
83+
return true;
84+
}
85+
return false;
86+
}
87+
6288
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
6389
if (attachments.length === 0) {
6490
return "";
6591
}
6692
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
6793
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
6894
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
69-
const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
95+
const allAudio = attachments.every(isBlueBubblesAudioAttachment);
7096
const tag = allImages
7197
? "<media:image>"
7298
: allVideos

0 commit comments

Comments
 (0)