@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 +1 -1
- package/src/attachments.test.ts +24 -36
- package/src/attachments.ts +2 -7
- package/src/chat.test.ts +7 -18
- package/src/chat.ts +4 -17
- package/src/media-send.test.ts +91 -45
- package/src/monitor-normalize.test.ts +32 -10
- package/src/monitor-normalize.ts +45 -28
- package/src/monitor-processing.ts +53 -1
- package/src/monitor-self-chat-cache.test.ts +190 -0
- package/src/monitor-self-chat-cache.ts +127 -0
- package/src/monitor.test.ts +447 -0
- package/src/monitor.webhook-auth.test.ts +132 -283
- package/src/multipart.ts +8 -0
- package/src/reactions.test.ts +4 -38
package/package.json
CHANGED
package/src/attachments.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/src/attachments.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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/media-send.test.ts
CHANGED
|
@@ -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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
185
|
+
const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
|
|
186
|
+
"allowed.txt",
|
|
187
|
+
"allowed",
|
|
188
|
+
);
|
|
128
189
|
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
202
|
+
const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile(
|
|
203
|
+
"allowed.txt",
|
|
204
|
+
"allowed",
|
|
205
|
+
);
|
|
150
206
|
|
|
151
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
13
|
-
handle:
|
|
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?.
|
|
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
|
-
|
|
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
|
});
|
package/src/monitor-normalize.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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,
|