@openclaw/bluebubbles 2026.2.19 → 2026.2.21

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.19",
3
+ "version": "2026.2.21",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { BlueBubblesConfigSchema } from "./config-schema.js";
3
+
4
+ describe("BlueBubblesConfigSchema", () => {
5
+ it("accepts account config when serverUrl and password are both set", () => {
6
+ const parsed = BlueBubblesConfigSchema.safeParse({
7
+ serverUrl: "http://localhost:1234",
8
+ password: "secret",
9
+ });
10
+ expect(parsed.success).toBe(true);
11
+ });
12
+
13
+ it("requires password when top-level serverUrl is configured", () => {
14
+ const parsed = BlueBubblesConfigSchema.safeParse({
15
+ serverUrl: "http://localhost:1234",
16
+ });
17
+ expect(parsed.success).toBe(false);
18
+ if (parsed.success) {
19
+ return;
20
+ }
21
+ expect(parsed.error.issues[0]?.path).toEqual(["password"]);
22
+ expect(parsed.error.issues[0]?.message).toBe(
23
+ "password is required when serverUrl is configured",
24
+ );
25
+ });
26
+
27
+ it("requires password when account serverUrl is configured", () => {
28
+ const parsed = BlueBubblesConfigSchema.safeParse({
29
+ accounts: {
30
+ work: {
31
+ serverUrl: "http://localhost:1234",
32
+ },
33
+ },
34
+ });
35
+ expect(parsed.success).toBe(false);
36
+ if (parsed.success) {
37
+ return;
38
+ }
39
+ expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
40
+ expect(parsed.error.issues[0]?.message).toBe(
41
+ "password is required when serverUrl is configured",
42
+ );
43
+ });
44
+
45
+ it("allows password omission when serverUrl is not configured", () => {
46
+ const parsed = BlueBubblesConfigSchema.safeParse({
47
+ accounts: {
48
+ work: {
49
+ name: "Work iMessage",
50
+ },
51
+ },
52
+ });
53
+ expect(parsed.success).toBe(true);
54
+ });
55
+ });
@@ -24,27 +24,39 @@ const bluebubblesGroupConfigSchema = z.object({
24
24
  tools: ToolPolicySchema,
25
25
  });
26
26
 
27
- const bluebubblesAccountSchema = z.object({
28
- name: z.string().optional(),
29
- enabled: z.boolean().optional(),
30
- markdown: MarkdownConfigSchema,
31
- serverUrl: z.string().optional(),
32
- password: z.string().optional(),
33
- webhookPath: z.string().optional(),
34
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
35
- allowFrom: z.array(allowFromEntry).optional(),
36
- groupAllowFrom: z.array(allowFromEntry).optional(),
37
- groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
38
- historyLimit: z.number().int().min(0).optional(),
39
- dmHistoryLimit: z.number().int().min(0).optional(),
40
- textChunkLimit: z.number().int().positive().optional(),
41
- chunkMode: z.enum(["length", "newline"]).optional(),
42
- mediaMaxMb: z.number().int().positive().optional(),
43
- mediaLocalRoots: z.array(z.string()).optional(),
44
- sendReadReceipts: z.boolean().optional(),
45
- blockStreaming: z.boolean().optional(),
46
- groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
47
- });
27
+ const bluebubblesAccountSchema = z
28
+ .object({
29
+ name: z.string().optional(),
30
+ enabled: z.boolean().optional(),
31
+ markdown: MarkdownConfigSchema,
32
+ serverUrl: z.string().optional(),
33
+ password: z.string().optional(),
34
+ webhookPath: z.string().optional(),
35
+ dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
36
+ allowFrom: z.array(allowFromEntry).optional(),
37
+ groupAllowFrom: z.array(allowFromEntry).optional(),
38
+ groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
39
+ historyLimit: z.number().int().min(0).optional(),
40
+ dmHistoryLimit: z.number().int().min(0).optional(),
41
+ textChunkLimit: z.number().int().positive().optional(),
42
+ chunkMode: z.enum(["length", "newline"]).optional(),
43
+ mediaMaxMb: z.number().int().positive().optional(),
44
+ mediaLocalRoots: z.array(z.string()).optional(),
45
+ sendReadReceipts: z.boolean().optional(),
46
+ blockStreaming: z.boolean().optional(),
47
+ groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
48
+ })
49
+ .superRefine((value, ctx) => {
50
+ const serverUrl = value.serverUrl?.trim() ?? "";
51
+ const password = value.password?.trim() ?? "";
52
+ if (serverUrl && !password) {
53
+ ctx.addIssue({
54
+ code: z.ZodIssueCode.custom,
55
+ path: ["password"],
56
+ message: "password is required when serverUrl is configured",
57
+ });
58
+ }
59
+ });
48
60
 
