@openclaw/bluebubbles 2026.2.13 → 2026.2.14

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.
@@ -67,6 +67,7 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
67
67
  template: "channel+name+time",
68
68
  }));
69
69
  const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
70
+ const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
70
71
  const mockChunkMarkdownText = vi.fn((text: string) => [text]);
71
72
 
72
73
  function createMockRuntime(): PluginRuntime {
@@ -124,12 +125,13 @@ function createMockRuntime(): PluginRuntime {
124
125
  vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
125
126
  dispatchReplyFromConfig:
126
127
  vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
127
- finalizeInboundContext:
128
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
128
+ finalizeInboundContext: vi.fn(
129
+ (ctx: Record<string, unknown>) => ctx,
130
+ ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
129
131
  formatAgentEnvelope:
130
132
  mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
131
133
  formatInboundEnvelope:
132
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
134
+ mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
133
135
  resolveEnvelopeFormatOptions:
134
136
  mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
135
137
  },
@@ -254,6 +256,9 @@ function createMockRequest(
254
256
  body: unknown,
255
257
  headers: Record<string, string> = {},
256
258
  ): IncomingMessage {
259
+ if (headers.host === undefined) {
260
+ headers.host = "localhost";
261
+ }
257
262
  const parsedUrl = new URL(url, "http://localhost");
258
263
  const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
259
264
  const hasAuthHeader =
@@ -557,6 +562,114 @@ describe("BlueBubbles webhook monitor", () => {
557
562
  expect(res.statusCode).toBe(401);
558
563
  });
559
564
 
565
+ it("rejects ambiguous routing when multiple targets match the same password", async () => {
566
+ const accountA = createMockAccount({ password: "secret-token" });
567
+ const accountB = createMockAccount({ password: "secret-token" });
568
+ const config: OpenClawConfig = {};
569
+ const core = createMockRuntime();
570
+ setBlueBubblesRuntime(core);
571
+
572
+ const sinkA = vi.fn();
573
+ const sinkB = vi.fn();
574
+
575
+ const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
576
+ type: "new-message",
577
+ data: {
578
+ text: "hello",
579
+ handle: { address: "+15551234567" },
580
+ isGroup: false,
581
+ isFromMe: false,
582
+ guid: "msg-1",
583
+ },
584
+ });
585
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
586
+ remoteAddress: "192.168.1.100",
587
+ };
588
+
589
+ const unregisterA = registerBlueBubblesWebhookTarget({
590
+ account: accountA,
591
+ config,
592
+ runtime: { log: vi.fn(), error: vi.fn() },
593
+ core,
594
+ path: "/bluebubbles-webhook",
595
+ statusSink: sinkA,
596
+ });
597
+ const unregisterB = registerBlueBubblesWebhookTarget({
598
+ account: accountB,
599
+ config,
600
+ runtime: { log: vi.fn(), error: vi.fn() },
601
+ core,
602
+ path: "/bluebubbles-webhook",
603
+ statusSink: sinkB,
604
+ });
605
+ unregister = () => {
606
+ unregisterA();
607
+ unregisterB();
608
+ };
609
+
610
+ const res = createMockResponse();
611
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
612
+
613
+ expect(handled).toBe(true);
614
+ expect(res.statusCode).toBe(401);
615
+ expect(sinkA).not.toHaveBeenCalled();
616
+ expect(sinkB).not.toHaveBeenCalled();
617
+ });
618
+
619
+ it("does not route to passwordless targets when a password-authenticated target matches", async () => {
620
+ const accountStrict = createMockAccount({ password: "secret-token" });
621
+ const accountFallback = createMockAccount({ password: undefined });
622
+ const config: OpenClawConfig = {};
623
+ const core = createMockRuntime();
624
+ setBlueBubblesRuntime(core);
625
+
626
+ const sinkStrict = vi.fn();
627
+ const sinkFallback = vi.fn();
628
+
629
+ const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
630
+ type: "new-message",
631
+ data: {
632
+ text: "hello",
633
+ handle: { address: "+15551234567" },
634
+ isGroup: false,
635
+ isFromMe: false,
636
+ guid: "msg-1",
637
+ },
638
+ });
639
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
640
+ remoteAddress: "192.168.1.100",
641
+ };
642
+
643
+ const unregisterStrict = registerBlueBubblesWebhookTarget({
644
+ account: accountStrict,
645
+ config,
646
+ runtime: { log: vi.fn(), error: vi.fn() },
647
+ core,
648
+ path: "/bluebubbles-webhook",
649
+ statusSink: sinkStrict,
650
+ });
651
+ const unregisterFallback = registerBlueBubblesWebhookTarget({
652
+ account: accountFallback,
653
+ config,
654
+ runtime: { log: vi.fn(), error: vi.fn() },
655
+ core,
656
+ path: "/bluebubbles-webhook",
657
+ statusSink: sinkFallback,
658
+ });
659
+ unregister = () => {
660
+ unregisterStrict();
661
+ unregisterFallback();
662
+ };
663
+
664
+ const res = createMockResponse();
665
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
666
+
667
+ expect(handled).toBe(true);
668
+ expect(res.statusCode).toBe(200);
669
+ expect(sinkStrict).toHaveBeenCalledTimes(1);
670
+ expect(sinkFallback).not.toHaveBeenCalled();
671
+ });
672
+
560
673
  it("requires authentication for loopback requests when password is configured", async () => {
561
674
  const account = createMockAccount({ password: "secret-token" });
562
675
  const config: OpenClawConfig = {};
@@ -594,6 +707,79 @@ describe("BlueBubbles webhook monitor", () => {
594
707
  }
595
708
  });
596
709
 
710
+ it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => {
711
+ const account = createMockAccount({ password: undefined });
712
+ const config: OpenClawConfig = {};
713
+ const core = createMockRuntime();
714
+ setBlueBubblesRuntime(core);
715
+
716
+ const req = createMockRequest(
717
+ "POST",
718
+ "/bluebubbles-webhook",
719
+ {
720
+ type: "new-message",
721
+ data: {
722
+ text: "hello",
723
+ handle: { address: "+15551234567" },
724
+ isGroup: false,
725
+ isFromMe: false,
726
+ guid: "msg-1",
727
+ },
728
+ },
729
+ { "x-forwarded-for": "203.0.113.10", host: "localhost" },
730
+ );
731
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
732
+ remoteAddress: "127.0.0.1",
733
+ };
734
+
735
+ unregister = registerBlueBubblesWebhookTarget({
736
+ account,
737
+ config,
738
+ runtime: { log: vi.fn(), error: vi.fn() },
739
+ core,
740
+ path: "/bluebubbles-webhook",
741
+ });
742
+
743
+ const res = createMockResponse();
744
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
745
+ expect(handled).toBe(true);
746
+ expect(res.statusCode).toBe(401);
747
+ });
748
+
749
+ it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
750
+ const account = createMockAccount({ password: undefined });
751
+ const config: OpenClawConfig = {};
752
+ const core = createMockRuntime();
753
+ setBlueBubblesRuntime(core);
754
+
755
+ const req = createMockRequest("POST", "/bluebubbles-webhook", {
756
+ type: "new-message",
757
+ data: {
758
+ text: "hello",
759
+ handle: { address: "+15551234567" },
760
+ isGroup: false,
761
+ isFromMe: false,
762
+ guid: "msg-1",
763
+ },
764
+ });
765
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
766
+ remoteAddress: "127.0.0.1",
767
+ };
768
+
769
+ unregister = registerBlueBubblesWebhookTarget({
770
+ account,
771
+ config,
772
+ runtime: { log: vi.fn(), error: vi.fn() },
773
+ core,
774
+ path: "/bluebubbles-webhook",
775
+ });
776
+
777
+ const res = createMockResponse();
778
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
779
+ expect(handled).toBe(true);
780
+ expect(res.statusCode).toBe(200);
781
+ });
782
+
597
783
  it("ignores unregistered webhook paths", async () => {
598
784
  const req = createMockRequest("POST", "/unregistered-path", {});
599
785
  const res = createMockResponse();
@@ -1261,6 +1447,145 @@ describe("BlueBubbles webhook monitor", () => {
1261
1447
  });
1262
1448
  });
