@openclaw/bluebubbles 2026.2.24 → 2026.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.2.24",
3
+ "version": "2026.3.1",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "openclaw": {
package/src/accounts.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import {
3
+ DEFAULT_ACCOUNT_ID,
4
+ normalizeAccountId,
5
+ normalizeOptionalAccountId,
6
+ } from "openclaw/plugin-sdk/account-id";
3
7
  import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
4
8
 
5
9
  export type ResolvedBlueBubblesAccount = {
@@ -28,6 +32,13 @@ export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
28
32
  }
29
33
 
30
34
  export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
35
+ const preferred = normalizeOptionalAccountId(cfg.channels?.bluebubbles?.defaultAccount);
36
+ if (
37
+ preferred &&
38
+ listBlueBubblesAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
39
+ ) {
40
+ return preferred;
41
+ }
31
42
  const ids = listBlueBubblesAccountIds(cfg);
32
43
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
33
44
  return DEFAULT_ACCOUNT_ID;
@@ -52,8 +63,9 @@ function mergeBlueBubblesAccountConfig(
52
63
  ): BlueBubblesAccountConfig {
53
64
  const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
54
65
  accounts?: unknown;
66
+ defaultAccount?: unknown;
55
67
  };
56
- const { accounts: _ignored, ...rest } = base;
68
+ const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...rest } = base;
57
69
  const account = resolveAccountConfig(cfg, accountId) ?? {};
58
70
  const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
59
71
  return { ...rest, ...account, chunkMode };
@@ -294,7 +294,7 @@ describe("downloadBlueBubblesAttachment", () => {
294
294
  expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
295
295
  });
296
296
 
297
- it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => {
297
+ it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
298
298
  const mockBuffer = new Uint8Array([1]);
299
299
  mockFetch.mockResolvedValueOnce({
300
300
  ok: true,
@@ -309,7 +309,25 @@ describe("downloadBlueBubblesAttachment", () => {
309
309
  });
310
310
 
311
311
  const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
312
- expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
312
+ expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] });
313
+ });
314
+
315
+ it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => {
316
+ const mockBuffer = new Uint8Array([1]);
317
+ mockFetch.mockResolvedValueOnce({
318
+ ok: true,
319
+ headers: new Headers(),
320
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
321
+ });
322
+
323
+ const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
324
+ await downloadBlueBubblesAttachment(attachment, {
325
+ serverUrl: "http://192.168.1.5:1234",
326
+ password: "test",
327
+ });
328
+
329
+ const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
330
+ expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] });
313
331
  });
314
332
  });
315
333
 
@@ -62,6 +62,15 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
62
62
  return resolveBlueBubblesServerAccount(params);
63
63
  }
64
64
 
