Skip to content

Commit 4c85fd8

Browse files
authored
BlueBubbles: enrich group participants with local Contacts names (#54984)
* BlueBubbles: enrich group participants with Contacts names * BlueBubbles: gate contact enrichment behind opt in config
1 parent f92c925 commit 4c85fd8

File tree

11 files changed

+730
-2
lines changed

11 files changed

+730
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
4040
- 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.
4141
- 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
4242
- 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
43+
- 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.
4344

4445
## 2026.3.24
4546

docs/.generated/config-baseline.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8540,6 +8540,16 @@
85408540
"tags": [],
85418541
"hasChildren": false
85428542
},
8543+
{
8544+
"path": "channels.bluebubbles.accounts.*.enrichGroupParticipantsFromContacts",
8545+
"kind": "channel",
8546+
"type": "boolean",
8547+
"required": false,
8548+
"deprecated": false,
8549+
"sensitive": false,
8550+
"tags": [],
8551+
"hasChildren": false
8552+
},
85438553
{
85448554
"path": "channels.bluebubbles.accounts.*.groupAllowFrom",
85458555
"kind": "channel",
@@ -9081,6 +9091,16 @@
90819091
"tags": [],
90829092
"hasChildren": false
90839093
},
9094+
{
9095+
"path": "channels.bluebubbles.enrichGroupParticipantsFromContacts",
9096+
"kind": "channel",
9097+
"type": "boolean",
9098+
"required": false,
9099+
"deprecated": false,
9100+
"sensitive": false,
9101+
"tags": [],
9102+
"hasChildren": false
9103+
},
90849104
{
90859105
"path": "channels.bluebubbles.groupAllowFrom",
90869106
"kind": "channel",

docs/.generated/config-baseline.jsonl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5643}
1+
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5645}
22
{"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}
33
{"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}
44
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -746,6 +746,7 @@
746746
{"recordType":"path","path":"channels.bluebubbles.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
747747
{"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}
748748
{"recordType":"path","path":"channels.bluebubbles.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
749+
{"recordType":"path","path":"channels.bluebubbles.accounts.*.enrichGroupParticipantsFromContacts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
749750
{"recordType":"path","path":"channels.bluebubbles.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
750751
{"recordType":"path","path":"channels.bluebubbles.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
751752
{"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 @@
795796
{"recordType":"path","path":"channels.bluebubbles.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
796797
{"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}
797798
{"recordType":"path","path":"channels.bluebubbles.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
799+
{"recordType":"path","path":"channels.bluebubbles.enrichGroupParticipantsFromContacts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
798800
{"recordType":"path","path":"channels.bluebubbles.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
799801
{"recordType":"path","path":"channels.bluebubbles.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
800802
{"recordType":"path","path":"channels.bluebubbles.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

docs/channels/bluebubbles.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,25 @@ Groups:
162162
- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`).
163163
- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
164164

165+
### Contact name enrichment (macOS, optional)
166+
167+
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:
168+
169+
- `channels.bluebubbles.enrichGroupParticipantsFromContacts = true` enables the lookup. Default: `false`.
170+
- Lookups run only after group access, command authorization, and mention gating have allowed the message through.
171+
- Only unnamed phone participants are enriched.
172+
- Raw phone numbers remain as the fallback when no local match is found.
173+
174+
```json5
175+
{
176+
channels: {
177+
bluebubbles: {
178+
enrichGroupParticipantsFromContacts: true,
179+
},
180+
},
181+
}
182+
```
183+
165184
### Mention gating (groups)
166185

167186
BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior:
@@ -300,6 +319,7 @@ Provider options:
300319
- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`).
301320
- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`).
302321
- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist.
322+
- `channels.bluebubbles.enrichGroupParticipantsFromContacts`: On macOS, optionally enrich unnamed group participants from local Contacts after gating passes. Default: `false`.
303323
- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.).
304324
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
305325
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies).

docs/channels/groups.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ Group inbound payloads set:
366366
- `WasMentioned` (mention gating result)
367367
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
368368

369+
Channel specific notes:
370+
371+
- 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.
372+
369373
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.
370374

371375
## iMessage specifics

extensions/bluebubbles/src/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const bluebubblesAccountSchema = z
4242
allowFrom: AllowFromListSchema,
4343
groupAllowFrom: AllowFromListSchema,
4444
groupPolicy: GroupPolicySchema.optional(),
45+
enrichGroupParticipantsFromContacts: z.boolean().optional(),
4546
historyLimit: z.number().int().min(0).optional(),
4647
dmHistoryLimit: z.number().int().min(0).optional(),
4748
textChunkLimit: z.number().int().positive().optional(),

extensions/bluebubbles/src/monitor-processing.ts

Lines changed: 13 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";
@@ -783,6 +784,18 @@ export async function processMessage(
783784
return;
784785
}
785786

787+
if (
788+
isGroup &&
789+
account.config.enrichGroupParticipantsFromContacts === true &&
790+
message.participants?.length
791+
) {
792+
// BlueBubbles only gives us participant handles, so enrich phone numbers from local Contacts
793+
// after access, command, and mention gating have already allowed the message through.
794+
message.participants = await enrichBlueBubblesParticipantsWithContactNames(
795+
message.participants,
796+
);
797+
}
798+
786799
// Cache allowed inbound messages so later replies can resolve sender/body without
787800
// surfacing dropped content (allowlist/mention/command gating).
788801
cacheInboundMessage();

extensions/bluebubbles/src/monitor.test.ts

Lines changed: 96 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,92 @@ 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("does not enrich group participants unless the config flag is enabled", async () => {
503+
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
504+
setupWebhookTarget();
505+
setBlueBubblesParticipantContactDepsForTest({
506+
platform: "darwin",
507+
resolvePhoneNames,
508+
});
509+
510+
const payload = createTimestampedNewMessagePayloadForTest({
511+
text: "hello bert",
512+
isGroup: true,
513+
chatGuid: "iMessage;+;chat123456",
514+
chatName: "Family",
515+
participants: [{ address: "+15551234567" }],
516+
});
517+
518+
await dispatchWebhookPayload(payload);
519+
520+
expect(resolvePhoneNames).not.toHaveBeenCalled();
521+
expect(getFirstDispatchCall().ctx.GroupMembers).toBe("+15551234567");
522+
});
523+
524+
it("enriches unnamed phone participants from local contacts after gating passes", async () => {
525+
const resolvePhoneNames = vi.fn(
526+
async (phoneKeys: string[]) =>
527+
new Map(
528+
phoneKeys.map((phoneKey) => [
529+
phoneKey,
530+
phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact",
531+
]),
532+
),
533+
);
534+
setupWebhookTarget({
535+
account: createMockAccount({
536+
enrichGroupParticipantsFromContacts: true,
537+
}),
538+
});
539+
setBlueBubblesParticipantContactDepsForTest({
540+
platform: "darwin",
541+
resolvePhoneNames,
542+
});
543+
544+
const payload = createTimestampedNewMessagePayloadForTest({
545+
text: "hello bert",
546+
isGroup: true,
547+
chatGuid: "iMessage;+;chat123456",
548+
chatName: "Family",
549+
participants: [{ address: "+15551234567" }, { address: "+15557654321" }],
550+
});
551+
552+
await dispatchWebhookPayload(payload);
553+
554+
expect(resolvePhoneNames).toHaveBeenCalledTimes(1);
555+
const callArgs = getFirstDispatchCall();
556+
expect(callArgs.ctx.GroupMembers).toBe(
557+
"Alice Contact (+15551234567), Bob Contact (+15557654321)",
558+
);
559+
});
560+
561+
it("does not read local contacts before mention gating allows the message", async () => {
562+
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
563+
setupWebhookTarget({
564+
account: createMockAccount({
565+
enrichGroupParticipantsFromContacts: true,
566+
}),
567+
});
568+
setBlueBubblesParticipantContactDepsForTest({
569+
platform: "darwin",
570+
resolvePhoneNames,
571+
});
572+
mockResolveRequireMention.mockReturnValueOnce(true);
573+
574+
const payload = createTimestampedNewMessagePayloadForTest({
575+
text: "hello group",
576+
isGroup: true,
577+
chatGuid: "iMessage;+;chat123456",
578+
chatName: "Family",
579+
participants: [{ address: "+15551234567" }],
580+
});
581+
582+
await dispatchWebhookPayload(payload);
583+
584+
expect(resolvePhoneNames).not.toHaveBeenCalled();
585+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
586+
});
492587
});
493588

494589
describe("group sender identity in envelope", () => {

0 commit comments

Comments
 (0)