BlueBubbles: enrich group participants with local Contacts names (#54984)

* BlueBubbles: enrich group participants with Contacts names

* BlueBubbles: gate contact enrichment behind opt in config
This commit is contained in:
Tyler Yust 2026-03-26 18:38:37 +09:00 committed by GitHub
parent f92c92515b
commit 4c85fd8569
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 730 additions and 2 deletions

View File

@ -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

View File

@ -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",

View File

@ -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}

View File

@ -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).

View File

@ -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

View File

@ -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(),

View File

@ -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();

View File

@ -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", () => {

View File

@ -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<string, string>());
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<string, string>());
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<string, string>());
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);
});
});

View File

@ -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<Map<string, string>>;
type ExecFileRunner = (
file: string,
args: string[],
options: ExecFileOptionsWithStringEncoding,
) => Promise<{ stdout: string; stderr: string }>;
type ReadDirRunner = (path: string) => Promise<string[]>;
type AccessRunner = (path: string) => Promise<unknown>;
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<string, ContactNameCacheEntry>();
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<string>();
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<boolean> {
try {
await deps.access(path);
return true;
} catch {
return false;
}
}
async function listContactsDatabases(deps: ResolvedParticipantContactNameDeps): Promise<string[]> {
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<Array<{ phoneKey: string; name: string }>> {
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<Map<string, string>> {
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<string, string>();
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<BlueBubblesParticipant[]> {
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<string>();
const cachedNames = new Map<string, string>();
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<string[]> {
return listContactsDatabases(resolveLookupDeps(deps));
}
export async function queryBlueBubblesContactsDatabaseForTest(
dbPath: string,
phoneKeys: string[],
deps?: ParticipantContactNameDeps,
): Promise<Array<{ phoneKey: string; name: string }>> {
return queryContactsDatabase(dbPath, phoneKeys, resolveLookupDeps(deps));
}
export async function resolveBlueBubblesParticipantContactNamesFromMacOsContactsForTest(
phoneKeys: string[],
deps?: ParticipantContactNameDeps,
): Promise<Map<string, string>> {
return resolvePhoneNamesFromMacOsContacts(phoneKeys, resolveLookupDeps(deps));
}
export function resetBlueBubblesParticipantContactNameCacheForTest(): void {
participantContactNameCache.clear();
}
export function setBlueBubblesParticipantContactDepsForTest(
deps?: ParticipantContactNameDeps,
): void {
participantContactNameDepsForTest = deps;
participantContactNameCache.clear();
}

View File

@ -33,6 +33,8 @@ export type BlueBubblesAccountConfig = {
groupAllowFrom?: Array<string | number>;
/** 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. */