@openclaw/bluebubbles 2026.2.9 → 2026.2.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/monitor-normalize.ts +842 -0
- package/src/monitor-processing.ts +979 -0
- package/src/monitor-reply-cache.ts +185 -0
- package/src/monitor-shared.ts +51 -0
- package/src/monitor.test.ts +42 -30
- package/src/monitor.ts +25 -2029
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import type { BlueBubblesAttachment } from "./types.js";
|
|
2
|
+
import { normalizeBlueBubblesHandle } from "./targets.js";
|
|
3
|
+
|
|
4
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
5
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
6
|
+
? (value as Record<string, unknown>)
|
|
7
|
+
: null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
|
|
11
|
+
if (!record) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const value = record[key];
|
|
15
|
+
return typeof value === "string" ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
|
|
19
|
+
if (!record) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const value = record[key];
|
|
23
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
|
|
27
|
+
if (!record) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const value = record[key];
|
|
31
|
+
return typeof value === "boolean" ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
|
35
|
+
if (!record) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const value = record[key];
|
|
39
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "string") {
|
|
43
|
+
const parsed = Number.parseFloat(value);
|
|
44
|
+
if (Number.isFinite(parsed)) {
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
|
52
|
+
const raw = message["attachments"];
|
|
53
|
+
if (!Array.isArray(raw)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const out: BlueBubblesAttachment[] = [];
|
|
57
|
+
for (const entry of raw) {
|
|
58
|
+
const record = asRecord(entry);
|
|
59
|
+
if (!record) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
out.push({
|
|
63
|
+
guid: readString(record, "guid"),
|
|
64
|
+
uti: readString(record, "uti"),
|
|
65
|
+
mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
|
|
66
|
+
transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
|
|
67
|
+
totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
|
|
68
|
+
height: readNumberLike(record, "height"),
|
|
69
|
+
width: readNumberLike(record, "width"),
|
|
70
|
+
originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
|
|
77
|
+
if (attachments.length === 0) {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
|
|
81
|
+
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
|
|
82
|
+
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
|
|
83
|
+
const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
|
|
84
|
+
const tag = allImages
|
|
85
|
+
? "<media:image>"
|
|
86
|
+
: allVideos
|
|
87
|
+
? "<media:video>"
|
|
88
|
+
: allAudio
|
|
89
|
+
? "<media:audio>"
|
|
90
|
+
: "<media:attachment>";
|
|
91
|
+
const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
|
|
92
|
+
const suffix = attachments.length === 1 ? label : `${label}s`;
|
|
93
|
+
return `${tag} (${attachments.length} ${suffix})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|
97
|
+
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
|
|
98
|
+
if (attachmentPlaceholder) {
|
|
99
|
+
return attachmentPlaceholder;
|
|
100
|
+
}
|
|
101
|
+
if (message.balloonBundleId) {
|
|
102
|
+
return "<media:sticker>";
|
|
103
|
+
}
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
|
108
|
+
export function formatReplyTag(message: {
|
|
109
|
+
replyToId?: string;
|
|
110
|
+
replyToShortId?: string;
|
|
111
|
+
}): string | null {
|
|
112
|
+
// Prefer short ID
|
|
113
|
+
const rawId = message.replyToShortId || message.replyToId;
|
|
114
|
+
if (!rawId) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return `[[reply_to:${rawId}]]`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractReplyMetadata(message: Record<string, unknown>): {
|
|
121
|
+
replyToId?: string;
|
|
122
|
+
replyToBody?: string;
|
|
123
|
+
replyToSender?: string;
|
|
124
|
+
} {
|
|
125
|
+
const replyRaw =
|
|
126
|
+
message["replyTo"] ??
|
|
127
|
+
message["reply_to"] ??
|
|
128
|
+
message["replyToMessage"] ??
|
|
129
|
+
message["reply_to_message"] ??
|
|
130
|
+
message["repliedMessage"] ??
|
|
131
|
+
message["quotedMessage"] ??
|
|
132
|
+
message["associatedMessage"] ??
|
|
133
|
+
message["reply"];
|
|
134
|
+
const replyRecord = asRecord(replyRaw);
|
|
135
|
+
const replyHandle =
|
|
136
|
+
asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
|
|
137
|
+
const replySenderRaw =
|
|
138
|
+
readString(replyHandle, "address") ??
|
|
139
|
+
readString(replyHandle, "handle") ??
|
|
140
|
+
readString(replyHandle, "id") ??
|
|
141
|
+
readString(replyRecord, "senderId") ??
|
|
142
|
+
readString(replyRecord, "sender") ??
|
|
143
|
+
readString(replyRecord, "from");
|
|
144
|
+
const normalizedSender = replySenderRaw
|
|
145
|
+
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
148
|
+
const replyToBody =
|
|
149
|
+
readString(replyRecord, "text") ??
|
|
150
|
+
readString(replyRecord, "body") ??
|
|
151
|
+
readString(replyRecord, "message") ??
|
|
152
|
+
readString(replyRecord, "subject") ??
|
|
153
|
+
undefined;
|
|
154
|
+
|
|
155
|
+
const directReplyId =
|
|
156
|
+
readString(message, "replyToMessageGuid") ??
|
|
157
|
+
readString(message, "replyToGuid") ??
|
|
158
|
+
readString(message, "replyGuid") ??
|
|
159
|
+
readString(message, "selectedMessageGuid") ??
|
|
160
|
+
readString(message, "selectedMessageId") ??
|
|
161
|
+
readString(message, "replyToMessageId") ??
|
|
162
|
+
readString(message, "replyId") ??
|
|
163
|
+
readString(replyRecord, "guid") ??
|
|
164
|
+
readString(replyRecord, "id") ??
|
|
165
|
+
readString(replyRecord, "messageId");
|
|
166
|
+
|
|
167
|
+
const associatedType =
|
|
168
|
+
readNumberLike(message, "associatedMessageType") ??
|
|
169
|
+
readNumberLike(message, "associated_message_type");
|
|
170
|
+
const associatedGuid =
|
|
171
|
+
readString(message, "associatedMessageGuid") ??
|
|
172
|
+
readString(message, "associated_message_guid") ??
|
|
173
|
+
readString(message, "associatedMessageId");
|
|
174
|
+
const isReactionAssociation =
|
|
175
|
+
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
|
176
|
+
|
|
177
|
+
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
|
|
178
|
+
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
|
|
179
|
+
const messageGuid = readString(message, "guid");
|
|
180
|
+
const fallbackReplyId =
|
|
181
|
+
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
|
|
182
|
+
? threadOriginatorGuid
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
|
|
187
|
+
replyToBody: replyToBody?.trim() || undefined,
|
|
188
|
+
replyToSender: normalizedSender || undefined,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
|
193
|
+
const chats = message["chats"];
|
|
194
|
+
if (!Array.isArray(chats) || chats.length === 0) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const first = chats[0];
|
|
198
|
+
return asRecord(first);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
|
202
|
+
if (typeof entry === "string" || typeof entry === "number") {
|
|
203
|
+
const raw = String(entry).trim();
|
|
204
|
+
if (!raw) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
|
208
|
+
return normalized ? { id: normalized } : null;
|
|
209
|
+
}
|
|
210
|
+
const record = asRecord(entry);
|
|
211
|
+
if (!record) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const nestedHandle =
|
|
215
|
+
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
|
216
|
+
const idRaw =
|
|
217
|
+
readString(record, "address") ??
|
|
218
|
+
readString(record, "handle") ??
|
|
219
|
+
readString(record, "id") ??
|
|
220
|
+
readString(record, "phoneNumber") ??
|
|
221
|
+
readString(record, "phone_number") ??
|
|
222
|
+
readString(record, "email") ??
|
|
223
|
+
readString(nestedHandle, "address") ??
|
|
224
|
+
readString(nestedHandle, "handle") ??
|
|
225
|
+
readString(nestedHandle, "id");
|
|
226
|
+
const nameRaw =
|
|
227
|
+
readString(record, "displayName") ??
|
|
228
|
+
readString(record, "name") ??
|
|
229
|
+
readString(record, "title") ??
|
|
230
|
+
readString(nestedHandle, "displayName") ??
|
|
231
|
+
readString(nestedHandle, "name");
|
|
232
|
+
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
|
233
|
+
if (!normalizedId) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const name = nameRaw?.trim() || undefined;
|
|
237
|
+
return { id: normalizedId, name };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
|
241
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const seen = new Set<string>();
|
|
245
|
+
const output: BlueBubblesParticipant[] = [];
|
|
246
|
+
for (const entry of raw) {
|
|
247
|
+
const normalized = normalizeParticipantEntry(entry);
|
|
248
|
+
if (!normalized?.id) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const key = normalized.id.toLowerCase();
|
|
252
|
+
if (seen.has(key)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
seen.add(key);
|
|
256
|
+
output.push(normalized);
|
|
257
|
+
}
|
|
258
|
+
return output;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function formatGroupMembers(params: {
|
|
262
|
+
participants?: BlueBubblesParticipant[];
|
|
263
|
+
fallback?: BlueBubblesParticipant;
|
|
264
|
+
}): string | undefined {
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
const ordered: BlueBubblesParticipant[] = [];
|
|
267
|
+
for (const entry of params.participants ?? []) {
|
|
268
|
+
if (!entry?.id) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const key = entry.id.toLowerCase();
|
|
272
|
+
if (seen.has(key)) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
seen.add(key);
|
|
276
|
+
ordered.push(entry);
|
|
277
|
+
}
|
|
278
|
+
if (ordered.length === 0 && params.fallback?.id) {
|
|
279
|
+
ordered.push(params.fallback);
|
|
280
|
+
}
|
|
281
|
+
if (ordered.length === 0) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
|
288
|
+
const guid = chatGuid?.trim();
|
|
289
|
+
if (!guid) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const parts = guid.split(";");
|
|
293
|
+
if (parts.length >= 3) {
|
|
294
|
+
if (parts[1] === "+") {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
if (parts[1] === "-") {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (guid.includes(";+;")) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (guid.includes(";-;")) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
|
311
|
+
const guid = chatGuid?.trim();
|
|
312
|
+
if (!guid) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
const parts = guid.split(";");
|
|
316
|
+
if (parts.length < 3) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
const identifier = parts[2]?.trim();
|
|
320
|
+
return identifier || undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function formatGroupAllowlistEntry(params: {
|
|
324
|
+
chatGuid?: string;
|
|
325
|
+
chatId?: number;
|
|
326
|
+
chatIdentifier?: string;
|
|
327
|
+
}): string | null {
|
|
328
|
+
const guid = params.chatGuid?.trim();
|
|
329
|
+
if (guid) {
|
|
330
|
+
return `chat_guid:${guid}`;
|
|
331
|
+
}
|
|
332
|
+
const chatId = params.chatId;
|
|
333
|
+
if (typeof chatId === "number" && Number.isFinite(chatId)) {
|
|
334
|
+
return `chat_id:${chatId}`;
|
|
335
|
+
}
|
|
336
|
+
const identifier = params.chatIdentifier?.trim();
|
|
337
|
+
if (identifier) {
|
|
338
|
+
return `chat_identifier:${identifier}`;
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export type BlueBubblesParticipant = {
|
|
344
|
+
id: string;
|
|
345
|
+
name?: string;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export type NormalizedWebhookMessage = {
|
|
349
|
+
text: string;
|
|
350
|
+
senderId: string;
|
|
351
|
+
senderName?: string;
|
|
352
|
+
messageId?: string;
|
|
353
|
+
timestamp?: number;
|
|
354
|
+
isGroup: boolean;
|
|
355
|
+
chatId?: number;
|
|
356
|
+
chatGuid?: string;
|
|
357
|
+
chatIdentifier?: string;
|
|
358
|
+
chatName?: string;
|
|
359
|
+
fromMe?: boolean;
|
|
360
|
+
attachments?: BlueBubblesAttachment[];
|
|
361
|
+
balloonBundleId?: string;
|
|
362
|
+
associatedMessageGuid?: string;
|
|
363
|
+
associatedMessageType?: number;
|
|
364
|
+
associatedMessageEmoji?: string;
|
|
365
|
+
isTapback?: boolean;
|
|
366
|
+
participants?: BlueBubblesParticipant[];
|
|
367
|
+
replyToId?: string;
|
|
368
|
+
replyToBody?: string;
|
|
369
|
+
replyToSender?: string;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export type NormalizedWebhookReaction = {
|
|
373
|
+
action: "added" | "removed";
|
|
374
|
+
emoji: string;
|
|
375
|
+
senderId: string;
|
|
376
|
+
senderName?: string;
|
|
377
|
+
messageId: string;
|
|
378
|
+
timestamp?: number;
|
|
379
|
+
isGroup: boolean;
|
|
380
|
+
chatId?: number;
|
|
381
|
+
chatGuid?: string;
|
|
382
|
+
chatIdentifier?: string;
|
|
383
|
+
chatName?: string;
|
|
384
|
+
fromMe?: boolean;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([
|
|
388
|
+
[2000, { emoji: "❤️", action: "added" }],
|
|
389
|
+
[2001, { emoji: "👍", action: "added" }],
|
|
390
|
+
[2002, { emoji: "👎", action: "added" }],
|
|
391
|
+
[2003, { emoji: "😂", action: "added" }],
|
|
392
|
+
[2004, { emoji: "‼️", action: "added" }],
|
|
393
|
+
[2005, { emoji: "❓", action: "added" }],
|
|
394
|
+
[3000, { emoji: "❤️", action: "removed" }],
|
|
395
|
+
[3001, { emoji: "👍", action: "removed" }],
|
|
396
|
+
[3002, { emoji: "👎", action: "removed" }],
|
|
397
|
+
[3003, { emoji: "😂", action: "removed" }],
|
|
398
|
+
[3004, { emoji: "‼️", action: "removed" }],
|
|
399
|
+
[3005, { emoji: "❓", action: "removed" }],
|
|
400
|
+
]);
|
|
401
|
+
|
|
402
|
+
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
|
|
403
|
+
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
|
|
404
|
+
["loved", { emoji: "❤️", action: "added" }],
|
|
405
|
+
["liked", { emoji: "👍", action: "added" }],
|
|
406
|
+
["disliked", { emoji: "👎", action: "added" }],
|
|
407
|
+
["laughed at", { emoji: "😂", action: "added" }],
|
|
408
|
+
["emphasized", { emoji: "‼️", action: "added" }],
|
|
409
|
+
["questioned", { emoji: "❓", action: "added" }],
|
|
410
|
+
// Removal patterns (e.g., "Removed a heart from")
|
|
411
|
+
["removed a heart from", { emoji: "❤️", action: "removed" }],
|
|
412
|
+
["removed a like from", { emoji: "👍", action: "removed" }],
|
|
413
|
+
["removed a dislike from", { emoji: "👎", action: "removed" }],
|
|
414
|
+
["removed a laugh from", { emoji: "😂", action: "removed" }],
|
|
415
|
+
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
|
|
416
|
+
["removed a question from", { emoji: "❓", action: "removed" }],
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
const TAPBACK_EMOJI_REGEX =
|
|
420
|
+
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
|
421
|
+
|
|
422
|
+
function extractFirstEmoji(text: string): string | null {
|
|
423
|
+
const match = text.match(TAPBACK_EMOJI_REGEX);
|
|
424
|
+
return match ? match[0] : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function extractQuotedTapbackText(text: string): string | null {
|
|
428
|
+
const match = text.match(/[“"]([^”"]+)[”"]/s);
|
|
429
|
+
return match ? match[1] : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isTapbackAssociatedType(type: number | undefined): boolean {
|
|
433
|
+
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
|
437
|
+
if (typeof type !== "number" || !Number.isFinite(type)) {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
if (type >= 3000 && type < 4000) {
|
|
441
|
+
return "removed";
|
|
442
|
+
}
|
|
443
|
+
if (type >= 2000 && type < 3000) {
|
|
444
|
+
return "added";
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
|
450
|
+
emojiHint?: string;
|
|
451
|
+
actionHint?: "added" | "removed";
|
|
452
|
+
replyToId?: string;
|
|
453
|
+
} | null {
|
|
454
|
+
const associatedType = message.associatedMessageType;
|
|
455
|
+
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
|
456
|
+
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
|
457
|
+
if (!hasTapbackType && !hasTapbackMarker) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
|
461
|
+
const actionHint = resolveTapbackActionHint(associatedType);
|
|
462
|
+
const emojiHint =
|
|
463
|
+
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
|
|
464
|
+
return { emojiHint, actionHint, replyToId };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
|
|
468
|
+
export function parseTapbackText(params: {
|
|
469
|
+
text: string;
|
|
470
|
+
emojiHint?: string;
|
|
471
|
+
actionHint?: "added" | "removed";
|
|
472
|
+
requireQuoted?: boolean;
|
|
473
|
+
}): {
|
|
474
|
+
emoji: string;
|
|
475
|
+
action: "added" | "removed";
|
|
476
|
+
quotedText: string;
|
|
477
|
+
} | null {
|
|
478
|
+
const trimmed = params.text.trim();
|
|
479
|
+
const lower = trimmed.toLowerCase();
|
|
480
|
+
if (!trimmed) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
|
485
|
+
if (lower.startsWith(pattern)) {
|
|
486
|
+
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
|
487
|
+
const afterPattern = trimmed.slice(pattern.length).trim();
|
|
488
|
+
if (params.requireQuoted) {
|
|
489
|
+
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
|
490
|
+
if (!strictMatch) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
return { emoji, action, quotedText: strictMatch[1] };
|
|
494
|
+
}
|
|
495
|
+
const quotedText =
|
|
496
|
+
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
|
|
497
|
+
return { emoji, action, quotedText };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (lower.startsWith("reacted")) {
|
|
502
|
+
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
|
503
|
+
if (!emoji) {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const quotedText = extractQuotedTapbackText(trimmed);
|
|
507
|
+
if (params.requireQuoted && !quotedText) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const fallback = trimmed.slice("reacted".length).trim();
|
|
511
|
+
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (lower.startsWith("removed")) {
|
|
515
|
+
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
|
516
|
+
if (!emoji) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
const quotedText = extractQuotedTapbackText(trimmed);
|
|
520
|
+
if (params.requireQuoted && !quotedText) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
const fallback = trimmed.slice("removed".length).trim();
|
|
524
|
+
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
530
|
+
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
|
531
|
+
const data =
|
|
532
|
+
asRecord(dataRaw) ??
|
|
533
|
+
(typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
|
|
534
|
+
const messageRaw = payload.message ?? data?.message ?? data;
|
|
535
|
+
const message =
|
|
536
|
+
asRecord(messageRaw) ??
|
|
537
|
+
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
|
|
538
|
+
if (!message) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
return message;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function normalizeWebhookMessage(
|
|
545
|
+
payload: Record<string, unknown>,
|
|
546
|
+
): NormalizedWebhookMessage | null {
|
|
547
|
+
const message = extractMessagePayload(payload);
|
|
548
|
+
if (!message) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const text =
|
|
553
|
+
readString(message, "text") ??
|
|
554
|
+
readString(message, "body") ??
|
|
555
|
+
readString(message, "subject") ??
|
|
556
|
+
"";
|
|
557
|
+
|
|
558
|
+
const handleValue = message.handle ?? message.sender;
|
|
559
|
+
const handle =
|
|
560
|
+
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
|
561
|
+
const senderId =
|
|
562
|
+
readString(handle, "address") ??
|
|
563
|
+
readString(handle, "handle") ??
|
|
564
|
+
readString(handle, "id") ??
|
|
565
|
+
readString(message, "senderId") ??
|
|
566
|
+
readString(message, "sender") ??
|
|
567
|
+
readString(message, "from") ??
|
|
568
|
+
"";
|
|
569
|
+
|
|
570
|
+
const senderName =
|
|
571
|
+
readString(handle, "displayName") ??
|
|
572
|
+
readString(handle, "name") ??
|
|
573
|
+
readString(message, "senderName") ??
|
|
574
|
+
undefined;
|
|
575
|
+
|
|
576
|
+
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
|
|
577
|
+
const chatFromList = readFirstChatRecord(message);
|
|
578
|
+
const chatGuid =
|
|
579
|
+
readString(message, "chatGuid") ??
|
|
580
|
+
readString(message, "chat_guid") ??
|
|
581
|
+
readString(chat, "chatGuid") ??
|
|
582
|
+
readString(chat, "chat_guid") ??
|
|
583
|
+
readString(chat, "guid") ??
|
|
584
|
+
readString(chatFromList, "chatGuid") ??
|
|
585
|
+
readString(chatFromList, "chat_guid") ??
|
|
586
|
+
readString(chatFromList, "guid");
|
|
587
|
+
const chatIdentifier =
|
|
588
|
+
readString(message, "chatIdentifier") ??
|
|
589
|
+
readString(message, "chat_identifier") ??
|
|
590
|
+
readString(chat, "chatIdentifier") ??
|
|
591
|
+
readString(chat, "chat_identifier") ??
|
|
592
|
+
readString(chat, "identifier") ??
|
|
593
|
+
readString(chatFromList, "chatIdentifier") ??
|
|
594
|
+
readString(chatFromList, "chat_identifier") ??
|
|
595
|
+
readString(chatFromList, "identifier") ??
|
|
596
|
+
extractChatIdentifierFromChatGuid(chatGuid);
|
|
597
|
+
const chatId =
|
|
598
|
+
readNumberLike(message, "chatId") ??
|
|
599
|
+
readNumberLike(message, "chat_id") ??
|
|
600
|
+
readNumberLike(chat, "chatId") ??
|
|
601
|
+
readNumberLike(chat, "chat_id") ??
|
|
602
|
+
readNumberLike(chat, "id") ??
|
|
603
|
+
readNumberLike(chatFromList, "chatId") ??
|
|
604
|
+
readNumberLike(chatFromList, "chat_id") ??
|
|
605
|
+
readNumberLike(chatFromList, "id");
|
|
606
|
+
const chatName =
|
|
607
|
+
readString(message, "chatName") ??
|
|
608
|
+
readString(chat, "displayName") ??
|
|
609
|
+
readString(chat, "name") ??
|
|
610
|
+
readString(chatFromList, "displayName") ??
|
|
611
|
+
readString(chatFromList, "name") ??
|
|
612
|
+
undefined;
|
|
613
|
+
|
|
614
|
+
const chatParticipants = chat ? chat["participants"] : undefined;
|
|
615
|
+
const messageParticipants = message["participants"];
|
|
616
|
+
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
|
617
|
+
const participants = Array.isArray(chatParticipants)
|
|
618
|
+
? chatParticipants
|
|
619
|
+
: Array.isArray(messageParticipants)
|
|
620
|
+
? messageParticipants
|
|
621
|
+
: Array.isArray(chatsParticipants)
|
|
622
|
+
? chatsParticipants
|
|
623
|
+
: [];
|
|
624
|
+
const normalizedParticipants = normalizeParticipantList(participants);
|
|
625
|
+
const participantsCount = participants.length;
|
|
626
|
+
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
|
627
|
+
const explicitIsGroup =
|
|
628
|
+
readBoolean(message, "isGroup") ??
|
|
629
|
+
readBoolean(message, "is_group") ??
|
|
630
|
+
readBoolean(chat, "isGroup") ??
|
|
631
|
+
readBoolean(message, "group");
|
|
632
|
+
const isGroup =
|
|
633
|
+
typeof groupFromChatGuid === "boolean"
|
|
634
|
+
? groupFromChatGuid
|
|
635
|
+
: (explicitIsGroup ?? participantsCount > 2);
|
|
636
|
+
|
|
637
|
+
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
|
638
|
+
const messageId =
|
|
639
|
+
readString(message, "guid") ??
|
|
640
|
+
readString(message, "id") ??
|
|
641
|
+
readString(message, "messageId") ??
|
|
642
|
+
undefined;
|
|
643
|
+
const balloonBundleId = readString(message, "balloonBundleId");
|
|
644
|
+
const associatedMessageGuid =
|
|
645
|
+
readString(message, "associatedMessageGuid") ??
|
|
646
|
+
readString(message, "associated_message_guid") ??
|
|
647
|
+
readString(message, "associatedMessageId") ??
|
|
648
|
+
undefined;
|
|
649
|
+
const associatedMessageType =
|
|
650
|
+
readNumberLike(message, "associatedMessageType") ??
|
|
651
|
+
readNumberLike(message, "associated_message_type");
|
|
652
|
+
const associatedMessageEmoji =
|
|
653
|
+
readString(message, "associatedMessageEmoji") ??
|
|
654
|
+
readString(message, "associated_message_emoji") ??
|
|
655
|
+
readString(message, "reactionEmoji") ??
|
|
656
|
+
readString(message, "reaction_emoji") ??
|
|
657
|
+
undefined;
|
|
658
|
+
const isTapback =
|
|
659
|
+
readBoolean(message, "isTapback") ??
|
|
660
|
+
readBoolean(message, "is_tapback") ??
|
|
661
|
+
readBoolean(message, "tapback") ??
|
|
662
|
+
undefined;
|
|
663
|
+
|
|
664
|
+
const timestampRaw =
|
|
665
|
+
readNumber(message, "date") ??
|
|
666
|
+
readNumber(message, "dateCreated") ??
|
|
667
|
+
readNumber(message, "timestamp");
|
|
668
|
+
const timestamp =
|
|
669
|
+
typeof timestampRaw === "number"
|
|
670
|
+
? timestampRaw > 1_000_000_000_000
|
|
671
|
+
? timestampRaw
|
|
672
|
+
: timestampRaw * 1000
|
|
673
|
+
: undefined;
|
|
674
|
+
|
|
675
|
+
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
|
676
|
+
if (!normalizedSender) {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
const replyMetadata = extractReplyMetadata(message);
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
text,
|
|
683
|
+
senderId: normalizedSender,
|
|
684
|
+
senderName,
|
|
685
|
+
messageId,
|
|
686
|
+
timestamp,
|
|
687
|
+
isGroup,
|
|
688
|
+
chatId,
|
|
689
|
+
chatGuid,
|
|
690
|
+
chatIdentifier,
|
|
691
|
+
chatName,
|
|
692
|
+
fromMe,
|
|
693
|
+
attachments: extractAttachments(message),
|
|
694
|
+
balloonBundleId,
|
|
695
|
+
associatedMessageGuid,
|
|
696
|
+
associatedMessageType,
|
|
697
|
+
associatedMessageEmoji,
|
|
698
|
+
isTapback,
|
|
699
|
+
participants: normalizedParticipants,
|
|
700
|
+
replyToId: replyMetadata.replyToId,
|
|
701
|
+
replyToBody: replyMetadata.replyToBody,
|
|
702
|
+
replyToSender: replyMetadata.replyToSender,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function normalizeWebhookReaction(
|
|
707
|
+
payload: Record<string, unknown>,
|
|
708
|
+
): NormalizedWebhookReaction | null {
|
|
709
|
+
const message = extractMessagePayload(payload);
|
|
710
|
+
if (!message) {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const associatedGuid =
|
|
715
|
+
readString(message, "associatedMessageGuid") ??
|
|
716
|
+
readString(message, "associated_message_guid") ??
|
|
717
|
+
readString(message, "associatedMessageId");
|
|
718
|
+
const associatedType =
|
|
719
|
+
readNumberLike(message, "associatedMessageType") ??
|
|
720
|
+
readNumberLike(message, "associated_message_type");
|
|
721
|
+
if (!associatedGuid || associatedType === undefined) {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
|
726
|
+
const associatedEmoji =
|
|
727
|
+
readString(message, "associatedMessageEmoji") ??
|
|
728
|
+
readString(message, "associated_message_emoji") ??
|
|
729
|
+
readString(message, "reactionEmoji") ??
|
|
730
|
+
readString(message, "reaction_emoji");
|
|
731
|
+
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
|
732
|
+
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
|
733
|
+
|
|
734
|
+
const handleValue = message.handle ?? message.sender;
|
|
735
|
+
const handle =
|
|
736
|
+
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
|
737
|
+
const senderId =
|
|
738
|
+
readString(handle, "address") ??
|
|
739
|
+
readString(handle, "handle") ??
|
|
740
|
+
readString(handle, "id") ??
|
|
741
|
+
readString(message, "senderId") ??
|
|
742
|
+
readString(message, "sender") ??
|
|
743
|
+
readString(message, "from") ??
|
|
744
|
+
"";
|
|
745
|
+
const senderName =
|
|
746
|
+
readString(handle, "displayName") ??
|
|
747
|
+
readString(handle, "name") ??
|
|
748
|
+
readString(message, "senderName") ??
|
|
749
|
+
undefined;
|
|
750
|
+
|
|
751
|
+
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
|
|
752
|
+
const chatFromList = readFirstChatRecord(message);
|
|
753
|
+
const chatGuid =
|
|
754
|
+
readString(message, "chatGuid") ??
|
|
755
|
+
readString(message, "chat_guid") ??
|
|
756
|
+
readString(chat, "chatGuid") ??
|
|
757
|
+
readString(chat, "chat_guid") ??
|
|
758
|
+
readString(chat, "guid") ??
|
|
759
|
+
readString(chatFromList, "chatGuid") ??
|
|
760
|
+
readString(chatFromList, "chat_guid") ??
|
|
761
|
+
readString(chatFromList, "guid");
|
|
762
|
+
const chatIdentifier =
|
|
763
|
+
readString(message, "chatIdentifier") ??
|
|
764
|
+
readString(message, "chat_identifier") ??
|
|
765
|
+
readString(chat, "chatIdentifier") ??
|
|
766
|
+
readString(chat, "chat_identifier") ??
|
|
767
|
+
readString(chat, "identifier") ??
|
|
768
|
+
readString(chatFromList, "chatIdentifier") ??
|
|
769
|
+
readString(chatFromList, "chat_identifier") ??
|
|
770
|
+
readString(chatFromList, "identifier") ??
|
|
771
|
+
extractChatIdentifierFromChatGuid(chatGuid);
|
|
772
|
+
const chatId =
|
|
773
|
+
readNumberLike(message, "chatId") ??
|
|
774
|
+
readNumberLike(message, "chat_id") ??
|
|
775
|
+
readNumberLike(chat, "chatId") ??
|
|
776
|
+
readNumberLike(chat, "chat_id") ??
|
|
777
|
+
readNumberLike(chat, "id") ??
|
|
778
|
+
readNumberLike(chatFromList, "chatId") ??
|
|
779
|
+
readNumberLike(chatFromList, "chat_id") ??
|
|
780
|
+
readNumberLike(chatFromList, "id");
|
|
781
|
+
const chatName =
|
|
782
|
+
readString(message, "chatName") ??
|
|
783
|
+
readString(chat, "displayName") ??
|
|
784
|
+
readString(chat, "name") ??
|
|
785
|
+
readString(chatFromList, "displayName") ??
|
|
786
|
+
readString(chatFromList, "name") ??
|
|
787
|
+
undefined;
|
|
788
|
+
|
|
789
|
+
const chatParticipants = chat ? chat["participants"] : undefined;
|
|
790
|
+
const messageParticipants = message["participants"];
|
|
791
|
+
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
|
792
|
+
const participants = Array.isArray(chatParticipants)
|
|
793
|
+
? chatParticipants
|
|
794
|
+
: Array.isArray(messageParticipants)
|
|
795
|
+
? messageParticipants
|
|
796
|
+
: Array.isArray(chatsParticipants)
|
|
797
|
+
? chatsParticipants
|
|
798
|
+
: [];
|
|
799
|
+
const participantsCount = participants.length;
|
|
800
|
+
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
|
801
|
+
const explicitIsGroup =
|
|
802
|
+
readBoolean(message, "isGroup") ??
|
|
803
|
+
readBoolean(message, "is_group") ??
|
|
804
|
+
readBoolean(chat, "isGroup") ??
|
|
805
|
+
readBoolean(message, "group");
|
|
806
|
+
const isGroup =
|
|
807
|
+
typeof groupFromChatGuid === "boolean"
|
|
808
|
+
? groupFromChatGuid
|
|
809
|
+
: (explicitIsGroup ?? participantsCount > 2);
|
|
810
|
+
|
|
811
|
+
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
|
812
|
+
const timestampRaw =
|
|
813
|
+
readNumberLike(message, "date") ??
|
|
814
|
+
readNumberLike(message, "dateCreated") ??
|
|
815
|
+
readNumberLike(message, "timestamp");
|
|
816
|
+
const timestamp =
|
|
817
|
+
typeof timestampRaw === "number"
|
|
818
|
+
? timestampRaw > 1_000_000_000_000
|
|
819
|
+
? timestampRaw
|
|
820
|
+
: timestampRaw * 1000
|
|
821
|
+
: undefined;
|
|
822
|
+
|
|
823
|
+
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
|
824
|
+
if (!normalizedSender) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
action,
|
|
830
|
+
emoji,
|
|
831
|
+
senderId: normalizedSender,
|
|
832
|
+
senderName,
|
|
833
|
+
messageId: associatedGuid,
|
|
834
|
+
timestamp,
|
|
835
|
+
isGroup,
|
|
836
|
+
chatId,
|
|
837
|
+
chatGuid,
|
|
838
|
+
chatIdentifier,
|
|
839
|
+
chatName,
|
|
840
|
+
fromMe,
|
|
841
|
+
};
|
|
842
|
+
}
|