@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 +1 -1
- package/src/attachments.test.ts +24 -36
- package/src/attachments.ts +2 -7
- package/src/chat.test.ts +7 -18
- package/src/chat.ts +4 -17
- package/src/media-send.test.ts +91 -45
- package/src/monitor-normalize.test.ts +15 -13
- package/src/monitor-normalize.ts +29 -22
- package/src/monitor.webhook-auth.test.ts +132 -283
- package/src/multipart.ts +8 -0
- package/src/reactions.test.ts +4 -38
package/package.json
CHANGED
package/src/attachments.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/src/attachments.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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/media-send.test.ts
CHANGED
|
@@ -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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
185
|
+
const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
|
|
186
|
+
"allowed.txt",
|
|
187
|
+
"allowed",
|
|
188
|
+
);
|
|
128
189
|
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
202
|
+
const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
|
|
203
|
+
"allowed.txt",
|
|
204
|
+
"allowed",
|
|
205
|
+
);
|
|
150
206
|
|
|
151
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
isFromMe: false,
|
|
87
|
-
handle: null,
|
|
88
|
-
chatGuid: "iMessage;-;+15551234567",
|
|
89
|
-
},
|
|
91
|
+
}),
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
expect(result).not.toBeNull();
|
package/src/monitor-normalize.ts
CHANGED
|
@@ -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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
const
|
|
362
|
+
unregister = () => {
|
|
363
|
+
for (const unregisterFn of unregisterFns) {
|
|
364
|
+
unregisterFn();
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
322
368
|
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
428
|
-
const
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
626
|
-
|
|
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
|
-
|
|
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 =
|
|
677
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/reactions.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|