65
+ function safeExtractHostname(url: string): string | undefined {
66
+ try {
67
+ const hostname = new URL(url).hostname.trim();
68
+ return hostname || undefined;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
65
74
  type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
66
75
 
67
76
  function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
@@ -89,12 +98,17 @@ export async function downloadBlueBubblesAttachment(
89
98
  password,
90
99
  });
91
100
  const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
101
+ const trustedHostname = safeExtractHostname(baseUrl);
92
102
  try {
93
103
  const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
94
104
  url,
95
105
  filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
96
106
  maxBytes,
97
- ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
107
+ ssrfPolicy: allowPrivateNetwork
108
+ ? { allowPrivateNetwork: true }
109
+ : trustedHostname
110
+ ? { allowedHostnames: [trustedHostname] }
111
+ : undefined,
98
112
  fetchImpl: async (input, init) =>
99
113
  await blueBubblesFetchWithTimeout(
100
114
  resolveRequestUrl(input),
@@ -61,5 +61,6 @@ const bluebubblesAccountSchema = z
61
61
 
62
62
  export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
63
63
  accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
64
+ defaultAccount: z.string().optional(),
64
65
  actions: bluebubblesActionSchema,
65
66
  });
@@ -1,14 +1,16 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import {
3
+ DM_GROUP_ACCESS_REASON,
4
+ createScopedPairingAccess,
3
5
  createReplyPrefixOptions,
4
6
  evictOldHistoryKeys,
5
7
  logAckFailure,
6
8
  logInboundDrop,
7
9
  logTypingFailure,
10
+ readStoreAllowFromForDmPolicy,
8
11
  recordPendingHistoryEntryIfEnabled,
9
12
  resolveAckReaction,
10
- resolveDmGroupAccessDecision,
11
- resolveEffectiveAllowFromLists,
13
+ resolveDmGroupAccessWithLists,
12
14
  resolveControlCommandGate,
13
15
  stripMarkdown,
14
16
  type HistoryEntry,
@@ -420,6 +422,11 @@ export async function processMessage(
420
422
  target: WebhookTarget,
421
423
  ): Promise<void> {
422
424
  const { account, config, runtime, core, statusSink } = target;
425
+ const pairing = createScopedPairingAccess({
426
+ core,
427
+ channel: "bluebubbles",
428
+ accountId: account.accountId,
429
+ });
423
430
  const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId);
424
431
 
425
432
  const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
@@ -501,27 +508,20 @@ export async function processMessage(
501
508
 
502
509
  const dmPolicy = account.config.dmPolicy ?? "pairing";
503
510
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
504
- const storeAllowFrom = await core.channel.pairing
505
- .readAllowFromStore("bluebubbles")
506
- .catch(() => []);
507
- const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
508
- allowFrom: account.config.allowFrom,
509
- groupAllowFrom: account.config.groupAllowFrom,
510
- storeAllowFrom,
511
+ const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
512
+ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
513
+ provider: "bluebubbles",
514
+ accountId: account.accountId,
511
515
  dmPolicy,
516
+ readStore: pairing.readStoreForDmPolicy,
512
517
  });
513
- const groupAllowEntry = formatGroupAllowlistEntry({
514
- chatGuid: message.chatGuid,
515
- chatId: message.chatId ?? undefined,
516
- chatIdentifier: message.chatIdentifier ?? undefined,
517
- });
518
- const groupName = message.chatName?.trim() || undefined;
519
- const accessDecision = resolveDmGroupAccessDecision({
518
+ const accessDecision = resolveDmGroupAccessWithLists({
520
519
  isGroup,
521
520
  dmPolicy,
522
521
  groupPolicy,
523
- effectiveAllowFrom,
524
- effectiveGroupAllowFrom,
522
+ allowFrom: configuredAllowFrom,
523
+ groupAllowFrom: account.config.groupAllowFrom,
524
+ storeAllowFrom,
525
525
  isSenderAllowed: (allowFrom) =>
526
526
  isAllowedBlueBubblesSender({
527
527
  allowFrom,
@@ -531,10 +531,18 @@ export async function processMessage(
531
531
  chatIdentifier: message.chatIdentifier ?? undefined,
532
532
  }),
533
533
  });
534
+ const effectiveAllowFrom = accessDecision.effectiveAllowFrom;
535
+ const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
536
+ const groupAllowEntry = formatGroupAllowlistEntry({
537
+ chatGuid: message.chatGuid,
538
+ chatId: message.chatId ?? undefined,
539
+ chatIdentifier: message.chatIdentifier ?? undefined,
540
+ });
541
+ const groupName = message.chatName?.trim() || undefined;
534
542
 
535
543
  if (accessDecision.decision !== "allow") {
536
544
  if (isGroup) {
537
- if (accessDecision.reason === "groupPolicy=disabled") {
545
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
538
546
  logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
539
547
  logGroupAllowlistHint({
540
548
  runtime,
@@ -545,7 +553,7 @@ export async function processMessage(
545
553
  });
546
554
  return;
547
555
  }
548
- if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") {
556
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
549
557
  logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
550
558
  logGroupAllowlistHint({
551
559
  runtime,
@@ -556,7 +564,7 @@ export async function processMessage(
556
564
  });
557
565
  return;
558
566
  }
559
- if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") {
567
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
560
568
  logVerbose(
561
569
  core,
562
570
  runtime,
@@ -579,15 +587,14 @@ export async function processMessage(
579
587
  return;
580
588
  }
581
589
 
582
- if (accessDecision.reason === "dmPolicy=disabled") {
590
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
583
591
  logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
584
592
  logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
585
593
  return;
586
594
  }
587
595
 
588
596
  if (accessDecision.decision === "pairing") {
589
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
590
- channel: "bluebubbles",
597
+ const { code, created } = await pairing.upsertPairingRequest({
591
598
  id: message.senderId,
592
599
  meta: { name: message.senderName },
593
600
  });
@@ -666,10 +673,11 @@ export async function processMessage(
666
673
  // Command gating (parity with iMessage/WhatsApp)
667
674
  const useAccessGroups = config.commands?.useAccessGroups !== false;
668
675
  const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
676
+ const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom;
669
677
  const ownerAllowedForCommands =
670
- effectiveAllowFrom.length > 0
678
+ commandDmAllowFrom.length > 0
671
679
  ? isAllowedBlueBubblesSender({
672
- allowFrom: effectiveAllowFrom,
680
+ allowFrom: commandDmAllowFrom,
673
681
  sender: message.senderId,
674
682
  chatId: message.chatId ?? undefined,
675
683
  chatGuid: message.chatGuid ?? undefined,
@@ -686,17 +694,16 @@ export async function processMessage(
686
694
  chatIdentifier: message.chatIdentifier ?? undefined,
687
695
  })
688
696
  : false;
689
- const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
690
697
  const commandGate = resolveControlCommandGate({
691
698
  useAccessGroups,
692
699
  authorizers: [
693
- { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
700
+ { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
694
701
  { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
695
702
  ],
696
703
  allowTextCommands: true,
697
704
  hasControlCommand: hasControlCmd,
698
705
  });
699
- const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
706
+ const commandAuthorized = commandGate.commandAuthorized;
700
707
 
701
708
  // Block control commands from unauthorized senders in groups
702
709
  if (isGroup && commandGate.shouldBlock) {
@@ -1091,14 +1098,15 @@ export async function processMessage(
1091
1098
  });
1092
1099
  }
1093
1100
  }
1101
+ const commandBody = messageText.trim();
1094
1102
 
1095
1103
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1096
1104
  Body: body,
1097
1105
  BodyForAgent: rawBody,
1098
1106
  InboundHistory: inboundHistory,
1099
1107
  RawBody: rawBody,
1100
- CommandBody: rawBody,
1101
- BodyForCommands: rawBody,
1108
+ CommandBody: commandBody,
1109
+ BodyForCommands: commandBody,
1102
1110
  MediaUrl: mediaUrls[0],
1103
1111
  MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
1104
1112
  MediaPath: mediaPaths[0],
@@ -1380,27 +1388,30 @@ export async function processReaction(
1380
1388
  target: WebhookTarget,
1381
1389
  ): Promise<void> {
1382
1390
  const { account, config, runtime, core } = target;
1391
+ const pairing = createScopedPairingAccess({
1392
+ core,
1393
+ channel: "bluebubbles",
1394
+ accountId: account.accountId,
1395
+ });
1383
1396
  if (reaction.fromMe) {
1384
1397
  return;
1385
1398
  }
1386
1399
 
1387
1400
  const dmPolicy = account.config.dmPolicy ?? "pairing";
1388
1401
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
1389
- const storeAllowFrom = await core.channel.pairing
1390
- .readAllowFromStore("bluebubbles")
1391
- .catch(() => []);
1392
- const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
1393
- allowFrom: account.config.allowFrom,
1394
- groupAllowFrom: account.config.groupAllowFrom,
1395
- storeAllowFrom,
1402
+ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
1403
+ provider: "bluebubbles",
1404
+ accountId: account.accountId,
1396
1405
  dmPolicy,
1406
+ readStore: pairing.readStoreForDmPolicy,
1397
1407
  });
1398
- const accessDecision = resolveDmGroupAccessDecision({
1408
+ const accessDecision = resolveDmGroupAccessWithLists({
1399
1409
  isGroup: reaction.isGroup,
1400
1410
  dmPolicy,
1401
1411
  groupPolicy,
1402
- effectiveAllowFrom,
1403
- effectiveGroupAllowFrom,
1412
+ allowFrom: account.config.allowFrom,
1413
+ groupAllowFrom: account.config.groupAllowFrom,
1414
+ storeAllowFrom,
1404
1415
  isSenderAllowed: (allowFrom) =>
1405
1416
  isAllowedBlueBubblesSender({
1406
1417
  allowFrom,
@@ -162,6 +162,24 @@ function createMockRuntime(): PluginRuntime {
162
162
  vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
163
163
  dispatchReplyFromConfig:
164
164
  vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
165
+ withReplyDispatcher: vi.fn(
166
+ async ({
167
+ dispatcher,
168
+ run,
169
+ onSettled,
170
+ }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
171
+ try {
172
+ return await run();
173
+ } finally {
174
+ dispatcher.markComplete();
175
+ try {
176
+ await dispatcher.waitForIdle();
177
+ } finally {
178
+ await onSettled?.();
179
+ }
180
+ }
181
+ },
182
+ ) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
165
183
  finalizeInboundContext: vi.fn(
166
184
  (ctx: Record<string, unknown>) => ctx,
167
185
  ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
@@ -2287,6 +2305,51 @@ describe("BlueBubbles webhook monitor", () => {
2287
2305
 
2288
2306
  expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
2289
2307
  });
2308
+
2309
+ it("does not auto-authorize DM control commands in open mode without allowlists", async () => {
2310
+ mockHasControlCommand.mockReturnValue(true);
2311
+
2312
+ const account = createMockAccount({
2313
+ dmPolicy: "open",
2314
+ allowFrom: [],
2315
+ });
2316
+ const config: OpenClawConfig = {};
2317
+ const core = createMockRuntime();
2318
+ setBlueBubblesRuntime(core);
2319
+
2320
+ unregister = registerBlueBubblesWebhookTarget({
2321
+ account,
2322
+ config,
2323
+ runtime: { log: vi.fn(), error: vi.fn() },
2324
+ core,
2325
+ path: "/bluebubbles-webhook",
2326
+ });
2327
+
2328
+ const payload = {
2329
+ type: "new-message",
2330
+ data: {
2331
+ text: "/status",
2332
+ handle: { address: "+15559999999" },
2333
+ isGroup: false,
2334
+ isFromMe: false,
2335
+ guid: "msg-dm-open-unauthorized",
2336
+ date: Date.now(),
2337
+ },
2338
+ };
2339
+
2340
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
2341
+ const res = createMockResponse();
2342
+
2343
+ await handleBlueBubblesWebhookRequest(req, res);
2344
+ await flushAsync();
2345
+
2346
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2347
+ const latestDispatch =
2348
+ mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[
2349
+ mockDispatchReplyWithBufferedBlockDispatcher.mock.calls.length - 1
2350
+ ]?.[0];
2351
+ expect(latestDispatch?.ctx?.CommandAuthorized).toBe(false);
2352
+ });
2290
2353
  });
2291
2354
 
2292
2355
  describe("typing/read receipt toggles", () => {
package/src/types.ts CHANGED
@@ -75,6 +75,8 @@ export type BlueBubblesActionConfig = {
75
75
  export type BlueBubblesConfig = {
76
76
  /** Optional per-account BlueBubbles configuration (multi-account). */
77
77
  accounts?: Record<string, BlueBubblesAccountConfig>;
78
+ /** Optional default account id when multiple accounts are configured. */
79
+ defaultAccount?: string;
78
80
  /** Per-action tool gating (default: true for all). */
79
81
  actions?: BlueBubblesActionConfig;
80
82
  } & BlueBubblesAccountConfig;