@openclaw/bluebubbles 2026.2.15 → 2026.2.19

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.
@@ -1,8 +1,8 @@
1
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
1
  import fs from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
5
4
  import { pathToFileURL } from "node:url";
5
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { sendBlueBubblesMedia } from "./media-send.js";
8
8
  import { setBlueBubblesRuntime } from "./runtime.js";
@@ -1,5 +1,5 @@
1
- import type { BlueBubblesAttachment } from "./types.js";
2
1
  import { normalizeBlueBubblesHandle } from "./targets.js";
2
+ import type { BlueBubblesAttachment } from "./types.js";
3
3
 
4
4
  function asRecord(value: unknown): Record<string, unknown> | null {
5
5
  return value && typeof value === "object" && !Array.isArray(value)
@@ -6,12 +6,8 @@ import {
6
6
  logTypingFailure,
7
7
  resolveAckReaction,
8
8
  resolveControlCommandGate,
9
+ stripMarkdown,
9
10
  } from "openclaw/plugin-sdk";
10
- import type {
11
- BlueBubblesCoreRuntime,
12
- BlueBubblesRuntimeEnv,
13
- WebhookTarget,
14
- } from "./monitor-shared.js";
15
11
  import { downloadBlueBubblesAttachment } from "./attachments.js";
16
12
  import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
17
13
  import { sendBlueBubblesMedia } from "./media-send.js";
@@ -32,6 +28,11 @@ import {
32
28
  resolveBlueBubblesMessageId,
33
29
  resolveReplyContextFromCache,
34
30
  } from "./monitor-reply-cache.js";
31
+ import type {
32
+ BlueBubblesCoreRuntime,
33
+ BlueBubblesRuntimeEnv,
34
+ WebhookTarget,
35
+ } from "./monitor-shared.js";
35
36
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
36
37
  import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
37
38
  import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
@@ -40,6 +41,135 @@ import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targe
40
41
  const DEFAULT_TEXT_LIMIT = 4000;
41
42
  const invalidAckReactions = new Set<string>();
42
43
  const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
44
+ const PENDING_OUTBOUND_MESSAGE_ID_TTL_MS = 2 * 60 * 1000;
45
+
46
+ type PendingOutboundMessageId = {
47
+ id: number;
48
+ accountId: string;
49
+ sessionKey: string;
50
+ outboundTarget: string;
51
+ chatGuid?: string;
52
+ chatIdentifier?: string;
53
+ chatId?: number;
54
+ snippetRaw: string;
55
+ snippetNorm: string;
56
+ isMediaSnippet: boolean;
57
+ createdAt: number;
58
+ };
59
+
60
+ const pendingOutboundMessageIds: PendingOutboundMessageId[] = [];
61
+ let pendingOutboundMessageIdCounter = 0;
62
+
63
+ function trimOrUndefined(value?: string | null): string | undefined {
64
+ const trimmed = value?.trim();
65
+ return trimmed ? trimmed : undefined;
66
+ }
67
+
68
+ function normalizeSnippet(value: string): string {
69
+ return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
70
+ }
71
+
72
+ function prunePendingOutboundMessageIds(now = Date.now()): void {
73
+ const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
74
+ for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
75
+ if (pendingOutboundMessageIds[i].createdAt < cutoff) {
76
+ pendingOutboundMessageIds.splice(i, 1);
77
+ }
78
+ }
79
+ }
80
+
81
+ function rememberPendingOutboundMessageId(entry: {
82
+ accountId: string;
83
+ sessionKey: string;
84
+ outboundTarget: string;
85
+ chatGuid?: string;
86
+ chatIdentifier?: string;
87
+ chatId?: number;
88
+ snippet: string;
89
+ }): number {
90
+ prunePendingOutboundMessageIds();
91
+ pendingOutboundMessageIdCounter += 1;
92
+ const snippetRaw = entry.snippet.trim();
93
+ const snippetNorm = normalizeSnippet(snippetRaw);
94
+ pendingOutboundMessageIds.push({
95
+ id: pendingOutboundMessageIdCounter,
96
+ accountId: entry.accountId,
97
+ sessionKey: entry.sessionKey,
98
+ outboundTarget: entry.outboundTarget,
99
+ chatGuid: trimOrUndefined(entry.chatGuid),
100
+ chatIdentifier: trimOrUndefined(entry.chatIdentifier),
101
+ chatId: typeof entry.chatId === "number" ? entry.chatId : undefined,
102
+ snippetRaw,
103
+ snippetNorm,
104
+ isMediaSnippet: snippetRaw.toLowerCase().startsWith("<media:"),
105
+ createdAt: Date.now(),
106
+ });
107
+ return pendingOutboundMessageIdCounter;
108
+ }
109
+
110
+ function forgetPendingOutboundMessageId(id: number): void {
111
+ const index = pendingOutboundMessageIds.findIndex((entry) => entry.id === id);
112
+ if (index >= 0) {
113
+ pendingOutboundMessageIds.splice(index, 1);
114
+ }
115
+ }
116
+
117
+ function chatsMatch(
118
+ left: Pick<PendingOutboundMessageId, "chatGuid" | "chatIdentifier" | "chatId">,
119
+ right: { chatGuid?: string; chatIdentifier?: string; chatId?: number },
120
+ ): boolean {
121
+ const leftGuid = trimOrUndefined(left.chatGuid);
122
+ const rightGuid = trimOrUndefined(right.chatGuid);
123
+ if (leftGuid && rightGuid) {
124
+ return leftGuid === rightGuid;
125
+ }
126
+
127
+ const leftIdentifier = trimOrUndefined(left.chatIdentifier);
128
+ const rightIdentifier = trimOrUndefined(right.chatIdentifier);
129
+ if (leftIdentifier && rightIdentifier) {
130
+ return leftIdentifier === rightIdentifier;
131
+ }
132
+
133
+ const leftChatId = typeof left.chatId === "number" ? left.chatId : undefined;
134
+ const rightChatId = typeof right.chatId === "number" ? right.chatId : undefined;
135
+ if (leftChatId !== undefined && rightChatId !== undefined) {
136
+ return leftChatId === rightChatId;
137
+ }
138
+
139
+ return false;
140
+ }
141
+
142
+ function consumePendingOutboundMessageId(params: {
143
+ accountId: string;
144
+ chatGuid?: string;
145
+ chatIdentifier?: string;
146
+ chatId?: number;
147
+ body: string;
148
+ }): PendingOutboundMessageId | null {
149
+ prunePendingOutboundMessageIds();
150
+ const bodyNorm = normalizeSnippet(params.body);
151
+ const isMediaBody = params.body.trim().toLowerCase().startsWith("<media:");
152
+
153
+ for (let i = 0; i < pendingOutboundMessageIds.length; i++) {
154
+ const entry = pendingOutboundMessageIds[i];
155
+ if (entry.accountId !== params.accountId) {
156
+ continue;
157
+ }
158
+ if (!chatsMatch(entry, params)) {
159
+ continue;
160
+ }
161
+ if (entry.snippetNorm && entry.snippetNorm === bodyNorm) {
162
+ pendingOutboundMessageIds.splice(i, 1);
163
+ return entry;
164
+ }
165
+ if (entry.isMediaSnippet && isMediaBody) {
166
+ pendingOutboundMessageIds.splice(i, 1);
167
+ return entry;
168
+ }
169
+ }
170
+
171
+ return null;
172
+ }
43
173
 
