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 + ); } /**