1263
1449
 
1450
+ describe("group sender identity in envelope", () => {
1451
+ it("includes sender in envelope body and group label as from for group messages", async () => {
1452
+ const account = createMockAccount({ groupPolicy: "open" });
1453
+ const config: OpenClawConfig = {};
1454
+ const core = createMockRuntime();
1455
+ setBlueBubblesRuntime(core);
1456
+
1457
+ unregister = registerBlueBubblesWebhookTarget({
1458
+ account,
1459
+ config,
1460
+ runtime: { log: vi.fn(), error: vi.fn() },
1461
+ core,
1462
+ path: "/bluebubbles-webhook",
1463
+ });
1464
+
1465
+ const payload = {
1466
+ type: "new-message",
1467
+ data: {
1468
+ text: "hello everyone",
1469
+ handle: { address: "+15551234567" },
1470
+ senderName: "Alice",
1471
+ isGroup: true,
1472
+ isFromMe: false,
1473
+ guid: "msg-1",
1474
+ chatGuid: "iMessage;+;chat123456",
1475
+ chatName: "Family Chat",
1476
+ date: Date.now(),
1477
+ },
1478
+ };
1479
+
1480
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
1481
+ const res = createMockResponse();
1482
+
1483
+ await handleBlueBubblesWebhookRequest(req, res);
1484
+ await flushAsync();
1485
+
1486
+ // formatInboundEnvelope should be called with group label + id as from, and sender info
1487
+ expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
1488
+ expect.objectContaining({
1489
+ from: "Family Chat id:iMessage;+;chat123456",
1490
+ chatType: "group",
1491
+ sender: { name: "Alice", id: "+15551234567" },
1492
+ }),
1493
+ );
1494
+ // ConversationLabel should be the group label + id, not the sender
1495
+ const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1496
+ expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
1497
+ expect(callArgs.ctx.SenderName).toBe("Alice");
1498
+ // BodyForAgent should be raw text, not the envelope-formatted body
1499
+ expect(callArgs.ctx.BodyForAgent).toBe("hello everyone");
1500
+ });
1501
+
1502
+ it("falls back to group:peerId when chatName is missing", async () => {
1503
+ const account = createMockAccount({ groupPolicy: "open" });
1504
+ const config: OpenClawConfig = {};
1505
+ const core = createMockRuntime();
1506
+ setBlueBubblesRuntime(core);
1507
+
1508
+ unregister = registerBlueBubblesWebhookTarget({
1509
+ account,
1510
+ config,
1511
+ runtime: { log: vi.fn(), error: vi.fn() },
1512
+ core,
1513
+ path: "/bluebubbles-webhook",
1514
+ });
1515
+
1516
+ const payload = {
1517
+ type: "new-message",
1518
+ data: {
1519
+ text: "hello",
1520
+ handle: { address: "+15551234567" },
1521
+ isGroup: true,
1522
+ isFromMe: false,
1523
+ guid: "msg-1",
1524
+ chatGuid: "iMessage;+;chat123456",
1525
+ date: Date.now(),
1526
+ },
1527
+ };
1528
+
1529
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
1530
+ const res = createMockResponse();
1531
+
1532
+ await handleBlueBubblesWebhookRequest(req, res);
1533
+ await flushAsync();
1534
+
1535
+ expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
1536
+ expect.objectContaining({
1537
+ from: expect.stringMatching(/^Group id:/),
1538
+ chatType: "group",
1539
+ sender: { name: undefined, id: "+15551234567" },
1540
+ }),
1541
+ );
1542
+ });
1543
+
1544
+ it("uses sender as from label for DM messages", async () => {
1545
+ const account = createMockAccount();
1546
+ const config: OpenClawConfig = {};
1547
+ const core = createMockRuntime();
1548
+ setBlueBubblesRuntime(core);
1549
+
1550
+ unregister = registerBlueBubblesWebhookTarget({
1551
+ account,
1552
+ config,
1553
+ runtime: { log: vi.fn(), error: vi.fn() },
1554
+ core,
1555
+ path: "/bluebubbles-webhook",
1556
+ });
1557
+
1558
+ const payload = {
1559
+ type: "new-message",
1560
+ data: {
1561
+ text: "hello",
1562
+ handle: { address: "+15551234567" },
1563
+ senderName: "Alice",
1564
+ isGroup: false,
1565
+ isFromMe: false,
1566
+ guid: "msg-1",
1567
+ date: Date.now(),
1568
+ },
1569
+ };
1570
+
1571
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
1572
+ const res = createMockResponse();
1573
+
1574
+ await handleBlueBubblesWebhookRequest(req, res);
1575
+ await flushAsync();
1576
+
1577
+ expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
1578
+ expect.objectContaining({
1579
+ from: "Alice id:+15551234567",
1580
+ chatType: "direct",
1581
+ sender: { name: "Alice", id: "+15551234567" },
1582
+ }),
1583
+ );
1584
+ const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1585
+ expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
1586
+ });
1587
+ });
1588
+
1264
1589
  describe("inbound debouncing", () => {
1265
1590
  it("coalesces text-only then attachment webhook events by messageId", async () => {
1266
1591
  vi.useFakeTimers();
package/src/monitor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { timingSafeEqual } from "node:crypto";
3
4
  import {
4
5
  normalizeWebhookMessage,
5
6
  normalizeWebhookReaction,
@@ -315,6 +316,73 @@ function maskSecret(value: string): string {
315
316
  return `${value.slice(0, 2)}***${value.slice(-2)}`;
316
317
  }
317
318
 
319
+ function normalizeAuthToken(raw: string): string {
320
+ const value = raw.trim();
321
+ if (!value) {
322
+ return "";
323
+ }
324
+ if (value.toLowerCase().startsWith("bearer ")) {
325
+ return value.slice("bearer ".length).trim();
326
+ }
327
+ return value;
328
+ }
329
+
330
+ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
331
+ const a = normalizeAuthToken(aRaw);
332
+ const b = normalizeAuthToken(bRaw);
333
+ if (!a || !b) {
334
+ return false;
335
+ }
336
+ const bufA = Buffer.from(a, "utf8");
337
+ const bufB = Buffer.from(b, "utf8");
338
+ if (bufA.length !== bufB.length) {
339
+ return false;
340
+ }
341
+ return timingSafeEqual(bufA, bufB);
342
+ }
343
+
344
+ function getHostName(hostHeader?: string | string[]): string {
345
+ const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? ""))
346
+ .trim()
347
+ .toLowerCase();
348
+ if (!host) {
349
+ return "";
350
+ }
351
+ // Bracketed IPv6: [::1]:18789
352
+ if (host.startsWith("[")) {
353
+ const end = host.indexOf("]");
354
+ if (end !== -1) {
355
+ return host.slice(1, end);
356
+ }
357
+ }
358
+ const [name] = host.split(":");
359
+ return name ?? "";
360
+ }
361
+
362
+ function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean {
363
+ const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase();
364
+ const remoteIsLoopback =
365
+ remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
366
+ if (!remoteIsLoopback) {
367
+ return false;
368
+ }
369
+
370
+ const host = getHostName(req.headers?.host);
371
+ const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
372
+ if (!hostIsLocal) {
373
+ return false;
374
+ }
375
+
376
+ // If a reverse proxy is in front, it will usually inject forwarding headers.
377
+ // Passwordless webhooks must never be accepted through a proxy.
378
+ const hasForwarded = Boolean(
379
+ req.headers?.["x-forwarded-for"] ||
380
+ req.headers?.["x-real-ip"] ||
381
+ req.headers?.["x-forwarded-host"],
382
+ );
383
+ return !hasForwarded;
384
+ }
385
+
318
386
  export async function handleBlueBubblesWebhookRequest(
319
387
  req: IncomingMessage,
320
388
  res: ServerResponse,
@@ -398,23 +466,36 @@ export async function handleBlueBubblesWebhookRequest(
398
466
  return true;
399
467
  }
400
468
 
401
- const matching = targets.filter((target) => {
402
- const token = target.account.config.password?.trim();
469
+ const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
470
+ const headerToken =
471
+ req.headers["x-guid"] ??
472
+ req.headers["x-password"] ??
473
+ req.headers["x-bluebubbles-guid"] ??
474
+ req.headers["authorization"];
475
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
476
+
477
+ const strictMatches: WebhookTarget[] = [];
478
+ const passwordlessTargets: WebhookTarget[] = [];
479
+ for (const target of targets) {
480
+ const token = target.account.config.password?.trim() ?? "";
403
481
  if (!token) {
404
- return true;
482
+ passwordlessTargets.push(target);
483
+ continue;
405
484
  }
406
- const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
407
- const headerToken =
408
- req.headers["x-guid"] ??
409
- req.headers["x-password"] ??
410
- req.headers["x-bluebubbles-guid"] ??
411
- req.headers["authorization"];
412
- const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
413
- if (guid && guid.trim() === token) {
414
- return true;
485
+ if (safeEqualSecret(guid, token)) {
486
+ strictMatches.push(target);
487
+ if (strictMatches.length > 1) {
488
+ break;
489
+ }
415
490
  }
416
- return false;
417
- });
491
+ }
492
+
493
+ const matching =
494
+ strictMatches.length > 0
495
+ ? strictMatches
496
+ : isDirectLocalLoopbackRequest(req)
497
+ ? passwordlessTargets
498
+ : [];
418
499
 
419
500
  if (matching.length === 0) {
420
501
  res.statusCode = 401;
@@ -425,24 +506,30 @@ export async function handleBlueBubblesWebhookRequest(
425
506
  return true;
426
507
  }
427
508
 
428
- for (const target of matching) {
429
- target.statusSink?.({ lastInboundAt: Date.now() });
430
- if (reaction) {
431
- processReaction(reaction, target).catch((err) => {
432
- target.runtime.error?.(
433
- `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
434
- );
435
- });
436
- } else if (message) {
437
- // Route messages through debouncer to coalesce rapid-fire events
438
- // (e.g., text message + URL balloon arriving as separate webhooks)
439
- const debouncer = getOrCreateDebouncer(target);
440
- debouncer.enqueue({ message, target }).catch((err) => {
441
- target.runtime.error?.(
442
- `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
443
- );
444
- });
445
- }
509
+ if (matching.length > 1) {
510
+ res.statusCode = 401;
511
+ res.end("ambiguous webhook target");
512
+ console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
513
+ return true;
514
+ }
515
+
516
+ const target = matching[0];
517
+ target.statusSink?.({ lastInboundAt: Date.now() });
518
+ if (reaction) {
519
+ processReaction(reaction, target).catch((err) => {
520
+ target.runtime.error?.(
521
+ `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
522
+ );
523
+ });
524
+ } else if (message) {
525
+ // Route messages through debouncer to coalesce rapid-fire events
526
+ // (e.g., text message + URL balloon arriving as separate webhooks)
527
+ const debouncer = getOrCreateDebouncer(target);
528
+ debouncer.enqueue({ message, target }).catch((err) => {
529
+ target.runtime.error?.(
530
+ `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
531
+ );
532
+ });
446
533
  }
447
534
 
448
535
  res.statusCode = 200;
@@ -484,6 +571,11 @@ export async function monitorBlueBubblesProvider(
484
571
  if (serverInfo?.os_version) {
485
572
  runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
486
573
  }
574
+ if (typeof serverInfo?.private_api === "boolean") {
575
+ runtime.log?.(
576
+ `[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`,
577
+ );
578
+ }
487
579
 
488
580
  const unregister = registerBlueBubblesWebhookTarget({
489
581
  account,
package/src/probe.ts CHANGED
@@ -85,6 +85,18 @@ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesS
85
85
  return null;
86
86
  }
87
87
 
88
+ /**
89
+ * Read cached private API capability for a BlueBubbles account.
90
+ * Returns null when capability is unknown (for example, before first probe).
91
+ */
92
+ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
93
+ const info = getCachedBlueBubblesServerInfo(accountId);
94
+ if (!info || typeof info.private_api !== "boolean") {
95
+ return null;
96
+ }
97
+ return info.private_api;
98
+ }
99
+
88
100
  /**
89
101
  * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
90
102
  */
package/src/reactions.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import { resolveBlueBubblesAccount } from "./accounts.js";
3
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
3
4
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
4
5
 
5
6
  export type BlueBubblesReactionOpts = {
@@ -123,7 +124,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
123
124
  if (!password) {
124
125
  throw new Error("BlueBubbles password is required");
125
126
  }
126
- return { baseUrl, password };
127
+ return { baseUrl, password, accountId: account.accountId };
127
128
  }
128
129
 
129
130
  export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
@@ -160,7 +161,12 @@ export async function sendBlueBubblesReaction(params: {
160
161
  throw new Error("BlueBubbles reaction requires messageGuid.");
161
162
  }
162
163
  const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
163
- const { baseUrl, password } = resolveAccount(params.opts ?? {});
164
+ const { baseUrl, password, accountId } = resolveAccount(params.opts ?? {});
165
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
166
+ throw new Error(
167
+ "BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
168
+ );
169
+ }
164
170
  const url = buildBlueBubblesApiUrl({
165
171
  baseUrl,
166
172
  path: "/api/v1/message/react",
@@ -0,0 +1,53 @@
1
+ import type { BlueBubblesSendTarget } from "./types.js";
2
+ import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
3
+
4
+ export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
5
+ const parsed = parseBlueBubblesTarget(raw);
6
+ if (parsed.kind === "handle") {
7
+ return {
8
+ kind: "handle",
9
+ address: normalizeBlueBubblesHandle(parsed.to),
10
+ service: parsed.service,
11
+ };
12
+ }
13
+ if (parsed.kind === "chat_id") {
14
+ return { kind: "chat_id", chatId: parsed.chatId };
15
+ }
16
+ if (parsed.kind === "chat_guid") {
17
+ return { kind: "chat_guid", chatGuid: parsed.chatGuid };
18
+ }
19
+ return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
20
+ }
21
+
22
+ export function extractBlueBubblesMessageId(payload: unknown): string {
23
+ if (!payload || typeof payload !== "object") {
24
+ return "unknown";
25
+ }
26
+ const record = payload as Record<string, unknown>;
27
+ const data =
28
+ record.data && typeof record.data === "object"
29
+ ? (record.data as Record<string, unknown>)
30
+ : null;
31
+ const candidates = [
32
+ record.messageId,
33
+ record.messageGuid,
34
+ record.message_guid,
35
+ record.guid,
36
+ record.id,
37
+ data?.messageId,
38
+ data?.messageGuid,
39
+ data?.message_guid,
40
+ data?.message_id,
41
+ data?.guid,
42
+ data?.id,
43
+ ];
44
+ for (const candidate of candidates) {
45
+ if (typeof candidate === "string" && candidate.trim()) {
46
+ return candidate.trim();
47
+ }
48
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
49
+ return String(candidate);
50
+ }
51
+ }
52
+ return "unknown";
53
+ }