@openclaw/bluebubbles 2026.2.21 → 2026.2.22
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/actions.test.ts +4 -11
- package/src/attachments.test.ts +91 -4
- package/src/attachments.ts +47 -15
- package/src/chat.test.ts +193 -1
- package/src/chat.ts +74 -124
- package/src/history.ts +177 -0
- package/src/monitor-normalize.test.ts +78 -0
- package/src/monitor-normalize.ts +41 -12
- package/src/monitor-processing.ts +383 -127
- package/src/monitor.test.ts +396 -4
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +4 -11
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +53 -3
- package/src/send.ts +49 -7
- package/src/targets.test.ts +19 -0
- package/src/targets.ts +46 -37
- package/src/test-harness.ts +31 -2
package/src/chat.ts
CHANGED
|
@@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function resolvePartIndex(partIndex: number | undefined): number {
|
|
30
|
+
return typeof partIndex === "number" ? partIndex : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sendPrivateApiJsonRequest(params: {
|
|
34
|
+
opts: BlueBubblesChatOpts;
|
|
35
|
+
feature: string;
|
|
36
|
+
action: string;
|
|
37
|
+
path: string;
|
|
38
|
+
method: "POST" | "PUT" | "DELETE";
|
|
39
|
+
payload?: unknown;
|
|
40
|
+
}): Promise<void> {
|
|
41
|
+
const { baseUrl, password, accountId } = resolveAccount(params.opts);
|
|
42
|
+
assertPrivateApiEnabled(accountId, params.feature);
|
|
43
|
+
const url = buildBlueBubblesApiUrl({
|
|
44
|
+
baseUrl,
|
|
45
|
+
path: params.path,
|
|
46
|
+
password,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const request: RequestInit = { method: params.method };
|
|
50
|
+
if (params.payload !== undefined) {
|
|
51
|
+
request.headers = { "Content-Type": "application/json" };
|
|
52
|
+
request.body = JSON.stringify(params.payload);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const errorText = await res.text().catch(() => "");
|
|
58
|
+
throw new Error(
|
|
59
|
+
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
export async function markBlueBubblesChatRead(
|
|
30
65
|
chatGuid: string,
|
|
31
66
|
opts: BlueBubblesChatOpts = {},
|
|
@@ -97,34 +132,18 @@ export async function editBlueBubblesMessage(
|
|
|
97
132
|
throw new Error("BlueBubbles edit requires newText");
|
|
98
133
|
}
|
|
99
134
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
135
|
+
await sendPrivateApiJsonRequest({
|
|
136
|
+
opts,
|
|
137
|
+
feature: "edit",
|
|
138
|
+
action: "edit",
|
|
139
|
+
method: "POST",
|
|
104
140
|
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
editedMessage: trimmedText,
|
|
110
|
-
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
|
|
111
|
-
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
115
|
-
url,
|
|
116
|
-
{
|
|
117
|
-
method: "POST",
|
|
118
|
-
headers: { "Content-Type": "application/json" },
|
|
119
|
-
body: JSON.stringify(payload),
|
|
141
|
+
payload: {
|
|
142
|
+
editedMessage: trimmedText,
|
|
143
|
+
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
|
|
144
|
+
partIndex: resolvePartIndex(opts.partIndex),
|
|
120
145
|
},
|
|
121
|
-
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
if (!res.ok) {
|
|
125
|
-
const errorText = await res.text().catch(() => "");
|
|
126
|
-
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
|
|
127
|
-
}
|
|
146
|
+
});
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
/**
|
|
@@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage(
|
|
|
140
159
|
throw new Error("BlueBubbles unsend requires messageGuid");
|
|
141
160
|
}
|
|
142
161
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
await sendPrivateApiJsonRequest({
|
|
163
|
+
opts,
|
|
164
|
+
feature: "unsend",
|
|
165
|
+
action: "unsend",
|
|
166
|
+
method: "POST",
|
|
147
167
|
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
|
|
148
|
-
|
|
168
|
+
payload: { partIndex: resolvePartIndex(opts.partIndex) },
|
|
149
169
|
});
|
|
150
|
-
|
|
151
|
-
const payload = {
|
|
152
|
-
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
156
|
-
url,
|
|
157
|
-
{
|
|
158
|
-
method: "POST",
|
|
159
|
-
headers: { "Content-Type": "application/json" },
|
|
160
|
-
body: JSON.stringify(payload),
|
|
161
|
-
},
|
|
162
|
-
opts.timeoutMs,
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
if (!res.ok) {
|
|
166
|
-
const errorText = await res.text().catch(() => "");
|
|
167
|
-
throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
|
|
168
|
-
}
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/**
|
|
@@ -181,28 +182,14 @@ export async function renameBlueBubblesChat(
|
|
|
181
182
|
throw new Error("BlueBubbles rename requires chatGuid");
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
await sendPrivateApiJsonRequest({
|
|
186
|
+
opts,
|
|
187
|
+
feature: "renameGroup",
|
|
188
|
+
action: "rename",
|
|
189
|
+
method: "PUT",
|
|
188
190
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
|
|
189
|
-
|
|
191
|
+
payload: { displayName },
|
|
190
192
|
});
|
|
191
|
-
|
|
192
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
193
|
-
url,
|
|
194
|
-
{
|
|
195
|
-
method: "PUT",
|
|
196
|
-
headers: { "Content-Type": "application/json" },
|
|
197
|
-
body: JSON.stringify({ displayName }),
|
|
198
|
-
},
|
|
199
|
-
opts.timeoutMs,
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
if (!res.ok) {
|
|
203
|
-
const errorText = await res.text().catch(() => "");
|
|
204
|
-
throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
|
|
205
|
-
}
|
|
206
193
|
}
|
|
207
194
|
|
|
208
195
|
/**
|
|
@@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant(
|
|
|
222
209
|
throw new Error("BlueBubbles addParticipant requires address");
|
|
223
210
|
}
|
|
224
211
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
212
|
+
await sendPrivateApiJsonRequest({
|
|
213
|
+
opts,
|
|
214
|
+
feature: "addParticipant",
|
|
215
|
+
action: "addParticipant",
|
|
216
|
+
method: "POST",
|
|
229
217
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
|
230
|
-
|
|
218
|
+
payload: { address: trimmedAddress },
|
|
231
219
|
});
|
|
232
|
-
|
|
233
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
234
|
-
url,
|
|
235
|
-
{
|
|
236
|
-
method: "POST",
|
|
237
|
-
headers: { "Content-Type": "application/json" },
|
|
238
|
-
body: JSON.stringify({ address: trimmedAddress }),
|
|
239
|
-
},
|
|
240
|
-
opts.timeoutMs,
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
if (!res.ok) {
|
|
244
|
-
const errorText = await res.text().catch(() => "");
|
|
245
|
-
throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
|
|
246
|
-
}
|
|
247
220
|
}
|
|
248
221
|
|
|
249
222
|
/**
|
|
@@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant(
|
|
|
263
236
|
throw new Error("BlueBubbles removeParticipant requires address");
|
|
264
237
|
}
|
|
265
238
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
239
|
+
await sendPrivateApiJsonRequest({
|
|
240
|
+
opts,
|
|
241
|
+
feature: "removeParticipant",
|
|
242
|
+
action: "removeParticipant",
|
|
243
|
+
method: "DELETE",
|
|
270
244
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
|
271
|
-
|
|
245
|
+
payload: { address: trimmedAddress },
|
|
272
246
|
});
|
|
273
|
-
|
|
274
|
-
const res = await blueBubblesFetchWithTimeout(
|
|
275
|
-
url,
|
|
276
|
-
{
|
|
277
|
-
method: "DELETE",
|
|
278
|
-
headers: { "Content-Type": "application/json" },
|
|
279
|
-
body: JSON.stringify({ address: trimmedAddress }),
|
|
280
|
-
},
|
|
281
|
-
opts.timeoutMs,
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
if (!res.ok) {
|
|
285
|
-
const errorText = await res.text().catch(() => "");
|
|
286
|
-
throw new Error(
|
|
287
|
-
`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`,
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
247
|
}
|
|
291
248
|
|
|
292
249
|
/**
|
|
@@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat(
|
|
|
301
258
|
throw new Error("BlueBubbles leaveChat requires chatGuid");
|
|
302
259
|
}
|
|
303
260
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
261
|
+
await sendPrivateApiJsonRequest({
|
|
262
|
+
opts,
|
|
263
|
+
feature: "leaveGroup",
|
|
264
|
+
action: "leaveChat",
|
|
265
|
+
method: "POST",
|
|
308
266
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
|
|
309
|
-
password,
|
|
310
267
|
});
|
|
311
|
-
|
|
312
|
-
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
|
|
313
|
-
|
|
314
|
-
if (!res.ok) {
|
|
315
|
-
const errorText = await res.text().catch(() => "");
|
|
316
|
-
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
|
|
317
|
-
}
|
|
318
268
|
}
|
|
319
269
|
|
|
320
270
|
/**
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
|
3
|
+
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type BlueBubblesHistoryEntry = {
|
|
6
|
+
sender: string;
|
|
7
|
+
body: string;
|
|
8
|
+
timestamp?: number;
|
|
9
|
+
messageId?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type BlueBubblesHistoryFetchResult = {
|
|
13
|
+
entries: BlueBubblesHistoryEntry[];
|
|
14
|
+
/**
|
|
15
|
+
* True when at least one API path returned a recognized response shape.
|
|
16
|
+
* False means all attempts failed or returned unusable data.
|
|
17
|
+
*/
|
|
18
|
+
resolved: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BlueBubblesMessageData = {
|
|
22
|
+
guid?: string;
|
|
23
|
+
text?: string;
|
|
24
|
+
handle_id?: string;
|
|
25
|
+
is_from_me?: boolean;
|
|
26
|
+
date_created?: number;
|
|
27
|
+
date_delivered?: number;
|
|
28
|
+
associated_message_guid?: string;
|
|
29
|
+
sender?: {
|
|
30
|
+
address?: string;
|
|
31
|
+
display_name?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type BlueBubblesChatOpts = {
|
|
36
|
+
serverUrl?: string;
|
|
37
|
+
password?: string;
|
|
38
|
+
accountId?: string;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
cfg?: OpenClawConfig;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function resolveAccount(params: BlueBubblesChatOpts) {
|
|
44
|
+
return resolveBlueBubblesServerAccount(params);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAX_HISTORY_FETCH_LIMIT = 100;
|
|
48
|
+
const HISTORY_SCAN_MULTIPLIER = 8;
|
|
49
|
+
const MAX_HISTORY_SCAN_MESSAGES = 500;
|
|
50
|
+
const MAX_HISTORY_BODY_CHARS = 2_000;
|
|
51
|
+
|
|
52
|
+
function clampHistoryLimit(limit: number): number {
|
|
53
|
+
if (!Number.isFinite(limit)) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const normalized = Math.floor(limit);
|
|
57
|
+
if (normalized <= 0) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncateHistoryBody(text: string): string {
|
|
64
|
+
if (text.length <= MAX_HISTORY_BODY_CHARS) {
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fetch message history from BlueBubbles API for a specific chat.
|
|
72
|
+
* This provides the initial backfill for both group chats and DMs.
|
|
73
|
+
*/
|
|
74
|
+
export async function fetchBlueBubblesHistory(
|
|
75
|
+
chatIdentifier: string,
|
|
76
|
+
limit: number,
|
|
77
|
+
opts: BlueBubblesChatOpts = {},
|
|
78
|
+
): Promise<BlueBubblesHistoryFetchResult> {
|
|
79
|
+
const effectiveLimit = clampHistoryLimit(limit);
|
|
80
|
+
if (!chatIdentifier.trim() || effectiveLimit <= 0) {
|
|
81
|
+
return { entries: [], resolved: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let baseUrl: string;
|
|
85
|
+
let password: string;
|
|
86
|
+
try {
|
|
87
|
+
({ baseUrl, password } = resolveAccount(opts));
|
|
88
|
+
} catch {
|
|
89
|
+
return { entries: [], resolved: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Try different common API patterns for fetching messages
|
|
93
|
+
const possiblePaths = [
|
|
94
|
+
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`,
|
|
95
|
+
`/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`,
|
|
96
|
+
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const path of possiblePaths) {
|
|
100
|
+
try {
|
|
101
|
+
const url = buildBlueBubblesApiUrl({ baseUrl, path, password });
|
|
102
|
+
const res = await blueBubblesFetchWithTimeout(
|
|
103
|
+
url,
|
|
104
|
+
{ method: "GET" },
|
|
105
|
+
opts.timeoutMs ?? 10000,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
continue; // Try next path
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const data = await res.json().catch(() => null);
|
|
113
|
+
if (!data) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Handle different response structures
|
|
118
|
+
let messages: unknown[] = [];
|
|
119
|
+
if (Array.isArray(data)) {
|
|
120
|
+
messages = data;
|
|
121
|
+
} else if (data.data && Array.isArray(data.data)) {
|
|
122
|
+
messages = data.data;
|
|
123
|
+
} else if (data.messages && Array.isArray(data.messages)) {
|
|
124
|
+
messages = data.messages;
|
|
125
|
+
} else {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const historyEntries: BlueBubblesHistoryEntry[] = [];
|
|
130
|
+
|
|
131
|
+
const maxScannedMessages = Math.min(
|
|
132
|
+
Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit),
|
|
133
|
+
MAX_HISTORY_SCAN_MESSAGES,
|
|
134
|
+
);
|
|
135
|
+
for (let i = 0; i < messages.length && i < maxScannedMessages; i++) {
|
|
136
|
+
const item = messages[i];
|
|
137
|
+
const msg = item as BlueBubblesMessageData;
|
|
138
|
+
|
|
139
|
+
// Skip messages without text content
|
|
140
|
+
const text = msg.text?.trim();
|
|
141
|
+
if (!text) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sender = msg.is_from_me
|
|
146
|
+
? "me"
|
|
147
|
+
: msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown";
|
|
148
|
+
const timestamp = msg.date_created || msg.date_delivered;
|
|
149
|
+
|
|
150
|
+
historyEntries.push({
|
|
151
|
+
sender,
|
|
152
|
+
body: truncateHistoryBody(text),
|
|
153
|
+
timestamp,
|
|
154
|
+
messageId: msg.guid,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sort by timestamp (oldest first for context)
|
|
159
|
+
historyEntries.sort((a, b) => {
|
|
160
|
+
const aTime = a.timestamp || 0;
|
|
161
|
+
const bTime = b.timestamp || 0;
|
|
162
|
+
return aTime - bTime;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit
|
|
167
|
+
resolved: true,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Continue to next path
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// If none of the API paths worked, return empty history
|
|
176
|
+
return { entries: [], resolved: false };
|
|
177
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeWebhookMessage", () => {
|
|
5
|
+
it("falls back to DM chatGuid handle when sender handle is missing", () => {
|
|
6
|
+
const result = normalizeWebhookMessage({
|
|
7
|
+
type: "new-message",
|
|
8
|
+
data: {
|
|
9
|
+
guid: "msg-1",
|
|
10
|
+
text: "hello",
|
|
11
|
+
isGroup: false,
|
|
12
|
+
isFromMe: false,
|
|
13
|
+
handle: null,
|
|
14
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(result).not.toBeNull();
|
|
19
|
+
expect(result?.senderId).toBe("+15551234567");
|
|
20
|
+
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not infer sender from group chatGuid when sender handle is missing", () => {
|
|
24
|
+
const result = normalizeWebhookMessage({
|
|
25
|
+
type: "new-message",
|
|
26
|
+
data: {
|
|
27
|
+
guid: "msg-1",
|
|
28
|
+
text: "hello group",
|
|
29
|
+
isGroup: true,
|
|
30
|
+
isFromMe: false,
|
|
31
|
+
handle: null,
|
|
32
|
+
chatGuid: "iMessage;+;chat123456",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts array-wrapped payload data", () => {
|
|
40
|
+
const result = normalizeWebhookMessage({
|
|
41
|
+
type: "new-message",
|
|
42
|
+
data: [
|
|
43
|
+
{
|
|
44
|
+
guid: "msg-1",
|
|
45
|
+
text: "hello",
|
|
46
|
+
handle: { address: "+15551234567" },
|
|
47
|
+
isGroup: false,
|
|
48
|
+
isFromMe: false,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).not.toBeNull();
|
|
54
|
+
expect(result?.senderId).toBe("+15551234567");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("normalizeWebhookReaction", () => {
|
|
59
|
+
it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
|
|
60
|
+
const result = normalizeWebhookReaction({
|
|
61
|
+
type: "updated-message",
|
|
62
|
+
data: {
|
|
63
|
+
guid: "msg-2",
|
|
64
|
+
associatedMessageGuid: "p:0/msg-1",
|
|
65
|
+
associatedMessageType: 2000,
|
|
66
|
+
isGroup: false,
|
|
67
|
+
isFromMe: false,
|
|
68
|
+
handle: null,
|
|
69
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).not.toBeNull();
|
|
74
|
+
expect(result?.senderId).toBe("+15551234567");
|
|
75
|
+
expect(result?.messageId).toBe("p:0/msg-1");
|
|
76
|
+
expect(result?.action).toBe("added");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/monitor-normalize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeBlueBubblesHandle } from "./targets.js";
|
|
1
|
+
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
|
2
2
|
import type { BlueBubblesAttachment } from "./types.js";
|
|
3
3
|
|
|
4
4
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -629,18 +629,42 @@ export function parseTapbackText(params: {
|
|
|
629
629
|
}
|
|
630
630
|
|
|
631
631
|
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
632
|
+
const parseRecord = (value: unknown): Record<string, unknown> | null => {
|
|
633
|
+
const record = asRecord(value);
|
|
634
|
+
if (record) {
|
|
635
|
+
return record;
|
|
636
|
+
}
|
|
637
|
+
if (Array.isArray(value)) {
|
|
638
|
+
for (const entry of value) {
|
|
639
|
+
const parsedEntry = parseRecord(entry);
|
|
640
|
+
if (parsedEntry) {
|
|
641
|
+
return parsedEntry;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
if (typeof value !== "string") {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const trimmed = value.trim();
|
|
650
|
+
if (!trimmed) {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
return parseRecord(JSON.parse(trimmed));
|
|
655
|
+
} catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
632
660
|
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
|
633
|
-
const data =
|
|
634
|
-
asRecord(dataRaw) ??
|
|
635
|
-
(typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
|
|
661
|
+
const data = parseRecord(dataRaw);
|
|
636
662
|
const messageRaw = payload.message ?? data?.message ?? data;
|
|
637
|
-
const message =
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (!message) {
|
|
641
|
-
return null;
|
|
663
|
+
const message = parseRecord(messageRaw);
|
|
664
|
+
if (message) {
|
|
665
|
+
return message;
|
|
642
666
|
}
|
|
643
|
-
return
|
|
667
|
+
return null;
|
|
644
668
|
}
|
|
645
669
|
|
|
646
670
|
export function normalizeWebhookMessage(
|
|
@@ -700,7 +724,10 @@ export function normalizeWebhookMessage(
|
|
|
700
724
|
: timestampRaw * 1000
|
|
701
725
|
: undefined;
|
|
702
726
|
|
|
703
|
-
|
|
727
|
+
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
|
|
728
|
+
const senderFallbackFromChatGuid =
|
|
729
|
+
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
|
730
|
+
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
|
704
731
|
if (!normalizedSender) {
|
|
705
732
|
return null;
|
|
706
733
|
}
|
|
@@ -774,7 +801,9 @@ export function normalizeWebhookReaction(
|
|
|
774
801
|
: timestampRaw * 1000
|
|
775
802
|
: undefined;
|
|
776
803
|
|
|
777
|
-
const
|
|
804
|
+
const senderFallbackFromChatGuid =
|
|
805
|
+
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
|
806
|
+
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
|
778
807
|
if (!normalizedSender) {
|
|
779
808
|
return null;
|
|
780
809
|
}
|