@openclaw/bluebubbles 2026.3.12 → 2026.3.13

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.3.12",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -82,6 +82,15 @@ describe("downloadBlueBubblesAttachment", () => {
82
82
  ).rejects.toThrow("too large");
83
83
  }
84
84
 
85
+ function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) {
86
+ mockFetch.mockResolvedValueOnce({
87
+ ok: true,
88
+ headers: new Headers(),
89
+ arrayBuffer: () => Promise.resolve(buffer.buffer),
90
+ });
91
+ return buffer;
92
+ }
93
+
85
94
  it("throws when guid is missing", async () => {
86
95
  const attachment: BlueBubblesAttachment = {};
87
96
  await expect(
@@ -159,12 +168,7 @@ describe("downloadBlueBubblesAttachment", () => {
159
168
  });
160
169
 
161
170
  it("encodes guid in URL", async () => {
162
- const mockBuffer = new Uint8Array([1]);
163
- mockFetch.mockResolvedValueOnce({
164
- ok: true,
165
- headers: new Headers(),
166
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
167
- });
171
+ mockSuccessfulAttachmentDownload();
168
172
 
169
173
  const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
170
174
  await downloadBlueBubblesAttachment(attachment, {
@@ -244,12 +248,7 @@ describe("downloadBlueBubblesAttachment", () => {
244
248
  });
245
249
 
246
250
  it("resolves credentials from config when opts not provided", async () => {
247
- const mockBuffer = new Uint8Array([1]);
248
- mockFetch.mockResolvedValueOnce({
249
- ok: true,
250
- headers: new Headers(),
251
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
252
- });
251
+ mockSuccessfulAttachmentDownload();
253
252
 
254
253
  const attachment: BlueBubblesAttachment = { guid: "att-config" };
255
254
  const result = await downloadBlueBubblesAttachment(attachment, {
@@ -270,12 +269,7 @@ describe("downloadBlueBubblesAttachment", () => {
270
269
  });
271
270
 
272
271
  it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
273
- const mockBuffer = new Uint8Array([1]);
274
- mockFetch.mockResolvedValueOnce({
275
- ok: true,
276
- headers: new Headers(),
277
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
278
- });
272
+ mockSuccessfulAttachmentDownload();
279
273
 
280
274
  const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
281
275
  await downloadBlueBubblesAttachment(attachment, {
@@ -295,12 +289,7 @@ describe("downloadBlueBubblesAttachment", () => {
295
289
  });
296
290
 
297
291
  it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
298
- const mockBuffer = new Uint8Array([1]);
299
- mockFetch.mockResolvedValueOnce({
300
- ok: true,
301
- headers: new Headers(),
302
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
303
- });
292
+ mockSuccessfulAttachmentDownload();
304
293
 
305
294
  const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
306
295
  await downloadBlueBubblesAttachment(attachment, {
@@ -313,12 +302,7 @@ describe("downloadBlueBubblesAttachment", () => {
313
302
  });
314
303
 
315
304
  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
- });
305
+ mockSuccessfulAttachmentDownload();
322
306
 
323
307
  const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
324
308
  await downloadBlueBubblesAttachment(attachment, {
@@ -352,6 +336,14 @@ describe("sendBlueBubblesAttachment", () => {
352
336
  return Buffer.from(body).toString("utf8");
353
337
  }
354
338
 
339
+ function expectVoiceAttachmentBody() {
340
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
341
+ const bodyText = decodeBody(body);
342
+ expect(bodyText).toContain('name="isAudioMessage"');
343
+ expect(bodyText).toContain("true");
344
+ return bodyText;
345
+ }
346
+
355
347
  it("marks voice memos when asVoice is true and mp3 is provided", async () => {
356
348
  mockFetch.mockResolvedValueOnce({
357
349
  ok: true,
@@ -367,10 +359,7 @@ describe("sendBlueBubblesAttachment", () => {
367
359
  opts: { serverUrl: "http://localhost:1234", password: "test" },
368
360
  });
369
361
 
370
- const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
371
- const bodyText = decodeBody(body);
372
- expect(bodyText).toContain('name="isAudioMessage"');
373
- expect(bodyText).toContain("true");
362
+ const bodyText = expectVoiceAttachmentBody();
374
363
  expect(bodyText).toContain('filename="voice.mp3"');
375
364
  });
376
365
 
@@ -389,8 +378,7 @@ describe("sendBlueBubblesAttachment", () => {
389
378
  opts: { serverUrl: "http://localhost:1234", password: "test" },
390
379
  });
391
380
 
392
- const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
393
- const bodyText = decodeBody(body);
381
+ const bodyText = expectVoiceAttachmentBody();
394
382
  expect(bodyText).toContain('filename="voice.mp3"');
395
383
  expect(bodyText).toContain('name="voice.mp3"');
396
384
  });
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
- import { postMultipartFormData } from "./multipart.js";
5
+ import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
6
6
  import {
7
7
  getCachedBlueBubblesPrivateApiStatus,
8
8
  isBlueBubblesPrivateApiStatusEnabled,
@@ -262,12 +262,7 @@ export async function sendBlueBubblesAttachment(params: {
262
262
  timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
263
263
  });
264
264
 
265
- if (!res.ok) {
266
- const errorText = await res.text();
267
- throw new Error(
268
- `BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`,
269
- );
270
- }
265
+ await assertMultipartActionOk(res, "attachment send");
271
266
 
272
267
  const responseBody = await res.text();
273
268
  if (!responseBody) {
package/src/chat.test.ts CHANGED
@@ -29,6 +29,11 @@ describe("chat", () => {
29
29
  });
30
30
  }
31
31
 
32
+ function mockTwoOkTextResponses() {
33
+ mockOkTextResponse();
34
+ mockOkTextResponse();
35
+ }
36
+
32
37
  async function expectCalledUrlIncludesPassword(params: {
33
38
  password: string;
34
39
  invoke: () => Promise<void>;
@@ -198,15 +203,7 @@ describe("chat", () => {
198
203
  });
199
204
 
200
205
  it("uses POST for start and DELETE for stop", async () => {
201
- mockFetch
202
- .mockResolvedValueOnce({
203
- ok: true,
204
- text: () => Promise.resolve(""),
205
- })
206
- .mockResolvedValueOnce({
207
- ok: true,
208
- text: () => Promise.resolve(""),
209
- });
206
+ mockTwoOkTextResponses();
210
207
 
211
208
  await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
212
209
  serverUrl: "http://localhost:1234",
@@ -442,15 +439,7 @@ describe("chat", () => {
442
439
  });
443
440
 
444
441
  it("adds and removes participant using matching endpoint", async () => {
445
- mockFetch
446
- .mockResolvedValueOnce({
447
- ok: true,
448
- text: () => Promise.resolve(""),
449
- })
450
- .mockResolvedValueOnce({
451
- ok: true,
452
- text: () => Promise.resolve(""),
453
- });
442
+ mockTwoOkTextResponses();
454
443
 
455
444
  await addBlueBubblesParticipant("chat-guid", "+15551234567", {
456
445
  serverUrl: "http://localhost:1234",
package/src/chat.ts CHANGED
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
- import { postMultipartFormData } from "./multipart.js";
5
+ import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
6
6
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
7
7
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
8
8
 
@@ -55,12 +55,7 @@ async function sendBlueBubblesChatEndpointRequest(params: {
55
55
  { method: params.method },
56
56
  params.opts.timeoutMs,
57
57
  );
58
- if (!res.ok) {
59
- const errorText = await res.text().catch(() => "");
60
- throw new Error(
61
- `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
62
- );
63
- }
58
+ await assertMultipartActionOk(res, params.action);
64
59
  }
65
60
 
66
61
  async function sendPrivateApiJsonRequest(params: {
@@ -86,12 +81,7 @@ async function sendPrivateApiJsonRequest(params: {
86
81
  }
87
82
 
88
83
  const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
89
- if (!res.ok) {
90
- const errorText = await res.text().catch(() => "");
91
- throw new Error(
92
- `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
93
- );
94
- }
84
+ await assertMultipartActionOk(res, params.action);
95
85
  }
96
86
 
97
87
  export async function markBlueBubblesChatRead(
@@ -329,8 +319,5 @@ export async function setGroupIconBlueBubbles(
329
319
  timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
330
320
  });
331
321
 
332
- if (!res.ok) {
333
- const errorText = await res.text().catch(() => "");
334
- throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
335
- }
322
+ await assertMultipartActionOk(res, "setGroupIcon");
336
323
  }
@@ -70,6 +70,70 @@ async function makeTempDir(): Promise<string> {
70
70
  return dir;
71
71
  }
72
72
 
73
+ async function makeTempFile(
74
+ fileName: string,
75
+ contents: string,
76
+ dir?: string,
77
+ ): Promise<{ dir: string; filePath: string }> {
78
+ const resolvedDir = dir ?? (await makeTempDir());
79
+ const filePath = path.join(resolvedDir, fileName);
80
+ await fs.writeFile(filePath, contents, "utf8");
81
+ return { dir: resolvedDir, filePath };
82
+ }
83
+
84
+ async function sendLocalMedia(params: {
85
+ cfg: OpenClawConfig;
86
+ mediaPath: string;
87
+ accountId?: string;
88
+ }) {
89
+ return sendBlueBubblesMedia({
90
+ cfg: params.cfg,
91
+ to: "chat:123",
92
+ accountId: params.accountId,
93
+ mediaPath: params.mediaPath,
94
+ });
95
+ }
96
+
97
+ async function expectRejectedLocalMedia(params: {
98
+ cfg: OpenClawConfig;
99
+ mediaPath: string;
100
+ error: RegExp;
101
+ accountId?: string;
102
+ }) {
103
+ await expect(
104
+ sendLocalMedia({
105
+ cfg: params.cfg,
106
+ mediaPath: params.mediaPath,
107
+ accountId: params.accountId,
108
+ }),
109
+ ).rejects.toThrow(params.error);
110
+
111
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
112
+ }
113
+
114
+ async function expectAllowedLocalMedia(params: {
115
+ cfg: OpenClawConfig;
116
+ mediaPath: string;
117
+ expectedAttachment: Record<string, unknown>;
118
+ accountId?: string;
119
+ expectMimeDetection?: boolean;
120
+ }) {
121
+ const result = await sendLocalMedia({
122
+ cfg: params.cfg,
123
+ mediaPath: params.mediaPath,
124
+ accountId: params.accountId,
125
+ });
126
+
127
+ expect(result).toEqual({ messageId: "msg-1" });
128
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
129
+ expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
130
+ expect.objectContaining(params.expectedAttachment),
131
+ );
132
+ if (params.expectMimeDetection) {
133
+ expect(runtimeMocks.detectMime).toHaveBeenCalled();
134
+ }
135
+ }
136
+
73
137
  beforeEach(() => {
74
138
  const runtime = createMockRuntime();
75
139
  runtimeMocks = runtime.mocks;
@@ -110,57 +174,43 @@ describe("sendBlueBubblesMedia local-path hardening", () => {
110
174
  const outsideFile = path.join(outsideDir, "outside.txt");
111
175
  await fs.writeFile(outsideFile, "not allowed", "utf8");
112
176
 
113
- await expect(
114
- sendBlueBubblesMedia({
115
- cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
116
- to: "chat:123",
117
- mediaPath: outsideFile,
118
- }),
119
- ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
120
-
121
- expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
177
+ await expectRejectedLocalMedia({
178
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
179
+ mediaPath: outsideFile,
180
+ error: /not under any configured mediaLocalRoots/i,
181
+ });
122
182
  });
123
183
 
124
184
  it("allows local paths that are explicitly configured", async () => {
125
- const allowedRoot = await makeTempDir();
126
- const allowedFile = path.join(allowedRoot, "allowed.txt");
127
- await fs.writeFile(allowedFile, "allowed", "utf8");
185
+ const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
186
+ "allowed.txt",
187
+ "allowed",
188
+ );
128
189
 
129
- const result = await sendBlueBubblesMedia({
190
+ await expectAllowedLocalMedia({
130
191
  cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
131
- to: "chat:123",
132
192
  mediaPath: allowedFile,
133
- });
134
-
135
- expect(result).toEqual({ messageId: "msg-1" });
136
- expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
137
- expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
138
- expect.objectContaining({
193
+ expectedAttachment: {
139
194
  filename: "allowed.txt",
140
195
  contentType: "text/plain",
141
- }),
142
- );
143
- expect(runtimeMocks.detectMime).toHaveBeenCalled();
196
+ },
197
+ expectMimeDetection: true,
198
+ });
144
199
  });
145
200
 
146
201
  it("allows file:// media paths and file:// local roots", async () => {
147
- const allowedRoot = await makeTempDir();
148
- const allowedFile = path.join(allowedRoot, "allowed.txt");
149
- await fs.writeFile(allowedFile, "allowed", "utf8");
202
+ const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
203
+ "allowed.txt",
204
+ "allowed",
205
+ );
150
206
 
151
- const result = await sendBlueBubblesMedia({
207
+ await expectAllowedLocalMedia({
152
208
  cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }),
153
- to: "chat:123",
154
209
  mediaPath: pathToFileURL(allowedFile).toString(),
155
- });
156
-
157
- expect(result).toEqual({ messageId: "msg-1" });
158
- expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
159
- expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
160
- expect.objectContaining({
210
+ expectedAttachment: {
161
211
  filename: "allowed.txt",
162
- }),
163
- );
212
+ },
213
+ });
164
214
  });
165
215
 
166
216
  it("uses account-specific mediaLocalRoots over top-level roots", async () => {
@@ -213,15 +263,11 @@ describe("sendBlueBubblesMedia local-path hardening", () => {
213
263
  return;
214
264
  }
215
265
 
216
- await expect(
217
- sendBlueBubblesMedia({
218
- cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
219
- to: "chat:123",
220
- mediaPath: linkPath,
221
- }),
222
- ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
223
-
224
- expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
266
+ await expectRejectedLocalMedia({
267
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
268
+ mediaPath: linkPath,
269
+ error: /not under any configured mediaLocalRoots/i,
270
+ });
225
271
  });
226
272
 
227
273
  it("rejects relative mediaLocalRoots entries", async () => {
@@ -1,18 +1,24 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
3
3
 
4
+ function createFallbackDmPayload(overrides: Record<string, unknown> = {}) {
5
+ return {
6
+ guid: "msg-1",
7
+ isGroup: false,
8
+ isFromMe: false,
9
+ handle: null,
10
+ chatGuid: "iMessage;-;+15551234567",
11
+ ...overrides,
12
+ };
13
+ }
14
+
4
15
  describe("normalizeWebhookMessage", () => {
5
16
  it("falls back to DM chatGuid handle when sender handle is missing", () => {
6
17
  const result = normalizeWebhookMessage({
7
18
  type: "new-message",
8
- data: {
9
- guid: "msg-1",
19
+ data: createFallbackDmPayload({
10
20
  text: "hello",
11
- isGroup: false,
12
- isFromMe: false,
13
- handle: null,
14
- chatGuid: "iMessage;-;+15551234567",
15
- },
21
+ }),
16
22
  });
17
23
 
18
24
  expect(result).not.toBeNull();
@@ -78,15 +84,11 @@ describe("normalizeWebhookReaction", () => {
78
84
  it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
79
85
  const result = normalizeWebhookReaction({
80
86
  type: "updated-message",
81
- data: {
87
+ data: createFallbackDmPayload({
82
88
  guid: "msg-2",
83
89
  associatedMessageGuid: "p:0/msg-1",
84
90
  associatedMessageType: 2000,
85
- isGroup: false,
86
- isFromMe: false,
87
- handle: null,
88
- chatGuid: "iMessage;-;+15551234567",
89
- },
91
+ }),
90
92
  });
91
93
 
92
94
  expect(result).not.toBeNull();
@@ -582,6 +582,29 @@ export function parseTapbackText(params: {
582
582
  return null;
583
583
  }
584
584
 
585
+ const parseLeadingReactionAction = (
586
+ prefix: "reacted" | "removed",
587
+ defaultAction: "added" | "removed",
588
+ ) => {
589
+ if (!lower.startsWith(prefix)) {
590
+ return null;
591
+ }
592
+ const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
593
+ if (!emoji) {
594
+ return null;
595
+ }
596
+ const quotedText = extractQuotedTapbackText(trimmed);
597
+ if (params.requireQuoted && !quotedText) {
598
+ return null;
599
+ }
600
+ const fallback = trimmed.slice(prefix.length).trim();
601
+ return {
602
+ emoji,
603
+ action: params.actionHint ?? defaultAction,
604
+ quotedText: quotedText ?? fallback,
605
+ };
606
+ };
607
+
585
608
  for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
586
609
  if (lower.startsWith(pattern)) {
587
610
  // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
@@ -599,30 +622,14 @@ export function parseTapbackText(params: {
599
622
  }
600
623
  }
601
624
 
602
- if (lower.startsWith("reacted")) {
603
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
604
- if (!emoji) {
605
- return null;
606
- }
607
- const quotedText = extractQuotedTapbackText(trimmed);
608
- if (params.requireQuoted && !quotedText) {
609
- return null;
610
- }
611
- const fallback = trimmed.slice("reacted".length).trim();
612
- return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
625
+ const reacted = parseLeadingReactionAction("reacted", "added");
626
+ if (reacted) {
627
+ return reacted;
613
628
  }
614
629
 
615
- if (lower.startsWith("removed")) {
616
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
617
- if (!emoji) {
618
- return null;
619
- }
620
- const quotedText = extractQuotedTapbackText(trimmed);
621
- if (params.requireQuoted && !quotedText) {
622
- return null;
623
- }
624
- const fallback = trimmed.slice("removed".length).trim();
625
- return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
630
+ const removed = parseLeadingReactionAction("removed", "removed");
631
+ if (removed) {
632
+ return removed;
626
633
  }
627
634
  return null;
628
635
  }
@@ -302,65 +302,102 @@ describe("BlueBubbles webhook monitor", () => {
302
302
  };
303
303
  }
304
304
 
305
- describe("webhook parsing + auth handling", () => {
306
- it("rejects non-POST requests", async () => {
307
- const account = createMockAccount();
308
- const config: OpenClawConfig = {};
309
- const core = createMockRuntime();
310
- setBlueBubblesRuntime(core);
305
+ async function dispatchWebhook(req: IncomingMessage) {
306
+ const res = createMockResponse();
307
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
308
+ return { handled, res };
309
+ }
310
+
311
+ function createWebhookRequestForTest(params?: {
312
+ method?: string;
313
+ url?: string;
314
+ body?: unknown;
315
+ headers?: Record<string, string>;
316
+ remoteAddress?: string;
317
+ }) {
318
+ const req = createMockRequest(
319
+ params?.method ?? "POST",
320
+ params?.url ?? "/bluebubbles-webhook",
321
+ params?.body ?? {},
322
+ params?.headers,
323
+ );
324
+ if (params?.remoteAddress) {
325
+ setRequestRemoteAddress(req, params.remoteAddress);
326
+ }
327
+ return req;
328
+ }
329
+
330
+ function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
331
+ const req = new EventEmitter() as IncomingMessage;
332
+ const destroyMock = vi.fn();
333
+ req.method = "POST";
334
+ req.url = url;
335
+ req.headers = {};
336
+ req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
337
+ setRequestRemoteAddress(req, "127.0.0.1");
338
+ return { req, destroyMock };
339
+ }
311
340
 
312
- unregister = registerBlueBubblesWebhookTarget({
341
+ function registerWebhookTargets(
342
+ params: Array<{
343
+ account: ResolvedBlueBubblesAccount;
344
+ statusSink?: (event: unknown) => void;
345
+ }>,
346
+ ) {
347
+ const config: OpenClawConfig = {};
348
+ const core = createMockRuntime();
349
+ setBlueBubblesRuntime(core);
350
+
351
+ const unregisterFns = params.map(({ account, statusSink }) =>
352
+ registerBlueBubblesWebhookTarget({
313
353
  account,
314
354
  config,
315
355
  runtime: { log: vi.fn(), error: vi.fn() },
316
356
  core,
317
357
  path: "/bluebubbles-webhook",
318
- });
358
+ statusSink,
359
+ }),
360
+ );
319
361
 
320
- const req = createMockRequest("GET", "/bluebubbles-webhook", {});
321
- const res = createMockResponse();
362
+ unregister = () => {
363
+ for (const unregisterFn of unregisterFns) {
364
+ unregisterFn();
365
+ }
366
+ };
367
+ }
322
368
 
323
- const handled = await handleBlueBubblesWebhookRequest(req, res);
369
+ async function expectWebhookStatus(
370
+ req: IncomingMessage,
371
+ expectedStatus: number,
372
+ expectedBody?: string,
373
+ ) {
374
+ const { handled, res } = await dispatchWebhook(req);
375
+ expect(handled).toBe(true);
376
+ expect(res.statusCode).toBe(expectedStatus);
377
+ if (expectedBody !== undefined) {
378
+ expect(res.body).toBe(expectedBody);
379
+ }
380
+ return res;
381
+ }
324
382
 
325
- expect(handled).toBe(true);
326
- expect(res.statusCode).toBe(405);
383
+ describe("webhook parsing + auth handling", () => {
384
+ it("rejects non-POST requests", async () => {
385
+ setupWebhookTarget();
386
+ const req = createWebhookRequestForTest({ method: "GET" });
387
+ await expectWebhookStatus(req, 405);
327
388
  });
328
389
 
329
390
  it("accepts POST requests with valid JSON payload", async () => {
330
391
  setupWebhookTarget();
331
392
  const payload = createNewMessagePayload({ date: Date.now() });
332
-
333
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
334
- const res = createMockResponse();
335
-
336
- const handled = await handleBlueBubblesWebhookRequest(req, res);
337
-
338
- expect(handled).toBe(true);
339
- expect(res.statusCode).toBe(200);
340
- expect(res.body).toBe("ok");
393
+ const req = createWebhookRequestForTest({ body: payload });
394
+ await expectWebhookStatus(req, 200, "ok");
341
395
  });
342
396
 
343
397
  it("rejects requests with invalid JSON", async () => {
344
- const account = createMockAccount();
345
- const config: OpenClawConfig = {};
346
- const core = createMockRuntime();
347
- setBlueBubblesRuntime(core);
348
-
349
- unregister = registerBlueBubblesWebhookTarget({
350
- account,
351
- config,
352
- runtime: { log: vi.fn(), error: vi.fn() },
353
- core,
354
- path: "/bluebubbles-webhook",
355
- });
356
-
357
- const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
358
- const res = createMockResponse();
359
-
360
- const handled = await handleBlueBubblesWebhookRequest(req, res);
361
-
362
- expect(handled).toBe(true);
363
- expect(res.statusCode).toBe(400);
398
+ setupWebhookTarget();
399
+ const req = createWebhookRequestForTest({ body: "invalid json {{" });
400
+ await expectWebhookStatus(req, 400);
364
401
  });
365
402
 
366
403
  it("accepts URL-encoded payload wrappers", async () => {
@@ -369,42 +406,17 @@ describe("BlueBubbles webhook monitor", () => {
369
406
  const encodedBody = new URLSearchParams({
370
407
  payload: JSON.stringify(payload),
371
408
  }).toString();
372
-
373
- const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
374
- const res = createMockResponse();
375
-
376
- const handled = await handleBlueBubblesWebhookRequest(req, res);
377
-
378
- expect(handled).toBe(true);
379
- expect(res.statusCode).toBe(200);
380
- expect(res.body).toBe("ok");
409
+ const req = createWebhookRequestForTest({ body: encodedBody });
410
+ await expectWebhookStatus(req, 200, "ok");
381
411
  });
382
412
 
383
413
  it("returns 408 when request body times out (Slow-Loris protection)", async () => {
384
414
  vi.useFakeTimers();
385
415
  try {
386
- const account = createMockAccount();
387
- const config: OpenClawConfig = {};
388
- const core = createMockRuntime();
389
- setBlueBubblesRuntime(core);
390
-
391
- unregister = registerBlueBubblesWebhookTarget({
392
- account,
393
- config,
394
- runtime: { log: vi.fn(), error: vi.fn() },
395
- core,
396
- path: "/bluebubbles-webhook",
397
- });
416
+ setupWebhookTarget();
398
417
 
399
418
  // Create a request that never sends data or ends (simulates slow-loris)
400
- const req = new EventEmitter() as IncomingMessage;
401
- req.method = "POST";
402
- req.url = "/bluebubbles-webhook?password=test-password";
403
- req.headers = {};
404
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
405
- remoteAddress: "127.0.0.1",
406
- };
407
- req.destroy = vi.fn();
419
+ const { req, destroyMock } = createHangingWebhookRequest();
408
420
 
409
421
  const res = createMockResponse();
410
422
 
@@ -416,7 +428,7 @@ describe("BlueBubbles webhook monitor", () => {
416
428
  const handled = await handledPromise;
417
429
  expect(handled).toBe(true);
418
430
  expect(res.statusCode).toBe(408);
419
- expect(req.destroy).toHaveBeenCalled();
431
+ expect(destroyMock).toHaveBeenCalled();
420
432
  } finally {
421
433
  vi.useRealTimers();
422
434
  }
@@ -424,140 +436,62 @@ describe("BlueBubbles webhook monitor", () => {
424
436
 
425
437
  it("rejects unauthorized requests before reading the body", async () => {
426
438
  const account = createMockAccount({ password: "secret-token" });
427
- const config: OpenClawConfig = {};
428
- const core = createMockRuntime();
429
- setBlueBubblesRuntime(core);
430
-
431
- unregister = registerBlueBubblesWebhookTarget({
432
- account,
433
- config,
434
- runtime: { log: vi.fn(), error: vi.fn() },
435
- core,
436
- path: "/bluebubbles-webhook",
437
- });
438
-
439
- const req = new EventEmitter() as IncomingMessage;
440
- req.method = "POST";
441
- req.url = "/bluebubbles-webhook?password=wrong-token";
442
- req.headers = {};
439
+ setupWebhookTarget({ account });
440
+ const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
443
441
  const onSpy = vi.spyOn(req, "on");
444
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
445
- remoteAddress: "127.0.0.1",
446
- };
447
-
448
- const res = createMockResponse();
449
- const handled = await handleBlueBubblesWebhookRequest(req, res);
450
-
451
- expect(handled).toBe(true);
452
- expect(res.statusCode).toBe(401);
442
+ await expectWebhookStatus(req, 401);
453
443
  expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
454
444
  });
455
445
 
456
446
  it("authenticates via password query parameter", async () => {
457
447
  const account = createMockAccount({ password: "secret-token" });
458
-
459
- // Mock non-localhost request
460
- const req = createMockRequest(
461
- "POST",
462
- "/bluebubbles-webhook?password=secret-token",
463
- createNewMessagePayload(),
464
- );
465
- setRequestRemoteAddress(req, "192.168.1.100");
466
448
  setupWebhookTarget({ account });
467
-
468
- const res = createMockResponse();
469
- const handled = await handleBlueBubblesWebhookRequest(req, res);
470
-
471
- expect(handled).toBe(true);
472
- expect(res.statusCode).toBe(200);
449
+ const req = createWebhookRequestForTest({
450
+ url: "/bluebubbles-webhook?password=secret-token",
451
+ body: createNewMessagePayload(),
452
+ remoteAddress: "192.168.1.100",
453
+ });
454
+ await expectWebhookStatus(req, 200);
473
455
  });
474
456
 
475
457
  it("authenticates via x-password header", async () => {
476
458
  const account = createMockAccount({ password: "secret-token" });
477
-
478
- const req = createMockRequest(
479
- "POST",
480
- "/bluebubbles-webhook",
481
- createNewMessagePayload(),
482
- { "x-password": "secret-token" }, // pragma: allowlist secret
483
- );
484
- setRequestRemoteAddress(req, "192.168.1.100");
485
459
  setupWebhookTarget({ account });
486
-
487
- const res = createMockResponse();
488
- const handled = await handleBlueBubblesWebhookRequest(req, res);
489
-
490
- expect(handled).toBe(true);
491
- expect(res.statusCode).toBe(200);
460
+ const req = createWebhookRequestForTest({
461
+ body: createNewMessagePayload(),
462
+ headers: { "x-password": "secret-token" }, // pragma: allowlist secret
463
+ remoteAddress: "192.168.1.100",
464
+ });
465
+ await expectWebhookStatus(req, 200);
492
466
  });
493
467
 
494
468
  it("rejects unauthorized requests with wrong password", async () => {
495
469
  const account = createMockAccount({ password: "secret-token" });
496
- const req = createMockRequest(
497
- "POST",
498
- "/bluebubbles-webhook?password=wrong-token",
499
- createNewMessagePayload(),
500
- );
501
- setRequestRemoteAddress(req, "192.168.1.100");
502
470
  setupWebhookTarget({ account });
503
-
504
- const res = createMockResponse();
505
- const handled = await handleBlueBubblesWebhookRequest(req, res);
506
-
507
- expect(handled).toBe(true);
508
- expect(res.statusCode).toBe(401);
471
+ const req = createWebhookRequestForTest({
472
+ url: "/bluebubbles-webhook?password=wrong-token",
473
+ body: createNewMessagePayload(),
474
+ remoteAddress: "192.168.1.100",
475
+ });
476
+ await expectWebhookStatus(req, 401);
509
477
  });
510
478
 
511
479
  it("rejects ambiguous routing when multiple targets match the same password", async () => {
512
480
  const accountA = createMockAccount({ password: "secret-token" });
513
481
  const accountB = createMockAccount({ password: "secret-token" });
514
- const config: OpenClawConfig = {};
515
- const core = createMockRuntime();
516
- setBlueBubblesRuntime(core);
517
-
518
482
  const sinkA = vi.fn();
519
483
  const sinkB = vi.fn();
520
-
521
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
522
- type: "new-message",
523
- data: {
524
- text: "hello",
525
- handle: { address: "+15551234567" },
526
- isGroup: false,
527
- isFromMe: false,
528
- guid: "msg-1",
529
- },
530
- });
531
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
484
+ registerWebhookTargets([
485
+ { account: accountA, statusSink: sinkA },
486
+ { account: accountB, statusSink: sinkB },
487
+ ]);
488
+
489
+ const req = createWebhookRequestForTest({
490
+ url: "/bluebubbles-webhook?password=secret-token",
491
+ body: createNewMessagePayload(),
532
492
  remoteAddress: "192.168.1.100",
533
- };
534
-
535
- const unregisterA = registerBlueBubblesWebhookTarget({
536
- account: accountA,
537
- config,
538
- runtime: { log: vi.fn(), error: vi.fn() },
539
- core,
540
- path: "/bluebubbles-webhook",
541
- statusSink: sinkA,
542
493
  });
543
- const unregisterB = registerBlueBubblesWebhookTarget({
544
- account: accountB,
545
- config,
546
- runtime: { log: vi.fn(), error: vi.fn() },
547
- core,
548
- path: "/bluebubbles-webhook",
549
- statusSink: sinkB,
550
- });
551
- unregister = () => {
552
- unregisterA();
553
- unregisterB();
554
- };
555
-
556
- const res = createMockResponse();
557
- const handled = await handleBlueBubblesWebhookRequest(req, res);
558
-
559
- expect(handled).toBe(true);
560
- expect(res.statusCode).toBe(401);
494
+ await expectWebhookStatus(req, 401);
561
495
  expect(sinkA).not.toHaveBeenCalled();
562
496
  expect(sinkB).not.toHaveBeenCalled();
563
497
  });
@@ -565,107 +499,38 @@ describe("BlueBubbles webhook monitor", () => {
565
499
  it("ignores targets without passwords when a password-authenticated target matches", async () => {
566
500
  const accountStrict = createMockAccount({ password: "secret-token" });
567
501
  const accountWithoutPassword = createMockAccount({ password: undefined });
568
- const config: OpenClawConfig = {};
569
- const core = createMockRuntime();
570
- setBlueBubblesRuntime(core);
571
-
572
502
  const sinkStrict = vi.fn();
573
503
  const sinkWithoutPassword = vi.fn();
574
-
575
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
576
- type: "new-message",
577
- data: {
578
- text: "hello",
579
- handle: { address: "+15551234567" },
580
- isGroup: false,
581
- isFromMe: false,
582
- guid: "msg-1",
583
- },
584
- });
585
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
504
+ registerWebhookTargets([
505
+ { account: accountStrict, statusSink: sinkStrict },
506
+ { account: accountWithoutPassword, statusSink: sinkWithoutPassword },
507
+ ]);
508
+
509
+ const req = createWebhookRequestForTest({
510
+ url: "/bluebubbles-webhook?password=secret-token",
511
+ body: createNewMessagePayload(),
586
512
  remoteAddress: "192.168.1.100",
587
- };
588
-
589
- const unregisterStrict = registerBlueBubblesWebhookTarget({
590
- account: accountStrict,
591
- config,
592
- runtime: { log: vi.fn(), error: vi.fn() },
593
- core,
594
- path: "/bluebubbles-webhook",
595
- statusSink: sinkStrict,
596
513
  });
597
- const unregisterNoPassword = registerBlueBubblesWebhookTarget({
598
- account: accountWithoutPassword,
599
- config,
600
- runtime: { log: vi.fn(), error: vi.fn() },
601
- core,
602
- path: "/bluebubbles-webhook",
603
- statusSink: sinkWithoutPassword,
604
- });
605
- unregister = () => {
606
- unregisterStrict();
607
- unregisterNoPassword();
608
- };
609
-
610
- const res = createMockResponse();
611
- const handled = await handleBlueBubblesWebhookRequest(req, res);
612
-
613
- expect(handled).toBe(true);
614
- expect(res.statusCode).toBe(200);
514
+ await expectWebhookStatus(req, 200);
615
515
  expect(sinkStrict).toHaveBeenCalledTimes(1);
616
516
  expect(sinkWithoutPassword).not.toHaveBeenCalled();
617
517
  });
618
518
 
619
519
  it("requires authentication for loopback requests when password is configured", async () => {
620
520
  const account = createMockAccount({ password: "secret-token" });
621
- const config: OpenClawConfig = {};
622
- const core = createMockRuntime();
623
- setBlueBubblesRuntime(core);
521
+ setupWebhookTarget({ account });
624
522
  for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
625
- const req = createMockRequest("POST", "/bluebubbles-webhook", {
626
- type: "new-message",
627
- data: {
628
- text: "hello",
629
- handle: { address: "+15551234567" },
630
- isGroup: false,
631
- isFromMe: false,
632
- guid: "msg-1",
633
- },
634
- });
635
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
523
+ const req = createWebhookRequestForTest({
524
+ body: createNewMessagePayload(),
636
525
  remoteAddress,
637
- };
638
-
639
- const loopbackUnregister = registerBlueBubblesWebhookTarget({
640
- account,
641
- config,
642
- runtime: { log: vi.fn(), error: vi.fn() },
643
- core,
644
- path: "/bluebubbles-webhook",
645
526
  });
646
-
647
- const res = createMockResponse();
648
- const handled = await handleBlueBubblesWebhookRequest(req, res);
649
- expect(handled).toBe(true);
650
- expect(res.statusCode).toBe(401);
651
-
652
- loopbackUnregister();
527
+ await expectWebhookStatus(req, 401);
653
528
  }
654
529
  });
655
530
 
656
531
  it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
657
532
  const account = createMockAccount({ password: undefined });
658
- const config: OpenClawConfig = {};
659
- const core = createMockRuntime();
660
- setBlueBubblesRuntime(core);
661
-
662
- unregister = registerBlueBubblesWebhookTarget({
663
- account,
664
- config,
665
- runtime: { log: vi.fn(), error: vi.fn() },
666
- core,
667
- path: "/bluebubbles-webhook",
668
- });
533
+ setupWebhookTarget({ account });
669
534
 
670
535
  const headerVariants: Record<string, string>[] = [
671
536
  { host: "localhost" },
@@ -673,28 +538,12 @@ describe("BlueBubbles webhook monitor", () => {
673
538
  { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
674
539
  ];
675
540
  for (const headers of headerVariants) {
676
- const req = createMockRequest(
677
- "POST",
678
- "/bluebubbles-webhook",
679
- {
680
- type: "new-message",
681
- data: {
682
- text: "hello",
683
- handle: { address: "+15551234567" },
684
- isGroup: false,
685
- isFromMe: false,
686
- guid: "msg-1",
687
- },
688
- },
541
+ const req = createWebhookRequestForTest({
542
+ body: createNewMessagePayload(),
689
543
  headers,
690
- );
691
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
692
544
  remoteAddress: "127.0.0.1",
693
- };
694
- const res = createMockResponse();
695
- const handled = await handleBlueBubblesWebhookRequest(req, res);
696
- expect(handled).toBe(true);
697
- expect(res.statusCode).toBe(401);
545
+ });
546
+ await expectWebhookStatus(req, 401);
698
547
  }
699
548
  });
700
549
 
package/src/multipart.ts CHANGED
@@ -30,3 +30,11 @@ export async function postMultipartFormData(params: {
30
30
  params.timeoutMs,
31
31
  );
32
32
  }
33
+
34
+ export async function assertMultipartActionOk(response: Response, action: string): Promise<void> {
35
+ if (response.ok) {
36
+ return;
37
+ }
38
+ const errorText = await response.text().catch(() => "");
39
+ throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`);
40
+ }
@@ -19,7 +19,7 @@ describe("reactions", () => {
19
19
  });
20
20
 
21
21
  describe("sendBlueBubblesReaction", () => {
22
- async function expectRemovedReaction(emoji: string) {
22
+ async function expectRemovedReaction(emoji: string, expectedReaction = "-love") {
23
23
  mockFetch.mockResolvedValueOnce({
24
24
  ok: true,
25
25
  text: () => Promise.resolve(""),
@@ -37,7 +37,7 @@ describe("reactions", () => {
37
37
  });
38
38
 
39
39
  const body = JSON.parse(mockFetch.mock.calls[0][1].body);
40
- expect(body.reaction).toBe("-love");
40
+ expect(body.reaction).toBe(expectedReaction);
41
41
  }
42
42
 
43
43
  it("throws when chatGuid is empty", async () => {
@@ -327,45 +327,11 @@ describe("reactions", () => {
327
327
 
328
328
  describe("reaction removal aliases", () => {
329
329
  it("handles emoji-based removal", async () => {
330
- mockFetch.mockResolvedValueOnce({
331
- ok: true,
332
- text: () => Promise.resolve(""),
333
- });
334
-
335
- await sendBlueBubblesReaction({
336
- chatGuid: "chat-123",
337
- messageGuid: "msg-123",
338
- emoji: "👍",
339
- remove: true,
340
- opts: {
341
- serverUrl: "http://localhost:1234",
342
- password: "test",
343
- },
344
- });
345
-
346
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
347
- expect(body.reaction).toBe("-like");
330
+ await expectRemovedReaction("👍", "-like");
348
331
  });
349
332
 
350
333
  it("handles text alias removal", async () => {
351
- mockFetch.mockResolvedValueOnce({
352
- ok: true,
353
- text: () => Promise.resolve(""),
354
- });
355
-
356
- await sendBlueBubblesReaction({
357
- chatGuid: "chat-123",
358
- messageGuid: "msg-123",
359
- emoji: "haha",
360
- remove: true,
361
- opts: {
362
- serverUrl: "http://localhost:1234",
363
- password: "test",
364
- },
365
- });
366
-
367
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
368
- expect(body.reaction).toBe("-laugh");
334
+ await expectRemovedReaction("haha", "-laugh");
369
335
  });
370
336
  });
371
337
  });