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.
This commit is contained in:
Roy Martin
2026-04-06 02:12:55 -07:00
committed by Omar Shahine
parent 77b424b15e
commit 8a6e715470
2 changed files with 123 additions and 6 deletions

View File

@@ -220,6 +220,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.

View File

@@ -248,7 +248,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;-;<handle>` and `SMS;-;<handle>` 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,
@@ -299,10 +305,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.
@@ -312,14 +329,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
);
}
/**