@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 +1 -1
- package/src/accounts.ts +14 -2
- package/src/attachments.test.ts +20 -2
- package/src/attachments.ts +15 -1
- package/src/config-schema.ts +1 -0
- package/src/monitor-processing.ts +40 -20
- package/src/monitor.test.ts +63 -0
- package/src/types.ts +2 -0
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import {
|
|
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 };
|
package/src/attachments.test.ts
CHANGED
|
@@ -294,7 +294,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
|
|
294
294
|
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
-
it("
|
|
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).
|
|
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
|
|
package/src/attachments.ts
CHANGED
|
@@ -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
|
|
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),
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
|
504
|
-
|
|
505
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
678
|
+
commandDmAllowFrom.length > 0
|
|
667
679
|
? isAllowedBlueBubblesSender({
|
|
668
|
-
allowFrom:
|
|
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:
|
|
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 =
|
|
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:
|
|
1097
|
-
BodyForCommands:
|
|
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
|
|
1386
|
-
|
|
1387
|
-
.
|
|
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,
|
package/src/monitor.test.ts
CHANGED
|
@@ -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;
|