Skip to content

Commit 2f7fc7d

Browse files
committed
BlueBubbles: enrich group participants with Contacts names
1 parent 06de515 commit 2f7fc7d

File tree

5 files changed

+407
-1
lines changed

5 files changed

+407
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
3636
- 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.
3737
- 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
3838
- 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
39+
- BlueBubbles/groups: enrich participant lists with local macOS Contacts names when available, so group member context shows names instead of only raw phone numbers.
3940

4041
## 2026.3.24
4142

extensions/bluebubbles/src/monitor-processing.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
BlueBubblesRuntimeEnv,
3434
WebhookTarget,
3535
} from "./monitor-shared.js";
36+
import { enrichBlueBubblesParticipantsWithContactNames } from "./participant-contact-names.js";
3637
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
3738
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
3839
import type { OpenClawConfig } from "./runtime-api.js";
@@ -460,6 +461,12 @@ export async function processMessage(
460461

461462
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
462463
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
464+
if (isGroup && message.participants?.length) {
465+
// BlueBubbles only gives us participant handles, so enrich phone numbers from local Contacts.
466+
message.participants = await enrichBlueBubblesParticipantsWithContactNames(
467+
message.participants,
468+
);
469+
}
463470

464471
const text = message.text.trim();
465472
const attachments = message.attachments ?? [];

extensions/bluebubbles/src/monitor.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
setupWebhookTargetsForTest,
2424
trackWebhookRegistrationForTest,
2525
} from "./monitor.webhook.test-helpers.js";
26+
import {
27+
resetBlueBubblesParticipantContactNameCacheForTest,
28+
setBlueBubblesParticipantContactDepsForTest,
29+
} from "./participant-contact-names.js";
2630
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
2731

2832
// Mock dependencies
@@ -185,12 +189,17 @@ describe("BlueBubbles webhook monitor", () => {
185189
hasControlCommandMock: mockHasControlCommand,
186190
resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers,
187191
buildMentionRegexesMock: mockBuildMentionRegexes,
188-
extraReset: resetBlueBubblesSelfChatCache,
192+
extraReset: () => {
193+
resetBlueBubblesSelfChatCache();
194+
resetBlueBubblesParticipantContactNameCacheForTest();
195+
setBlueBubblesParticipantContactDepsForTest();
196+
},
189197
});
190198
});
191199

192200
afterEach(() => {
193201
unregister?.();
202+
setBlueBubblesParticipantContactDepsForTest();
194203
vi.useRealTimers();
195204
});
196205

@@ -489,6 +498,37 @@ describe("BlueBubbles webhook monitor", () => {
489498
expect(callArgs.ctx.GroupSubject).toBe("Family");
490499
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
491500
});
501+
502+
it("enriches unnamed phone participants from local contacts before building ctx", async () => {
503+
setupWebhookTarget();
504+
setBlueBubblesParticipantContactDepsForTest({
505+
platform: "darwin",
506+
resolvePhoneNames: vi.fn(
507+
async (phoneKeys: string[]) =>
508+
new Map(
509+
phoneKeys.map((phoneKey) => [
510+
phoneKey,
511+
phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact",
512+
]),
513+
),
514+
),
515+
});
516+
517+
const payload = createTimestampedNewMessagePayloadForTest({
518+
text: "hello group",
519+
isGroup: true,
520+
chatGuid: "iMessage;+;chat123456",
521+
chatName: "Family",
522+
participants: [{ address: "+15551234567" }, { address: "+15557654321" }],
523+
});
524+
525+
await dispatchWebhookPayload(payload);
526+
527+
const callArgs = getFirstDispatchCall();
528+
expect(callArgs.ctx.GroupMembers).toBe(
529+
"Alice Contact (+15551234567), Bob Contact (+15557654321)",
530+
);
531+
});
492532
});
493533

494534
describe("group sender identity in envelope", () => {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
enrichBlueBubblesParticipantsWithContactNames,
4+
resetBlueBubblesParticipantContactNameCacheForTest,
5+
} from "./participant-contact-names.js";
6+
7+
describe("enrichBlueBubblesParticipantsWithContactNames", () => {
8+
beforeEach(() => {
9+
resetBlueBubblesParticipantContactNameCacheForTest();
10+
});
11+
12+
it("enriches unnamed phone participants and reuses cached names across formats", async () => {
13+
const resolver = vi.fn(
14+
async (phoneKeys: string[]) =>
15+
new Map(
16+
phoneKeys.map((phoneKey) => [
17+
phoneKey,
18+
phoneKey === "5551234567" ? "Alice Example" : "Bob Example",
19+
]),
20+
),
21+
);
22+
23+
const first = await enrichBlueBubblesParticipantsWithContactNames(
24+
[{ id: "+1 (555) 123-4567" }, { id: "+15557654321" }],
25+
{
26+
platform: "darwin",
27+
now: () => 1_000,
28+
resolvePhoneNames: resolver,
29+
},
30+
);
31+
32+
expect(first).toEqual([
33+
{ id: "+1 (555) 123-4567", name: "Alice Example" },
34+
{ id: "+15557654321", name: "Bob Example" },
35+
]);
36+
expect(resolver).toHaveBeenCalledTimes(1);
37+
expect(resolver).toHaveBeenCalledWith(["5551234567", "5557654321"]);
38+
39+
const secondResolver = vi.fn(async () => new Map<string, string>());
40+
const second = await enrichBlueBubblesParticipantsWithContactNames([{ id: "+15551234567" }], {
41+
platform: "darwin",
42+
now: () => 2_000,
43+
resolvePhoneNames: secondResolver,
44+
});
45+
46+
expect(second).toEqual([{ id: "+15551234567", name: "Alice Example" }]);
47+
expect(secondResolver).not.toHaveBeenCalled();
48+
});
49+
50+
it("skips email addresses and keeps existing participant names", async () => {
51+
const resolver = vi.fn(async () => new Map<string, string>());
52+
53+
const participants = await enrichBlueBubblesParticipantsWithContactNames(
54+
[{ id: "[email protected]" }, { id: "+15551234567", name: "Alice Existing" }],
55+
{
56+
platform: "darwin",
57+
now: () => 1_000,
58+
resolvePhoneNames: resolver,
59+
},
60+
);
61+
62+
expect(participants).toEqual([
63+
{ id: "[email protected]" },
64+
{ id: "+15551234567", name: "Alice Existing" },
65+
]);
66+
expect(resolver).not.toHaveBeenCalled();
67+
});
68+
69+
it("gracefully returns original participants when lookup fails", async () => {
70+
const participants = [{ id: "+15551234567" }, { id: "+15557654321" }];
71+
72+
await expect(
73+
enrichBlueBubblesParticipantsWithContactNames(participants, {
74+
platform: "darwin",
75+
now: () => 1_000,
76+
resolvePhoneNames: vi.fn(async () => {
77+
throw new Error("contacts unavailable");
78+
}),
79+
}),
80+
).resolves.toBe(participants);
81+
});
82+
83+
it("skips contact lookup on non macOS hosts", async () => {
84+
const participants = [{ id: "+15551234567" }];
85+
86+
const result = await enrichBlueBubblesParticipantsWithContactNames(participants, {
87+
platform: "linux",
88+
});
89+
90+
expect(result).toBe(participants);
91+
});
92+
});

0 commit comments

Comments
 (0)