@openclaw/bluebubbles 2026.2.25 → 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.25",
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,10 +1,13 @@
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
13
  resolveDmGroupAccessWithLists,
@@ -419,6 +422,11 @@ export async function processMessage(
419
422
  target: WebhookTarget,
420
423
  ): Promise<void> {
421
424
  const { account, config, runtime, core, statusSink } = target;
425
+ const pairing = createScopedPairingAccess({
426
+ core,
427
+ channel: "bluebubbles",
428
+ accountId: account.accountId,
429
+ });
422
430
  const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId);
423
431
 
424
432
  const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
@@ -500,14 +508,18 @@ export async function processMessage(
500
508
 
501
509
  const dmPolicy = account.config.dmPolicy ?? "pairing";
502
510
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
503
- const storeAllowFrom = await core.channel.pairing
504
- .readAllowFromStore("bluebubbles")
505
- .catch(() => []);
511
+ const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
512
+ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
513
+ provider: "bluebubbles",
514
+ accountId: account.accountId,
515
+ dmPolicy,
516
+ readStore: pairing.readStoreForDmPolicy,
517
+ });
506
518
  const accessDecision = resolveDmGroupAccessWithLists({
507
519
  isGroup,
508
520
  dmPolicy,
509
521
  groupPolicy,
510
- allowFrom: account.config.allowFrom,
522
+ allowFrom: configuredAllowFrom,
511
523
  groupAllowFrom: account.config.groupAllowFrom,
512
524
  storeAllowFrom,
513
525
  isSenderAllowed: (allowFrom) =>
@@ -530,7 +542,7 @@ export async function processMessage(
530
542
 
531
543
  if (accessDecision.decision !== "allow") {
532
544
  if (isGroup) {
533
- if (accessDecision.reason === "groupPolicy=disabled") {
545
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
534
546
  logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
535
547
  logGroupAllowlistHint({
536
548
  runtime,
@@ -541,7 +553,7 @@ export async function processMessage(
541
553
  });
542
554
  return;
543
555
  }
544
- if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") {
556
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
545
557
  logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
546
558
  logGroupAllowlistHint({
547
559
  runtime,
@@ -552,7 +564,7 @@ export async function processMessage(
552
564
  });
553
565
  return;
554
566
  }
555
- if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") {
567
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
556
568
  logVerbose(
557
569
  core,
558
570
  runtime,
@@ -575,15 +587,14 @@ export async function processMessage(
575
587
  return;
576
588
  }
577
589
 
578
- if (accessDecision.reason === "dmPolicy=disabled") {
590
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
579
591
  logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
580
592
  logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
581
593
  return;
582
594
  }
583
595
 
584
596
  if (accessDecision.decision === "pairing") {
585
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
586
- channel: "bluebubbles",
597
+ const { code, created } = await pairing.upsertPairingRequest({
587
598
  id: message.senderId,
588
599
  meta: { name: message.senderName },
589
600
  });
@@ -662,10 +673,11 @@ export async function processMessage(
662
673
  // Command gating (parity with iMessage/WhatsApp)
663
674
  const useAccessGroups = config.commands?.useAccessGroups !== false;
664
675
  const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
676
+ const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom;
665
677
  const ownerAllowedForCommands =
666
- effectiveAllowFrom.length > 0
678
+ commandDmAllowFrom.length > 0
667
679
  ? isAllowedBlueBubblesSender({
668
- allowFrom: effectiveAllowFrom,
680
+ allowFrom: commandDmAllowFrom,
669
681
  sender: message.senderId,
670
682
  chatId: message.chatId ?? undefined,
671
683
  chatGuid: message.chatGuid ?? undefined,
@@ -682,17 +694,16 @@ export async function processMessage(
682
694
  chatIdentifier: message.chatIdentifier ?? undefined,
683
695
  })
684
696
  : false;
685
- const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
686
697
  const commandGate = resolveControlCommandGate({
687
698
  useAccessGroups,
688
699
  authorizers: [
689
- { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
700
+ { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
690
701
  { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
691
702
  ],
692
703
  allowTextCommands: true,
693
704
  hasControlCommand: hasControlCmd,
694
705
  });
695
- const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
706
+ const commandAuthorized = commandGate.commandAuthorized;
696
707
 
697
708
  // Block control commands from unauthorized senders in groups
698
709
  if (isGroup && commandGate.shouldBlock) {
@@ -1087,14 +1098,15 @@ export async function processMessage(
1087
1098
  });
1088
1099
  }
1089
1100
  }
1101
+ const commandBody = messageText.trim();
1090
1102
 
1091
1103
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1092
1104
  Body: body,
1093
1105
  BodyForAgent: rawBody,
1094
1106
  InboundHistory: inboundHistory,
1095
1107
  RawBody: rawBody,
1096
- CommandBody: rawBody,
1097
- BodyForCommands: rawBody,
1108
+ CommandBody: commandBody,
1109
+ BodyForCommands: commandBody,
1098
1110
  MediaUrl: mediaUrls[0],
1099
1111
  MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
1100
1112
  MediaPath: mediaPaths[0],
@@ -1376,15 +1388,23 @@ export async function processReaction(
1376
1388
  target: WebhookTarget,
1377
1389
  ): Promise<void> {
1378
1390
  const { account, config, runtime, core } = target;
1391
+ const pairing = createScopedPairingAccess({
1392
+ core,
1393
+ channel: "bluebubbles",
1394
+ accountId: account.accountId,
1395
+ });
1379
1396
  if (reaction.fromMe) {
1380
1397
  return;
1381
1398
  }
1382
1399
 
1383
1400
  const dmPolicy = account.config.dmPolicy ?? "pairing";
1384
1401
  const groupPolicy = account.config.groupPolicy ?? "allowlist";
1385
- const storeAllowFrom = await core.channel.pairing
1386
- .readAllowFromStore("bluebubbles")
1387
- .catch(() => []);
1402
+ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
1403
+ provider: "bluebubbles",
1404
+ accountId: account.accountId,
1405
+ dmPolicy,
1406
+ readStore: pairing.readStoreForDmPolicy,
1407
+ });
1388
1408
  const accessDecision = resolveDmGroupAccessWithLists({
1389
1409
  isGroup: reaction.isGroup,
1390
1410
  dmPolicy,
@@ -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;