@openclaw/bluebubbles 2026.2.25 → 2026.3.2
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/README.md +1 -1
- package/index.ts +0 -2
- package/package.json +1 -1
- package/src/account-resolve.ts +19 -2
- package/src/accounts.test.ts +25 -0
- package/src/accounts.ts +18 -5
- package/src/actions.ts +4 -19
- package/src/attachments.test.ts +20 -2
- package/src/attachments.ts +15 -1
- package/src/channel.ts +3 -10
- package/src/config-schema.test.ts +12 -0
- package/src/config-schema.ts +5 -3
- package/src/monitor-debounce.ts +205 -0
- package/src/monitor-processing.ts +43 -22
- package/src/monitor.test.ts +87 -717
- package/src/monitor.ts +157 -364
- package/src/monitor.webhook-auth.test.ts +862 -0
- package/src/monitor.webhook-route.test.ts +44 -0
- package/src/onboarding.secret-input.test.ts +81 -0
- package/src/onboarding.ts +8 -2
- package/src/probe.ts +5 -4
- package/src/secret-input.ts +19 -0
- package/src/send-helpers.ts +34 -22
- package/src/send.test.ts +24 -0
- package/src/send.ts +7 -2
- package/src/targets.ts +2 -5
- package/src/types.ts +2 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
|
6
|
+
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
|
7
|
+
import { fetchBlueBubblesHistory } from "./history.js";
|
|
8
|
+
import {
|
|
9
|
+
handleBlueBubblesWebhookRequest,
|
|
10
|
+
registerBlueBubblesWebhookTarget,
|
|
11
|
+
resolveBlueBubblesMessageId,
|
|
12
|
+
_resetBlueBubblesShortIdState,
|
|
13
|
+
} from "./monitor.js";
|
|
14
|
+
import { setBlueBubblesRuntime } from "./runtime.js";
|
|
15
|
+
|
|
16
|
+
// Mock dependencies
|
|
17
|
+
vi.mock("./send.js", () => ({
|
|
18
|
+
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
|
|
19
|
+
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./chat.js", () => ({
|
|
23
|
+
markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("./attachments.js", () => ({
|
|
28
|
+
downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({
|
|
29
|
+
buffer: Buffer.from("test"),
|
|
30
|
+
contentType: "image/jpeg",
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("./reactions.js", async () => {
|
|
35
|
+
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
|
|
36
|
+
return {
|
|
37
|
+
...actual,
|
|
38
|
+
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
vi.mock("./history.js", () => ({
|
|
43
|
+
fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Mock runtime
|
|
47
|
+
const mockEnqueueSystemEvent = vi.fn();
|
|
48
|
+
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
|
|
49
|
+
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
|
50
|
+
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
|
|
51
|
+
const mockResolveAgentRoute = vi.fn(() => ({
|
|
52
|
+
agentId: "main",
|
|
53
|
+
channel: "bluebubbles",
|
|
54
|
+
accountId: "default",
|
|
55
|
+
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
|
56
|
+
mainSessionKey: "agent:main:main",
|
|
57
|
+
matchedBy: "default",
|
|
58
|
+
}));
|
|
59
|
+
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
|
60
|
+
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
|
61
|
+
regexes.some((r) => r.test(text)),
|
|
62
|
+
);
|
|
63
|
+
const mockMatchesMentionWithExplicit = vi.fn(
|
|
64
|
+
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
|
|
65
|
+
if (params.explicitWasMentioned) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return params.mentionRegexes.some((regex) => regex.test(params.text));
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
const mockResolveRequireMention = vi.fn(() => false);
|
|
72
|
+
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
|
|
73
|
+
type DispatchReplyParams = Parameters<
|
|
74
|
+
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
|
|
75
|
+
>[0];
|
|
76
|
+
const EMPTY_DISPATCH_RESULT = {
|
|
77
|
+
queuedFinal: false,
|
|
78
|
+
counts: { tool: 0, block: 0, final: 0 },
|
|
79
|
+
} as const;
|
|
80
|
+
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
|
81
|
+
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
|
|
82
|
+
);
|
|
83
|
+
const mockHasControlCommand = vi.fn(() => false);
|
|
84
|
+
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
|
85
|
+
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
|
86
|
+
id: "test-media.jpg",
|
|
87
|
+
path: "/tmp/test-media.jpg",
|
|
88
|
+
size: Buffer.byteLength("test"),
|
|
89
|
+
contentType: "image/jpeg",
|
|
90
|
+
});
|
|
91
|
+
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
|
|
92
|
+
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
|
|
93
|
+
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
|
|
94
|
+
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
95
|
+
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
96
|
+
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
|
97
|
+
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
|
98
|
+
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
|
99
|
+
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
|
100
|
+
const mockResolveChunkMode = vi.fn(() => "length" as const);
|
|
101
|
+
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
|
102
|
+
|
|
103
|
+
function createMockRuntime(): PluginRuntime {
|
|
104
|
+
return createPluginRuntimeMock({
|
|
105
|
+
system: {
|
|
106
|
+
enqueueSystemEvent: mockEnqueueSystemEvent,
|
|
107
|
+
},
|
|
108
|
+
channel: {
|
|
109
|
+
text: {
|
|
110
|
+
chunkMarkdownText: mockChunkMarkdownText,
|
|
111
|
+
chunkByNewline: mockChunkByNewline,
|
|
112
|
+
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
|
113
|
+
chunkTextWithMode: mockChunkTextWithMode,
|
|
114
|
+
resolveChunkMode:
|
|
115
|
+
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
|
116
|
+
hasControlCommand: mockHasControlCommand,
|
|
117
|
+
},
|
|
118
|
+
reply: {
|
|
119
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
120
|
+
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
|
121
|
+
formatAgentEnvelope: mockFormatAgentEnvelope,
|
|
122
|
+
formatInboundEnvelope: mockFormatInboundEnvelope,
|
|
123
|
+
resolveEnvelopeFormatOptions:
|
|
124
|
+
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
125
|
+
},
|
|
126
|
+
routing: {
|
|
127
|
+
resolveAgentRoute:
|
|
128
|
+
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
129
|
+
},
|
|
130
|
+
pairing: {
|
|
131
|
+
buildPairingReply: mockBuildPairingReply,
|
|
132
|
+
readAllowFromStore: mockReadAllowFromStore,
|
|
133
|
+
upsertPairingRequest: mockUpsertPairingRequest,
|
|
134
|
+
},
|
|
135
|
+
media: {
|
|
136
|
+
saveMediaBuffer:
|
|
137
|
+
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
|
138
|
+
},
|
|
139
|
+
session: {
|
|
140
|
+
resolveStorePath: mockResolveStorePath,
|
|
141
|
+
readSessionUpdatedAt: mockReadSessionUpdatedAt,
|
|
142
|
+
},
|
|
143
|
+
mentions: {
|
|
144
|
+
buildMentionRegexes: mockBuildMentionRegexes,
|
|
145
|
+
matchesMentionPatterns: mockMatchesMentionPatterns,
|
|
146
|
+
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
|
147
|
+
},
|
|
148
|
+
groups: {
|
|
149
|
+
resolveGroupPolicy:
|
|
150
|
+
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
|
151
|
+
resolveRequireMention: mockResolveRequireMention,
|
|
152
|
+
},
|
|
153
|
+
commands: {
|
|
154
|
+
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function createMockAccount(
|
|
161
|
+
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
|
162
|
+
): ResolvedBlueBubblesAccount {
|
|
163
|
+
return {
|
|
164
|
+
accountId: "default",
|
|
165
|
+
enabled: true,
|
|
166
|
+
configured: true,
|
|
167
|
+
config: {
|
|
168
|
+
serverUrl: "http://localhost:1234",
|
|
169
|
+
password: "test-password",
|
|
170
|
+
dmPolicy: "open",
|
|
171
|
+
groupPolicy: "open",
|
|
172
|
+
allowFrom: [],
|
|
173
|
+
groupAllowFrom: [],
|
|
174
|
+
...overrides,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function createMockRequest(
|
|
180
|
+
method: string,
|
|
181
|
+
url: string,
|
|
182
|
+
body: unknown,
|
|
183
|
+
headers: Record<string, string> = {},
|
|
184
|
+
): IncomingMessage {
|
|
185
|
+
if (headers.host === undefined) {
|
|
186
|
+
headers.host = "localhost";
|
|
187
|
+
}
|
|
188
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
189
|
+
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
|
190
|
+
const hasAuthHeader =
|
|
191
|
+
headers["x-guid"] !== undefined ||
|
|
192
|
+
headers["x-password"] !== undefined ||
|
|
193
|
+
headers["x-bluebubbles-guid"] !== undefined ||
|
|
194
|
+
headers.authorization !== undefined;
|
|
195
|
+
if (!hasAuthQuery && !hasAuthHeader) {
|
|
196
|
+
parsedUrl.searchParams.set("password", "test-password");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const req = new EventEmitter() as IncomingMessage;
|
|
200
|
+
req.method = method;
|
|
201
|
+
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
|
|
202
|
+
req.headers = headers;
|
|
203
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
|
204
|
+
|
|
205
|
+
// Emit body data after a microtask
|
|
206
|
+
// oxlint-disable-next-line no-floating-promises
|
|
207
|
+
Promise.resolve().then(() => {
|
|
208
|
+
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
|
209
|
+
req.emit("data", Buffer.from(bodyStr));
|
|
210
|
+
req.emit("end");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return req;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
|
|
217
|
+
const res = {
|
|
218
|
+
statusCode: 200,
|
|
219
|
+
body: "",
|
|
220
|
+
setHeader: vi.fn(),
|
|
221
|
+
end: vi.fn((data?: string) => {
|
|
222
|
+
res.body = data ?? "";
|
|
223
|
+
}),
|
|
224
|
+
} as unknown as ServerResponse & { body: string; statusCode: number };
|
|
225
|
+
return res;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const flushAsync = async () => {
|
|
229
|
+
for (let i = 0; i < 2; i += 1) {
|
|
230
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function getFirstDispatchCall(): DispatchReplyParams {
|
|
235
|
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
236
|
+
if (!callArgs) {
|
|
237
|
+
throw new Error("expected dispatch call arguments");
|
|
238
|
+
}
|
|
239
|
+
return callArgs;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
describe("BlueBubbles webhook monitor", () => {
|
|
243
|
+
let unregister: () => void;
|
|
244
|
+
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
vi.clearAllMocks();
|
|
247
|
+
// Reset short ID state between tests for predictable behavior
|
|
248
|
+
_resetBlueBubblesShortIdState();
|
|
249
|
+
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
|
|
250
|
+
mockReadAllowFromStore.mockResolvedValue([]);
|
|
251
|
+
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
|
252
|
+
mockResolveRequireMention.mockReturnValue(false);
|
|
253
|
+
mockHasControlCommand.mockReturnValue(false);
|
|
254
|
+
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
|
255
|
+
mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]);
|
|
256
|
+
|
|
257
|
+
setBlueBubblesRuntime(createMockRuntime());
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
unregister?.();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("webhook parsing + auth handling", () => {
|
|
265
|
+
it("rejects non-POST requests", async () => {
|
|
266
|
+
const account = createMockAccount();
|
|
267
|
+
const config: OpenClawConfig = {};
|
|
268
|
+
const core = createMockRuntime();
|
|
269
|
+
setBlueBubblesRuntime(core);
|
|
270
|
+
|
|
271
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
272
|
+
account,
|
|
273
|
+
config,
|
|
274
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
275
|
+
core,
|
|
276
|
+
path: "/bluebubbles-webhook",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
|
|
280
|
+
const res = createMockResponse();
|
|
281
|
+
|
|
282
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
283
|
+
|
|
284
|
+
expect(handled).toBe(true);
|
|
285
|
+
expect(res.statusCode).toBe(405);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("accepts POST requests with valid JSON payload", async () => {
|
|
289
|
+
const account = createMockAccount();
|
|
290
|
+
const config: OpenClawConfig = {};
|
|
291
|
+
const core = createMockRuntime();
|
|
292
|
+
setBlueBubblesRuntime(core);
|
|
293
|
+
|
|
294
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
295
|
+
account,
|
|
296
|
+
config,
|
|
297
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
298
|
+
core,
|
|
299
|
+
path: "/bluebubbles-webhook",
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const payload = {
|
|
303
|
+
type: "new-message",
|
|
304
|
+
data: {
|
|
305
|
+
text: "hello",
|
|
306
|
+
handle: { address: "+15551234567" },
|
|
307
|
+
isGroup: false,
|
|
308
|
+
isFromMe: false,
|
|
309
|
+
guid: "msg-1",
|
|
310
|
+
date: Date.now(),
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
315
|
+
const res = createMockResponse();
|
|
316
|
+
|
|
317
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
318
|
+
|
|
319
|
+
expect(handled).toBe(true);
|
|
320
|
+
expect(res.statusCode).toBe(200);
|
|
321
|
+
expect(res.body).toBe("ok");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("rejects requests with invalid JSON", async () => {
|
|
325
|
+
const account = createMockAccount();
|
|
326
|
+
const config: OpenClawConfig = {};
|
|
327
|
+
const core = createMockRuntime();
|
|
328
|
+
setBlueBubblesRuntime(core);
|
|
329
|
+
|
|
330
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
331
|
+
account,
|
|
332
|
+
config,
|
|
333
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
334
|
+
core,
|
|
335
|
+
path: "/bluebubbles-webhook",
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
|
|
339
|
+
const res = createMockResponse();
|
|
340
|
+
|
|
341
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
342
|
+
|
|
343
|
+
expect(handled).toBe(true);
|
|
344
|
+
expect(res.statusCode).toBe(400);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("accepts URL-encoded payload wrappers", async () => {
|
|
348
|
+
const account = createMockAccount();
|
|
349
|
+
const config: OpenClawConfig = {};
|
|
350
|
+
const core = createMockRuntime();
|
|
351
|
+
setBlueBubblesRuntime(core);
|
|
352
|
+
|
|
353
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
354
|
+
account,
|
|
355
|
+
config,
|
|
356
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
357
|
+
core,
|
|
358
|
+
path: "/bluebubbles-webhook",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const payload = {
|
|
362
|
+
type: "new-message",
|
|
363
|
+
data: {
|
|
364
|
+
text: "hello",
|
|
365
|
+
handle: { address: "+15551234567" },
|
|
366
|
+
isGroup: false,
|
|
367
|
+
isFromMe: false,
|
|
368
|
+
guid: "msg-1",
|
|
369
|
+
date: Date.now(),
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
const encodedBody = new URLSearchParams({
|
|
373
|
+
payload: JSON.stringify(payload),
|
|
374
|
+
}).toString();
|
|
375
|
+
|
|
376
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
|
377
|
+
const res = createMockResponse();
|
|
378
|
+
|
|
379
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
380
|
+
|
|
381
|
+
expect(handled).toBe(true);
|
|
382
|
+
expect(res.statusCode).toBe(200);
|
|
383
|
+
expect(res.body).toBe("ok");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
|
387
|
+
vi.useFakeTimers();
|
|
388
|
+
try {
|
|
389
|
+
const account = createMockAccount();
|
|
390
|
+
const config: OpenClawConfig = {};
|
|
391
|
+
const core = createMockRuntime();
|
|
392
|
+
setBlueBubblesRuntime(core);
|
|
393
|
+
|
|
394
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
395
|
+
account,
|
|
396
|
+
config,
|
|
397
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
398
|
+
core,
|
|
399
|
+
path: "/bluebubbles-webhook",
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Create a request that never sends data or ends (simulates slow-loris)
|
|
403
|
+
const req = new EventEmitter() as IncomingMessage;
|
|
404
|
+
req.method = "POST";
|
|
405
|
+
req.url = "/bluebubbles-webhook?password=test-password";
|
|
406
|
+
req.headers = {};
|
|
407
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
408
|
+
remoteAddress: "127.0.0.1",
|
|
409
|
+
};
|
|
410
|
+
req.destroy = vi.fn();
|
|
411
|
+
|
|
412
|
+
const res = createMockResponse();
|
|
413
|
+
|
|
414
|
+
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
|
415
|
+
|
|
416
|
+
// Advance past the 30s timeout
|
|
417
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
418
|
+
|
|
419
|
+
const handled = await handledPromise;
|
|
420
|
+
expect(handled).toBe(true);
|
|
421
|
+
expect(res.statusCode).toBe(408);
|
|
422
|
+
expect(req.destroy).toHaveBeenCalled();
|
|
423
|
+
} finally {
|
|
424
|
+
vi.useRealTimers();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("rejects unauthorized requests before reading the body", async () => {
|
|
429
|
+
const account = createMockAccount({ password: "secret-token" });
|
|
430
|
+
const config: OpenClawConfig = {};
|
|
431
|
+
const core = createMockRuntime();
|
|
432
|
+
setBlueBubblesRuntime(core);
|
|
433
|
+
|
|
434
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
435
|
+
account,
|
|
436
|
+
config,
|
|
437
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
438
|
+
core,
|
|
439
|
+
path: "/bluebubbles-webhook",
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const req = new EventEmitter() as IncomingMessage;
|
|
443
|
+
req.method = "POST";
|
|
444
|
+
req.url = "/bluebubbles-webhook?password=wrong-token";
|
|
445
|
+
req.headers = {};
|
|
446
|
+
const onSpy = vi.spyOn(req, "on");
|
|
447
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
448
|
+
remoteAddress: "127.0.0.1",
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const res = createMockResponse();
|
|
452
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
453
|
+
|
|
454
|
+
expect(handled).toBe(true);
|
|
455
|
+
expect(res.statusCode).toBe(401);
|
|
456
|
+
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("authenticates via password query parameter", async () => {
|
|
460
|
+
const account = createMockAccount({ password: "secret-token" });
|
|
461
|
+
const config: OpenClawConfig = {};
|
|
462
|
+
const core = createMockRuntime();
|
|
463
|
+
setBlueBubblesRuntime(core);
|
|
464
|
+
|
|
465
|
+
// Mock non-localhost request
|
|
466
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
467
|
+
type: "new-message",
|
|
468
|
+
data: {
|
|
469
|
+
text: "hello",
|
|
470
|
+
handle: { address: "+15551234567" },
|
|
471
|
+
isGroup: false,
|
|
472
|
+
isFromMe: false,
|
|
473
|
+
guid: "msg-1",
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
477
|
+
remoteAddress: "192.168.1.100",
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
481
|
+
account,
|
|
482
|
+
config,
|
|
483
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
484
|
+
core,
|
|
485
|
+
path: "/bluebubbles-webhook",
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const res = createMockResponse();
|
|
489
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
490
|
+
|
|
491
|
+
expect(handled).toBe(true);
|
|
492
|
+
expect(res.statusCode).toBe(200);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("authenticates via x-password header", async () => {
|
|
496
|
+
const account = createMockAccount({ password: "secret-token" });
|
|
497
|
+
const config: OpenClawConfig = {};
|
|
498
|
+
const core = createMockRuntime();
|
|
499
|
+
setBlueBubblesRuntime(core);
|
|
500
|
+
|
|
501
|
+
const req = createMockRequest(
|
|
502
|
+
"POST",
|
|
503
|
+
"/bluebubbles-webhook",
|
|
504
|
+
{
|
|
505
|
+
type: "new-message",
|
|
506
|
+
data: {
|
|
507
|
+
text: "hello",
|
|
508
|
+
handle: { address: "+15551234567" },
|
|
509
|
+
isGroup: false,
|
|
510
|
+
isFromMe: false,
|
|
511
|
+
guid: "msg-1",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
{ "x-password": "secret-token" },
|
|
515
|
+
);
|
|
516
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
517
|
+
remoteAddress: "192.168.1.100",
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
521
|
+
account,
|
|
522
|
+
config,
|
|
523
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
524
|
+
core,
|
|
525
|
+
path: "/bluebubbles-webhook",
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const res = createMockResponse();
|
|
529
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
530
|
+
|
|
531
|
+
expect(handled).toBe(true);
|
|
532
|
+
expect(res.statusCode).toBe(200);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("rejects unauthorized requests with wrong password", async () => {
|
|
536
|
+
const account = createMockAccount({ password: "secret-token" });
|
|
537
|
+
const config: OpenClawConfig = {};
|
|
538
|
+
const core = createMockRuntime();
|
|
539
|
+
setBlueBubblesRuntime(core);
|
|
540
|
+
|
|
541
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
|
|
542
|
+
type: "new-message",
|
|
543
|
+
data: {
|
|
544
|
+
text: "hello",
|
|
545
|
+
handle: { address: "+15551234567" },
|
|
546
|
+
isGroup: false,
|
|
547
|
+
isFromMe: false,
|
|
548
|
+
guid: "msg-1",
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
552
|
+
remoteAddress: "192.168.1.100",
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
556
|
+
account,
|
|
557
|
+
config,
|
|
558
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
559
|
+
core,
|
|
560
|
+
path: "/bluebubbles-webhook",
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const res = createMockResponse();
|
|
564
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
565
|
+
|
|
566
|
+
expect(handled).toBe(true);
|
|
567
|
+
expect(res.statusCode).toBe(401);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
|
571
|
+
const accountA = createMockAccount({ password: "secret-token" });
|
|
572
|
+
const accountB = createMockAccount({ password: "secret-token" });
|
|
573
|
+
const config: OpenClawConfig = {};
|
|
574
|
+
const core = createMockRuntime();
|
|
575
|
+
setBlueBubblesRuntime(core);
|
|
576
|
+
|
|
577
|
+
const sinkA = vi.fn();
|
|
578
|
+
const sinkB = vi.fn();
|
|
579
|
+
|
|
580
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
581
|
+
type: "new-message",
|
|
582
|
+
data: {
|
|
583
|
+
text: "hello",
|
|
584
|
+
handle: { address: "+15551234567" },
|
|
585
|
+
isGroup: false,
|
|
586
|
+
isFromMe: false,
|
|
587
|
+
guid: "msg-1",
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
591
|
+
remoteAddress: "192.168.1.100",
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const unregisterA = registerBlueBubblesWebhookTarget({
|
|
595
|
+
account: accountA,
|
|
596
|
+
config,
|
|
597
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
598
|
+
core,
|
|
599
|
+
path: "/bluebubbles-webhook",
|
|
600
|
+
statusSink: sinkA,
|
|
601
|
+
});
|
|
602
|
+
const unregisterB = registerBlueBubblesWebhookTarget({
|
|
603
|
+
account: accountB,
|
|
604
|
+
config,
|
|
605
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
606
|
+
core,
|
|
607
|
+
path: "/bluebubbles-webhook",
|
|
608
|
+
statusSink: sinkB,
|
|
609
|
+
});
|
|
610
|
+
unregister = () => {
|
|
611
|
+
unregisterA();
|
|
612
|
+
unregisterB();
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const res = createMockResponse();
|
|
616
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
617
|
+
|
|
618
|
+
expect(handled).toBe(true);
|
|
619
|
+
expect(res.statusCode).toBe(401);
|
|
620
|
+
expect(sinkA).not.toHaveBeenCalled();
|
|
621
|
+
expect(sinkB).not.toHaveBeenCalled();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
|
625
|
+
const accountStrict = createMockAccount({ password: "secret-token" });
|
|
626
|
+
const accountWithoutPassword = createMockAccount({ password: undefined });
|
|
627
|
+
const config: OpenClawConfig = {};
|
|
628
|
+
const core = createMockRuntime();
|
|
629
|
+
setBlueBubblesRuntime(core);
|
|
630
|
+
|
|
631
|
+
const sinkStrict = vi.fn();
|
|
632
|
+
const sinkWithoutPassword = vi.fn();
|
|
633
|
+
|
|
634
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
635
|
+
type: "new-message",
|
|
636
|
+
data: {
|
|
637
|
+
text: "hello",
|
|
638
|
+
handle: { address: "+15551234567" },
|
|
639
|
+
isGroup: false,
|
|
640
|
+
isFromMe: false,
|
|
641
|
+
guid: "msg-1",
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
645
|
+
remoteAddress: "192.168.1.100",
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const unregisterStrict = registerBlueBubblesWebhookTarget({
|
|
649
|
+
account: accountStrict,
|
|
650
|
+
config,
|
|
651
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
652
|
+
core,
|
|
653
|
+
path: "/bluebubbles-webhook",
|
|
654
|
+
statusSink: sinkStrict,
|
|
655
|
+
});
|
|
656
|
+
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
|
657
|
+
account: accountWithoutPassword,
|
|
658
|
+
config,
|
|
659
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
660
|
+
core,
|
|
661
|
+
path: "/bluebubbles-webhook",
|
|
662
|
+
statusSink: sinkWithoutPassword,
|
|
663
|
+
});
|
|
664
|
+
unregister = () => {
|
|
665
|
+
unregisterStrict();
|
|
666
|
+
unregisterNoPassword();
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const res = createMockResponse();
|
|
670
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
671
|
+
|
|
672
|
+
expect(handled).toBe(true);
|
|
673
|
+
expect(res.statusCode).toBe(200);
|
|
674
|
+
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
|
675
|
+
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("requires authentication for loopback requests when password is configured", async () => {
|
|
679
|
+
const account = createMockAccount({ password: "secret-token" });
|
|
680
|
+
const config: OpenClawConfig = {};
|
|
681
|
+
const core = createMockRuntime();
|
|
682
|
+
setBlueBubblesRuntime(core);
|
|
683
|
+
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
|
684
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
685
|
+
type: "new-message",
|
|
686
|
+
data: {
|
|
687
|
+
text: "hello",
|
|
688
|
+
handle: { address: "+15551234567" },
|
|
689
|
+
isGroup: false,
|
|
690
|
+
isFromMe: false,
|
|
691
|
+
guid: "msg-1",
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
695
|
+
remoteAddress,
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const loopbackUnregister = registerBlueBubblesWebhookTarget({
|
|
699
|
+
account,
|
|
700
|
+
config,
|
|
701
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
702
|
+
core,
|
|
703
|
+
path: "/bluebubbles-webhook",
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const res = createMockResponse();
|
|
707
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
708
|
+
expect(handled).toBe(true);
|
|
709
|
+
expect(res.statusCode).toBe(401);
|
|
710
|
+
|
|
711
|
+
loopbackUnregister();
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
|
716
|
+
const account = createMockAccount({ password: undefined });
|
|
717
|
+
const config: OpenClawConfig = {};
|
|
718
|
+
const core = createMockRuntime();
|
|
719
|
+
setBlueBubblesRuntime(core);
|
|
720
|
+
|
|
721
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
722
|
+
account,
|
|
723
|
+
config,
|
|
724
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
725
|
+
core,
|
|
726
|
+
path: "/bluebubbles-webhook",
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const headerVariants: Record<string, string>[] = [
|
|
730
|
+
{ host: "localhost" },
|
|
731
|
+
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
|
|
732
|
+
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
|
733
|
+
];
|
|
734
|
+
for (const headers of headerVariants) {
|
|
735
|
+
const req = createMockRequest(
|
|
736
|
+
"POST",
|
|
737
|
+
"/bluebubbles-webhook",
|
|
738
|
+
{
|
|
739
|
+
type: "new-message",
|
|
740
|
+
data: {
|
|
741
|
+
text: "hello",
|
|
742
|
+
handle: { address: "+15551234567" },
|
|
743
|
+
isGroup: false,
|
|
744
|
+
isFromMe: false,
|
|
745
|
+
guid: "msg-1",
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
headers,
|
|
749
|
+
);
|
|
750
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
751
|
+
remoteAddress: "127.0.0.1",
|
|
752
|
+
};
|
|
753
|
+
const res = createMockResponse();
|
|
754
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
755
|
+
expect(handled).toBe(true);
|
|
756
|
+
expect(res.statusCode).toBe(401);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("ignores unregistered webhook paths", async () => {
|
|
761
|
+
const req = createMockRequest("POST", "/unregistered-path", {});
|
|
762
|
+
const res = createMockResponse();
|
|
763
|
+
|
|
764
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
765
|
+
|
|
766
|
+
expect(handled).toBe(false);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("parses chatId when provided as a string (webhook variant)", async () => {
|
|
770
|
+
const { resolveChatGuidForTarget } = await import("./send.js");
|
|
771
|
+
vi.mocked(resolveChatGuidForTarget).mockClear();
|
|
772
|
+
|
|
773
|
+
const account = createMockAccount({ groupPolicy: "open" });
|
|
774
|
+
const config: OpenClawConfig = {};
|
|
775
|
+
const core = createMockRuntime();
|
|
776
|
+
setBlueBubblesRuntime(core);
|
|
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 payload = {
|
|
787
|
+
type: "new-message",
|
|
788
|
+
data: {
|
|
789
|
+
text: "hello from group",
|
|
790
|
+
handle: { address: "+15551234567" },
|
|
791
|
+
isGroup: true,
|
|
792
|
+
isFromMe: false,
|
|
793
|
+
guid: "msg-1",
|
|
794
|
+
chatId: "123",
|
|
795
|
+
date: Date.now(),
|
|
796
|
+
},
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
800
|
+
const res = createMockResponse();
|
|
801
|
+
|
|
802
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
803
|
+
await flushAsync();
|
|
804
|
+
|
|
805
|
+
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
|
806
|
+
expect.objectContaining({
|
|
807
|
+
target: { kind: "chat_id", chatId: 123 },
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
|
|
813
|
+
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
|
|
814
|
+
vi.mocked(sendMessageBlueBubbles).mockClear();
|
|
815
|
+
vi.mocked(resolveChatGuidForTarget).mockClear();
|
|
816
|
+
|
|
817
|
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
|
818
|
+
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
|
819
|
+
return EMPTY_DISPATCH_RESULT;
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
const account = createMockAccount({ groupPolicy: "open" });
|
|
823
|
+
const config: OpenClawConfig = {};
|
|
824
|
+
const core = createMockRuntime();
|
|
825
|
+
setBlueBubblesRuntime(core);
|
|
826
|
+
|
|
827
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
828
|
+
account,
|
|
829
|
+
config,
|
|
830
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
831
|
+
core,
|
|
832
|
+
path: "/bluebubbles-webhook",
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const payload = {
|
|
836
|
+
type: "new-message",
|
|
837
|
+
data: {
|
|
838
|
+
text: "hello from group",
|
|
839
|
+
handle: { address: "+15551234567" },
|
|
840
|
+
isGroup: true,
|
|
841
|
+
isFromMe: false,
|
|
842
|
+
guid: "msg-1",
|
|
843
|
+
chat: { chatGuid: "iMessage;+;chat123456" },
|
|
844
|
+
date: Date.now(),
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
849
|
+
const res = createMockResponse();
|
|
850
|
+
|
|
851
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
852
|
+
await flushAsync();
|
|
853
|
+
|
|
854
|
+
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
|
855
|
+
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
|
856
|
+
"chat_guid:iMessage;+;chat123456",
|
|
857
|
+
expect.any(String),
|
|
858
|
+
expect.any(Object),
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
});
|