@openclaw/bluebubbles 2026.2.12 → 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.
@@ -0,0 +1,185 @@
1
+ const REPLY_CACHE_MAX = 2000;
2
+ const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
3
+
4
+ type BlueBubblesReplyCacheEntry = {
5
+ accountId: string;
6
+ messageId: string;
7
+ shortId: string;
8
+ chatGuid?: string;
9
+ chatIdentifier?: string;
10
+ chatId?: number;
11
+ senderLabel?: string;
12
+ body?: string;
13
+ timestamp: number;
14
+ };
15
+
16
+ // Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
17
+ const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
18
+
19
+ // Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
20
+ const blueBubblesShortIdToUuid = new Map<string, string>();
21
+ const blueBubblesUuidToShortId = new Map<string, string>();
22
+ let blueBubblesShortIdCounter = 0;
23
+
24
+ function trimOrUndefined(value?: string | null): string | undefined {
25
+ const trimmed = value?.trim();
26
+ return trimmed ? trimmed : undefined;
27
+ }
28
+
29
+ function generateShortId(): string {
30
+ blueBubblesShortIdCounter += 1;
31
+ return String(blueBubblesShortIdCounter);
32
+ }
33
+
34
+ export function rememberBlueBubblesReplyCache(
35
+ entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
36
+ ): BlueBubblesReplyCacheEntry {
37
+ const messageId = entry.messageId.trim();
38
+ if (!messageId) {
39
+ return { ...entry, shortId: "" };
40
+ }
41
+
42
+ // Check if we already have a short ID for this GUID
43
+ let shortId = blueBubblesUuidToShortId.get(messageId);
44
+ if (!shortId) {
45
+ shortId = generateShortId();
46
+ blueBubblesShortIdToUuid.set(shortId, messageId);
47
+ blueBubblesUuidToShortId.set(messageId, shortId);
48
+ }
49
+
50
+ const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
51
+
52
+ // Refresh insertion order.
53
+ blueBubblesReplyCacheByMessageId.delete(messageId);
54
+ blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
55
+
56
+ // Opportunistic prune.
57
+ const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
58
+ for (const [key, value] of blueBubblesReplyCacheByMessageId) {
59
+ if (value.timestamp < cutoff) {
60
+ blueBubblesReplyCacheByMessageId.delete(key);
61
+ // Clean up short ID mappings for expired entries
62
+ if (value.shortId) {
63
+ blueBubblesShortIdToUuid.delete(value.shortId);
64
+ blueBubblesUuidToShortId.delete(key);
65
+ }
66
+ continue;
67
+ }
68
+ break;
69
+ }
70
+ while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
71
+ const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
72
+ if (!oldest) {
73
+ break;
74
+ }
75
+ const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
76
+ blueBubblesReplyCacheByMessageId.delete(oldest);
77
+ // Clean up short ID mappings for evicted entries
78
+ if (oldEntry?.shortId) {
79
+ blueBubblesShortIdToUuid.delete(oldEntry.shortId);
80
+ blueBubblesUuidToShortId.delete(oldest);
81
+ }
82
+ }
83
+
84
+ return fullEntry;
85
+ }
86
+
87
+ /**
88
+ * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
89
+ * Returns the input unchanged if it's already a GUID or not found in the mapping.
90
+ */
91
+ export function resolveBlueBubblesMessageId(
92
+ shortOrUuid: string,
93
+ opts?: { requireKnownShortId?: boolean },
94
+ ): string {
95
+ const trimmed = shortOrUuid.trim();
96
+ if (!trimmed) {
97
+ return trimmed;
98
+ }
99
+
100
+ // If it looks like a short ID (numeric), try to resolve it
101
+ if (/^\d+$/.test(trimmed)) {
102
+ const uuid = blueBubblesShortIdToUuid.get(trimmed);
103
+ if (uuid) {
104
+ return uuid;
105
+ }
106
+ if (opts?.requireKnownShortId) {
107
+ throw new Error(
108
+ `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
109
+ );
110
+ }
111
+ }
112
+
113
+ // Return as-is (either already a UUID or not found)
114
+ return trimmed;
115
+ }
116
+
117
+ /**
118
+ * Resets the short ID state. Only use in tests.
119
+ * @internal
120
+ */
121
+ export function _resetBlueBubblesShortIdState(): void {
122
+ blueBubblesShortIdToUuid.clear();
123
+ blueBubblesUuidToShortId.clear();
124
+ blueBubblesReplyCacheByMessageId.clear();
125
+ blueBubblesShortIdCounter = 0;
126
+ }
127
+
128
+ /**
129
+ * Gets the short ID for a message GUID, if one exists.
130
+ */
131
+ export function getShortIdForUuid(uuid: string): string | undefined {
132
+ return blueBubblesUuidToShortId.get(uuid.trim());
133
+ }
134
+
135
+ export function resolveReplyContextFromCache(params: {
136
+ accountId: string;
137
+ replyToId: string;
138
+ chatGuid?: string;
139
+ chatIdentifier?: string;
140
+ chatId?: number;
141
+ }): BlueBubblesReplyCacheEntry | null {
142
+ const replyToId = params.replyToId.trim();
143
+ if (!replyToId) {
144
+ return null;
145
+ }
146
+
147
+ const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
148
+ if (!cached) {
149
+ return null;
150
+ }
151
+ if (cached.accountId !== params.accountId) {
152
+ return null;
153
+ }
154
+
155
+ const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
156
+ if (cached.timestamp < cutoff) {
157
+ blueBubblesReplyCacheByMessageId.delete(replyToId);
158
+ return null;
159
+ }
160
+
161
+ const chatGuid = trimOrUndefined(params.chatGuid);
162
+ const chatIdentifier = trimOrUndefined(params.chatIdentifier);
163
+ const cachedChatGuid = trimOrUndefined(cached.chatGuid);
164
+ const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
165
+ const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
166
+ const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
167
+
168
+ // Avoid cross-chat collisions if we have identifiers.
169
+ if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
170
+ return null;
171
+ }
172
+ if (
173
+ !chatGuid &&
174
+ chatIdentifier &&
175
+ cachedChatIdentifier &&
176
+ chatIdentifier !== cachedChatIdentifier
177
+ ) {
178
+ return null;
179
+ }
180
+ if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
181
+ return null;
182
+ }
183
+
184
+ return cached;
185
+ }
@@ -0,0 +1,51 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type { ResolvedBlueBubblesAccount } from "./accounts.js";
3
+ import type { BlueBubblesAccountConfig } from "./types.js";
4
+ import { getBlueBubblesRuntime } from "./runtime.js";
5
+
6
+ export type BlueBubblesRuntimeEnv = {
7
+ log?: (message: string) => void;
8
+ error?: (message: string) => void;
9
+ };
10
+
11
+ export type BlueBubblesMonitorOptions = {
12
+ account: ResolvedBlueBubblesAccount;
13
+ config: OpenClawConfig;
14
+ runtime: BlueBubblesRuntimeEnv;
15
+ abortSignal: AbortSignal;
16
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
17
+ webhookPath?: string;
18
+ };
19
+
20
+ export type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
21
+
22
+ export type WebhookTarget = {
23
+ account: ResolvedBlueBubblesAccount;
24
+ config: OpenClawConfig;
25
+ runtime: BlueBubblesRuntimeEnv;
26
+ core: BlueBubblesCoreRuntime;
27
+ path: string;
28
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
29
+ };
30
+
31
+ export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
32
+
33
+ export function normalizeWebhookPath(raw: string): string {
34
+ const trimmed = raw.trim();
35
+ if (!trimmed) {
36
+ return "/";
37
+ }
38
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
39
+ if (withSlash.length > 1 && withSlash.endsWith("/")) {
40
+ return withSlash.slice(0, -1);
41
+ }
42
+ return withSlash;
43
+ }
44
+
45
+ export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
46
+ const raw = config?.webhookPath?.trim();
47
+ if (raw) {
48
+ return normalizeWebhookPath(raw);
49
+ }
50
+ return DEFAULT_WEBHOOK_PATH;
51
+ }
@@ -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 =
@@ -404,7 +409,7 @@ describe("BlueBubbles webhook monitor", () => {
404
409
  expect(res.statusCode).toBe(400);
405
410
  });
406
411
 
407
- it("returns 400 when request body times out (Slow-Loris protection)", async () => {
412
+ it("returns 408 when request body times out (Slow-Loris protection)", async () => {
408
413
  vi.useFakeTimers();
409
414
  try {
410
415
  const account = createMockAccount();
@@ -439,7 +444,7 @@ describe("BlueBubbles webhook monitor", () => {
439
444
 
440
445
  const handled = await handledPromise;
441
446
  expect(handled).toBe(true);
442
- expect(res.statusCode).toBe(400);
447
+ expect(res.statusCode).toBe(408);
443
448
  expect(req.destroy).toHaveBeenCalled();
444
449
  } finally {
445
450
  vi.useRealTimers();
@@ -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();