44
174
  export function logVerbose(
45
175
  core: BlueBubblesCoreRuntime,
@@ -158,6 +288,26 @@ export async function processMessage(
158
288
  if (message.fromMe) {
159
289
  // Cache from-me messages so reply context can resolve sender/body.
160
290
  cacheInboundMessage();
291
+ if (cacheMessageId) {
292
+ const pending = consumePendingOutboundMessageId({
293
+ accountId: account.accountId,
294
+ chatGuid: message.chatGuid,
295
+ chatIdentifier: message.chatIdentifier,
296
+ chatId: message.chatId,
297
+ body: rawBody,
298
+ });
299
+ if (pending) {
300
+ const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId;
301
+ const previewSource = pending.snippetRaw || rawBody;
302
+ const preview = previewSource
303
+ ? ` "${previewSource.slice(0, 12)}${previewSource.length > 12 ? "…" : ""}"`
304
+ : "";
305
+ core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
306
+ sessionKey: pending.sessionKey,
307
+ contextKey: `bluebubbles:outbound:${pending.outboundTarget}:${cacheMessageId}`,
308
+ });
309
+ }
310
+ }
161
311
  return;
162
312
  }
163
313
 
@@ -629,10 +779,10 @@ export async function processMessage(
629
779
  ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
630
780
  : message.senderId;
631
781
 
632
- const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
782
+ const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string): boolean => {
633
783
  const trimmed = messageId?.trim();
634
784
  if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
635
- return;
785
+ return false;
636
786
  }
