mirror of https://github.com/openclaw/openclaw.git
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:
parent
f92c92515b
commit
4c85fd8569
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue