@openclaw/bluebubbles 2026.2.6 → 2026.2.12
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 +82 -28
- package/src/monitor.ts +35 -19
- 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
|
@@ -254,9 +254,20 @@ function createMockRequest(
|
|
|
254
254
|
body: unknown,
|
|
255
255
|
headers: Record<string, string> = {},
|
|
256
256
|
): IncomingMessage {
|
|
257
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
258
|
+
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
|
259
|
+
const hasAuthHeader =
|
|
260
|
+
headers["x-guid"] !== undefined ||
|
|
261
|
+
headers["x-password"] !== undefined ||
|
|
262
|
+
headers["x-bluebubbles-guid"] !== undefined ||
|
|
263
|
+
headers.authorization !== undefined;
|
|
264
|
+
if (!hasAuthQuery && !hasAuthHeader) {
|
|
265
|
+
parsedUrl.searchParams.set("password", "test-password");
|
|
266
|
+
}
|
|
267
|
+
|
|
257
268
|
const req = new EventEmitter() as IncomingMessage;
|
|
258
269
|
req.method = method;
|
|
259
|
-
req.url =
|
|
270
|
+
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
|
|
260
271
|
req.headers = headers;
|
|
261
272
|
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
|
262
273
|
|
|
@@ -393,6 +404,48 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
393
404
|
expect(res.statusCode).toBe(400);
|
|
394
405
|
});
|
|
395
406
|
|
|
407
|
+
it("returns 400 when request body times out (Slow-Loris protection)", async () => {
|
|
408
|
+
vi.useFakeTimers();
|
|
409
|
+
try {
|
|
410
|
+
const account = createMockAccount();
|
|
411
|
+
const config: OpenClawConfig = {};
|
|
412
|
+
const core = createMockRuntime();
|
|
413
|
+
setBlueBubblesRuntime(core);
|
|
414
|
+
|
|
415
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
416
|
+
account,
|
|
417
|
+
config,
|
|
418
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
419
|
+
core,
|
|
420
|
+
path: "/bluebubbles-webhook",
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Create a request that never sends data or ends (simulates slow-loris)
|
|
424
|
+
const req = new EventEmitter() as IncomingMessage;
|
|
425
|
+
req.method = "POST";
|
|
426
|
+
req.url = "/bluebubbles-webhook";
|
|
427
|
+
req.headers = {};
|
|
428
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
429
|
+
remoteAddress: "127.0.0.1",
|
|
430
|
+
};
|
|
431
|
+
req.destroy = vi.fn();
|
|
432
|
+
|
|
433
|
+
const res = createMockResponse();
|
|
434
|
+
|
|
435
|
+
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
|
436
|
+
|
|
437
|
+
// Advance past the 30s timeout
|
|
438
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
439
|
+
|
|
440
|
+
const handled = await handledPromise;
|
|
441
|
+
expect(handled).toBe(true);
|
|
442
|
+
expect(res.statusCode).toBe(400);
|
|
443
|
+
expect(req.destroy).toHaveBeenCalled();
|
|
444
|
+
} finally {
|
|
445
|
+
vi.useRealTimers();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
396
449
|
it("authenticates via password query parameter", async () => {
|
|
397
450
|
const account = createMockAccount({ password: "secret-token" });
|
|
398
451
|
const config: OpenClawConfig = {};
|
|
@@ -504,40 +557,41 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
504
557
|
expect(res.statusCode).toBe(401);
|
|
505
558
|
});
|
|
506
559
|
|
|
507
|
-
it("
|
|
560
|
+
it("requires authentication for loopback requests when password is configured", async () => {
|
|
508
561
|
const account = createMockAccount({ password: "secret-token" });
|
|
509
562
|
const config: OpenClawConfig = {};
|
|
510
563
|
const core = createMockRuntime();
|
|
511
564
|
setBlueBubblesRuntime(core);
|
|
565
|
+
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
|
566
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
567
|
+
type: "new-message",
|
|
568
|
+
data: {
|
|
569
|
+
text: "hello",
|
|
570
|
+
handle: { address: "+15551234567" },
|
|
571
|
+
isGroup: false,
|
|
572
|
+
isFromMe: false,
|
|
573
|
+
guid: "msg-1",
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
577
|
+
remoteAddress,
|
|
578
|
+
};
|
|
512
579
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
guid: "msg-1",
|
|
521
|
-
},
|
|
522
|
-
});
|
|
523
|
-
// Localhost address
|
|
524
|
-
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
525
|
-
remoteAddress: "127.0.0.1",
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
unregister = registerBlueBubblesWebhookTarget({
|
|
529
|
-
account,
|
|
530
|
-
config,
|
|
531
|
-
runtime: { log: vi.fn(), error: vi.fn() },
|
|
532
|
-
core,
|
|
533
|
-
path: "/bluebubbles-webhook",
|
|
534
|
-
});
|
|
580
|
+
const loopbackUnregister = registerBlueBubblesWebhookTarget({
|
|
581
|
+
account,
|
|
582
|
+
config,
|
|
583
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
584
|
+
core,
|
|
585
|
+
path: "/bluebubbles-webhook",
|
|
586
|
+
});
|
|
535
587
|
|
|
536
|
-
|
|
537
|
-
|
|
588
|
+
const res = createMockResponse();
|
|
589
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
590
|
+
expect(handled).toBe(true);
|
|
591
|
+
expect(res.statusCode).toBe(401);
|
|
538
592
|
|
|
539
|
-
|
|
540
|
-
|
|
593
|
+
loopbackUnregister();
|
|
594
|
+
}
|
|
541
595
|
});
|
|
542
596
|
|
|
543
597
|
it("ignores unregistered webhook paths", async () => {
|
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
|
}
|
|
@@ -1513,10 +1533,6 @@ export async function handleBlueBubblesWebhookRequest(
|
|
|
1513
1533
|
if (guid && guid.trim() === token) {
|
|
1514
1534
|
return true;
|
|
1515
1535
|
}
|
|
1516
|
-
const remote = req.socket?.remoteAddress ?? "";
|
|
1517
|
-
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
|
|
1518
|
-
return true;
|
|
1519
|
-
}
|
|
1520
1536
|
return false;
|
|
1521
1537
|
});
|
|
1522
1538
|
|
|
@@ -1786,7 +1802,7 @@ async function processMessage(
|
|
|
1786
1802
|
channel: "bluebubbles",
|
|
1787
1803
|
accountId: account.accountId,
|
|
1788
1804
|
peer: {
|
|
1789
|
-
kind: isGroup ? "group" : "
|
|
1805
|
+
kind: isGroup ? "group" : "direct",
|
|
1790
1806
|
id: peerId,
|
|
1791
1807
|
},
|
|
1792
1808
|
});
|
|
@@ -1899,7 +1915,7 @@ async function processMessage(
|
|
|
1899
1915
|
maxBytes,
|
|
1900
1916
|
});
|
|
1901
1917
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
1902
|
-
downloaded.buffer,
|
|
1918
|
+
Buffer.from(downloaded.buffer),
|
|
1903
1919
|
downloaded.contentType,
|
|
1904
1920
|
"inbound",
|
|
1905
1921
|
maxBytes,
|
|
@@ -2331,7 +2347,7 @@ async function processMessage(
|
|
|
2331
2347
|
},
|
|
2332
2348
|
});
|
|
2333
2349
|
}
|
|
2334
|
-
if (shouldStopTyping) {
|
|
2350
|
+
if (shouldStopTyping && chatGuidForActions) {
|
|
2335
2351
|
// Stop typing after streaming completes to avoid a stuck indicator.
|
|
2336
2352
|
sendBlueBubblesTyping(chatGuidForActions, false, {
|
|
2337
2353
|
cfg: config,
|
|
@@ -2424,7 +2440,7 @@ async function processReaction(
|
|
|
2424
2440
|
channel: "bluebubbles",
|
|
2425
2441
|
accountId: account.accountId,
|
|
2426
2442
|
peer: {
|
|
2427
|
-
kind: reaction.isGroup ? "group" : "
|
|
2443
|
+
kind: reaction.isGroup ? "group" : "direct",
|
|
2428
2444
|
id: peerId,
|
|
2429
2445
|
},
|
|
2430
2446
|
});
|
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. */
|