@openclaw/bluebubbles 2026.2.17 → 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.17",
3
+ "version": "2026.2.21",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
package/src/chat.test.ts CHANGED
@@ -13,29 +13,20 @@ installBlueBubblesFetchTestHooks({
13
13
 
14
14
  describe("chat", () => {
15
15
  describe("markBlueBubblesChatRead", () => {
16
- it("does nothing when chatGuid is empty", async () => {
17
- await markBlueBubblesChatRead("", {
18
- serverUrl: "http://localhost:1234",
19
- password: "test",
20
- });
21
- expect(mockFetch).not.toHaveBeenCalled();
22
- });
23
-
24
- it("does nothing when chatGuid is whitespace", async () => {
25
- await markBlueBubblesChatRead(" ", {
26
- serverUrl: "http://localhost:1234",
27
- password: "test",
28
- });
16
+ it("does nothing when chatGuid is empty or whitespace", async () => {
17
+ for (const chatGuid of ["", " "]) {
18
+ await markBlueBubblesChatRead(chatGuid, {
19
+ serverUrl: "http://localhost:1234",
20
+ password: "test",
21
+ });
22
+ }
29
23
  expect(mockFetch).not.toHaveBeenCalled();
30
24
  });
31
25
 
32
- it("throws when serverUrl is missing", async () => {
26
+ it("throws when required credentials are missing", async () => {
33
27
  await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
34
28
  "serverUrl is required",
35
29
  );
36
- });
37
-
38
- it("throws when password is missing", async () => {
39
30
  await expect(
40
31
  markBlueBubblesChatRead("chat-guid", {
41
32
  serverUrl: "http://localhost:1234",
@@ -141,29 +132,20 @@ describe("chat", () => {
141
132
  });
142
133
 
143
134
  describe("sendBlueBubblesTyping", () => {
144
- it("does nothing when chatGuid is empty", async () => {
145
- await sendBlueBubblesTyping("", true, {
146
- serverUrl: "http://localhost:1234",
147
- password: "test",
148
- });
149
- expect(mockFetch).not.toHaveBeenCalled();
150
- });
151
-
152
- it("does nothing when chatGuid is whitespace", async () => {
153
- await sendBlueBubblesTyping(" ", false, {
154
- serverUrl: "http://localhost:1234",
155
- password: "test",
156
- });
135
+ it("does nothing when chatGuid is empty or whitespace", async () => {
136
+ for (const chatGuid of ["", " "]) {
137
+ await sendBlueBubblesTyping(chatGuid, true, {
138
+ serverUrl: "http://localhost:1234",
139
+ password: "test",
140
+ });
141
+ }
157
142
  expect(mockFetch).not.toHaveBeenCalled();
158
143
  });
159
144
 
160
- it("throws when serverUrl is missing", async () => {
145
+ it("throws when required credentials are missing", async () => {
161
146
  await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
162
147
  "serverUrl is required",
163
148
  );
164
- });
165
-
166
- it("throws when password is missing", async () => {
167
149
  await expect(
168
150
  sendBlueBubblesTyping("chat-guid", true, {
169
151
  serverUrl: "http://localhost:1234",
@@ -171,49 +153,46 @@ describe("chat", () => {
171
153
  ).rejects.toThrow("password is required");
172
154
  });
173
155
 
174
- it("sends typing start with POST method", async () => {
175
- mockFetch.mockResolvedValueOnce({
176
- ok: true,
177
- text: () => Promise.resolve(""),
178
- });
156
+ it("does not send typing when private API is disabled", async () => {
157
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
179
158
 
180
159
  await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
181
160
  serverUrl: "http://localhost:1234",
182
161
  password: "test",
183
162
  });
184
163
 
185
- expect(mockFetch).toHaveBeenCalledWith(
186
- expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
187
- expect.objectContaining({ method: "POST" }),
188
- );
164
+ expect(mockFetch).not.toHaveBeenCalled();
189
165
  });
190
166
 
191
- it("does not send typing when private API is disabled", async () => {
192
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
167
+ it("uses POST for start and DELETE for stop", async () => {
168
+ mockFetch
169
+ .mockResolvedValueOnce({
170
+ ok: true,
171
+ text: () => Promise.resolve(""),
172
+ })
173
+ .mockResolvedValueOnce({
174
+ ok: true,
175
+ text: () => Promise.resolve(""),
176
+ });
193
177
 
194
178
  await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
195
179
  serverUrl: "http://localhost:1234",
196
180
  password: "test",
197
181
  });
198
-
199
- expect(mockFetch).not.toHaveBeenCalled();
200
- });
201
-
202
- it("sends typing stop with DELETE method", async () => {
203
- mockFetch.mockResolvedValueOnce({
204
- ok: true,
205
- text: () => Promise.resolve(""),
206
- });
207
-
208
182
  await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
209
183
  serverUrl: "http://localhost:1234",
210
184
  password: "test",
211
185
  });
212
186
 
213
- expect(mockFetch).toHaveBeenCalledWith(
214
- expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
215
- expect.objectContaining({ method: "DELETE" }),
187
+ expect(mockFetch).toHaveBeenCalledTimes(2);
188
+ expect(mockFetch.mock.calls[0][0]).toContain(
189
+ "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
216
190
  );
191
+ expect(mockFetch.mock.calls[0][1].method).toBe("POST");
192
+ expect(mockFetch.mock.calls[1][0]).toContain(
193
+ "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
194
+ );
195
+ expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
217
196
  });
218
197
 
219
198
  it("includes password in URL query", async () => {
@@ -297,31 +276,6 @@ describe("chat", () => {
297
276
  expect(calledUrl).toContain("typing-server:8888");
298
277
  expect(calledUrl).toContain("password=typing-pass");
299
278
  });
300
-
301
- it("can start and stop typing in sequence", async () => {
302
- mockFetch
303
- .mockResolvedValueOnce({
304
- ok: true,
305
- text: () => Promise.resolve(""),
306
- })
307
- .mockResolvedValueOnce({
308
- ok: true,
309
- text: () => Promise.resolve(""),
310
- });
311
-
312
- await sendBlueBubblesTyping("chat-123", true, {
313
- serverUrl: "http://localhost:1234",
314
- password: "test",
315
- });
316
- await sendBlueBubblesTyping("chat-123", false, {
317
- serverUrl: "http://localhost:1234",
318
- password: "test",
319
- });
320
-
321
- expect(mockFetch).toHaveBeenCalledTimes(2);
322
- expect(mockFetch.mock.calls[0][1].method).toBe("POST");
323
- expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
324
- });
325
279
  });
326
280
 
327
281
  describe("setGroupIconBlueBubbles", () => {
@@ -343,13 +297,10 @@ describe("chat", () => {
343
297
  ).rejects.toThrow("image buffer");
344
298
  });
345
299
 
346
- it("throws when serverUrl is missing", async () => {
300
+ it("throws when required credentials are missing", async () => {
347
301
  await expect(
348
302
  setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
349
303
  ).rejects.toThrow("serverUrl is required");
350
- });
351
-
352
- it("throws when password is missing", async () => {
353
304
  await expect(
354
305
  setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
355
306
  serverUrl: "http://localhost:1234",
@@ -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) => {