@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 +1 -1
- package/src/chat.test.ts +38 -87
- package/src/config-schema.test.ts +55 -0
- package/src/config-schema.ts +33 -21
- package/src/monitor.test.ts +77 -66
- package/src/monitor.ts +64 -130
package/package.json
CHANGED
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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("
|
|
175
|
-
|
|
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).
|
|
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("
|
|
192
|
-
|
|
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).
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
+
});
|
package/src/config-schema.ts
CHANGED
|
@@ -24,27 +24,39 @@ const bluebubblesGroupConfigSchema = z.object({
|
|
|
24
24
|
tools: ToolPolicySchema,
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
const bluebubblesAccountSchema = z
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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(),
|
package/src/monitor.test.ts
CHANGED
|
@@ -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("
|
|
701
|
+
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
|
663
702
|
const accountStrict = createMockAccount({ password: "secret-token" });
|
|
664
|
-
const
|
|
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
|
|
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
|
|
695
|
-
account:
|
|
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:
|
|
739
|
+
statusSink: sinkWithoutPassword,
|
|
701
740
|
});
|
|
702
741
|
unregister = () => {
|
|
703
742
|
unregisterStrict();
|
|
704
|
-
|
|
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(
|
|
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
|
|
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
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
356
|
+
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
|
|
398
357
|
if (!body.ok) {
|
|
399
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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 (
|
|
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 (
|
|
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 =
|
|
443
|
+
const target = matchedTarget.target;
|
|
510
444
|
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
511
445
|
if (reaction) {
|
|
512
446
|
processReaction(reaction, target).catch((err) => {
|