637
787
  // Cache outbound message to get short ID
638
788
  const cacheEntry = rememberBlueBubblesReplyCache({
@@ -651,6 +801,7 @@ export async function processMessage(
651
801
  sessionKey: route.sessionKey,
652
802
  contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
653
803
  });
804
+ return true;
654
805
  };
655
806
  const sanitizeReplyDirectiveText = (value: string): string => {
656
807
  if (privateApiEnabled) {
@@ -768,16 +919,33 @@ export async function processMessage(
768
919
  for (const mediaUrl of mediaList) {
769
920
  const caption = first ? text : undefined;
770
921
  first = false;
771
- const result = await sendBlueBubblesMedia({
772
- cfg: config,
773
- to: outboundTarget,
774
- mediaUrl,
775
- caption: caption ?? undefined,
776
- replyToId: replyToMessageGuid || null,
922
+ const cachedBody = (caption ?? "").trim() || "<media:attachment>";
923
+ const pendingId = rememberPendingOutboundMessageId({
777
924
  accountId: account.accountId,
925
+ sessionKey: route.sessionKey,
926
+ outboundTarget,
927
+ chatGuid: chatGuidForActions ?? chatGuid,
928
+ chatIdentifier,
929
+ chatId,
930
+ snippet: cachedBody,
778
931
  });
779
- const cachedBody = (caption ?? "").trim() || "<media:attachment>";
780
- maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
932
+ let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
933
+ try {
934
+ result = await sendBlueBubblesMedia({
935
+ cfg: config,
936
+ to: outboundTarget,
937
+ mediaUrl,
938
+ caption: caption ?? undefined,
939
+ replyToId: replyToMessageGuid || null,
940
+ accountId: account.accountId,
941
+ });
942
+ } catch (err) {
943
+ forgetPendingOutboundMessageId(pendingId);
944
+ throw err;
945
+ }
946
+ if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) {
947
+ forgetPendingOutboundMessageId(pendingId);
948
+ }
781
949
  sentMessage = true;
782
950
  statusSink?.({ lastOutboundAt: Date.now() });
783
951
  if (info.kind === "block") {
@@ -811,12 +979,29 @@ export async function processMessage(
811
979
  return;
812
980
  }
813
981
  for (const chunk of chunks) {
814
- const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
815
- cfg: config,
982
+ const pendingId = rememberPendingOutboundMessageId({
816
983
  accountId: account.accountId,
817
- replyToMessageGuid: replyToMessageGuid || undefined,
984
+ sessionKey: route.sessionKey,
985
+ outboundTarget,
986
+ chatGuid: chatGuidForActions ?? chatGuid,
987
+ chatIdentifier,
988
+ chatId,
989
+ snippet: chunk,
818
990
  });
819
- maybeEnqueueOutboundMessageId(result.messageId, chunk);
991
+ let result: Awaited<ReturnType<typeof sendMessageBlueBubbles>>;
992
+ try {
993
+ result = await sendMessageBlueBubbles(outboundTarget, chunk, {
994
+ cfg: config,
995
+ accountId: account.accountId,
996
+ replyToMessageGuid: replyToMessageGuid || undefined,
997
+ });
998
+ } catch (err) {
999
+ forgetPendingOutboundMessageId(pendingId);
1000
+ throw err;
1001
+ }
1002
+ if (maybeEnqueueOutboundMessageId(result.messageId, chunk)) {
1003
+ forgetPendingOutboundMessageId(pendingId);
1004
+ }
820
1005
  sentMessage = true;
821
1006
  statusSink?.({ lastOutboundAt: Date.now() });
822
1007
  if (info.kind === "block") {
@@ -1,7 +1,7 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
3
- import type { BlueBubblesAccountConfig } from "./types.js";
4
3
  import { getBlueBubblesRuntime } from "./runtime.js";
4
+ import type { BlueBubblesAccountConfig } from "./types.js";
5
5
 
6
6
  export type BlueBubblesRuntimeEnv = {
7
7
  log?: (message: string) => void;
@@ -1,6 +1,6 @@
1
+ import { EventEmitter } from "node:events";
1
2
  import type { IncomingMessage, ServerResponse } from "node:http";
2
3
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
- import { EventEmitter } from "node:events";
4
4
  import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -52,9 +52,22 @@ const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
52
52
  const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
53
53
  regexes.some((r) => r.test(text)),
54
54
  );
55
+ const mockMatchesMentionWithExplicit = vi.fn(
56
+ (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
57
+ if (params.explicitWasMentioned) {
58
+ return true;
59
+ }
60
+ return params.mentionRegexes.some((regex) => regex.test(params.text));
61
+ },
62
+ );
55
63
  const mockResolveRequireMention = vi.fn(() => false);
56
64
  const mockResolveGroupPolicy = vi.fn(() => "open");
57
- const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined);
65
+ type DispatchReplyParams = Parameters<
66
+ PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
67
+ >[0];
68
+ const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
69
+ async (_params: DispatchReplyParams): Promise<void> => undefined,
70
+ );
58
71
  const mockHasControlCommand = vi.fn(() => false);
59
72
  const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
60
73
  const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
@@ -69,6 +82,10 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
69
82
  const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
70
83
  const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
71
84
  const mockChunkMarkdownText = vi.fn((text: string) => [text]);
85
+ const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
86
+ const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
87
+ const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
88
+ const mockResolveChunkMode = vi.fn(() => "length");
72
89
 
73
90
  function createMockRuntime(): PluginRuntime {
74
91
  return {
@@ -81,6 +98,9 @@ function createMockRuntime(): PluginRuntime {
81
98
  enqueueSystemEvent:
82
99
  mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
83
100
  runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
101
+ formatNativeDependencyHint: vi.fn(
102
+ () => "",
103
+ ) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
84
104
  },
85
105
  media: {
86
106
  loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
@@ -91,6 +111,9 @@ function createMockRuntime(): PluginRuntime {
91
111
  getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
92
112
  resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
93
113
  },
114
+ tts: {
115
+ textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
116
+ },
94
117
  tools: {
95
118
  createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
96
119
  createMemorySearchTool:
@@ -102,6 +125,14 @@ function createMockRuntime(): PluginRuntime {
102
125
  chunkMarkdownText:
103
126
  mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
104
127
  chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
128
+ chunkByNewline:
129
+ mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
130
+ chunkMarkdownTextWithMode:
131
+ mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
132
+ chunkTextWithMode:
133
+ mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
134
+ resolveChunkMode:
135
+ mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
105
136
  resolveTextChunkLimit: vi.fn(
106
137
  () => 4000,
107
138
  ) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
@@ -170,6 +201,8 @@ function createMockRuntime(): PluginRuntime {
170
201
  mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
171
202
  matchesMentionPatterns:
172
203
  mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
204
+ matchesMentionWithExplicit:
205
+ mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
173
206
  },
174
207
  reactions: {
175
208
  shouldAckReaction,
@@ -206,6 +239,8 @@ function createMockRuntime(): PluginRuntime {
206
239
  vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
207
240
  },
208
241
  discord: {} as PluginRuntime["channel"]["discord"],
242
+ activity: {} as PluginRuntime["channel"]["activity"],
243
+ line: {} as PluginRuntime["channel"]["line"],
209
244
  slack: {} as PluginRuntime["channel"]["slack"],
210
245
  telegram: {} as PluginRuntime["channel"]["telegram"],
211
246
  signal: {} as PluginRuntime["channel"]["signal"],
@@ -305,6 +340,14 @@ const flushAsync = async () => {
305
340
  }
306
341
  };
307
342
 
343
+ function getFirstDispatchCall(): DispatchReplyParams {
344
+ const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
345
+ if (!callArgs) {
346
+ throw new Error("expected dispatch call arguments");
347
+ }
348
+ return callArgs;
349
+ }
350
+
308
351
  describe("BlueBubbles webhook monitor", () => {
309
352
  let unregister: () => void;
310
353
 
@@ -1319,7 +1362,7 @@ describe("BlueBubbles webhook monitor", () => {
1319
1362
  await flushAsync();
1320
1363
 
1321
1364
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1322
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1365
+ const callArgs = getFirstDispatchCall();
1323
1366
  expect(callArgs.ctx.WasMentioned).toBe(true);
1324
1367
  });
1325
1368
 
@@ -1441,7 +1484,7 @@ describe("BlueBubbles webhook monitor", () => {
1441
1484
  await flushAsync();
1442
1485
 
1443
1486
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1444
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1487
+ const callArgs = getFirstDispatchCall();
1445
1488
  expect(callArgs.ctx.GroupSubject).toBe("Family");
1446
1489
  expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
1447
1490
  });
@@ -1492,7 +1535,7 @@ describe("BlueBubbles webhook monitor", () => {
1492
1535
  }),
1493
1536
  );
1494
1537
  // ConversationLabel should be the group label + id, not the sender
1495
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1538
+ const callArgs = getFirstDispatchCall();
1496
1539
  expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
1497
1540
  expect(callArgs.ctx.SenderName).toBe("Alice");
1498
1541
  // BodyForAgent should be raw text, not the envelope-formatted body
@@ -1581,7 +1624,7 @@ describe("BlueBubbles webhook monitor", () => {
1581
1624
  sender: { name: "Alice", id: "+15551234567" },
1582
1625
  }),
1583
1626
  );
1584
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1627
+ const callArgs = getFirstDispatchCall();
1585
1628
  expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
1586
1629
  });
1587
1630
  });
@@ -1716,7 +1759,7 @@ describe("BlueBubbles webhook monitor", () => {
1716
1759
  await vi.advanceTimersByTimeAsync(600);
1717
1760
 
1718
1761
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
1719
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1762
+ const callArgs = getFirstDispatchCall();
1720
1763
  expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
1721
1764
  expect(callArgs.ctx.Body).toContain("hello");
1722
1765
  } finally {
@@ -1765,7 +1808,7 @@ describe("BlueBubbles webhook monitor", () => {
1765
1808
  await flushAsync();
1766
1809
 
1767
1810
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1768
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1811
+ const callArgs = getFirstDispatchCall();
1769
1812
  // ReplyToId is the full UUID since it wasn't previously cached
1770
1813
  expect(callArgs.ctx.ReplyToId).toBe("msg-0");
1771
1814
  expect(callArgs.ctx.ReplyToBody).toBe("original message");
@@ -1813,7 +1856,7 @@ describe("BlueBubbles webhook monitor", () => {
1813
1856
  await flushAsync();
1814
1857
 
1815
1858
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1816
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1859
+ const callArgs = getFirstDispatchCall();
1817
1860
  expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
1818
1861
  expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
1819
1862
  expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
@@ -1879,7 +1922,7 @@ describe("BlueBubbles webhook monitor", () => {
1879
1922
  await flushAsync();
1880
1923
 
1881
1924
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1882
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1925
+ const callArgs = getFirstDispatchCall();
1883
1926
  // ReplyToId uses short ID "1" (first cached message) for token savings
1884
1927
  expect(callArgs.ctx.ReplyToId).toBe("1");
1885
1928
  expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
@@ -1924,7 +1967,7 @@ describe("BlueBubbles webhook monitor", () => {
1924
1967
  await flushAsync();
1925
1968
 
1926
1969
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1927
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
1970
+ const callArgs = getFirstDispatchCall();
1928
1971
  expect(callArgs.ctx.ReplyToId).toBe("msg-0");
1929
1972
  });
1930
1973
  });
@@ -1964,7 +2007,7 @@ describe("BlueBubbles webhook monitor", () => {
1964
2007
  await flushAsync();
1965
2008
 
1966
2009
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1967
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
2010
+ const callArgs = getFirstDispatchCall();
1968
2011
  expect(callArgs.ctx.RawBody).toBe("Loved this idea");
1969
2012
  expect(callArgs.ctx.Body).toContain("Loved this idea");
1970
2013
  expect(callArgs.ctx.Body).not.toContain("reacted with");
@@ -2004,7 +2047,7 @@ describe("BlueBubbles webhook monitor", () => {
2004
2047
  await flushAsync();
2005
2048
 
2006
2049
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2007
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
2050
+ const callArgs = getFirstDispatchCall();
2008
2051
  expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
2009
2052
  expect(callArgs.ctx.Body).toContain("reacted with 😅");
2010
2053
  expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
@@ -2427,6 +2470,149 @@ describe("BlueBubbles webhook monitor", () => {
2427
2470
  }),
2428
2471
  );
2429
2472
  });
2473
+
2474
+ it("falls back to from-me webhook when send response has no message id", async () => {
2475
+ mockEnqueueSystemEvent.mockClear();
2476
+
2477
+ const { sendMessageBlueBubbles } = await import("./send.js");
2478
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
2479
+
2480
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2481
+ await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2482
+ });
2483
+
2484
+ const account = createMockAccount();
2485
+ const config: OpenClawConfig = {};
2486
+ const core = createMockRuntime();
2487
+ setBlueBubblesRuntime(core);
2488
+
2489
+ unregister = registerBlueBubblesWebhookTarget({
2490
+ account,
2491
+ config,
2492
+ runtime: { log: vi.fn(), error: vi.fn() },
2493
+ core,
2494
+ path: "/bluebubbles-webhook",
2495
+ });
2496
+
2497
+ const inboundPayload = {
2498
+ type: "new-message",
2499
+ data: {
2500
+ text: "hello",
2501
+ handle: { address: "+15551234567" },
2502
+ isGroup: false,
2503
+ isFromMe: false,
2504
+ guid: "msg-1",
2505
+ chatGuid: "iMessage;-;+15551234567",
2506
+ date: Date.now(),
2507
+ },
2508
+ };
2509
+
2510
+ const inboundReq = createMockRequest("POST", "/bluebubbles-webhook", inboundPayload);
2511
+ const inboundRes = createMockResponse();
2512
+
2513
+ await handleBlueBubblesWebhookRequest(inboundReq, inboundRes);
2514
+ await flushAsync();
2515
+
2516
+ // Send response did not include a message id, so nothing should be enqueued yet.
2517
+ expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
2518
+
2519
+ const fromMePayload = {
2520
+ type: "new-message",
2521
+ data: {
2522
+ text: "replying now",
2523
+ handle: { address: "+15557654321" },
2524
+ isGroup: false,
2525
+ isFromMe: true,
2526
+ guid: "msg-out-456",
2527
+ chatGuid: "iMessage;-;+15551234567",
2528
+ date: Date.now(),
2529
+ },
2530
+ };
2531
+
2532
+ const fromMeReq = createMockRequest("POST", "/bluebubbles-webhook", fromMePayload);
2533
+ const fromMeRes = createMockResponse();
2534
+
2535
+ await handleBlueBubblesWebhookRequest(fromMeReq, fromMeRes);
2536
+ await flushAsync();
2537
+
2538
+ expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
2539
+ 'Assistant sent "replying now" [message_id:2]',
2540
+ expect.objectContaining({
2541
+ sessionKey: "agent:main:bluebubbles:dm:+15551234567",
2542
+ }),
2543
+ );
2544
+ });
2545
+
2546
+ it("matches from-me fallback by chatIdentifier when chatGuid is missing", async () => {
2547
+ mockEnqueueSystemEvent.mockClear();
2548
+
2549
+ const { sendMessageBlueBubbles } = await import("./send.js");
2550
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
2551
+
2552
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2553
+ await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2554
+ });
2555
+
2556
+ const account = createMockAccount();
2557
+ const config: OpenClawConfig = {};
2558
+ const core = createMockRuntime();
2559
+ setBlueBubblesRuntime(core);
2560
+
2561
+ unregister = registerBlueBubblesWebhookTarget({
2562
+ account,
2563
+ config,
2564
+ runtime: { log: vi.fn(), error: vi.fn() },
2565
+ core,
2566
+ path: "/bluebubbles-webhook",
2567
+ });
2568
+
2569
+ const inboundPayload = {
2570
+ type: "new-message",
2571
+ data: {
2572
+ text: "hello",
2573
+ handle: { address: "+15551234567" },
2574
+ isGroup: false,
2575
+ isFromMe: false,
2576
+ guid: "msg-1",
2577
+ chatGuid: "iMessage;-;+15551234567",
2578
+ date: Date.now(),
2579
+ },
2580
+ };
2581
+
2582
+ const inboundReq = createMockRequest("POST", "/bluebubbles-webhook", inboundPayload);
2583
+ const inboundRes = createMockResponse();
2584
+
2585
+ await handleBlueBubblesWebhookRequest(inboundReq, inboundRes);
2586
+ await flushAsync();
2587
+
2588
+ expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
2589
+
2590
+ const fromMePayload = {
2591
+ type: "new-message",
2592
+ data: {
2593
+ text: "replying now",
2594
+ handle: { address: "+15557654321" },
2595
+ isGroup: false,
2596
+ isFromMe: true,
2597
+ guid: "msg-out-789",
2598
+ chatIdentifier: "+15551234567",
2599
+ date: Date.now(),
2600
+ },
2601
+ };
2602
+
2603
+ const fromMeReq = createMockRequest("POST", "/bluebubbles-webhook", fromMePayload);
2604
+ const fromMeRes = createMockResponse();
2605
+
2606
+ await handleBlueBubblesWebhookRequest(fromMeReq, fromMeRes);
2607
+ await flushAsync();
2608
+
2609
+ expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
2610
+ 'Assistant sent "replying now" [message_id:2]',
2611
+ expect.objectContaining({
2612
+ sessionKey: "agent:main:bluebubbles:dm:+15551234567",
2613
+ }),
2614
+ );
2615
+ });
2430
2616
  });
2431
2617
 
2432
2618
  describe("reaction events", () => {
@@ -2624,7 +2810,7 @@ describe("BlueBubbles webhook monitor", () => {
2624
2810
  await flushAsync();
2625
2811
 
2626
2812
  expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2627
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
2813
+ const callArgs = getFirstDispatchCall();
2628
2814
  // MessageSid should be short ID "1" instead of full UUID
2629
2815
  expect(callArgs.ctx.MessageSid).toBe("1");
2630
2816
  expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");