From ba76cca9276330e08d0df2cf06ca4ebbd5f192d8 Mon Sep 17 00:00:00 2001 From: Roy Martin Date: Mon, 6 Apr 2026 02:12:55 -0700 Subject: [PATCH] fix(bluebubbles): prefer iMessage over SMS when both chats exist When sending to a handle (phone number) that has both an iMessage and an SMS chat in BlueBubbles, resolveChatGuidForTarget returned whichever chat it encountered first in the page. This caused messages to silently downgrade from iMessage to SMS for recipients who use iMessage, which is never what the user wants. The fix tracks iMessage and SMS matches separately and returns the iMessage match when available, falling back to SMS only when no iMessage chat exists for the handle. Applies to both direct handle matches (chat guid contains the handle) and participant matches. Also short-circuits page iteration once an iMessage match is found. Tests: 5 new unit tests covering iMessage preference, SMS fallback, and participant-match preference. --- extensions/bluebubbles/src/send.test.ts | 84 +++++++++++++++++++++++++ extensions/bluebubbles/src/send.ts | 45 +++++++++++-- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index e2f501f3fe4..6a3c6f01576 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -214,6 +214,90 @@ describe("send", () => { expect(result).toBe("iMessage;-;+15551234567"); }); + it("prefers iMessage over SMS when both chats exist for the same handle", async () => { + // Both chats exist; we should never silently downgrade to SMS. + const result = await resolveHandleTargetGuid([ + { + guid: "SMS;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ]); + + expect(result).toBe("iMessage;-;+15551234567"); + }); + + it("prefers iMessage over SMS even when SMS appears first", async () => { + const result = await resolveHandleTargetGuid([ + { + guid: "SMS;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + { + guid: "iMessage;-;+15559999999", + participants: [{ address: "+15559999999" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ]); + + expect(result).toBe("iMessage;-;+15551234567"); + }); + + it("falls back to SMS when no iMessage chat exists for the handle", async () => { + // First page: SMS-only DM. Second page: empty (stops pagination). + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "SMS;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+15551234567", + service: "imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("SMS;-;+15551234567"); + }); + + it("prefers iMessage over SMS via participant match", async () => { + const result = await resolveHandleTargetGuid([ + { + guid: "SMS;-;alt-handle", + participants: [{ address: "+15551234567" }], + }, + { + guid: "iMessage;-;alt-handle", + participants: [{ address: "+15551234567" }], + }, + ]); + + expect(result).toBe("iMessage;-;alt-handle"); + }); + it("returns null when handle only exists in group chat (not DM)", async () => { // This is the critical fix: if a phone number only exists as a participant in a group chat // (no direct DM chat), we should NOT send to that group. Return null instead. diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 1e3b3c802d6..681b58b96e1 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -245,7 +245,13 @@ export async function resolveChatGuidForTarget(params: { params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null; const limit = 500; - let participantMatch: string | null = null; + // When matching by handle, prefer iMessage over SMS. A user may have both + // an `iMessage;-;` and `SMS;-;` chat; we should never silently + // downgrade to SMS when an iMessage chat exists for the same handle. + let directHandleIMessageMatch: string | null = null; + let directHandleSmsMatch: string | null = null; + let participantIMessageMatch: string | null = null; + let participantSmsMatch: string | null = null; for (let offset = 0; offset < 5000; offset += limit) { const chats = await queryChats({ baseUrl: params.baseUrl, @@ -296,10 +302,21 @@ export async function resolveChatGuidForTarget(params: { if (normalizedHandle) { const guid = extractChatGuid(chat); const directHandle = guid ? extractHandleFromChatGuid(guid) : null; - if (directHandle && directHandle === normalizedHandle) { - return guid; + if (directHandle && directHandle === normalizedHandle && guid) { + // Prefer iMessage over SMS. If this chat is iMessage we can return + // immediately; if it is SMS we remember it as a fallback and keep + // scanning in case an iMessage chat for the same handle exists. + if (guid.startsWith("iMessage;-;")) { + return guid; + } + if (guid.startsWith("SMS;-;") && !directHandleSmsMatch) { + directHandleSmsMatch = guid; + } else if (!directHandleIMessageMatch && !directHandleSmsMatch) { + // Unknown service; treat as a last-resort direct match. + directHandleIMessageMatch = guid; + } } - if (!participantMatch && guid) { + if (guid && !participantIMessageMatch) { // Only consider DM chats (`;-;` separator) as participant matches. // Group chats (`;+;` separator) should never match when searching by handle/phone. // This prevents routing "send to +1234567890" to a group chat that contains that number. @@ -309,14 +326,30 @@ export async function resolveChatGuidForTarget(params: { normalizeBlueBubblesHandle(entry), ); if (participants.includes(normalizedHandle)) { - participantMatch = guid; + if (guid.startsWith("iMessage;-;")) { + participantIMessageMatch = guid; + } else if (guid.startsWith("SMS;-;") && !participantSmsMatch) { + participantSmsMatch = guid; + } else if (!participantSmsMatch) { + participantSmsMatch = guid; + } } } } } } + // If we already found an iMessage direct or participant match we can stop + // scanning further pages; any later SMS chat would still lose out. + if (directHandleIMessageMatch || participantIMessageMatch) { + break; + } } - return participantMatch; + return ( + directHandleIMessageMatch ?? + participantIMessageMatch ?? + directHandleSmsMatch ?? + participantSmsMatch + ); } /**