diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0d99260ff..02cda913ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - CLI/message send: write manual `openclaw message send` deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617. - CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider - WhatsApp: fix infinite echo loop in self-chat DM mode where the bot's own outbound replies were re-processed as new inbound user messages. (#54570) Thanks @joelnishanth +- BlueBubbles/groups: optionally enrich unnamed participant lists with local macOS Contacts names after group gating passes, so group member context can show names instead of only raw phone numbers. ## 2026.3.24 diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index b91f6a63fd1..6a68aeb0bd0 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8540,6 +8540,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.bluebubbles.accounts.*.enrichGroupParticipantsFromContacts", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.bluebubbles.accounts.*.groupAllowFrom", "kind": "channel", @@ -9081,6 +9091,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.bluebubbles.enrichGroupParticipantsFromContacts", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.bluebubbles.groupAllowFrom", "kind": "channel", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index c9885a0dfd5..f9e44ff4b57 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5643} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5645} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -746,6 +746,7 @@ {"recordType":"path","path":"channels.bluebubbles.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.accounts.*.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["pairing","allowlist","open","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.bluebubbles.accounts.*.enrichGroupParticipantsFromContacts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.bluebubbles.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -795,6 +796,7 @@ {"recordType":"path","path":"channels.bluebubbles.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["pairing","allowlist","open","disabled"],"deprecated":false,"sensitive":false,"tags":["access","channels","network"],"label":"BlueBubbles DM Policy","help":"Direct message access control (\"pairing\" recommended). \"open\" requires channels.bluebubbles.allowFrom=[\"*\"].","hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.bluebubbles.enrichGroupParticipantsFromContacts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.bluebubbles.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.bluebubbles.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index bf328656ff3..ef171b7882d 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -162,6 +162,25 @@ Groups: - `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`). - `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +### Contact name enrichment (macOS, optional) + +BlueBubbles group webhooks often only include raw participant addresses. If you want `GroupMembers` context to show local contact names instead, you can opt in to local Contacts enrichment on macOS: + +- `channels.bluebubbles.enrichGroupParticipantsFromContacts = true` enables the lookup. Default: `false`. +- Lookups run only after group access, command authorization, and mention gating have allowed the message through. +- Only unnamed phone participants are enriched. +- Raw phone numbers remain as the fallback when no local match is found. + +```json5 +{ + channels: { + bluebubbles: { + enrichGroupParticipantsFromContacts: true, + }, + }, +} +``` + ### Mention gating (groups) BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior: @@ -300,6 +319,7 @@ Provider options: - `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`). - `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`). - `channels.bluebubbles.groupAllowFrom`: Group sender allowlist. +- `channels.bluebubbles.enrichGroupParticipantsFromContacts`: On macOS, optionally enrich unnamed group participants from local Contacts after gating passes. Default: `false`. - `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.). - `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). - `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies). diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 97822134d6a..f7aa57633aa 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -366,6 +366,10 @@ Group inbound payloads set: - `WasMentioned` (mention gating result) - Telegram forum topics also include `MessageThreadId` and `IsForum`. +Channel specific notes: + +- BlueBubbles can optionally enrich unnamed macOS group participants from the local Contacts database before populating `GroupMembers`. This is off by default and only runs after normal group gating passes. + The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences. ## iMessage specifics diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 7dab48feec5..d5746ea9e4c 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -42,6 +42,7 @@ const bluebubblesAccountSchema = z allowFrom: AllowFromListSchema, groupAllowFrom: AllowFromListSchema, groupPolicy: GroupPolicySchema.optional(), + enrichGroupParticipantsFromContacts: z.boolean().optional(), 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-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 71a867725a5..c5e39e3d558 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -33,6 +33,7 @@ import type { BlueBubblesRuntimeEnv, WebhookTarget, } from "./monitor-shared.js"; +import { enrichBlueBubblesParticipantsWithContactNames } from "./participant-contact-names.js"; import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import type { OpenClawConfig } from "./runtime-api.js"; @@ -783,6 +784,18 @@ export async function processMessage( return; } + if ( + isGroup && + account.config.enrichGroupParticipantsFromContacts === true && + message.participants?.length + ) { + // BlueBubbles only gives us participant handles, so enrich phone numbers from local Contacts + // after access, command, and mention gating have already allowed the message through. + message.participants = await enrichBlueBubblesParticipantsWithContactNames( + message.participants, + ); + } + // Cache allowed inbound messages so later replies can resolve sender/body without // surfacing dropped content (allowlist/mention/command gating). cacheInboundMessage(); diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 410df46fc79..918e019ffaf 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -23,6 +23,10 @@ import { setupWebhookTargetsForTest, trackWebhookRegistrationForTest, } from "./monitor.webhook.test-helpers.js"; +import { + resetBlueBubblesParticipantContactNameCacheForTest, + setBlueBubblesParticipantContactDepsForTest, +} from "./participant-contact-names.js"; import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; // Mock dependencies @@ -185,12 +189,17 @@ describe("BlueBubbles webhook monitor", () => { hasControlCommandMock: mockHasControlCommand, resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers, buildMentionRegexesMock: mockBuildMentionRegexes, - extraReset: resetBlueBubblesSelfChatCache, + extraReset: () => { + resetBlueBubblesSelfChatCache(); + resetBlueBubblesParticipantContactNameCacheForTest(); + setBlueBubblesParticipantContactDepsForTest(); + }, }); }); afterEach(() => { unregister?.(); + setBlueBubblesParticipantContactDepsForTest(); vi.useRealTimers(); }); @@ -489,6 +498,92 @@ describe("BlueBubbles webhook monitor", () => { expect(callArgs.ctx.GroupSubject).toBe("Family"); expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)"); }); + + it("does not enrich group participants unless the config flag is enabled", async () => { + const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]])); + setupWebhookTarget(); + setBlueBubblesParticipantContactDepsForTest({ + platform: "darwin", + resolvePhoneNames, + }); + + const payload = createTimestampedNewMessagePayloadForTest({ + text: "hello bert", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + chatName: "Family", + participants: [{ address: "+15551234567" }], + }); + + await dispatchWebhookPayload(payload); + + expect(resolvePhoneNames).not.toHaveBeenCalled(); + expect(getFirstDispatchCall().ctx.GroupMembers).toBe("+15551234567"); + }); + + it("enriches unnamed phone participants from local contacts after gating passes", async () => { + const resolvePhoneNames = vi.fn( + async (phoneKeys: string[]) => + new Map( + phoneKeys.map((phoneKey) => [ + phoneKey, + phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact", + ]), + ), + ); + setupWebhookTarget({ + account: createMockAccount({ + enrichGroupParticipantsFromContacts: true, + }), + }); + setBlueBubblesParticipantContactDepsForTest({ + platform: "darwin", + resolvePhoneNames, + }); + + const payload = createTimestampedNewMessagePayloadForTest({ + text: "hello bert", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + chatName: "Family", + participants: [{ address: "+15551234567" }, { address: "+15557654321" }], + }); + + await dispatchWebhookPayload(payload); + + expect(resolvePhoneNames).toHaveBeenCalledTimes(1); + const callArgs = getFirstDispatchCall(); + expect(callArgs.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({ + account: createMockAccount({ + enrichGroupParticipantsFromContacts: true, + }), + }); + setBlueBubblesParticipantContactDepsForTest({ + platform: "darwin", + resolvePhoneNames, + }); + mockResolveRequireMention.mockReturnValueOnce(true); + + const payload = createTimestampedNewMessagePayloadForTest({ + text: "hello group", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + chatName: "Family", + participants: [{ address: "+15551234567" }], + }); + + await dispatchWebhookPayload(payload); + + expect(resolvePhoneNames).not.toHaveBeenCalled(); + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); }); describe("group sender identity in envelope", () => { diff --git a/extensions/bluebubbles/src/participant-contact-names.test.ts b/extensions/bluebubbles/src/participant-contact-names.test.ts new file mode 100644 index 00000000000..f415b30b4fb --- /dev/null +++ b/extensions/bluebubbles/src/participant-contact-names.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + enrichBlueBubblesParticipantsWithContactNames, + listBlueBubblesContactsDatabasesForTest, + queryBlueBubblesContactsDatabaseForTest, + resetBlueBubblesParticipantContactNameCacheForTest, + resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest, +} from "./participant-contact-names.js"; + +describe("enrichBlueBubblesParticipantsWithContactNames", () => { + beforeEach(() => { + resetBlueBubblesParticipantContactNameCacheForTest(); + }); + + it("enriches unnamed phone participants and reuses cached names across formats", async () => { + const resolver = vi.fn( + async (phoneKeys: string[]) => + new Map( + phoneKeys.map((phoneKey) => [ + phoneKey, + phoneKey === "5551234567" ? "Alice Example" : "Bob Example", + ]), + ), + ); + + const first = await enrichBlueBubblesParticipantsWithContactNames( + [{ id: "+1 (555) 123-4567" }, { id: "+15557654321" }], + { + platform: "darwin", + now: () => 1_000, + resolvePhoneNames: resolver, + }, + ); + + expect(first).toEqual([ + { id: "+1 (555) 123-4567", name: "Alice Example" }, + { id: "+15557654321", name: "Bob Example" }, + ]); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith(["5551234567", "5557654321"]); + + const secondResolver = vi.fn(async () => new Map()); + const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { + platform: "darwin", + now: () => 2_000, + resolvePhoneNames: secondResolver, + }); + + expect(second).toEqual([{ id: "+15551234567", name: "Alice Example" }]); + expect(secondResolver).not.toHaveBeenCalled(); + }); + + it("retries negative cache entries after the short negative ttl expires", async () => { + const firstResolver = vi.fn(async () => new Map()); + const secondResolver = vi.fn(async () => new Map([["5551234567", "Alice Example"]])); + + const first = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { + platform: "darwin", + now: () => 1_000, + resolvePhoneNames: firstResolver, + }); + const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { + platform: "darwin", + now: () => 1_500, + resolvePhoneNames: secondResolver, + }); + const third = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], { + platform: "darwin", + now: () => 1_000 + 6 * 60 * 1000, + resolvePhoneNames: secondResolver, + }); + + expect(first).toEqual([{ id: "+15551234567" }]); + expect(second).toEqual([{ id: "+15551234567" }]); + expect(third).toEqual([{ id: "+15551234567", name: "Alice Example" }]); + expect(firstResolver).toHaveBeenCalledTimes(1); + expect(secondResolver).toHaveBeenCalledTimes(1); + }); + + it("skips email addresses and keeps existing participant names", async () => { + const resolver = vi.fn(async () => new Map()); + + const participants = await enrichBlueBubblesParticipantsWithContactNames( + [{ id: "alice@example.com" }, { id: "+15551234567", name: "Alice Existing" }], + { + platform: "darwin", + now: () => 1_000, + resolvePhoneNames: resolver, + }, + ); + + expect(participants).toEqual([ + { id: "alice@example.com" }, + { id: "+15551234567", name: "Alice Existing" }, + ]); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("gracefully returns original participants when lookup fails", async () => { + const participants = [{ id: "+15551234567" }, { id: "+15557654321" }]; + + await expect( + enrichBlueBubblesParticipantsWithContactNames(participants, { + platform: "darwin", + now: () => 1_000, + resolvePhoneNames: vi.fn(async () => { + throw new Error("contacts unavailable"); + }), + }), + ).resolves.toBe(participants); + }); + + it("lists contacts databases from the current home directory", async () => { + const readdir = vi.fn(async () => ["source-a", "source-b"]); + const access = vi.fn(async (path: string) => { + if (!path.endsWith("source-a/AddressBook-v22.abcddb")) { + throw new Error("missing"); + } + }); + + const databases = await listBlueBubblesContactsDatabasesForTest({ + homeDir: "/Users/tester", + readdir, + access, + }); + + expect(readdir).toHaveBeenCalledWith( + "/Users/tester/Library/Application Support/AddressBook/Sources", + ); + expect(databases).toEqual([ + "/Users/tester/Library/Application Support/AddressBook/Sources/source-a/AddressBook-v22.abcddb", + ]); + }); + + it("queries only the requested phone keys in sqlite", async () => { + const execFileAsync = vi.fn(async (_file: string, _args: string[], _options: unknown) => ({ + stdout: "5551234567\tAlice Example\n5557654321\tBob Example\n", + stderr: "", + })); + + const rows = await queryBlueBubblesContactsDatabaseForTest( + "/tmp/AddressBook-v22.abcddb", + ["5551234567", "5557654321"], + { execFileAsync }, + ); + + expect(rows).toEqual([ + { phoneKey: "5551234567", name: "Alice Example" }, + { phoneKey: "5557654321", name: "Bob Example" }, + ]); + expect(execFileAsync).toHaveBeenCalledTimes(1); + const sql = execFileAsync.mock.calls[0]?.[1]?.[3]; + expect(sql).toContain("WHERE digits IN ('5551234567', '5557654321')"); + }); + + it("resolves names through the macOS contacts path across multiple databases", async () => { + const readdir = vi.fn(async () => ["source-a", "source-b"]); + const access = vi.fn(async () => undefined); + const execFileAsync = vi + .fn(async (_file: string, _args: string[], _options: unknown) => ({ + stdout: "", + stderr: "", + })) + .mockResolvedValueOnce({ stdout: "5551234567\tAlice Example\n", stderr: "" }) + .mockResolvedValueOnce({ stdout: "5557654321\tBob Example\n", stderr: "" }); + + const resolved = await resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest( + ["5551234567", "5557654321"], + { + homeDir: "/Users/tester", + readdir, + access, + execFileAsync, + }, + ); + + expect([...resolved.entries()]).toEqual([ + ["5551234567", "Alice Example"], + ["5557654321", "Bob Example"], + ]); + expect(execFileAsync).toHaveBeenCalledTimes(2); + }); + + it("skips contact lookup on non macOS hosts", async () => { + const participants = [{ id: "+15551234567" }]; + + const result = await enrichBlueBubblesParticipantsWithContactNames(participants, { + platform: "linux", + }); + + expect(result).toBe(participants); + }); +}); diff --git a/extensions/bluebubbles/src/participant-contact-names.ts b/extensions/bluebubbles/src/participant-contact-names.ts new file mode 100644 index 00000000000..1218801a0be --- /dev/null +++ b/extensions/bluebubbles/src/participant-contact-names.ts @@ -0,0 +1,377 @@ +import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; +import { access, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { BlueBubblesParticipant } from "./monitor-normalize.js"; + +const execFileAsync = promisify(execFile) as ExecFileRunner; +const CONTACT_NAME_CACHE_TTL_MS = 60 * 60 * 1000; +const NEGATIVE_CONTACT_NAME_CACHE_TTL_MS = 5 * 60 * 1000; +const MAX_PARTICIPANT_CONTACT_NAME_CACHE_ENTRIES = 2048; +const SQLITE_MAX_BUFFER = 8 * 1024 * 1024; +const SQLITE_PHONE_DIGITS_SQL = + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(p.ZFULLNUMBER, ''), ' ', ''), '(', ''), ')', ''), '-', ''), '+', ''), '.', ''), '\n', ''), '\r', '')"; + +type ContactNameCacheEntry = { + name?: string; + expiresAt: number; +}; + +type ResolvePhoneNamesFn = (phoneKeys: string[]) => Promise>; +type ExecFileRunner = ( + file: string, + args: string[], + options: ExecFileOptionsWithStringEncoding, +) => Promise<{ stdout: string; stderr: string }>; +type ReadDirRunner = (path: string) => Promise; +type AccessRunner = (path: string) => Promise; + +type ParticipantContactNameDeps = { + platform?: NodeJS.Platform; + now?: () => number; + resolvePhoneNames?: ResolvePhoneNamesFn; + homeDir?: string; + readdir?: ReadDirRunner; + access?: AccessRunner; + execFileAsync?: ExecFileRunner; +}; + +type ResolvedParticipantContactNameDeps = { + platform: NodeJS.Platform; + now: () => number; + resolvePhoneNames?: ResolvePhoneNamesFn; + homeDir?: string; + readdir: ReadDirRunner; + access: AccessRunner; + execFileAsync: ExecFileRunner; +}; + +const participantContactNameCache = new Map(); +let participantContactNameDepsForTest: ParticipantContactNameDeps | undefined; + +function normalizePhoneLookupKey(value: string): string | null { + const digits = value.replace(/\D/g, ""); + if (!digits) { + return null; + } + const normalized = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits; + return normalized.length >= 7 ? normalized : null; +} + +function uniqueNormalizedPhoneLookupKeys(phoneKeys: string[]): string[] { + const unique = new Set(); + for (const phoneKey of phoneKeys) { + const normalized = normalizePhoneLookupKey(phoneKey); + if (normalized) { + unique.add(normalized); + } + } + return [...unique]; +} + +function resolveParticipantPhoneLookupKey(participant: BlueBubblesParticipant): string | null { + if (participant.id.includes("@")) { + return null; + } + return normalizePhoneLookupKey(participant.id); +} + +function trimParticipantContactNameCache(now: number): void { + for (const [phoneKey, entry] of participantContactNameCache) { + if (entry.expiresAt <= now) { + participantContactNameCache.delete(phoneKey); + } + } + while (participantContactNameCache.size > MAX_PARTICIPANT_CONTACT_NAME_CACHE_ENTRIES) { + const oldestPhoneKey = participantContactNameCache.keys().next().value; + if (!oldestPhoneKey) { + return; + } + participantContactNameCache.delete(oldestPhoneKey); + } +} + +function readFreshCacheEntry(phoneKey: string, now: number): ContactNameCacheEntry | null { + const cached = participantContactNameCache.get(phoneKey); + if (!cached) { + return null; + } + if (cached.expiresAt <= now) { + participantContactNameCache.delete(phoneKey); + return null; + } + participantContactNameCache.delete(phoneKey); + participantContactNameCache.set(phoneKey, cached); + return cached; +} + +function writeCacheEntry(phoneKey: string, name: string | undefined, now: number): void { + participantContactNameCache.delete(phoneKey); + participantContactNameCache.set(phoneKey, { + name, + expiresAt: now + (name ? CONTACT_NAME_CACHE_TTL_MS : NEGATIVE_CONTACT_NAME_CACHE_TTL_MS), + }); + trimParticipantContactNameCache(now); +} + +function buildAddressBookSourcesDir(homeDir?: string): string | null { + const trimmedHomeDir = homeDir?.trim(); + if (!trimmedHomeDir) { + return null; + } + return join(trimmedHomeDir, "Library", "Application Support", "AddressBook", "Sources"); +} + +async function fileExists( + path: string, + deps: ResolvedParticipantContactNameDeps, +): Promise { + try { + await deps.access(path); + return true; + } catch { + return false; + } +} + +async function listContactsDatabases(deps: ResolvedParticipantContactNameDeps): Promise { + const sourcesDir = buildAddressBookSourcesDir(deps.homeDir); + if (!sourcesDir) { + return []; + } + let entries: string[] = []; + try { + entries = await deps.readdir(sourcesDir); + } catch { + return []; + } + const databases: string[] = []; + for (const entry of entries) { + const dbPath = join(sourcesDir, entry, "AddressBook-v22.abcddb"); + if (await fileExists(dbPath, deps)) { + databases.push(dbPath); + } + } + return databases; +} + +function buildSqlitePhoneKeyList(phoneKeys: string[]): string { + return uniqueNormalizedPhoneLookupKeys(phoneKeys) + .map((phoneKey) => `'${phoneKey}'`) + .join(", "); +} + +async function queryContactsDatabase( + dbPath: string, + phoneKeys: string[], + deps: ResolvedParticipantContactNameDeps, +): Promise> { + const sqlitePhoneKeyList = buildSqlitePhoneKeyList(phoneKeys); + if (!sqlitePhoneKeyList) { + return []; + } + const sql = ` +SELECT digits, name +FROM ( + SELECT + ${SQLITE_PHONE_DIGITS_SQL} AS digits, + TRIM( + CASE + WHEN TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, '')) != '' + THEN TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, '')) + ELSE COALESCE(r.ZORGANIZATION, '') + END + ) AS name + FROM ZABCDRECORD r + JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK + WHERE p.ZFULLNUMBER IS NOT NULL +) +WHERE digits IN (${sqlitePhoneKeyList}) + AND name != ''; +`; + const options: ExecFileOptionsWithStringEncoding = { + encoding: "utf8", + maxBuffer: SQLITE_MAX_BUFFER, + }; + const { stdout } = await deps.execFileAsync( + "sqlite3", + ["-separator", "\t", dbPath, sql], + options, + ); + const rows: Array<{ phoneKey: string; name: string }> = []; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const [digitsRaw, ...nameParts] = trimmed.split("\t"); + const phoneKey = normalizePhoneLookupKey(digitsRaw ?? ""); + const name = nameParts.join("\t").trim(); + if (!phoneKey || !name) { + continue; + } + rows.push({ phoneKey, name }); + } + return rows; +} + +async function resolvePhoneNamesFromMacOsContacts( + phoneKeys: string[], + deps: ResolvedParticipantContactNameDeps, +): Promise> { + const normalizedPhoneKeys = uniqueNormalizedPhoneLookupKeys(phoneKeys); + if (normalizedPhoneKeys.length === 0) { + return new Map(); + } + const databases = await listContactsDatabases(deps); + if (databases.length === 0) { + return new Map(); + } + + const unresolved = new Set(normalizedPhoneKeys); + const resolved = new Map(); + for (const dbPath of databases) { + let rows: Array<{ phoneKey: string; name: string }> = []; + try { + rows = await queryContactsDatabase(dbPath, [...unresolved], deps); + } catch { + continue; + } + for (const row of rows) { + if (!unresolved.has(row.phoneKey) || resolved.has(row.phoneKey)) { + continue; + } + resolved.set(row.phoneKey, row.name); + unresolved.delete(row.phoneKey); + if (unresolved.size === 0) { + return resolved; + } + } + } + + return resolved; +} + +function resolveLookupDeps(deps?: ParticipantContactNameDeps): ResolvedParticipantContactNameDeps { + const merged = { + ...participantContactNameDepsForTest, + ...deps, + }; + return { + platform: merged.platform ?? process.platform, + now: merged.now ?? (() => Date.now()), + resolvePhoneNames: merged.resolvePhoneNames, + homeDir: merged.homeDir ?? process.env.HOME, + readdir: merged.readdir ?? readdir, + access: merged.access ?? access, + execFileAsync: merged.execFileAsync ?? execFileAsync, + }; +} + +export async function enrichBlueBubblesParticipantsWithContactNames( + participants: BlueBubblesParticipant[] | undefined, + deps?: ParticipantContactNameDeps, +): Promise { + if (!Array.isArray(participants) || participants.length === 0) { + return []; + } + + const resolvedDeps = resolveLookupDeps(deps); + const lookup = + resolvedDeps.resolvePhoneNames ?? + ((phoneKeys: string[]) => resolvePhoneNamesFromMacOsContacts(phoneKeys, resolvedDeps)); + const shouldAttemptLookup = + Boolean(resolvedDeps.resolvePhoneNames) || resolvedDeps.platform === "darwin"; + if (!shouldAttemptLookup) { + return participants; + } + + const nowMs = resolvedDeps.now(); + trimParticipantContactNameCache(nowMs); + const pendingPhoneKeys = new Set(); + const cachedNames = new Map(); + + for (const participant of participants) { + if (participant.name?.trim()) { + continue; + } + const phoneKey = resolveParticipantPhoneLookupKey(participant); + if (!phoneKey) { + continue; + } + const cached = readFreshCacheEntry(phoneKey, nowMs); + if (cached?.name) { + cachedNames.set(phoneKey, cached.name); + continue; + } + if (!cached) { + pendingPhoneKeys.add(phoneKey); + } + } + + if (pendingPhoneKeys.size > 0) { + try { + const resolved = await lookup([...pendingPhoneKeys]); + for (const phoneKey of pendingPhoneKeys) { + const name = resolved.get(phoneKey)?.trim() || undefined; + writeCacheEntry(phoneKey, name, nowMs); + if (name) { + cachedNames.set(phoneKey, name); + } + } + } catch { + return participants; + } + } + + let didChange = false; + const enriched = participants.map((participant) => { + if (participant.name?.trim()) { + return participant; + } + const phoneKey = resolveParticipantPhoneLookupKey(participant); + if (!phoneKey) { + return participant; + } + const name = cachedNames.get(phoneKey)?.trim(); + if (!name) { + return participant; + } + didChange = true; + return { ...participant, name }; + }); + + return didChange ? enriched : participants; +} + +export async function listBlueBubblesContactsDatabasesForTest( + deps?: ParticipantContactNameDeps, +): Promise { + return listContactsDatabases(resolveLookupDeps(deps)); +} + +export async function queryBlueBubblesContactsDatabaseForTest( + dbPath: string, + phoneKeys: string[], + deps?: ParticipantContactNameDeps, +): Promise> { + return queryContactsDatabase(dbPath, phoneKeys, resolveLookupDeps(deps)); +} + +export async function resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest( + phoneKeys: string[], + deps?: ParticipantContactNameDeps, +): Promise> { + return resolvePhoneNamesFromMacOsContacts(phoneKeys, resolveLookupDeps(deps)); +} + +export function resetBlueBubblesParticipantContactNameCacheForTest(): void { + participantContactNameCache.clear(); +} + +export function setBlueBubblesParticipantContactDepsForTest( + deps?: ParticipantContactNameDeps, +): void { + participantContactNameDepsForTest = deps; + participantContactNameCache.clear(); +} diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index ccb4337b3eb..fba3d3c4575 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -33,6 +33,8 @@ export type BlueBubblesAccountConfig = { groupAllowFrom?: Array; /** Group message handling policy. */ groupPolicy?: GroupPolicy; + /** Enrich unnamed group participants with local macOS Contacts names after gating. Default: false. */ + enrichGroupParticipantsFromContacts?: boolean; /** Max group messages to keep as history context (0 disables). */ historyLimit?: number; /** Max DM turns to keep as history context. */