From cc077ef1ef1e8f46dd5c6deee99bb3169bcf94f0 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Thu, 26 Mar 2026 05:44:18 -0700 Subject: [PATCH] fix(bluebubbles): enable group participant enrichment by default, add fallback fetch and handle field aliases --- extensions/bluebubbles/src/config-schema.ts | 2 +- .../bluebubbles/src/monitor-normalize.test.ts | 42 +++++ .../bluebubbles/src/monitor-normalize.ts | 41 +++-- .../bluebubbles/src/monitor-processing.ts | 157 +++++++++++++++++- extensions/bluebubbles/src/monitor.test.ts | 67 +++++++- .../src/monitor.webhook-auth.test.ts | 8 + .../bluebubbles/src/setup-surface.test.ts | 31 ++++ extensions/bluebubbles/src/types.ts | 2 +- 8 files changed, 331 insertions(+), 19 deletions(-) diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index d5746ea9e4c..0e06e0d134d 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -42,7 +42,7 @@ const bluebubblesAccountSchema = z allowFrom: AllowFromListSchema, groupAllowFrom: AllowFromListSchema, groupPolicy: GroupPolicySchema.optional(), - enrichGroupParticipantsFromContacts: z.boolean().optional(), + enrichGroupParticipantsFromContacts: z.boolean().optional().default(true), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), textChunkLimit: z.number().int().positive().optional(), diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts index 62651279237..d101fa61ccf 100644 --- a/extensions/bluebubbles/src/monitor-normalize.test.ts +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -78,6 +78,48 @@ describe("normalizeWebhookMessage", () => { expect(result).not.toBeNull(); expect(result?.senderId).toBe("+15551234567"); }); + + it("normalizes participant handles from the handles field", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: { + guid: "msg-handles-1", + text: "hello group", + isGroup: true, + isFromMe: false, + handle: { address: "+15550000000" }, + chatGuid: "iMessage;+;chat123456", + handles: [ + { address: "+15551234567", displayName: "Alice" }, + { address: "+15557654321", displayName: "Bob" }, + ], + }, + }); + + expect(result).not.toBeNull(); + expect(result?.participants).toEqual([ + { id: "+15551234567", name: "Alice" }, + { id: "+15557654321", name: "Bob" }, + ]); + }); + + it("normalizes participant handles from the participantHandles field", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: { + guid: "msg-participant-handles-1", + text: "hello group", + isGroup: true, + isFromMe: false, + handle: { address: "+15550000000" }, + chatGuid: "iMessage;+;chat123456", + participantHandles: [{ address: "+15551234567" }, "+15557654321"], + }, + }); + + expect(result).not.toBeNull(); + expect(result?.participants).toEqual([{ id: "+15551234567" }, { id: "+15557654321" }]); + }); }); describe("normalizeWebhookReaction", () => { diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 339f380ba89..b59c285ece3 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -189,6 +189,25 @@ function readFirstChatRecord(message: Record): Record | null): unknown[] | undefined { + if (!record) { + return undefined; + } + const participants = record["participants"]; + if (Array.isArray(participants)) { + return participants; + } + const handles = record["handles"]; + if (Array.isArray(handles)) { + return handles; + } + const participantHandles = record["participantHandles"]; + if (Array.isArray(participantHandles)) { + return participantHandles; + } + return undefined; +} + function extractSenderInfo(message: Record): { senderId: string; senderIdExplicit: boolean; @@ -265,16 +284,11 @@ function extractChatContext(message: Record): { readString(chatFromList, "name") ?? undefined; - const chatParticipants = chat ? chat["participants"] : undefined; - const messageParticipants = message["participants"]; - const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; - const participants = Array.isArray(chatParticipants) - ? chatParticipants - : Array.isArray(messageParticipants) - ? messageParticipants - : Array.isArray(chatsParticipants) - ? chatsParticipants - : []; + const participants = + readParticipantEntries(chat) ?? + readParticipantEntries(message) ?? + readParticipantEntries(chatFromList) ?? + []; const participantsCount = participants.length; const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid); const explicitIsGroup = @@ -336,13 +350,14 @@ function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | nul return { id: normalizedId, name }; } -function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] { - if (!Array.isArray(raw) || raw.length === 0) { +export function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] { + const entries = Array.isArray(raw) ? raw : (readParticipantEntries(asRecord(raw)) ?? []); + if (entries.length === 0) { return []; } const seen = new Set(); const output: BlueBubblesParticipant[] = []; - for (const entry of raw) { + for (const entry of entries) { const normalized = normalizeParticipantEntry(entry); if (!normalized?.id) { continue; diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index c5e39e3d558..7809dcde0be 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -12,6 +12,7 @@ import { formatGroupAllowlistEntry, formatGroupMembers, formatReplyTag, + normalizeParticipantList, parseTapbackText, resolveGroupFlagFromChatGuid, resolveTapbackContext, @@ -62,6 +63,7 @@ import { isAllowedBlueBubblesSender, normalizeBlueBubblesHandle, } from "./targets.js"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); @@ -94,6 +96,134 @@ function normalizeSnippet(value: string): string { return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase(); } +type BlueBubblesChatRecord = Record; + +function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined) { + return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; +} + +function extractBlueBubblesChatGuid(chat: BlueBubblesChatRecord): string | undefined { + const candidates = [chat.chatGuid, chat.guid, chat.chat_guid]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return undefined; +} + +function extractBlueBubblesChatId(chat: BlueBubblesChatRecord): number | undefined { + const candidates = [chat.chatId, chat.id, chat.chat_id]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + } + return undefined; +} + +function extractChatIdentifierFromChatGuid(chatGuid: string): string | undefined { + const parts = chatGuid.split(";"); + if (parts.length < 3) { + return undefined; + } + const identifier = parts[2]?.trim(); + return identifier || undefined; +} + +function extractBlueBubblesChatIdentifier(chat: BlueBubblesChatRecord): string | undefined { + const candidates = [chat.chatIdentifier, chat.chat_identifier, chat.identifier]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + const chatGuid = extractBlueBubblesChatGuid(chat); + return chatGuid ? extractChatIdentifierFromChatGuid(chatGuid) : undefined; +} + +async function queryBlueBubblesChats(params: { + baseUrl: string; + password: string; + timeoutMs?: number; + offset: number; + limit: number; + allowPrivateNetwork?: boolean; +}): Promise { + const url = buildBlueBubblesApiUrl({ + baseUrl: params.baseUrl, + path: "/api/v1/chat/query", + password: params.password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + limit: params.limit, + offset: params.offset, + with: ["participants"], + }), + }, + params.timeoutMs, + blueBubblesPolicy(params.allowPrivateNetwork), + ); + if (!res.ok) { + return []; + } + const payload = (await res.json().catch(() => null)) as Record | null; + const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null; + return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; +} + +async function fetchBlueBubblesParticipantsForInboundMessage(params: { + baseUrl: string; + password: string; + chatGuid?: string; + chatId?: number; + chatIdentifier?: string; + allowPrivateNetwork?: boolean; +}): Promise { + if (!params.chatGuid && params.chatId == null && !params.chatIdentifier) { + return null; + } + + const limit = 500; + for (let offset = 0; offset < 5000; offset += limit) { + const chats = await queryBlueBubblesChats({ + baseUrl: params.baseUrl, + password: params.password, + offset, + limit, + allowPrivateNetwork: params.allowPrivateNetwork, + }); + if (chats.length === 0) { + return null; + } + + for (const chat of chats) { + const chatGuid = extractBlueBubblesChatGuid(chat); + const chatId = extractBlueBubblesChatId(chat); + const chatIdentifier = extractBlueBubblesChatIdentifier(chat); + const matches = + (params.chatGuid && chatGuid === params.chatGuid) || + (params.chatId != null && chatId === params.chatId) || + (params.chatIdentifier && + (chatIdentifier === params.chatIdentifier || chatGuid === params.chatIdentifier)); + if (matches) { + return normalizeParticipantList(chat); + } + } + + if (chats.length < limit) { + return null; + } + } + + return null; +} + function isBlueBubblesSelfChatMessage( message: NormalizedWebhookMessage, isGroup: boolean, @@ -784,6 +914,31 @@ export async function processMessage( return; } + const baseUrl = normalizeSecretInputString(account.config.serverUrl); + const password = normalizeSecretInputString(account.config.password); + + if (isGroup && !message.participants?.length && baseUrl && password) { + try { + const fetchedParticipants = await fetchBlueBubblesParticipantsForInboundMessage({ + baseUrl, + password, + chatGuid: message.chatGuid, + chatId: message.chatId, + chatIdentifier: message.chatIdentifier, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }); + if (fetchedParticipants?.length) { + message.participants = fetchedParticipants; + } + } catch (err) { + logVerbose( + core, + runtime, + `bluebubbles: participant fallback lookup failed chat=${peerId}: ${String(err)}`, + ); + } + } + if ( isGroup && account.config.enrichGroupParticipantsFromContacts === true && @@ -800,8 +955,6 @@ export async function processMessage( // surfacing dropped content (allowlist/mention/command gating). cacheInboundMessage(); - const baseUrl = normalizeSecretInputString(account.config.serverUrl); - const password = normalizeSecretInputString(account.config.password); const maxBytes = account.config.mediaMaxMb && account.config.mediaMaxMb > 0 ? account.config.mediaMaxMb * 1024 * 1024 diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 918e019ffaf..7fd9edad9ec 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -108,6 +108,7 @@ const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockResolveChunkMode = vi.fn(() => "length" as const); const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); +const mockFetch = vi.fn(); function createMockRuntime(): PluginRuntime { return createBlueBubblesMonitorTestRuntime({ @@ -180,6 +181,12 @@ describe("BlueBubbles webhook monitor", () => { } beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); resetBlueBubblesMonitorTestState({ createRuntime: createMockRuntime, fetchHistoryMock: mockFetchBlueBubblesHistory, @@ -201,6 +208,7 @@ describe("BlueBubbles webhook monitor", () => { unregister?.(); setBlueBubblesParticipantContactDepsForTest(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); describe("DM pairing behavior vs allowFrom", () => { @@ -499,9 +507,13 @@ describe("BlueBubbles webhook monitor", () => { expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)"); }); - it("does not enrich group participants unless the config flag is enabled", async () => { + it("does not enrich group participants when the config flag is disabled", async () => { const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]])); - setupWebhookTarget(); + setupWebhookTarget({ + account: createMockAccount({ + enrichGroupParticipantsFromContacts: false, + }), + }); setBlueBubblesParticipantContactDepsForTest({ platform: "darwin", resolvePhoneNames, @@ -558,6 +570,57 @@ describe("BlueBubbles webhook monitor", () => { ); }); + it("fetches missing group participants from the BlueBubbles API before contact enrichment", async () => { + const resolvePhoneNames = vi.fn( + async (phoneKeys: string[]) => + new Map( + phoneKeys.map((phoneKey) => [ + phoneKey, + phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact", + ]), + ), + ); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;+;chat123456", + participants: [{ address: "+15551234567" }, { address: "+15557654321" }], + }, + ], + }), + }); + setupWebhookTarget({ + account: createMockAccount({ + enrichGroupParticipantsFromContacts: true, + }), + }); + setBlueBubblesParticipantContactDepsForTest({ + platform: "darwin", + resolvePhoneNames, + }); + + const payload = createTimestampedNewMessagePayloadForTest({ + text: "hello bert", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + chatName: "Family", + }); + + await dispatchWebhookPayload(payload); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/query"), + expect.objectContaining({ method: "POST" }), + ); + expect(resolvePhoneNames).toHaveBeenCalledTimes(1); + expect(getFirstDispatchCall().ctx.GroupMembers).toBe( + "Alice Contact (+15551234567), Bob Contact (+15557654321)", + ); + }); + it("does not read local contacts before mention gating allows the message", async () => { const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]])); setupWebhookTarget({ diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 12687031481..306af6cf1a8 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -107,6 +107,7 @@ const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockResolveChunkMode = vi.fn(() => "length" as const); const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); +const mockFetch = vi.fn(); const TEST_WEBHOOK_PASSWORD = "secret-token"; function createMockRuntime(): PluginRuntime { @@ -142,6 +143,12 @@ describe("BlueBubbles webhook monitor", () => { let unregister: () => void; beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); resetBlueBubblesMonitorTestState({ createRuntime: createMockRuntime, fetchHistoryMock: mockFetchBlueBubblesHistory, @@ -156,6 +163,7 @@ describe("BlueBubbles webhook monitor", () => { afterEach(() => { unregister?.(); + vi.unstubAllGlobals(); }); function setupWebhookTarget(params?: { diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts index a6c1417fcbb..077b3a410fb 100644 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -239,6 +239,37 @@ describe("BlueBubblesConfigSchema", () => { }); expect(parsed.success).toBe(true); }); + + it("defaults enrichGroupParticipantsFromContacts to true", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + serverUrl: "http://localhost:1234", + password: "secret", // pragma: allowlist secret + }); + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data.enrichGroupParticipantsFromContacts).toBe(true); + }); + + it("defaults account enrichGroupParticipantsFromContacts to true", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + accounts: { + work: { + serverUrl: "http://localhost:1234", + password: "secret", // pragma: allowlist secret + }, + }, + }); + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + const accountConfig = ( + parsed.data as { accounts?: { work?: { enrichGroupParticipantsFromContacts?: boolean } } } + ).accounts?.work; + expect(accountConfig?.enrichGroupParticipantsFromContacts).toBe(true); + }); }); describe("bluebubbles group policy", () => { diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index fba3d3c4575..25d8885a2a0 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -33,7 +33,7 @@ export type BlueBubblesAccountConfig = { groupAllowFrom?: Array; /** Group message handling policy. */ groupPolicy?: GroupPolicy; - /** Enrich unnamed group participants with local macOS Contacts names after gating. Default: false. */ + /** Enrich unnamed group participants with local macOS Contacts names after gating. Default: true. */ enrichGroupParticipantsFromContacts?: boolean; /** Max group messages to keep as history context (0 disables). */ historyLimit?: number;