49
61
  export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
50
62
  accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
@@ -452,6 +452,45 @@ describe("BlueBubbles webhook monitor", () => {
452
452
  expect(res.statusCode).toBe(400);
453
453
  });
454
454
 
455
+ it("accepts URL-encoded payload wrappers", async () => {
456
+ const account = createMockAccount();
457
+ const config: OpenClawConfig = {};
458
+ const core = createMockRuntime();
459
+ setBlueBubblesRuntime(core);
460
+
461
+ unregister = registerBlueBubblesWebhookTarget({
462
+ account,
463
+ config,
464
+ runtime: { log: vi.fn(), error: vi.fn() },
465
+ core,
466
+ path: "/bluebubbles-webhook",
467
+ });
468
+
469
+ const payload = {
470
+ type: "new-message",
471
+ data: {
472
+ text: "hello",
473
+ handle: { address: "+15551234567" },
474
+ isGroup: false,
475
+ isFromMe: false,
476
+ guid: "msg-1",
477
+ date: Date.now(),
478
+ },
479
+ };
480
+ const encodedBody = new URLSearchParams({
481
+ payload: JSON.stringify(payload),
482
+ }).toString();
483
+
484
+ const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
485
+ const res = createMockResponse();
486
+
487
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
488
+
489
+ expect(handled).toBe(true);
490
+ expect(res.statusCode).toBe(200);
491
+ expect(res.body).toBe("ok");
492
+ });
493
+
455
494
  it("returns 408 when request body times out (Slow-Loris protection)", async () => {
456
495
  vi.useFakeTimers();
457
496
  try {
@@ -659,15 +698,15 @@ describe("BlueBubbles webhook monitor", () => {
659
698
  expect(sinkB).not.toHaveBeenCalled();
660
699
  });
661
700
 
662
- it("does not route to passwordless targets when a password-authenticated target matches", async () => {
701
+ it("ignores targets without passwords when a password-authenticated target matches", async () => {
663
702
  const accountStrict = createMockAccount({ password: "secret-token" });
664
- const accountFallback = createMockAccount({ password: undefined });
703
+ const accountWithoutPassword = createMockAccount({ password: undefined });
665
704
  const config: OpenClawConfig = {};
666
705
  const core = createMockRuntime();
667
706
  setBlueBubblesRuntime(core);
668
707
 
669
708
  const sinkStrict = vi.fn();
670
- const sinkFallback = vi.fn();
709
+ const sinkWithoutPassword = vi.fn();
671
710
 
672
711
  const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
673
712
  type: "new-message",
@@ -691,17 +730,17 @@ describe("BlueBubbles webhook monitor", () => {
691
730
  path: "/bluebubbles-webhook",
692
731
  statusSink: sinkStrict,
693
732
  });
694
- const unregisterFallback = registerBlueBubblesWebhookTarget({
695
- account: accountFallback,
733
+ const unregisterNoPassword = registerBlueBubblesWebhookTarget({
734
+ account: accountWithoutPassword,
696
735
  config,
697
736
  runtime: { log: vi.fn(), error: vi.fn() },
698
737
  core,
699
738
  path: "/bluebubbles-webhook",
700
- statusSink: sinkFallback,
739
+ statusSink: sinkWithoutPassword,
701
740
  });
702
741
  unregister = () => {
703
742
  unregisterStrict();
704
- unregisterFallback();
743
+ unregisterNoPassword();
705
744
  };
706
745
 
707
746
  const res = createMockResponse();
@@ -710,7 +749,7 @@ describe("BlueBubbles webhook monitor", () => {
710
749
  expect(handled).toBe(true);
711
750
  expect(res.statusCode).toBe(200);
712
751
  expect(sinkStrict).toHaveBeenCalledTimes(1);
713
- expect(sinkFallback).not.toHaveBeenCalled();
752
+ expect(sinkWithoutPassword).not.toHaveBeenCalled();
714
753
  });
715
754
 
716
755
  it("requires authentication for loopback requests when password is configured", async () => {
@@ -750,65 +789,12 @@ describe("BlueBubbles webhook monitor", () => {
750
789
  }
751
790
  });
752
791
 
753
- it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => {
792
+ it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
754
793
  const account = createMockAccount({ password: undefined });
755
794
  const config: OpenClawConfig = {};
756
795
  const core = createMockRuntime();
757
796
  setBlueBubblesRuntime(core);
758
797
 
759
- const req = createMockRequest(
760
- "POST",
761
- "/bluebubbles-webhook",
762
- {
763
- type: "new-message",
764
- data: {
765
- text: "hello",
766
- handle: { address: "+15551234567" },
767
- isGroup: false,
768
- isFromMe: false,
769
- guid: "msg-1",
770
- },
771
- },
772
- { "x-forwarded-for": "203.0.113.10", host: "localhost" },
773
- );
774
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
775
- remoteAddress: "127.0.0.1",
776
- };
777
-
778
- unregister = registerBlueBubblesWebhookTarget({
779
- account,
780
- config,
781
- runtime: { log: vi.fn(), error: vi.fn() },
782
- core,
783
- path: "/bluebubbles-webhook",
784
- });
785
-
786
- const res = createMockResponse();
787
- const handled = await handleBlueBubblesWebhookRequest(req, res);
788
- expect(handled).toBe(true);
789
- expect(res.statusCode).toBe(401);
790
- });
791
-
792
- it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
793
- const account = createMockAccount({ password: undefined });
794
- const config: OpenClawConfig = {};
795
- const core = createMockRuntime();
796
- setBlueBubblesRuntime(core);
797
-
798
- const req = createMockRequest("POST", "/bluebubbles-webhook", {
799
- type: "new-message",
800
- data: {
801
- text: "hello",
802
- handle: { address: "+15551234567" },
803
- isGroup: false,
804
- isFromMe: false,
805
- guid: "msg-1",
806
- },
807
- });
808
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
809
- remoteAddress: "127.0.0.1",
810
- };
811
-
812
798
  unregister = registerBlueBubblesWebhookTarget({
813
799
  account,
814
800
  config,
@@ -817,10 +803,35 @@ describe("BlueBubbles webhook monitor", () => {
817
803
  path: "/bluebubbles-webhook",
818
804
  });
819
805
 
820
- const res = createMockResponse();
821
- const handled = await handleBlueBubblesWebhookRequest(req, res);
822
- expect(handled).toBe(true);
823
- expect(res.statusCode).toBe(200);
806
+ const headerVariants: Record<string, string>[] = [
807
+ { host: "localhost" },
808
+ { host: "localhost", "x-forwarded-for": "203.0.113.10" },
809
+ { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
810
+ ];
811
+ for (const headers of headerVariants) {
812
+ const req = createMockRequest(
813
+ "POST",
814
+ "/bluebubbles-webhook",
815
+ {
816
+ type: "new-message",
817
+ data: {
818
+ text: "hello",
819
+ handle: { address: "+15551234567" },
820
+ isGroup: false,
821
+ isFromMe: false,
822
+ guid: "msg-1",
823
+ },
824
+ },
825
+ headers,
826
+ );
827
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
828
+ remoteAddress: "127.0.0.1",
829
+ };
830
+ const res = createMockResponse();
831
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
832
+ expect(handled).toBe(true);
833
+ expect(res.statusCode).toBe(401);
834
+ }
824
835
  });
825
836
 
826
837
  it("ignores unregistered webhook paths", async () => {
package/src/monitor.ts CHANGED
@@ -2,8 +2,12 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
4
  import {
5
+ isRequestBodyLimitError,
6
+ readRequestBodyWithLimit,
5
7
  registerWebhookTarget,
6
8
  rejectNonPostWebhookRequest,
9
+ requestBodyErrorToText,
10
+ resolveSingleWebhookTarget,
7
11
  resolveWebhookTargets,
8
12
  } from "openclaw/plugin-sdk";
9
13
  import {
@@ -239,64 +243,61 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
239
243
  };
240
244
  }
241
245
 
242
- async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
243
- const chunks: Buffer[] = [];
244
- let total = 0;
245
- return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
246
- let done = false;
247
- const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
248
- if (done) {
249
- return;
250
- }
251
- done = true;
252
- clearTimeout(timer);
253
- resolve(result);
254
- };
246
+ type ReadBlueBubblesWebhookBodyResult =
247
+ | { ok: true; value: unknown }
248
+ | { ok: false; statusCode: number; error: string };
255
249
 
256
- const timer = setTimeout(() => {
257
- finish({ ok: false, error: "request body timeout" });
258
- req.destroy();
259
- }, timeoutMs);
250
+ function parseBlueBubblesWebhookPayload(
251
+ rawBody: string,
252
+ ): { ok: true; value: unknown } | { ok: false; error: string } {
253
+ const trimmed = rawBody.trim();
254
+ if (!trimmed) {
255
+ return { ok: false, error: "empty payload" };
256
+ }
257
+ try {
258
+ return { ok: true, value: JSON.parse(trimmed) as unknown };
259
+ } catch {
260
+ const params = new URLSearchParams(rawBody);
261
+ const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
262
+ if (!payload) {
263
+ return { ok: false, error: "invalid json" };
264
+ }
265
+ try {
266
+ return { ok: true, value: JSON.parse(payload) as unknown };
267
+ } catch (error) {
268
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
269
+ }
270
+ }
271
+ }
260
272
 
261
- req.on("data", (chunk: Buffer) => {
262
- total += chunk.length;
263
- if (total > maxBytes) {
264
- finish({ ok: false, error: "payload too large" });
265
- req.destroy();
266
- return;
267
- }
268
- chunks.push(chunk);
269
- });
270
- req.on("end", () => {
271
- try {
272
- const raw = Buffer.concat(chunks).toString("utf8");
273
- if (!raw.trim()) {
274
- finish({ ok: false, error: "empty payload" });
275
- return;
276
- }
277
- try {
278
- finish({ ok: true, value: JSON.parse(raw) as unknown });
279
- return;
280
- } catch {
281
- const params = new URLSearchParams(raw);
282
- const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
283
- if (payload) {
284
- finish({ ok: true, value: JSON.parse(payload) as unknown });
285
- return;
286
- }
287
- throw new Error("invalid json");
288
- }
289
- } catch (err) {
290
- finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
291
- }
292
- });
293
- req.on("error", (err) => {
294
- finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
295
- });
296
- req.on("close", () => {
297
- finish({ ok: false, error: "connection closed" });
273
+ async function readBlueBubblesWebhookBody(
274
+ req: IncomingMessage,
275
+ maxBytes: number,
276
+ ): Promise<ReadBlueBubblesWebhookBodyResult> {
277
+ try {
278
+ const rawBody = await readRequestBodyWithLimit(req, {
279
+ maxBytes,
280
+ timeoutMs: 30_000,
298
281
  });
299
- });
282
+ const parsed = parseBlueBubblesWebhookPayload(rawBody);
283
+ if (!parsed.ok) {
284
+ return { ok: false, statusCode: 400, error: parsed.error };
285
+ }
286
+ return parsed;
287
+ } catch (error) {
288
+ if (isRequestBodyLimitError(error)) {
289
+ return {
290
+ ok: false,
291
+ statusCode: error.statusCode,
292
+ error: requestBodyErrorToText(error.code),
293
+ };
294
+ }
295
+ return {
296
+ ok: false,
297
+ statusCode: 400,
298
+ error: error instanceof Error ? error.message : String(error),
299
+ };
300
+ }
300
301
  }
301
302
 
302
303
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -337,48 +338,6 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
337
338
  return timingSafeEqual(bufA, bufB);
338
339
  }
339
340
 
340
- function getHostName(hostHeader?: string | string[]): string {
341
- const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? ""))
342
- .trim()
343
- .toLowerCase();
344
- if (!host) {
345
- return "";
346
- }
347
- // Bracketed IPv6: [::1]:18789
348
- if (host.startsWith("[")) {
349
- const end = host.indexOf("]");
350
- if (end !== -1) {
351
- return host.slice(1, end);
352
- }
353
- }
354
- const [name] = host.split(":");
355
- return name ?? "";
356
- }
357
-
358
- function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean {
359
- const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase();
360
- const remoteIsLoopback =
361
- remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
362
- if (!remoteIsLoopback) {
363
- return false;
364
- }
365
-
366
- const host = getHostName(req.headers?.host);
367
- const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
368
- if (!hostIsLocal) {
369
- return false;
370
- }
371
-
372
- // If a reverse proxy is in front, it will usually inject forwarding headers.
373
- // Passwordless webhooks must never be accepted through a proxy.
374
- const hasForwarded = Boolean(
375
- req.headers?.["x-forwarded-for"] ||
376
- req.headers?.["x-real-ip"] ||
377
- req.headers?.["x-forwarded-host"],
378
- );
379
- return !hasForwarded;
380
- }
381
-
382
341
  export async function handleBlueBubblesWebhookRequest(
383
342
  req: IncomingMessage,
384
343
  res: ServerResponse,
@@ -394,15 +353,9 @@ export async function handleBlueBubblesWebhookRequest(
394
353
  return true;
395
354
  }
396
355
 
397
- const body = await readJsonBody(req, 1024 * 1024);
356
+ const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
398
357
  if (!body.ok) {
399
- if (body.error === "payload too large") {
400
- res.statusCode = 413;
401
- } else if (body.error === "request body timeout") {
402
- res.statusCode = 408;
403
- } else {
404
- res.statusCode = 400;
405
- }
358
+ res.statusCode = body.statusCode;
406
359
  res.end(body.error ?? "invalid payload");
407
360
  console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
408
361
  return true;
@@ -466,31 +419,12 @@ export async function handleBlueBubblesWebhookRequest(
466
419
  req.headers["x-bluebubbles-guid"] ??
467
420
  req.headers["authorization"];
468
421
  const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
469
-
470
- const strictMatches: WebhookTarget[] = [];
471
- const passwordlessTargets: WebhookTarget[] = [];
472
- for (const target of targets) {
422
+ const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
473
423
  const token = target.account.config.password?.trim() ?? "";
474
- if (!token) {
475
- passwordlessTargets.push(target);
476
- continue;
477
- }
478
- if (safeEqualSecret(guid, token)) {
479
- strictMatches.push(target);
480
- if (strictMatches.length > 1) {
481
- break;
482
- }
483
- }
484
- }
485
-
486
- const matching =
487
- strictMatches.length > 0
488
- ? strictMatches
489
- : isDirectLocalLoopbackRequest(req)
490
- ? passwordlessTargets
491
- : [];
424
+ return safeEqualSecret(guid, token);
425
+ });
492
426
 
493
- if (matching.length === 0) {
427
+ if (matchedTarget.kind === "none") {
494
428
  res.statusCode = 401;
495
429
  res.end("unauthorized");
496
430
  console.warn(
@@ -499,14 +433,14 @@ export async function handleBlueBubblesWebhookRequest(
499
433
  return true;
500
434
  }
501
435
 
502
- if (matching.length > 1) {
436
+ if (matchedTarget.kind === "ambiguous") {
503
437
  res.statusCode = 401;
504
438
  res.end("ambiguous webhook target");
505
439
  console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
506
440
  return true;
507
441
  }
508
442
 
509
- const target = matching[0];
443
+ const target = matchedTarget.target;
510
444
  target.statusSink?.({ lastInboundAt: Date.now() });
511
445
  if (reaction) {
512
446
  processReaction(reaction, target).catch((err) => {