From 9caff5f873cd9d590e5efd95d00d469485fc234e Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Sun, 7 Jun 2026 19:14:13 -0700 Subject: [PATCH] fix(imessage): gate split-send coalescing on imsg balloon metadata with back-compat (#90858) Gate iMessage same-sender DM split-send coalescing on imsg's structural `balloon_bundle_id` URL-balloon marker (openclaw/imsg#137) instead of timing/ text-shape inference, with a session capability latch and a back-compat path: - URL-balloon marker present -> merge (precise split-send). - Build known to emit balloon metadata (session latch) -> keep non-marker buckets separate (the precision win). - Build that never emits balloon metadata (older imsg) -> preserve the legacy unconditional merge, so split-send users do not regress to two turns. Never merges more than shipped main already did. Verified live end-to-end: the patched gateway, watching a real chat.db via an imsg #137 build, merged a real iPhone-sent `Dump ` split-send into one turn. Client-side removal once imsg coalesces upstream is tracked in #91243 (openclaw/imsg#141). Closes #90795 --- docs/channels/imessage.md | 27 ++-- .../imessage/src/monitor.last-route.test.ts | 151 ++++++++++++++++++ .../imessage/src/monitor/coalesce.test.ts | 50 ++++++ extensions/imessage/src/monitor/coalesce.ts | 54 +++++++ .../imessage/src/monitor/monitor-provider.ts | 46 ++++-- .../src/monitor/parse-notification.test.ts | 22 +++ .../src/monitor/parse-notification.ts | 1 + extensions/imessage/src/monitor/types.ts | 1 + 8 files changed, 327 insertions(+), 25 deletions(-) diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 59292734531b..5c1d3dfe7d53 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -654,14 +654,14 @@ When a user types a command and a URL together — e.g. `Dump https://example.co The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple's send pipeline, not anything OpenClaw or `imsg` introduces. -`channels.imessage.coalesceSameSenderDms` opts a DM into merging consecutive same-sender rows into a single agent turn. Group chats continue to dispatch per-message so multi-user turn structure is preserved. +`channels.imessage.coalesceSameSenderDms` opts a DM into buffering consecutive same-sender rows. When `imsg` exposes the structural URL-preview marker `balloon_bundle_id: "com.apple.messages.URLBalloonProvider"` on one of the source rows, OpenClaw merges only that real split-send and keeps any other buffered rows as separate turns. On older `imsg` builds that emit no balloon metadata at all, OpenClaw cannot tell a split-send from separate sends, so it falls back to merging the bucket. That preserves the pre-metadata behavior rather than regressing `Dump ` split-sends into two turns. Group chats continue to dispatch per-message so multi-user turn structure is preserved. Enable when: - You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.). - - Your users paste URLs, images, or long content alongside commands. + - Your users paste URLs alongside commands. - You can accept the added DM turn latency (see below). Leave disabled when: @@ -702,7 +702,8 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc - - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a payload row is coming. Group-chat messages keep instant dispatch. + - **Precise merging needs current `imsg` payload metadata.** When the URL row includes `balloon_bundle_id`, only that real split-send merges and other buffered rows stay separate. On older `imsg` builds that expose no balloon metadata, OpenClaw falls back to merging the buffered bucket so `Dump ` split-sends are not regressed into two turns (interim back-compat, removed once `imsg` coalesces split-sends upstream). + - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a URL-preview row is coming. Group-chat messages keep instant dispatch. - **Merged output is bounded.** Merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source GUID is tracked in `coalescedMessageGuids` for downstream telemetry. - **DM-only.** Group chats fall through to per-message dispatch so the bot stays responsive when multiple people are typing. - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. Legacy BlueBubbles configs that set `channels.bluebubbles.coalesceSameSenderDms` should migrate that value to `channels.imessage.coalesceSameSenderDms`. @@ -712,15 +713,17 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc ### Scenarios and what the agent sees -| User composes | `chat.db` produces | Flag off (default) | Flag on + 2500 ms window | -| ------------------------------------------------------------------ | --------------------- | --------------------------------------- | ----------------------------------------------------------------------- | -| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` | -| `Save this 📎image.jpg caption` (attachment + text) | 2 rows | Two turns (attachment dropped on merge) | One turn: text + image preserved | -| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** | -| URL pasted alone | 1 row | Instant dispatch | Instant dispatch (only one entry in bucket) | -| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) | -| Rapid flood (>10 small DMs inside window) | N rows | N turns | One turn, bounded output (first + latest, text/attachment caps applied) | -| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced | +The "Flag on" column shows behavior on an `imsg` build that emits `balloon_bundle_id`. On older `imsg` builds that emit no balloon metadata at all, the rows below marked "Two turns" / "N turns" instead fall back to a legacy merge (one turn): OpenClaw cannot structurally tell a split-send from separate sends, so it preserves the pre-metadata merge. Precise separation activates once the build emits balloon metadata. + +| User composes | `chat.db` produces | Flag off (default) | Flag on + window (imsg emits balloon metadata) | +| ------------------------------------------------------------------ | ----------------------------------- | --------------------------------------- | ------------------------------------------------ | +| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` | +| `Save this 📎image.jpg caption` (attachment + text) | 2 rows without URL balloon metadata | Two turns | Two turns (legacy merge on metadata-less builds) | +| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** | +| URL pasted alone | 1 row | Instant dispatch | Wait up to window, then dispatch | +| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) | +| Rapid flood (>10 small DMs inside window) | N rows without URL balloon metadata | N turns | N turns (legacy merge on metadata-less builds) | +| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced | ## Catching up after gateway downtime diff --git a/extensions/imessage/src/monitor.last-route.test.ts b/extensions/imessage/src/monitor.last-route.test.ts index e2c1799551e0..62230eb7e37f 100644 --- a/extensions/imessage/src/monitor.last-route.test.ts +++ b/extensions/imessage/src/monitor.last-route.test.ts @@ -1053,6 +1053,7 @@ describe("iMessage monitor last-route updates", () => { id: 78, guid: "LIVE-GUID-78", text: "https://example.com", + balloon_bundle_id: "com.apple.messages.URLBalloonProvider", created_at: "2026-05-22T15:30:01.000Z", }, ]) { @@ -1105,4 +1106,154 @@ describe("iMessage monitor last-route updates", () => { expect((await loadIMessageCatchupCursor("default"))?.lastSeenRowid).toBe(78); }); }); + + it("legacy-merges coalesce buckets when imsg emits no balloon metadata (older builds)", async () => { + // Back-compat: older imsg builds emit no balloon_bundle_id, so a Dump + URL + // split-send arrives as two fieldless rows. We cannot structurally tell that + // apart from separate sends, so we preserve the pre-metadata merge rather + // than regress split-send users to two turns. Removed once imsg coalesces + // upstream (openclaw/imsg#141, tracked by #91243). + debouncerControl.holdEntries = true; + + let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; + const client = { + request: vi.fn(async (method: string) => { + if (method === "watch.subscribe") { + return { subscription: 1 }; + } + throw new Error(`unexpected imsg method ${method}`); + }), + waitForClose: vi.fn(async () => { + for (const row of [ + { id: 91, guid: "LIVE-GUID-91", text: "Dump", created_at: "2026-05-22T15:30:00.000Z" }, + { + id: 92, + guid: "LIVE-GUID-92", + text: "https://example.com", + created_at: "2026-05-22T15:30:01.000Z", + }, + ]) { + onNotification?.({ + method: "message", + params: { + message: { + ...row, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + is_group: false, + }, + }, + }); + } + await vi.waitFor(() => { + expect(debouncerControl.flush).toBeDefined(); + }); + await debouncerControl.flush?.(); + await Promise.resolve(); + }), + stop: vi.fn(async () => {}), + }; + createIMessageRpcClientMock.mockImplementation(async (params) => { + if (!params?.onNotification) { + throw new Error("expected iMessage notification handler"); + } + onNotification = params.onNotification; + return client as never; + }); + + await monitorIMessageProvider({ + config: { + channels: { + imessage: { + coalesceSameSenderDms: true, + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + sendReadReceipts: false, + }, + }, + messages: { inbound: { debounceMs: 2500 } }, + session: { mainKey: "main" }, + } as never, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + }); + + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + const mergedBody = dispatchInboundMessageMock.mock.calls[0]?.[0].ctx.Body ?? ""; + expect(mergedBody).toContain("Dump"); + expect(mergedBody).toContain("https://example.com"); + }); + + it("merges coalesce buckets when imsg marks the URL balloon row structurally", async () => { + debouncerControl.holdEntries = true; + + let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; + const client = { + request: vi.fn(async (method: string) => { + if (method === "watch.subscribe") { + return { subscription: 1 }; + } + throw new Error(`unexpected imsg method ${method}`); + }), + waitForClose: vi.fn(async () => { + for (const row of [ + { id: 93, guid: "LIVE-GUID-93", text: "Dump", created_at: "2026-05-22T15:30:00.000Z" }, + { + id: 94, + guid: "LIVE-GUID-94", + text: "https://example.com", + balloon_bundle_id: "com.apple.messages.URLBalloonProvider", + created_at: "2026-05-22T15:30:01.000Z", + }, + ]) { + onNotification?.({ + method: "message", + params: { + message: { + ...row, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + is_group: false, + }, + }, + }); + } + await vi.waitFor(() => { + expect(debouncerControl.flush).toBeDefined(); + }); + await debouncerControl.flush?.(); + await Promise.resolve(); + }), + stop: vi.fn(async () => {}), + }; + createIMessageRpcClientMock.mockImplementation(async (params) => { + if (!params?.onNotification) { + throw new Error("expected iMessage notification handler"); + } + onNotification = params.onNotification; + return client as never; + }); + + await monitorIMessageProvider({ + config: { + channels: { + imessage: { + coalesceSameSenderDms: true, + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + sendReadReceipts: false, + }, + }, + messages: { inbound: { debounceMs: 2500 } }, + session: { mainKey: "main" }, + } as never, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + }); + + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessageMock.mock.calls[0]?.[0].ctx.Body).toContain( + "Dump https://example.com", + ); + }); }); diff --git a/extensions/imessage/src/monitor/coalesce.test.ts b/extensions/imessage/src/monitor/coalesce.test.ts index 0e4e23248ce9..06bb58f15a88 100644 --- a/extensions/imessage/src/monitor/coalesce.test.ts +++ b/extensions/imessage/src/monitor/coalesce.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { combineIMessagePayloads, + hasIMessageUrlBalloonBundleID, + IMESSAGE_URL_BALLOON_BUNDLE_ID, MAX_COALESCED_ATTACHMENTS, MAX_COALESCED_ENTRIES, MAX_COALESCED_TEXT_CHARS, + shouldCombineIMessagePayloadBucket, } from "./coalesce.js"; import type { IMessagePayload } from "./types.js"; @@ -21,6 +24,52 @@ const makePayload = (overrides: Partial = {}): IMessagePayload }); describe("combineIMessagePayloads", () => { + it("recognizes URL balloon rows from imsg structural metadata", () => { + const text = makePayload({ text: "Dump" }); + const balloon = makePayload({ + text: "https://example.com/article", + balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID, + }); + + expect(hasIMessageUrlBalloonBundleID(text)).toBe(false); + expect(hasIMessageUrlBalloonBundleID(balloon)).toBe(true); + // A real URL split-send merges regardless of the session capability latch. + expect(shouldCombineIMessagePayloadBucket([text, balloon], false)).toBe(true); + expect(shouldCombineIMessagePayloadBucket([text, balloon], true)).toBe(true); + }); + + it("falls back to a legacy merge when the build has never emitted balloon metadata (older imsg)", () => { + // Older imsg builds emit no balloon_bundle_id at all. We cannot tell a URL + // split-send from separate sends, so we preserve the pre-metadata merge + // rather than regress split-send users to two turns. Back-compat path, + // removed once imsg coalesces upstream (openclaw/imsg#141, tracked by #91243). + const text = makePayload({ text: "Dump" }); + const url = makePayload({ text: "https://example.com/article" }); + expect(shouldCombineIMessagePayloadBucket([text, url], false)).toBe(true); + }); + + it("keeps a plain bucket separate once the build is known to emit balloon metadata", () => { + // Capability latch is true (a prior row this session carried metadata), so a + // plain bucket with no URL marker is genuinely not a split-send. imsg omits + // the field for plain rows, so this case is indistinguishable per-bucket and + // depends on the session-level signal. + const a = makePayload({ text: "first" }); + const b = makePayload({ text: "second" }); + expect(shouldCombineIMessagePayloadBucket([a, b], true)).toBe(false); + }); + + it("keeps a bucket separate when imsg exposes balloon metadata in the bucket but no URL marker", () => { + // New imsg surfaced balloon metadata in this very bucket, proving this build + // emits the field, but the bucket is not a URL split-send. Keep separate even + // if the latch had not flipped yet. + const text = makePayload({ text: "hi" }); + const nonUrlBalloon = makePayload({ + text: "tap to vote", + balloon_bundle_id: "com.apple.messages.MSMessageExtensionBalloonPlugin", + }); + expect(shouldCombineIMessagePayloadBucket([text, nonUrlBalloon], false)).toBe(false); + }); + it("throws on empty input", () => { expect(() => combineIMessagePayloads([])).toThrow( "combineIMessagePayloads: cannot combine empty payloads", @@ -44,6 +93,7 @@ describe("combineIMessagePayloads", () => { const balloon = makePayload({ id: 42, text: "https://example.com/article", + balloon_bundle_id: IMESSAGE_URL_BALLOON_BUNDLE_ID, guid: "row-2", created_at: "2025-01-01T00:00:01.500Z", }); diff --git a/extensions/imessage/src/monitor/coalesce.ts b/extensions/imessage/src/monitor/coalesce.ts index c763aacd739a..4e7717bb2e8b 100644 --- a/extensions/imessage/src/monitor/coalesce.ts +++ b/extensions/imessage/src/monitor/coalesce.ts @@ -16,6 +16,60 @@ import type { IMessagePayload } from "./types.js"; export const MAX_COALESCED_TEXT_CHARS = 4000; export const MAX_COALESCED_ATTACHMENTS = 20; export const MAX_COALESCED_ENTRIES = 10; +export const IMESSAGE_URL_BALLOON_BUNDLE_ID = "com.apple.messages.URLBalloonProvider"; + +export function hasIMessageUrlBalloonBundleID(payload: IMessagePayload): boolean { + return payload.balloon_bundle_id === IMESSAGE_URL_BALLOON_BUNDLE_ID; +} + +// imsg only emits `balloon_bundle_id` for rows that actually carry a balloon +// (the nil case is omitted on the wire), so a present, non-empty value is the +// signal that this build exposes balloon metadata at all. +export function hasIMessageBalloonMetadata(payload: IMessagePayload): boolean { + return typeof payload.balloon_bundle_id === "string" && payload.balloon_bundle_id.length > 0; +} + +/** + * Decide whether a debounced same-sender bucket should merge into one turn. + * + * `buildEmitsBalloonMetadata` is a session-level capability latch: once any + * inbound row from this imsg build has carried balloon metadata, absence of a + * URL marker is meaningful (the row genuinely is not a URL split-send), so we + * can keep ordinary buffered DMs separate. It must be session-scoped, not + * per-bucket: imsg omits `balloon_bundle_id` on the wire for non-balloon rows, + * so a bucket of plain text rows looks identical on old and new builds. + */ +export function shouldCombineIMessagePayloadBucket( + payloads: readonly IMessagePayload[], + buildEmitsBalloonMetadata: boolean, +): boolean { + // Precise path: a real Apple URL-preview split-send carries the URL-balloon + // marker on the preview row — merge it into one turn. + if (payloads.some(hasIMessageUrlBalloonBundleID)) { + return true; + } + // Metadata-capable build (observed earlier this session or in this bucket): + // the missing URL marker is trustworthy, so keep ordinary buffered DMs as + // separate turns. This is the precision the structural gate exists for. + if (buildEmitsBalloonMetadata || payloads.some(hasIMessageBalloonMetadata)) { + return false; + } + // Back-compat (remove once imsg coalesces split-sends upstream — see + // openclaw/imsg#141, tracked by #91243): a build that has never emitted any + // balloon metadata cannot structurally tell a `Dump ` split-send from + // separate sends. Preserve the pre-metadata merge so split-send users do not + // regress to two turns on a released imsg that lacks the field. + // + // This never merges more than the shipped behavior already did: with + // `coalesceSameSenderDms` enabled, `main` debounces every same-sender DM and + // merges each multi-entry bucket unconditionally. So an unlatched session + // (old build, or a metadata-capable build before its first balloon row) is + // identical to today, not a new regression. Flushing these buckets instead + // would re-break old-imsg split-sends — the very case this guards. Fully + // closing the pre-latch window needs an imsg-advertised capability flag, which + // is part of the upstream #141 work. + return true; +} export type CoalescedIMessagePayload = IMessagePayload & { /** diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index a67aae3cdafa..c89a900a6ce4 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -66,7 +66,11 @@ import { normalizeIMessageHandle } from "../targets.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; import { runIMessageCatchup } from "./catchup-bridge.js"; import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js"; -import { combineIMessagePayloads } from "./coalesce.js"; +import { + combineIMessagePayloads, + hasIMessageBalloonMetadata, + shouldCombineIMessagePayloadBucket, +} from "./coalesce.js"; import { repairIMessageConversationAnchor } from "./conversation-repair.js"; import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js"; import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js"; @@ -359,6 +363,12 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const debounceMsOverride = coalesceSameSenderDms && !hasExplicitInboundDebounce ? 2500 : undefined; + // Session capability latch: flips true once any inbound row from this imsg + // build carries balloon metadata. The coalesce flush gate needs a build-level + // (not per-bucket) signal because imsg omits `balloon_bundle_id` for plain + // rows, so a bucket of plain text looks identical on old and new builds. + let imsgEmitsBalloonMetadata = false; + const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{ message: IMessagePayload; }>({ @@ -376,12 +386,10 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ? `chat:${msg.chat_id}` : (msg.chat_guid ?? msg.chat_identifier ?? "unknown"); - // With coalesceSameSenderDms enabled, DMs key on chat:sender so two - // distinct user sends — `Dump` followed by a pasted URL that Apple - // delivers as a separate row — fall into the same bucket and merge - // into one agent turn. Group chats fall through to the legacy key so - // shouldDebounce can route them to the instant-dispatch path and - // preserve multi-user turn structure. + // With coalesceSameSenderDms enabled, DMs key on chat:sender so Apple's + // split text row and URL-balloon row land in the same bucket. The flush + // path still requires imsg's structural balloon metadata before merging. + // Group chats keep the legacy key to preserve multi-user turn structure. if (coalesceSameSenderDms && msg.is_group !== true) { return `imessage:${accountInfo.accountId}:dm:${conversationId}:${sender}`; } @@ -398,11 +406,10 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P return false; } - // With coalesceSameSenderDms enabled, debounce DM messages aggressively - // (text, media, control commands) so split-sends — `Dump `, - // `Save 📎image caption`, and rapid floods — merge into one agent - // turn. Group chats keep instant dispatch so the bot stays responsive - // when multiple people are typing. + // Hold opt-in DMs long enough for a following URL-balloon row to arrive. + // The flush gate (shouldCombineIMessagePayloadBucket) decides merge vs. + // separate: it merges precisely on imsg's balloon marker, and falls back + // to a legacy merge only when the build emits no balloon metadata at all. if (coalesceSameSenderDms) { return msg.is_group !== true; } @@ -425,7 +432,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P return; } - const combined = combineIMessagePayloads(entries.map((e) => e.message)); + const messages = entries.map((e) => e.message); + if (!shouldCombineIMessagePayloadBucket(messages, imsgEmitsBalloonMetadata)) { + for (const message of messages) { + await handleMessageNow(message); + } + return; + } + + const combined = combineIMessagePayloads(messages); if (shouldLogVerbose()) { const text = combined.text ?? ""; const preview = text.slice(0, 50); @@ -1038,6 +1053,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P runtime.error?.(`imessage: dropping malformed RPC message payload (keys=${shape})`); return; } + // Latch build capability from any row that carries balloon metadata so the + // coalesce flush gate can trust a missing URL marker on later plain buckets. + if (!imsgEmitsBalloonMetadata && hasIMessageBalloonMetadata(message)) { + imsgEmitsBalloonMetadata = true; + } if ( watchStartupRowidWatermark !== null && typeof message.id === "number" && diff --git a/extensions/imessage/src/monitor/parse-notification.test.ts b/extensions/imessage/src/monitor/parse-notification.test.ts index 801e676d6df4..f17be3e60790 100644 --- a/extensions/imessage/src/monitor/parse-notification.test.ts +++ b/extensions/imessage/src/monitor/parse-notification.test.ts @@ -68,6 +68,28 @@ describe("parseIMessageNotification", () => { expect(parsed?.reacted_to_guid).toBe("target-guid"); }); + it("preserves imsg balloon bundle metadata when present", () => { + const parsed = parseIMessageNotification({ + message: { + id: 1, + guid: "link-preview-guid", + chat_id: 2, + sender: "+10000000000", + is_from_me: false, + text: "https://example.com/article", + balloon_bundle_id: "com.apple.messages.URLBalloonProvider", + attachments: null, + chat_identifier: null, + chat_guid: null, + chat_name: null, + participants: null, + is_group: false, + }, + }); + + expect(parsed?.balloon_bundle_id).toBe("com.apple.messages.URLBalloonProvider"); + }); + it("accepts iMessage attachment transfer_name and uti metadata", () => { const parsed = parseIMessageNotification({ message: { diff --git a/extensions/imessage/src/monitor/parse-notification.ts b/extensions/imessage/src/monitor/parse-notification.ts index dcea0dd3d248..28e741435e58 100644 --- a/extensions/imessage/src/monitor/parse-notification.ts +++ b/extensions/imessage/src/monitor/parse-notification.ts @@ -66,6 +66,7 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null !isOptionalNumber(message.chat_id) || !isOptionalString(message.sender) || !isOptionalString(message.destination_caller_id) || + !isOptionalString(message.balloon_bundle_id) || !isOptionalBoolean(message.is_from_me) || !isOptionalString(message.text) || !isOptionalStringOrNumber(message.reply_to_id) || diff --git a/extensions/imessage/src/monitor/types.ts b/extensions/imessage/src/monitor/types.ts index 0b36048ce289..0e22342cd3ab 100644 --- a/extensions/imessage/src/monitor/types.ts +++ b/extensions/imessage/src/monitor/types.ts @@ -17,6 +17,7 @@ export type IMessagePayload = { chat_id?: number | null; sender?: string | null; destination_caller_id?: string | null; + balloon_bundle_id?: string | null; is_from_me?: boolean | null; text?: string | null; reply_to_id?: number | string | null;