@openclaw/bluebubbles 2026.3.11 → 2026.3.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.3.11",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -82,6 +82,15 @@ describe("downloadBlueBubblesAttachment", () => {
82
82
  ).rejects.toThrow("too large");
83
83
  }
84
84
 
85
+ function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) {
86
+ mockFetch.mockResolvedValueOnce({
87
+ ok: true,
88
+ headers: new Headers(),
89
+ arrayBuffer: () => Promise.resolve(buffer.buffer),
90
+ });
91
+ return buffer;
92
+ }
93
+
85
94
  it("throws when guid is missing", async () => {
86
95
  const attachment: BlueBubblesAttachment = {};
87
96
  await expect(
@@ -159,12 +168,7 @@ describe("downloadBlueBubblesAttachment", () => {
159
168
  });
160
169
 
161
170
  it("encodes guid in URL", async () => {
162
- const mockBuffer = new Uint8Array([1]);
163
- mockFetch.mockResolvedValueOnce({
164
- ok: true,
165
- headers: new Headers(),
166
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
167
- });
171
+ mockSuccessfulAttachmentDownload();
168
172
 
169
173
  const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
170
174
  await downloadBlueBubblesAttachment(attachment, {
@@ -244,12 +248,7 @@ describe("downloadBlueBubblesAttachment", () => {
244
248
  });
245
249
 
246
250
  it("resolves credentials from config when opts not provided", async () => {
247
- const mockBuffer = new Uint8Array([1]);
248
- mockFetch.mockResolvedValueOnce({
249
- ok: true,
250
- headers: new Headers(),
251
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
252
- });
251
+ mockSuccessfulAttachmentDownload();
253
252
 
254
253
  const attachment: BlueBubblesAttachment = { guid: "att-config" };
255
254
  const result = await downloadBlueBubblesAttachment(attachment, {
@@ -270,12 +269,7 @@ describe("downloadBlueBubblesAttachment", () => {
270
269
  });
271
270
 
272
271
  it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
273
- const mockBuffer = new Uint8Array([1]);
274
- mockFetch.mockResolvedValueOnce({
275
- ok: true,
276
- headers: new Headers(),
277
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
278
- });
272
+ mockSuccessfulAttachmentDownload();
279
273
 
280
274
  const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
281
275
  await downloadBlueBubblesAttachment(attachment, {
@@ -295,12 +289,7 @@ describe("downloadBlueBubblesAttachment", () => {
295
289
  });
296
290
 
297
291
  it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
298
- const mockBuffer = new Uint8Array([1]);
299
- mockFetch.mockResolvedValueOnce({
300
- ok: true,
301
- headers: new Headers(),
302
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
303
- });
292
+ mockSuccessfulAttachmentDownload();
304
293
 
305
294
  const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
306
295
  await downloadBlueBubblesAttachment(attachment, {
@@ -313,12 +302,7 @@ describe("downloadBlueBubblesAttachment", () => {
313
302
  });
314
303
 
315
304
  it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => {
316
- const mockBuffer = new Uint8Array([1]);
317
- mockFetch.mockResolvedValueOnce({
318
- ok: true,
319
- headers: new Headers(),
320
- arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
321
- });
305
+ mockSuccessfulAttachmentDownload();
322
306
 
323
307
  const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
324
308
  await downloadBlueBubblesAttachment(attachment, {
@@ -352,6 +336,14 @@ describe("sendBlueBubblesAttachment", () => {
352
336
  return Buffer.from(body).toString("utf8");
353
337
  }
354
338
 
339
+ function expectVoiceAttachmentBody() {
340
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
341
+ const bodyText = decodeBody(body);
342
+ expect(bodyText).toContain('name="isAudioMessage"');
343
+ expect(bodyText).toContain("true");
344
+ return bodyText;
345
+ }
346
+
355
347
  it("marks voice memos when asVoice is true and mp3 is provided", async () => {
356
348
  mockFetch.mockResolvedValueOnce({
357
349
  ok: true,
@@ -367,10 +359,7 @@ describe("sendBlueBubblesAttachment", () => {
367
359
  opts: { serverUrl: "http://localhost:1234", password: "test" },
368
360
  });
369
361
 
370
- const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
371
- const bodyText = decodeBody(body);
372
- expect(bodyText).toContain('name="isAudioMessage"');
373
- expect(bodyText).toContain("true");
362
+ const bodyText = expectVoiceAttachmentBody();
374
363
  expect(bodyText).toContain('filename="voice.mp3"');
375
364
  });
376
365
 
@@ -389,8 +378,7 @@ describe("sendBlueBubblesAttachment", () => {
389
378
  opts: { serverUrl: "http://localhost:1234", password: "test" },
390
379
  });
391
380
 
392
- const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
393
- const bodyText = decodeBody(body);
381
+ const bodyText = expectVoiceAttachmentBody();
394
382
  expect(bodyText).toContain('filename="voice.mp3"');
395
383
  expect(bodyText).toContain('name="voice.mp3"');
396
384
  });
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
- import { postMultipartFormData } from "./multipart.js";
5
+ import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
6
6
  import {
7
7
  getCachedBlueBubblesPrivateApiStatus,
8
8
  isBlueBubblesPrivateApiStatusEnabled,
@@ -262,12 +262,7 @@ export async function sendBlueBubblesAttachment(params: {
262
262
  timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
263
263
  });
264
264
 
265
- if (!res.ok) {
266
- const errorText = await res.text();
267
- throw new Error(
268
- `BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`,
269
- );
270
- }
265
+ await assertMultipartActionOk(res, "attachment send");
271
266
 
272
267
  const responseBody = await res.text();
273
268
  if (!responseBody) {
package/src/chat.test.ts CHANGED
@@ -29,6 +29,11 @@ describe("chat", () => {
29
29
  });
30
30
  }
31
31
 
32
+ function mockTwoOkTextResponses() {
33
+ mockOkTextResponse();
34
+ mockOkTextResponse();
35
+ }
36
+
32
37
  async function expectCalledUrlIncludesPassword(params: {
33
38
  password: string;
34
39
  invoke: () => Promise<void>;
@@ -198,15 +203,7 @@ describe("chat", () => {
198
203
  });
199
204
 
200
205
  it("uses POST for start and DELETE for stop", async () => {
201
- mockFetch
202
- .mockResolvedValueOnce({
203
- ok: true,
204
- text: () => Promise.resolve(""),
205
- })
206
- .mockResolvedValueOnce({
207
- ok: true,
208
- text: () => Promise.resolve(""),
209
- });
206
+ mockTwoOkTextResponses();
210
207
 
211
208
  await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
212
209
  serverUrl: "http://localhost:1234",
@@ -442,15 +439,7 @@ describe("chat", () => {
442
439
  });
443
440
 
444
441
  it("adds and removes participant using matching endpoint", async () => {
445
- mockFetch
446
- .mockResolvedValueOnce({
447
- ok: true,
448
- text: () => Promise.resolve(""),
449
- })
450
- .mockResolvedValueOnce({
451
- ok: true,
452
- text: () => Promise.resolve(""),
453
- });
442
+ mockTwoOkTextResponses();
454
443
 
455
444
  await addBlueBubblesParticipant("chat-guid", "+15551234567", {
456
445
  serverUrl: "http://localhost:1234",
package/src/chat.ts CHANGED
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
- import { postMultipartFormData } from "./multipart.js";
5
+ import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
6
6
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
7
7
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
8
8
 
@@ -55,12 +55,7 @@ async function sendBlueBubblesChatEndpointRequest(params: {
55
55
  { method: params.method },
56
56
  params.opts.timeoutMs,
57
57
  );
58
- if (!res.ok) {
59
- const errorText = await res.text().catch(() => "");
60
- throw new Error(
61
- `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
62
- );
63
- }
58
+ await assertMultipartActionOk(res, params.action);
64
59
  }
65
60
 
66
61
  async function sendPrivateApiJsonRequest(params: {
@@ -86,12 +81,7 @@ async function sendPrivateApiJsonRequest(params: {
86
81
  }
87
82
 
88
83
  const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
89
- if (!res.ok) {
90
- const errorText = await res.text().catch(() => "");
91
- throw new Error(
92
- `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
93
- );
94
- }
84
+ await assertMultipartActionOk(res, params.action);
95
85
  }
96
86
 
97
87
  export async function markBlueBubblesChatRead(
@@ -329,8 +319,5 @@ export async function setGroupIconBlueBubbles(
329
319
  timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
330
320
  });
331
321
 
332
- if (!res.ok) {
333
- const errorText = await res.text().catch(() => "");
334
- throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
335
- }
322
+ await assertMultipartActionOk(res, "setGroupIcon");
336
323
  }
@@ -70,6 +70,70 @@ async function makeTempDir(): Promise<string> {
70
70
  return dir;
71
71
  }
72
72
 
73
+ async function makeTempFile(
74
+ fileName: string,
75
+ contents: string,
76
+ dir?: string,
77
+ ): Promise<{ dir: string; filePath: string }> {
78
+ const resolvedDir = dir ?? (await makeTempDir());
79
+ const filePath = path.join(resolvedDir, fileName);
80
+ await fs.writeFile(filePath, contents, "utf8");
81
+ return { dir: resolvedDir, filePath };
82
+ }
83
+
84
+ async function sendLocalMedia(params: {
85
+ cfg: OpenClawConfig;
86
+ mediaPath: string;
87
+ accountId?: string;
88
+ }) {
89
+ return sendBlueBubblesMedia({
90
+ cfg: params.cfg,
91
+ to: "chat:123",
92
+ accountId: params.accountId,
93
+ mediaPath: params.mediaPath,
94
+ });
95
+ }
96
+
97
+ async function expectRejectedLocalMedia(params: {
98
+ cfg: OpenClawConfig;
99
+ mediaPath: string;
100
+ error: RegExp;
101
+ accountId?: string;
102
+ }) {
103
+ await expect(
104
+ sendLocalMedia({
105
+ cfg: params.cfg,
106
+ mediaPath: params.mediaPath,
107
+ accountId: params.accountId,
108
+ }),
109
+ ).rejects.toThrow(params.error);
110
+
111
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
112
+ }
113
+
114
+ async function expectAllowedLocalMedia(params: {
115
+ cfg: OpenClawConfig;
116
+ mediaPath: string;
117
+ expectedAttachment: Record<string, unknown>;
118
+ accountId?: string;
119
+ expectMimeDetection?: boolean;
120
+ }) {
121
+ const result = await sendLocalMedia({
122
+ cfg: params.cfg,
123
+ mediaPath: params.mediaPath,
124
+ accountId: params.accountId,
125
+ });
126
+
127
+ expect(result).toEqual({ messageId: "msg-1" });
128
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
129
+ expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
130
+ expect.objectContaining(params.expectedAttachment),
131
+ );
132
+ if (params.expectMimeDetection) {
133
+ expect(runtimeMocks.detectMime).toHaveBeenCalled();
134
+ }
135
+ }
136
+
73
137
  beforeEach(() => {
74
138
  const runtime = createMockRuntime();
75
139
  runtimeMocks = runtime.mocks;
@@ -110,57 +174,43 @@ describe("sendBlueBubblesMedia local-path hardening", () => {
110
174
  const outsideFile = path.join(outsideDir, "outside.txt");
111
175
  await fs.writeFile(outsideFile, "not allowed", "utf8");
112
176
 
113
- await expect(
114
- sendBlueBubblesMedia({
115
- cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
116
- to: "chat:123",
117
- mediaPath: outsideFile,
118
- }),
119
- ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
120
-
121
- expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
177
+ await expectRejectedLocalMedia({
178
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
179
+ mediaPath: outsideFile,
180
+ error: /not under any configured mediaLocalRoots/i,
181
+ });
122
182
  });
123
183
 
124
184
  it("allows local paths that are explicitly configured", async () => {
125
- const allowedRoot = await makeTempDir();
126
- const allowedFile = path.join(allowedRoot, "allowed.txt");
127
- await fs.writeFile(allowedFile, "allowed", "utf8");
185
+ const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
186
+ "allowed.txt",
187
+ "allowed",
188
+ );
128
189
 
129
- const result = await sendBlueBubblesMedia({
190
+ await expectAllowedLocalMedia({
130
191
  cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
131
- to: "chat:123",
132
192
  mediaPath: allowedFile,
133
- });
134
-
135
- expect(result).toEqual({ messageId: "msg-1" });
136
- expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
137
- expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
138
- expect.objectContaining({
193
+ expectedAttachment: {
139
194
  filename: "allowed.txt",
140
195
  contentType: "text/plain",
141
- }),
142
- );
143
- expect(runtimeMocks.detectMime).toHaveBeenCalled();
196
+ },
197
+ expectMimeDetection: true,
198
+ });
144
199
  });
145
200
 
146
201
  it("allows file:// media paths and file:// local roots", async () => {
147
- const allowedRoot = await makeTempDir();
148
- const allowedFile = path.join(allowedRoot, "allowed.txt");
149
- await fs.writeFile(allowedFile, "allowed", "utf8");
202
+ const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
203
+ "allowed.txt",
204
+ "allowed",
205
+ );
150
206
 
151
- const result = await sendBlueBubblesMedia({
207
+ await expectAllowedLocalMedia({
152
208
  cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }),
153
- to: "chat:123",
154
209
  mediaPath: pathToFileURL(allowedFile).toString(),
155
- });
156
-
157
- expect(result).toEqual({ messageId: "msg-1" });
158
- expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
159
- expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
160
- expect.objectContaining({
210
+ expectedAttachment: {
161
211
  filename: "allowed.txt",
162
- }),
163
- );
212
+ },
213
+ });
164
214
  });
165
215
 
166
216
  it("uses account-specific mediaLocalRoots over top-level roots", async () => {
@@ -213,15 +263,11 @@ describe("sendBlueBubblesMedia local-path hardening", () => {
213
263
  return;
214
264
  }
215
265
 
216
- await expect(
217
- sendBlueBubblesMedia({
218
- cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
219
- to: "chat:123",
220
- mediaPath: linkPath,
221
- }),
222
- ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
223
-
224
- expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
266
+ await expectRejectedLocalMedia({
267
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
268
+ mediaPath: linkPath,
269
+ error: /not under any configured mediaLocalRoots/i,
270
+ });
225
271
  });
226
272
 
227
273
  it("rejects relative mediaLocalRoots entries", async () => {
@@ -1,23 +1,48 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
3
3
 
4
+ function createFallbackDmPayload(overrides: Record<string, unknown> = {}) {
5
+ return {
6
+ guid: "msg-1",
7
+ isGroup: false,
8
+ isFromMe: false,
9
+ handle: null,
10
+ chatGuid: "iMessage;-;+15551234567",
11
+ ...overrides,
12
+ };
13
+ }
14
+
4
15
  describe("normalizeWebhookMessage", () => {
5
16
  it("falls back to DM chatGuid handle when sender handle is missing", () => {
17
+ const result = normalizeWebhookMessage({
18
+ type: "new-message",
19
+ data: createFallbackDmPayload({
20
+ text: "hello",
21
+ }),
22
+ });
23
+
24
+ expect(result).not.toBeNull();
25
+ expect(result?.senderId).toBe("+15551234567");
26
+ expect(result?.senderIdExplicit).toBe(false);
27
+ expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
28
+ });
29
+
30
+ it("marks explicit sender handles as explicit identity", () => {
6
31
  const result = normalizeWebhookMessage({
7
32
  type: "new-message",
8
33
  data: {
9
- guid: "msg-1",
34
+ guid: "msg-explicit-1",
10
35
  text: "hello",
11
36
  isGroup: false,
12
- isFromMe: false,
13
- handle: null,
37
+ isFromMe: true,
38
+ handle: { address: "+15551234567" },
14
39
  chatGuid: "iMessage;-;+15551234567",
15
40
  },
16
41
  });
17
42
 
18
43
  expect(result).not.toBeNull();
19
44
  expect(result?.senderId).toBe("+15551234567");
20
- expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
45
+ expect(result?.senderIdExplicit).toBe(true);
21
46
  });
22
47
 
23
48
  it("does not infer sender from group chatGuid when sender handle is missing", () => {
@@ -59,19 +84,16 @@ describe("normalizeWebhookReaction", () => {
59
84
  it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
60
85
  const result = normalizeWebhookReaction({
61
86
  type: "updated-message",
62
- data: {
87
+ data: createFallbackDmPayload({
63
88
  guid: "msg-2",
64
89
  associatedMessageGuid: "p:0/msg-1",
65
90
  associatedMessageType: 2000,
66
- isGroup: false,
67
- isFromMe: false,
68
- handle: null,
69
- chatGuid: "iMessage;-;+15551234567",
70
- },
91
+ }),
71
92
  });
72
93
 
73
94
  expect(result).not.toBeNull();
74
95
  expect(result?.senderId).toBe("+15551234567");
96
+ expect(result?.senderIdExplicit).toBe(false);
75
97
  expect(result?.messageId).toBe("p:0/msg-1");
76
98
  expect(result?.action).toBe("added");
77
99
  });
@@ -191,12 +191,13 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
191
191
 
192
192
  function extractSenderInfo(message: Record<string, unknown>): {
193
193
  senderId: string;
194
+ senderIdExplicit: boolean;
194
195
  senderName?: string;
195
196
  } {
196
197
  const handleValue = message.handle ?? message.sender;
197
198
  const handle =
198
199
  asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
199
- const senderId =
200
+ const senderIdRaw =
200
201
  readString(handle, "address") ??
201
202
  readString(handle, "handle") ??
202
203
  readString(handle, "id") ??
@@ -204,13 +205,18 @@ function extractSenderInfo(message: Record<string, unknown>): {
204
205
  readString(message, "sender") ??
205
206
  readString(message, "from") ??
206
207
  "";
208
+ const senderId = senderIdRaw.trim();
207
209
  const senderName =
208
210
  readString(handle, "displayName") ??
209
211
  readString(handle, "name") ??
210
212
  readString(message, "senderName") ??
211
213
  undefined;
212
214
 
213
- return { senderId, senderName };
215
+ return {
216
+ senderId,
217
+ senderIdExplicit: Boolean(senderId),
218
+ senderName,
219
+ };
214
220
  }
215
221
 
216
222
  function extractChatContext(message: Record<string, unknown>): {
@@ -441,6 +447,7 @@ export type BlueBubblesParticipant = {
441
447
  export type NormalizedWebhookMessage = {
442
448
  text: string;
443
449
  senderId: string;
450
+ senderIdExplicit: boolean;
444
451
  senderName?: string;
445
452
  messageId?: string;
446
453
  timestamp?: number;
@@ -466,6 +473,7 @@ export type NormalizedWebhookReaction = {
466
473
  action: "added" | "removed";
467
474
  emoji: string;
468
475
  senderId: string;
476
+ senderIdExplicit: boolean;
469
477
  senderName?: string;
470
478
  messageId: string;
471
479
  timestamp?: number;
@@ -574,6 +582,29 @@ export function parseTapbackText(params: {
574
582
  return null;
575
583
  }
576
584
 
585
+ const parseLeadingReactionAction = (
586
+ prefix: "reacted" | "removed",
587
+ defaultAction: "added" | "removed",
588
+ ) => {
589
+ if (!lower.startsWith(prefix)) {
590
+ return null;
591
+ }
592
+ const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
593
+ if (!emoji) {
594
+ return null;
595
+ }
596
+ const quotedText = extractQuotedTapbackText(trimmed);
597
+ if (params.requireQuoted && !quotedText) {
598
+ return null;
599
+ }
600
+ const fallback = trimmed.slice(prefix.length).trim();
601
+ return {
602
+ emoji,
603
+ action: params.actionHint ?? defaultAction,
604
+ quotedText: quotedText ?? fallback,
605
+ };
606
+ };
607
+
577
608
  for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
578
609
  if (lower.startsWith(pattern)) {
579
610
  // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
@@ -591,30 +622,14 @@ export function parseTapbackText(params: {
591
622
  }
592
623
  }
593
624
 
594
- if (lower.startsWith("reacted")) {
595
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
596
- if (!emoji) {
597
- return null;
598
- }
599
- const quotedText = extractQuotedTapbackText(trimmed);
600
- if (params.requireQuoted && !quotedText) {
601
- return null;
602
- }
603
- const fallback = trimmed.slice("reacted".length).trim();
604
- return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
625
+ const reacted = parseLeadingReactionAction("reacted", "added");
626
+ if (reacted) {
627
+ return reacted;
605
628
  }
606
629
 
607
- if (lower.startsWith("removed")) {
608
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
609
- if (!emoji) {
610
- return null;
611
- }
612
- const quotedText = extractQuotedTapbackText(trimmed);
613
- if (params.requireQuoted && !quotedText) {
614
- return null;
615
- }
616
- const fallback = trimmed.slice("removed".length).trim();
617
- return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
630
+ const removed = parseLeadingReactionAction("removed", "removed");
631
+ if (removed) {
632
+ return removed;
618
633
  }
619
634
  return null;
620
635
  }
@@ -672,7 +687,7 @@ export function normalizeWebhookMessage(
672
687
  readString(message, "subject") ??
673
688
  "";
674
689
 
675
- const { senderId, senderName } = extractSenderInfo(message);
690
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
676
691
  const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
677
692
  extractChatContext(message);
678
693
  const normalizedParticipants = normalizeParticipantList(participants);
@@ -717,7 +732,7 @@ export function normalizeWebhookMessage(
717
732
 
718
733
  // BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
719
734
  const senderFallbackFromChatGuid =
720
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
735
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
721
736
  const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
722
737
  if (!normalizedSender) {
723
738
  return null;
@@ -727,6 +742,7 @@ export function normalizeWebhookMessage(
727
742
  return {
728
743
  text,
729
744
  senderId: normalizedSender,
745
+ senderIdExplicit,
730
746
  senderName,
731
747
  messageId,
732
748
  timestamp,
@@ -777,7 +793,7 @@ export function normalizeWebhookReaction(
777
793
  const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
778
794
  const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
779
795
 
780
- const { senderId, senderName } = extractSenderInfo(message);
796
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
781
797
  const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
782
798
 
783
799
  const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
@@ -793,7 +809,7 @@ export function normalizeWebhookReaction(
793
809
  : undefined;
794
810
 
795
811
  const senderFallbackFromChatGuid =
796
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
812
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
797
813
  const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
798
814
  if (!normalizedSender) {
799
815
  return null;
@@ -803,6 +819,7 @@ export function normalizeWebhookReaction(
803
819
  action,
804
820
  emoji,
805
821
  senderId: normalizedSender,
822
+ senderIdExplicit,
806
823
  senderName,
807
824
  messageId: associatedGuid,
808
825
  timestamp,