@openclaw/bluebubbles 2026.2.6 → 2026.2.9
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/actions.ts +1 -1
- package/src/attachments.ts +3 -1
- package/src/chat.ts +5 -1
- package/src/monitor.test.ts +42 -0
- package/src/monitor.ts +35 -15
- package/src/probe.ts +10 -1
- package/src/send.test.ts +81 -0
- package/src/send.ts +9 -2
- package/src/types.ts +2 -2
package/package.json
CHANGED
package/src/actions.ts
CHANGED
|
@@ -86,7 +86,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|
|
86
86
|
if (!spec?.gate) {
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
89
|
-
if (spec.unsupportedOnMacOS26 && macOS26) {
|
|
89
|
+
if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
|
|
90
90
|
continue;
|
|
91
91
|
}
|
|
92
92
|
if (gate(spec.gate)) {
|
package/src/attachments.ts
CHANGED
|
@@ -26,7 +26,9 @@ const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
|
|
26
26
|
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
|
27
27
|
const trimmed = input?.trim() ?? "";
|
|
28
28
|
const base = trimmed ? path.basename(trimmed) : "";
|
|
29
|
-
|
|
29
|
+
const name = base || fallback;
|
|
30
|
+
// Strip characters that could enable multipart header injection (CWE-93)
|
|
31
|
+
return name.replace(/[\r\n"\\]/g, "_");
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
package/src/chat.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
4
5
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
5
6
|
|
|
@@ -336,10 +337,13 @@ export async function setGroupIconBlueBubbles(
|
|
|
336
337
|
const parts: Uint8Array[] = [];
|
|
337
338
|
const encoder = new TextEncoder();
|
|
338
339
|
|
|
340
|
+
// Sanitize filename to prevent multipart header injection (CWE-93)
|
|
341
|
+
const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png";
|
|
342
|
+
|
|
339
343
|
// Add file field named "icon" as per API spec
|
|
340
344
|
parts.push(encoder.encode(`--${boundary}\r\n`));
|
|
341
345
|
parts.push(
|
|
342
|
-
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${
|
|
346
|
+
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`),
|
|
343
347
|
);
|
|
344
348
|
parts.push(
|
|
345
349
|
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
|
package/src/monitor.test.ts
CHANGED
|
@@ -393,6 +393,48 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
393
393
|
expect(res.statusCode).toBe(400);
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
it("returns 400 when request body times out (Slow-Loris protection)", async () => {
|
|
397
|
+
vi.useFakeTimers();
|
|
398
|
+
try {
|
|
399
|
+
const account = createMockAccount();
|
|
400
|
+
const config: OpenClawConfig = {};
|
|
401
|
+
const core = createMockRuntime();
|
|
402
|
+
setBlueBubblesRuntime(core);
|
|
403
|
+
|
|
404
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
405
|
+
account,
|
|
406
|
+
config,
|
|
407
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
408
|
+
core,
|
|
409
|
+
path: "/bluebubbles-webhook",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Create a request that never sends data or ends (simulates slow-loris)
|
|
413
|
+
const req = new EventEmitter() as IncomingMessage;
|
|
414
|
+
req.method = "POST";
|
|
415
|
+
req.url = "/bluebubbles-webhook";
|
|
416
|
+
req.headers = {};
|
|
417
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
418
|
+
remoteAddress: "127.0.0.1",
|
|
419
|
+
};
|
|
420
|
+
req.destroy = vi.fn();
|
|
421
|
+
|
|
422
|
+
const res = createMockResponse();
|
|
423
|
+
|
|
424
|
+
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
|
425
|
+
|
|
426
|
+
// Advance past the 30s timeout
|
|
427
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
428
|
+
|
|
429
|
+
const handled = await handledPromise;
|
|
430
|
+
expect(handled).toBe(true);
|
|
431
|
+
expect(res.statusCode).toBe(400);
|
|
432
|
+
expect(req.destroy).toHaveBeenCalled();
|
|
433
|
+
} finally {
|
|
434
|
+
vi.useRealTimers();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
396
438
|
it("authenticates via password query parameter", async () => {
|
|
397
439
|
const account = createMockAccount({ password: "secret-token" });
|
|
398
440
|
const config: OpenClawConfig = {};
|
package/src/monitor.ts
CHANGED
|
@@ -361,14 +361,16 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
|
|
|
361
361
|
|
|
362
362
|
const webhookTargets = new Map<string, WebhookTarget[]>();
|
|
363
363
|
|
|
364
|
+
type BlueBubblesDebouncer = {
|
|
365
|
+
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
|
|
366
|
+
flushKey: (key: string) => Promise<void>;
|
|
367
|
+
};
|
|
368
|
+
|
|
364
369
|
/**
|
|
365
370
|
* Maps webhook targets to their inbound debouncers.
|
|
366
371
|
* Each target gets its own debouncer keyed by a unique identifier.
|
|
367
372
|
*/
|
|
368
|
-
const targetDebouncers = new Map<
|
|
369
|
-
WebhookTarget,
|
|
370
|
-
ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]>
|
|
371
|
-
>();
|
|
373
|
+
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
|
|
372
374
|
|
|
373
375
|
function resolveBlueBubblesDebounceMs(
|
|
374
376
|
config: OpenClawConfig,
|
|
@@ -508,14 +510,29 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
|
|
|
508
510
|
};
|
|
509
511
|
}
|
|
510
512
|
|
|
511
|
-
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
513
|
+
async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
|
|
512
514
|
const chunks: Buffer[] = [];
|
|
513
515
|
let total = 0;
|
|
514
516
|
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
|
517
|
+
let done = false;
|
|
518
|
+
const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
|
|
519
|
+
if (done) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
done = true;
|
|
523
|
+
clearTimeout(timer);
|
|
524
|
+
resolve(result);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const timer = setTimeout(() => {
|
|
528
|
+
finish({ ok: false, error: "request body timeout" });
|
|
529
|
+
req.destroy();
|
|
530
|
+
}, timeoutMs);
|
|
531
|
+
|
|
515
532
|
req.on("data", (chunk: Buffer) => {
|
|
516
533
|
total += chunk.length;
|
|
517
534
|
if (total > maxBytes) {
|
|
518
|
-
|
|
535
|
+
finish({ ok: false, error: "payload too large" });
|
|
519
536
|
req.destroy();
|
|
520
537
|
return;
|
|
521
538
|
}
|
|
@@ -525,27 +542,30 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
|
525
542
|
try {
|
|
526
543
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
527
544
|
if (!raw.trim()) {
|
|
528
|
-
|
|
545
|
+
finish({ ok: false, error: "empty payload" });
|
|
529
546
|
return;
|
|
530
547
|
}
|
|
531
548
|
try {
|
|
532
|
-
|
|
549
|
+
finish({ ok: true, value: JSON.parse(raw) as unknown });
|
|
533
550
|
return;
|
|
534
551
|
} catch {
|
|
535
552
|
const params = new URLSearchParams(raw);
|
|
536
553
|
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
|
537
554
|
if (payload) {
|
|
538
|
-
|
|
555
|
+
finish({ ok: true, value: JSON.parse(payload) as unknown });
|
|
539
556
|
return;
|
|
540
557
|
}
|
|
541
558
|
throw new Error("invalid json");
|
|
542
559
|
}
|
|
543
560
|
} catch (err) {
|
|
544
|
-
|
|
561
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
545
562
|
}
|
|
546
563
|
});
|
|
547
564
|
req.on("error", (err) => {
|
|
548
|
-
|
|
565
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
566
|
+
});
|
|
567
|
+
req.on("close", () => {
|
|
568
|
+
finish({ ok: false, error: "connection closed" });
|
|
549
569
|
});
|
|
550
570
|
});
|
|
551
571
|
}
|
|
@@ -1786,7 +1806,7 @@ async function processMessage(
|
|
|
1786
1806
|
channel: "bluebubbles",
|
|
1787
1807
|
accountId: account.accountId,
|
|
1788
1808
|
peer: {
|
|
1789
|
-
kind: isGroup ? "group" : "
|
|
1809
|
+
kind: isGroup ? "group" : "direct",
|
|
1790
1810
|
id: peerId,
|
|
1791
1811
|
},
|
|
1792
1812
|
});
|
|
@@ -1899,7 +1919,7 @@ async function processMessage(
|
|
|
1899
1919
|
maxBytes,
|
|
1900
1920
|
});
|
|
1901
1921
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
1902
|
-
downloaded.buffer,
|
|
1922
|
+
Buffer.from(downloaded.buffer),
|
|
1903
1923
|
downloaded.contentType,
|
|
1904
1924
|
"inbound",
|
|
1905
1925
|
maxBytes,
|
|
@@ -2331,7 +2351,7 @@ async function processMessage(
|
|
|
2331
2351
|
},
|
|
2332
2352
|
});
|
|
2333
2353
|
}
|
|
2334
|
-
if (shouldStopTyping) {
|
|
2354
|
+
if (shouldStopTyping && chatGuidForActions) {
|
|
2335
2355
|
// Stop typing after streaming completes to avoid a stuck indicator.
|
|
2336
2356
|
sendBlueBubblesTyping(chatGuidForActions, false, {
|
|
2337
2357
|
cfg: config,
|
|
@@ -2424,7 +2444,7 @@ async function processReaction(
|
|
|
2424
2444
|
channel: "bluebubbles",
|
|
2425
2445
|
accountId: account.accountId,
|
|
2426
2446
|
peer: {
|
|
2427
|
-
kind: reaction.isGroup ? "group" : "
|
|
2447
|
+
kind: reaction.isGroup ? "group" : "direct",
|
|
2428
2448
|
id: peerId,
|
|
2429
2449
|
},
|
|
2430
2450
|
});
|
package/src/probe.ts
CHANGED
|
@@ -16,7 +16,9 @@ export type BlueBubblesServerInfo = {
|
|
|
16
16
|
computer_id?: string;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
/** Cache server info by account ID to avoid repeated API calls
|
|
19
|
+
/** Cache server info by account ID to avoid repeated API calls.
|
|
20
|
+
* Size-capped to prevent unbounded growth (#4948). */
|
|
21
|
+
const MAX_SERVER_INFO_CACHE_SIZE = 64;
|
|
20
22
|
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
|
|
21
23
|
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
22
24
|
|
|
@@ -56,6 +58,13 @@ export async function fetchBlueBubblesServerInfo(params: {
|
|
|
56
58
|
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
|
57
59
|
if (data) {
|
|
58
60
|
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
|
|
61
|
+
// Evict oldest entries if cache exceeds max size
|
|
62
|
+
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
|
|
63
|
+
const oldest = serverInfoCache.keys().next().value;
|
|
64
|
+
if (oldest !== undefined) {
|
|
65
|
+
serverInfoCache.delete(oldest);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
59
68
|
}
|
|
60
69
|
return data ?? null;
|
|
61
70
|
} catch {
|
package/src/send.test.ts
CHANGED
|
@@ -370,6 +370,16 @@ describe("send", () => {
|
|
|
370
370
|
).rejects.toThrow("requires text");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
it("throws when text becomes empty after markdown stripping", async () => {
|
|
374
|
+
// Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown
|
|
375
|
+
await expect(
|
|
376
|
+
sendMessageBlueBubbles("+15551234567", "***", {
|
|
377
|
+
serverUrl: "http://localhost:1234",
|
|
378
|
+
password: "test",
|
|
379
|
+
}),
|
|
380
|
+
).rejects.toThrow("empty after markdown removal");
|
|
381
|
+
});
|
|
382
|
+
|
|
373
383
|
it("throws when serverUrl is missing", async () => {
|
|
374
384
|
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
|
|
375
385
|
"serverUrl is required",
|
|
@@ -438,6 +448,77 @@ describe("send", () => {
|
|
|
438
448
|
expect(body.method).toBeUndefined();
|
|
439
449
|
});
|
|
440
450
|
|
|
451
|
+
it("strips markdown formatting from outbound messages", async () => {
|
|
452
|
+
mockFetch
|
|
453
|
+
.mockResolvedValueOnce({
|
|
454
|
+
ok: true,
|
|
455
|
+
json: () =>
|
|
456
|
+
Promise.resolve({
|
|
457
|
+
data: [
|
|
458
|
+
{
|
|
459
|
+
guid: "iMessage;-;+15551234567",
|
|
460
|
+
participants: [{ address: "+15551234567" }],
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
}),
|
|
464
|
+
})
|
|
465
|
+
.mockResolvedValueOnce({
|
|
466
|
+
ok: true,
|
|
467
|
+
text: () =>
|
|
468
|
+
Promise.resolve(
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
data: { guid: "msg-uuid-stripped" },
|
|
471
|
+
}),
|
|
472
|
+
),
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const result = await sendMessageBlueBubbles(
|
|
476
|
+
"+15551234567",
|
|
477
|
+
"**Bold** and *italic* with `code`\n## Header",
|
|
478
|
+
{
|
|
479
|
+
serverUrl: "http://localhost:1234",
|
|
480
|
+
password: "test",
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
expect(result.messageId).toBe("msg-uuid-stripped");
|
|
485
|
+
|
|
486
|
+
const sendCall = mockFetch.mock.calls[1];
|
|
487
|
+
const body = JSON.parse(sendCall[1].body);
|
|
488
|
+
// Markdown should be stripped: no asterisks, backticks, or hashes
|
|
489
|
+
expect(body.message).toBe("Bold and italic with code\nHeader");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("strips markdown when creating a new chat", async () => {
|
|
493
|
+
mockFetch
|
|
494
|
+
.mockResolvedValueOnce({
|
|
495
|
+
ok: true,
|
|
496
|
+
json: () => Promise.resolve({ data: [] }),
|
|
497
|
+
})
|
|
498
|
+
.mockResolvedValueOnce({
|
|
499
|
+
ok: true,
|
|
500
|
+
text: () =>
|
|
501
|
+
Promise.resolve(
|
|
502
|
+
JSON.stringify({
|
|
503
|
+
data: { guid: "new-msg-stripped" },
|
|
504
|
+
}),
|
|
505
|
+
),
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
|
|
509
|
+
serverUrl: "http://localhost:1234",
|
|
510
|
+
password: "test",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(result.messageId).toBe("new-msg-stripped");
|
|
514
|
+
|
|
515
|
+
const createCall = mockFetch.mock.calls[1];
|
|
516
|
+
expect(createCall[0]).toContain("/api/v1/chat/new");
|
|
517
|
+
const body = JSON.parse(createCall[1].body);
|
|
518
|
+
// Markdown should be stripped
|
|
519
|
+
expect(body.message).toBe("Welcome to the chat!");
|
|
520
|
+
});
|
|
521
|
+
|
|
441
522
|
it("creates a new chat when handle target is missing", async () => {
|
|
442
523
|
mockFetch
|
|
443
524
|
.mockResolvedValueOnce({
|
package/src/send.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { stripMarkdown } from "openclaw/plugin-sdk";
|
|
3
4
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
4
5
|
import {
|
|
5
6
|
extractHandleFromChatGuid,
|
|
@@ -332,6 +333,7 @@ async function createNewChatWithMessage(params: {
|
|
|
332
333
|
const payload = {
|
|
333
334
|
addresses: [params.address],
|
|
334
335
|
message: params.message,
|
|
336
|
+
tempGuid: `temp-${crypto.randomUUID()}`,
|
|
335
337
|
};
|
|
336
338
|
const res = await blueBubblesFetchWithTimeout(
|
|
337
339
|
url,
|
|
@@ -377,6 +379,11 @@ export async function sendMessageBlueBubbles(
|
|
|
377
379
|
if (!trimmedText.trim()) {
|
|
378
380
|
throw new Error("BlueBubbles send requires text");
|
|
379
381
|
}
|
|
382
|
+
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
|
|
383
|
+
const strippedText = stripMarkdown(trimmedText);
|
|
384
|
+
if (!strippedText.trim()) {
|
|
385
|
+
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
|
|
386
|
+
}
|
|
380
387
|
|
|
381
388
|
const account = resolveBlueBubblesAccount({
|
|
382
389
|
cfg: opts.cfg ?? {},
|
|
@@ -406,7 +413,7 @@ export async function sendMessageBlueBubbles(
|
|
|
406
413
|
baseUrl,
|
|
407
414
|
password,
|
|
408
415
|
address: target.address,
|
|
409
|
-
message:
|
|
416
|
+
message: strippedText,
|
|
410
417
|
timeoutMs: opts.timeoutMs,
|
|
411
418
|
});
|
|
412
419
|
}
|
|
@@ -419,7 +426,7 @@ export async function sendMessageBlueBubbles(
|
|
|
419
426
|
const payload: Record<string, unknown> = {
|
|
420
427
|
chatGuid,
|
|
421
428
|
tempGuid: crypto.randomUUID(),
|
|
422
|
-
message:
|
|
429
|
+
message: strippedText,
|
|
423
430
|
};
|
|
424
431
|
if (needsPrivateApi) {
|
|
425
432
|
payload.method = "private-api";
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export type
|
|
1
|
+
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
|
2
|
+
export type { DmPolicy, GroupPolicy };
|
|
3
3
|
|
|
4
4
|
export type BlueBubblesGroupConfig = {
|
|
5
5
|
/** If true, only respond in this group when mentioned. */
|