@spectrum-ts/imessage 12.4.0 → 12.6.0
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/dist/index.js +381 -57
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ErrorCode, NotFoundError, ValidationError, createGrpcClient } from "@photon-ai/advanced-imessage/grpc";
|
|
2
2
|
import { sanitizePhone, withSpan } from "@photon-ai/otel";
|
|
3
3
|
import { UnsupportedError, appLayoutSchema, cloud, definePlatform, fromVCard, mergeStreams, read, text, toVCard } from "@spectrum-ts/core";
|
|
4
|
-
import { addMemberSchema, asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asReply, asText, asVoice, avatarSchema, buildPhotoAction, createLogger, createTokenRenewal, ensureM4a, errorAttrs, groupSchema, leaveSpaceSchema, messageEffectSchema, photoActionSchema, reactionSchema, removeMemberSchema, renameSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
|
|
4
|
+
import { addMemberSchema, asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asReply, asText, asVoice, avatarSchema, buildPhotoAction, createLogger, createStreamGroup, createTokenRenewal, ensureM4a, errorAttrs, groupSchema, leaveSpaceSchema, messageEffectSchema, photoActionSchema, reactionSchema, readSchema, removeMemberSchema, renameSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
|
|
5
5
|
import z from "zod";
|
|
6
6
|
import { Marked } from "marked";
|
|
7
7
|
import { LRUCache } from "lru-cache";
|
|
@@ -179,6 +179,73 @@ function effect(input, messageEffect) {
|
|
|
179
179
|
} };
|
|
180
180
|
}
|
|
181
181
|
//#endregion
|
|
182
|
+
//#region src/lines.ts
|
|
183
|
+
const linesLog = createLogger("spectrum.imessage.lines");
|
|
184
|
+
const observers = /* @__PURE__ */ new WeakMap();
|
|
185
|
+
const lineIds = /* @__PURE__ */ new WeakMap();
|
|
186
|
+
let fallbackKeys = 0;
|
|
187
|
+
/** Pairs an entry with the cloud instance it was built from. */
|
|
188
|
+
const setLineId = (entry, instanceId) => {
|
|
189
|
+
lineIds.set(entry, instanceId);
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Stable per-entry key. Falls back to a generated id for explicitly-configured
|
|
193
|
+
* clients, which carry no instance id and may legitimately repeat a phone
|
|
194
|
+
* number — phone alone would collide.
|
|
195
|
+
*/
|
|
196
|
+
const lineKey = (entry) => {
|
|
197
|
+
const existing = lineIds.get(entry);
|
|
198
|
+
if (existing) return existing;
|
|
199
|
+
fallbackKeys += 1;
|
|
200
|
+
const generated = `line-${fallbackKeys}`;
|
|
201
|
+
lineIds.set(entry, generated);
|
|
202
|
+
return generated;
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Registers `observer` and returns a disposer that removes just this one, so a
|
|
206
|
+
* closed message stream stops being called into. `clearLineObservers` remains
|
|
207
|
+
* the whole-array teardown used when the client itself is destroyed.
|
|
208
|
+
*/
|
|
209
|
+
const addLineObserver = (clients, observer) => {
|
|
210
|
+
const existing = observers.get(clients);
|
|
211
|
+
if (existing) existing.add(observer);
|
|
212
|
+
else observers.set(clients, new Set([observer]));
|
|
213
|
+
return () => {
|
|
214
|
+
const current = observers.get(clients);
|
|
215
|
+
if (!current?.delete(observer)) return;
|
|
216
|
+
if (current.size === 0) observers.delete(clients);
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
const clearLineObservers = (clients) => {
|
|
220
|
+
observers.delete(clients);
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Synchronous by contract: `reconcile` calls this immediately after pushing the
|
|
224
|
+
* entry, with no await in between, so an observer can never see a half-applied
|
|
225
|
+
* array. A throwing observer is contained — it must not be able to reject the
|
|
226
|
+
* token refresh, which would stall renewal for every line.
|
|
227
|
+
*/
|
|
228
|
+
const notifyLineAttached = (clients, entry) => {
|
|
229
|
+
for (const observer of observers.get(clients) ?? []) try {
|
|
230
|
+
observer.attach(entry);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
linesLog.warn("imessage line observer failed to attach", errorAttrs(error), error instanceof Error ? error : void 0);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Returns each observer's detach promise so the caller can settle them off the
|
|
237
|
+
* refresh path — a wedged stream close must not stall token renewal.
|
|
238
|
+
*/
|
|
239
|
+
const notifyLineDetached = (clients, entry) => {
|
|
240
|
+
const pending = [];
|
|
241
|
+
for (const observer of observers.get(clients) ?? []) try {
|
|
242
|
+
pending.push(Promise.resolve(observer.detach(entry)));
|
|
243
|
+
} catch (error) {
|
|
244
|
+
linesLog.warn("imessage line observer failed to detach", errorAttrs(error), error instanceof Error ? error : void 0);
|
|
245
|
+
}
|
|
246
|
+
return pending;
|
|
247
|
+
};
|
|
248
|
+
//#endregion
|
|
182
249
|
//#region src/types.ts
|
|
183
250
|
/**
|
|
184
251
|
* Sentinel phone for shared-token mode. The single shared client serves an
|
|
@@ -232,18 +299,92 @@ const messageSchema = z.object({
|
|
|
232
299
|
//#endregion
|
|
233
300
|
//#region src/auth.ts
|
|
234
301
|
const FORCE_REFRESH_MIN_INTERVAL_MS = 5e3;
|
|
302
|
+
const authLog = createLogger("spectrum.imessage.auth");
|
|
235
303
|
const cloudAuthState = /* @__PURE__ */ new WeakMap();
|
|
236
|
-
const
|
|
237
|
-
const phone = data.numbers?.[instanceId];
|
|
238
|
-
if (!phone) throw new Error(`iMessage instance ${instanceId} has no phone assigned`);
|
|
239
|
-
return phone;
|
|
240
|
-
};
|
|
304
|
+
const instanceAttrs = (instanceId) => ({ "spectrum.imessage.instance": instanceId });
|
|
241
305
|
async function createCloudClients(projectId, projectSecret) {
|
|
242
306
|
let tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
243
307
|
let lastRefreshAt = Date.now();
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
308
|
+
const entries = [];
|
|
309
|
+
const records = /* @__PURE__ */ new Map();
|
|
310
|
+
const buildEntry = (instanceId, phone, initialToken) => ({
|
|
311
|
+
phone,
|
|
312
|
+
client: createGrpcClient({
|
|
313
|
+
address: `${instanceId}.imsg.photon.codes:443`,
|
|
314
|
+
autoIdempotency: true,
|
|
315
|
+
retry: true,
|
|
316
|
+
tls: true,
|
|
317
|
+
token: async () => {
|
|
318
|
+
await renewal.refreshIfNeeded();
|
|
319
|
+
if (tokenData.type !== "dedicated") return initialToken;
|
|
320
|
+
return tokenData.auth[instanceId] ?? initialToken;
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
});
|
|
324
|
+
const retire = async (entry) => {
|
|
325
|
+
await Promise.allSettled(notifyLineDetached(entries, entry));
|
|
326
|
+
await entry.client.close();
|
|
327
|
+
};
|
|
328
|
+
const removeMissing = (data) => {
|
|
329
|
+
let removed = 0;
|
|
330
|
+
for (const [instanceId, entry] of records) {
|
|
331
|
+
if (data.auth[instanceId]) continue;
|
|
332
|
+
records.delete(instanceId);
|
|
333
|
+
const index = entries.indexOf(entry);
|
|
334
|
+
if (index >= 0) entries.splice(index, 1);
|
|
335
|
+
removed += 1;
|
|
336
|
+
retire(entry).catch((error) => {
|
|
337
|
+
authLog.warn("failed to retire imessage line", {
|
|
338
|
+
...instanceAttrs(instanceId),
|
|
339
|
+
...errorAttrs(error)
|
|
340
|
+
}, error instanceof Error ? error : void 0);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return removed;
|
|
344
|
+
};
|
|
345
|
+
const addOrSync = (data) => {
|
|
346
|
+
let added = 0;
|
|
347
|
+
for (const [instanceId, token] of Object.entries(data.auth)) {
|
|
348
|
+
const phone = data.numbers?.[instanceId];
|
|
349
|
+
const existing = records.get(instanceId);
|
|
350
|
+
if (existing) {
|
|
351
|
+
if (phone) existing.phone = phone;
|
|
352
|
+
else authLog.warn("imessage line lost its phone number; keeping the last known number", instanceAttrs(instanceId));
|
|
353
|
+
notifyLineAttached(entries, existing);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!phone) {
|
|
357
|
+
authLog.warn("skipping imessage line without a phone number", instanceAttrs(instanceId));
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const entry = buildEntry(instanceId, phone, token);
|
|
361
|
+
setLineId(entry, instanceId);
|
|
362
|
+
records.set(instanceId, entry);
|
|
363
|
+
entries.push(entry);
|
|
364
|
+
notifyLineAttached(entries, entry);
|
|
365
|
+
added += 1;
|
|
366
|
+
}
|
|
367
|
+
return added;
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* Brings the client set in line with the token payload, which is the only
|
|
371
|
+
* inventory the cloud exposes: keys present but untracked are newly
|
|
372
|
+
* provisioned, tracked keys that vanished were deprovisioned.
|
|
373
|
+
*
|
|
374
|
+
* An empty payload means the project has no lines, not that the response is
|
|
375
|
+
* suspect — keeping entries the payload no longer covers would leave the
|
|
376
|
+
* client routing through channels whose tokens have stopped being refreshed.
|
|
377
|
+
* A genuinely malformed payload (no `auth` at all) throws instead, which the
|
|
378
|
+
* caller contains before any line is removed.
|
|
379
|
+
*/
|
|
380
|
+
const reconcile = (data) => {
|
|
381
|
+
const removed = removeMissing(data);
|
|
382
|
+
const added = addOrSync(data);
|
|
383
|
+
if (added > 0 || removed > 0) authLog.info("imessage lines reconciled", {
|
|
384
|
+
"spectrum.imessage.lines.added": added,
|
|
385
|
+
"spectrum.imessage.lines.removed": removed,
|
|
386
|
+
"spectrum.imessage.lines.total": entries.length
|
|
387
|
+
});
|
|
247
388
|
};
|
|
248
389
|
const renewal = createTokenRenewal({
|
|
249
390
|
expiresInSeconds: () => tokenData.expiresIn,
|
|
@@ -251,7 +392,12 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
251
392
|
refresh: async () => {
|
|
252
393
|
tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
253
394
|
lastRefreshAt = Date.now();
|
|
254
|
-
if (tokenData.type
|
|
395
|
+
if (tokenData.type !== "dedicated") return;
|
|
396
|
+
try {
|
|
397
|
+
reconcile(tokenData);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
authLog.error("imessage line reconcile failed", errorAttrs(error), error instanceof Error ? error : void 0);
|
|
400
|
+
}
|
|
255
401
|
}
|
|
256
402
|
});
|
|
257
403
|
const forceRefresh = async () => {
|
|
@@ -263,10 +409,11 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
263
409
|
forceRefresh
|
|
264
410
|
};
|
|
265
411
|
if (tokenData.type === "shared") {
|
|
266
|
-
const
|
|
412
|
+
const address = process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "imessage.spectrum.photon.codes:443";
|
|
413
|
+
entries.push({
|
|
267
414
|
phone: SHARED_PHONE,
|
|
268
415
|
client: createGrpcClient({
|
|
269
|
-
address
|
|
416
|
+
address,
|
|
270
417
|
autoIdempotency: true,
|
|
271
418
|
retry: true,
|
|
272
419
|
tls: true,
|
|
@@ -275,35 +422,16 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
275
422
|
return tokenData.token;
|
|
276
423
|
}
|
|
277
424
|
})
|
|
278
|
-
}
|
|
425
|
+
});
|
|
279
426
|
cloudAuthState.set(entries, cloudAuth);
|
|
280
427
|
return entries;
|
|
281
428
|
}
|
|
282
|
-
|
|
283
|
-
for (const [instanceId, token] of Object.entries(dedicated.auth)) {
|
|
284
|
-
const entry = {
|
|
285
|
-
phone: requirePhone(dedicated, instanceId),
|
|
286
|
-
client: createGrpcClient({
|
|
287
|
-
address: `${instanceId}.imsg.photon.codes:443`,
|
|
288
|
-
autoIdempotency: true,
|
|
289
|
-
retry: true,
|
|
290
|
-
tls: true,
|
|
291
|
-
token: async () => {
|
|
292
|
-
await renewal.refreshIfNeeded();
|
|
293
|
-
return tokenData.auth[instanceId] ?? token;
|
|
294
|
-
}
|
|
295
|
-
})
|
|
296
|
-
};
|
|
297
|
-
records.push({
|
|
298
|
-
entry,
|
|
299
|
-
instanceId
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
const entries = records.map((r) => r.entry);
|
|
429
|
+
reconcile(tokenData);
|
|
303
430
|
cloudAuthState.set(entries, cloudAuth);
|
|
304
431
|
return entries;
|
|
305
432
|
}
|
|
306
433
|
async function disposeCloudAuth(clients) {
|
|
434
|
+
clearLineObservers(clients);
|
|
307
435
|
const auth = cloudAuthState.get(clients);
|
|
308
436
|
if (auth) {
|
|
309
437
|
auth.dispose();
|
|
@@ -409,6 +537,12 @@ const IMESSAGE_PLATFORM = "imessage";
|
|
|
409
537
|
const PART_PREFIX = /^p:(\d+)\//;
|
|
410
538
|
const dmChatGuid = (address) => `any;-;${address}`;
|
|
411
539
|
const chatTypeFromGuid = (guid) => guid.includes(";+;") ? "group" : "dm";
|
|
540
|
+
const dmPeerFromChatGuid = (guid) => {
|
|
541
|
+
if (chatTypeFromGuid(guid) !== "dm") return;
|
|
542
|
+
const separator = guid.indexOf(";-;");
|
|
543
|
+
if (separator < 0) return;
|
|
544
|
+
return guid.slice(separator + 3) || void 0;
|
|
545
|
+
};
|
|
412
546
|
const toChatGuid = (value) => value;
|
|
413
547
|
const toMessageGuid = (value) => value;
|
|
414
548
|
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
|
|
@@ -671,7 +805,7 @@ const getRemoteAttachment = async (client, guid) => {
|
|
|
671
805
|
};
|
|
672
806
|
//#endregion
|
|
673
807
|
//#region src/remote/inbound.ts
|
|
674
|
-
const log$
|
|
808
|
+
const log$5 = createLogger("spectrum.imessage.inbound");
|
|
675
809
|
const messageAttachments = (message) => message.content.attachments;
|
|
676
810
|
const resolveChatGuid = (message, hint) => {
|
|
677
811
|
if (hint) return hint;
|
|
@@ -735,7 +869,7 @@ const toVCardContent = async (client, info) => {
|
|
|
735
869
|
try {
|
|
736
870
|
return asContact(fromVCard((await downloadPrimaryAttachment(client, info.guid)).toString("utf8")));
|
|
737
871
|
} catch (err) {
|
|
738
|
-
log$
|
|
872
|
+
log$5.warn("failed to parse vCard attachment; falling back to attachment content", {
|
|
739
873
|
"spectrum.imessage.attachment.guid": info.guid,
|
|
740
874
|
...errorAttrs(err)
|
|
741
875
|
}, err);
|
|
@@ -823,7 +957,7 @@ const resolveReplyTarget = async (client, base, targetGuid, currentGuid, options
|
|
|
823
957
|
if (options.cache) cacheMessage(options.cache, rebuilt);
|
|
824
958
|
return rebuilt;
|
|
825
959
|
} catch (err) {
|
|
826
|
-
if (!(err instanceof NotFoundError)) log$
|
|
960
|
+
if (!(err instanceof NotFoundError)) log$5.warn("failed to resolve iMessage reply target; falling back to stub target", {
|
|
827
961
|
"spectrum.imessage.message.guid": currentGuid,
|
|
828
962
|
"spectrum.imessage.reply.target_guid": targetGuid,
|
|
829
963
|
...errorAttrs(err)
|
|
@@ -860,6 +994,36 @@ const cacheMessage = (cache, message) => {
|
|
|
860
994
|
for (const item of group.items) if (isIMessageMessage(item)) cache.set(item.id, item);
|
|
861
995
|
}
|
|
862
996
|
};
|
|
997
|
+
/**
|
|
998
|
+
* Resolve a guid to the spectrum message it maps to, for events that reference
|
|
999
|
+
* their target only by guid (reactions, read receipts). Cache first — outbound
|
|
1000
|
+
* sends are cached at send time by `cacheRemoteOutbound`, inbound messages when
|
|
1001
|
+
* they are converted — then one `messages.get` + rebuild, which also warms the
|
|
1002
|
+
* cache so sibling events for the same guid (a group's other participants) are
|
|
1003
|
+
* free.
|
|
1004
|
+
*
|
|
1005
|
+
* Any failure drops the event: these are secondary signals and must never wedge
|
|
1006
|
+
* the stream. Deliberately unlike `getMessage` below (the public
|
|
1007
|
+
* `space.getMessage` action), which rethrows non-`NotFoundError` failures; and
|
|
1008
|
+
* unlike it this never resolves `p:N/guid` child ids, because event payloads
|
|
1009
|
+
* always carry the parent guid.
|
|
1010
|
+
*/
|
|
1011
|
+
const resolveTargetMessage = async (client, cache, chatGuid, targetGuid, phone) => {
|
|
1012
|
+
const cached = cache.get(targetGuid);
|
|
1013
|
+
if (cached) return cached;
|
|
1014
|
+
try {
|
|
1015
|
+
const rebuilt = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), phone, chatGuid);
|
|
1016
|
+
cacheMessage(cache, rebuilt);
|
|
1017
|
+
return rebuilt;
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
log$5.debug("event target could not be resolved; dropping the event", {
|
|
1020
|
+
"spectrum.imessage.target_guid": targetGuid,
|
|
1021
|
+
"spectrum.imessage.chat_guid": chatGuid,
|
|
1022
|
+
...errorAttrs(error)
|
|
1023
|
+
}, error instanceof Error ? error : void 0);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
863
1027
|
const toInboundMessages = async (client, cache, event, phone) => {
|
|
864
1028
|
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
|
|
865
1029
|
const messageGuidStr = event.message.guid;
|
|
@@ -955,13 +1119,8 @@ const asProviderReaction = (emoji, target) => reactionSchema.parse({
|
|
|
955
1119
|
type: "reaction"
|
|
956
1120
|
});
|
|
957
1121
|
const resolveReactionTarget = async (client, cache, chat, targetGuid, partIndex, phone) => {
|
|
958
|
-
|
|
959
|
-
if (!candidate)
|
|
960
|
-
candidate = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), phone, chat);
|
|
961
|
-
cacheMessage(cache, candidate);
|
|
962
|
-
} catch {
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
1122
|
+
const candidate = await resolveTargetMessage(client, cache, chat, targetGuid, phone);
|
|
1123
|
+
if (!candidate) return;
|
|
965
1124
|
if (candidate.content.type === "group") {
|
|
966
1125
|
const items = candidate.content.items;
|
|
967
1126
|
if (!Array.isArray(items)) return candidate;
|
|
@@ -1503,7 +1662,7 @@ const randomPhone = (clients) => {
|
|
|
1503
1662
|
};
|
|
1504
1663
|
//#endregion
|
|
1505
1664
|
//#region src/remote/contact-share.ts
|
|
1506
|
-
const log$
|
|
1665
|
+
const log$4 = createLogger("spectrum.imessage.contact");
|
|
1507
1666
|
const SHARE_TTL_MS = 1440 * 60 * 1e3;
|
|
1508
1667
|
const MAX_TRACKED_CHATS = 1e4;
|
|
1509
1668
|
const isPreconditionFailure = (error) => typeof error === "object" && error !== null && "code" in error && error.code === ErrorCode.preconditionFailed;
|
|
@@ -1551,10 +1710,10 @@ var ContactShareTracker = class {
|
|
|
1551
1710
|
this.cache.set(chatGuid, true);
|
|
1552
1711
|
const safeChatGuid = sanitizeErrorMessage(chatGuid);
|
|
1553
1712
|
this.client.chats.shareContactInfo(chatGuid).then(() => {
|
|
1554
|
-
log$
|
|
1713
|
+
log$4.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
|
|
1555
1714
|
}).catch((error) => {
|
|
1556
1715
|
if (!isPreconditionFailure(error)) this.cache.delete(chatGuid);
|
|
1557
|
-
log$
|
|
1716
|
+
log$4.warn("failed to share contact card", {
|
|
1558
1717
|
"spectrum.imessage.contact.chat": safeChatGuid,
|
|
1559
1718
|
...errorAttrs(error)
|
|
1560
1719
|
}, error);
|
|
@@ -1580,7 +1739,7 @@ const getContactShareTracker = (client) => {
|
|
|
1580
1739
|
};
|
|
1581
1740
|
//#endregion
|
|
1582
1741
|
//#region src/remote/group-events.ts
|
|
1583
|
-
const log$
|
|
1742
|
+
const log$3 = createLogger("spectrum.imessage.group");
|
|
1584
1743
|
/**
|
|
1585
1744
|
* Synthetic id for a `group.changed` event — shared between the stream item
|
|
1586
1745
|
* (the dedup key across live/catch-up) and the surfaced message. `sequence`
|
|
@@ -1610,7 +1769,7 @@ const fetchIconContent = async (client, event) => {
|
|
|
1610
1769
|
}
|
|
1611
1770
|
});
|
|
1612
1771
|
} catch (e) {
|
|
1613
|
-
log$
|
|
1772
|
+
log$3.error("failed to fetch changed group icon", {
|
|
1614
1773
|
"spectrum.imessage.group.chat": event.chatGuid,
|
|
1615
1774
|
...errorAttrs(e)
|
|
1616
1775
|
}, e);
|
|
@@ -1665,7 +1824,7 @@ const toGroupEventMessages = async (client, event, phone) => {
|
|
|
1665
1824
|
};
|
|
1666
1825
|
//#endregion
|
|
1667
1826
|
//#region src/remote/polls.ts
|
|
1668
|
-
const log$
|
|
1827
|
+
const log$2 = createLogger("spectrum.imessage.poll");
|
|
1669
1828
|
const isVotedPollEvent = (event) => event.delta.type === "voted";
|
|
1670
1829
|
const isUnvotedPollEvent = (event) => event.delta.type === "unvoted";
|
|
1671
1830
|
const toCachedPoll = (input) => {
|
|
@@ -1697,7 +1856,7 @@ const cachePollEvent = (cache, event) => {
|
|
|
1697
1856
|
cache.set(event.pollMessageGuid, cached);
|
|
1698
1857
|
return cached;
|
|
1699
1858
|
} catch (e) {
|
|
1700
|
-
log$
|
|
1859
|
+
log$2.error("failed to cache poll", {
|
|
1701
1860
|
"spectrum.imessage.poll.guid": event.pollMessageGuid,
|
|
1702
1861
|
...errorAttrs(e)
|
|
1703
1862
|
}, e);
|
|
@@ -1709,7 +1868,7 @@ const fetchPollInfo = async (client, cache, event) => {
|
|
|
1709
1868
|
cachePollInfo(cache, info);
|
|
1710
1869
|
return info;
|
|
1711
1870
|
} catch (e) {
|
|
1712
|
-
log$
|
|
1871
|
+
log$2.error("failed to fetch poll", {
|
|
1713
1872
|
"spectrum.imessage.poll.guid": event.pollMessageGuid,
|
|
1714
1873
|
...errorAttrs(e)
|
|
1715
1874
|
}, e);
|
|
@@ -1722,7 +1881,7 @@ const resolvePoll = async (client, cache, event) => {
|
|
|
1722
1881
|
try {
|
|
1723
1882
|
return cachePollInfo(cache, await client.polls.get(event.pollMessageGuid));
|
|
1724
1883
|
} catch (e) {
|
|
1725
|
-
log$
|
|
1884
|
+
log$2.error("failed to resolve poll", {
|
|
1726
1885
|
"spectrum.imessage.poll.guid": event.pollMessageGuid,
|
|
1727
1886
|
...errorAttrs(e)
|
|
1728
1887
|
}, e);
|
|
@@ -1782,10 +1941,140 @@ const toPollDeltaMessages = async (client, pollCache, event, phone) => {
|
|
|
1782
1941
|
return [];
|
|
1783
1942
|
};
|
|
1784
1943
|
//#endregion
|
|
1944
|
+
//#region src/remote/read-receipts.ts
|
|
1945
|
+
const log$1 = createLogger("spectrum.imessage.read");
|
|
1946
|
+
/**
|
|
1947
|
+
* Synthetic id for a `message.read` event — shared between the stream item
|
|
1948
|
+
* (the dedup key across live/catch-up) and the surfaced message. `sequence` is
|
|
1949
|
+
* monotonic per line, so N participants reading the same message produce N
|
|
1950
|
+
* distinct ids, and the same event replayed through catch-up produces the id
|
|
1951
|
+
* it produced live.
|
|
1952
|
+
*/
|
|
1953
|
+
const readReceiptMessageId = (event) => `${event.messageGuid}:read:${event.sequence}`;
|
|
1954
|
+
const asProviderRead = (target) => readSchema.parse({
|
|
1955
|
+
target,
|
|
1956
|
+
type: "read"
|
|
1957
|
+
});
|
|
1958
|
+
/**
|
|
1959
|
+
* Whether the reader could be identified at all, decided without resolving
|
|
1960
|
+
* the target. A DM always can (the peer is in the guid); a group only if the
|
|
1961
|
+
* platform named an actor. Lets an unattributable group receipt drop before
|
|
1962
|
+
* paying for an RPC.
|
|
1963
|
+
*/
|
|
1964
|
+
const isAttributable = (event) => dmPeerFromChatGuid(event.chatGuid) !== void 0 || event.actor?.address !== void 0;
|
|
1965
|
+
/**
|
|
1966
|
+
* Identify the reader.
|
|
1967
|
+
*
|
|
1968
|
+
* `event.actor` is **not** the reader on this arm. Verified against a live
|
|
1969
|
+
* line: in a DM where the peer read our message, `actor` came back as *our
|
|
1970
|
+
* own* line's address, not theirs.
|
|
1971
|
+
*
|
|
1972
|
+
* So the chat guid comes first. In a DM there are exactly two participants
|
|
1973
|
+
* and the target is already known to be ours, which makes the reader
|
|
1974
|
+
* definitionally the other one — no address comparison needed, and therefore
|
|
1975
|
+
* correct on pooled lines too, where `phone` is the `"shared"` sentinel and
|
|
1976
|
+
* comparing it against a real address is meaningless.
|
|
1977
|
+
*
|
|
1978
|
+
* A group guid carries no participant list, so there `actor` is the only
|
|
1979
|
+
* possible attribution. It is trusted only when it names neither this line
|
|
1980
|
+
* nor the target's own sender; the latter is what catches the shared-mode
|
|
1981
|
+
* case, since the sentinel never equals a real address. An unattributable
|
|
1982
|
+
* receipt is dropped rather than surfaced with a wrong reader — the reader's
|
|
1983
|
+
* identity is the entire payload.
|
|
1984
|
+
*/
|
|
1985
|
+
const toReader = (event, target, phone) => {
|
|
1986
|
+
const peer = dmPeerFromChatGuid(event.chatGuid);
|
|
1987
|
+
if (peer) return {
|
|
1988
|
+
id: peer,
|
|
1989
|
+
address: peer
|
|
1990
|
+
};
|
|
1991
|
+
const actor = event.actor;
|
|
1992
|
+
if (actor?.address && actor.address !== phone && actor.address !== target.sender?.address) return toSenderRef(actor);
|
|
1993
|
+
};
|
|
1994
|
+
/**
|
|
1995
|
+
* Convert a `message.read` event into an inbound `read` message — someone read
|
|
1996
|
+
* a message the agent sent. `sender` is the reader; `content.target` is ours.
|
|
1997
|
+
*
|
|
1998
|
+
* Two guards, cheapest first:
|
|
1999
|
+
*
|
|
2000
|
+
* 1. **The reader must be identifiable -> else drop.** Unlike a membership
|
|
2001
|
+
* change (where the state change itself is the payload and
|
|
2002
|
+
* `sender: undefined` still carries meaning), the reader's identity *is*
|
|
2003
|
+
* the payload of a receipt. An unattributed receipt in a group is
|
|
2004
|
+
* indistinguishable noise and would corrupt any "N of M have read" tally.
|
|
2005
|
+
* Same call as `toReactionMessages`. See `toReader`.
|
|
2006
|
+
*
|
|
2007
|
+
* 2. **The target must be one of ours.** `event.isFromMe` is not trustworthy
|
|
2008
|
+
* here: the proto carries no comment for it on this arm, and it most
|
|
2009
|
+
* plausibly describes the *underlying message* — which for a genuine
|
|
2010
|
+
* receipt is ours, i.e. `true` — so keying self-suppression off it would
|
|
2011
|
+
* suppress every receipt worth surfacing. The durable invariant is that a
|
|
2012
|
+
* peer's receipt always points at a message we sent, so the resolved target
|
|
2013
|
+
* must be `direction: "outbound"`. That one check also suppresses the echo
|
|
2014
|
+
* of our own `chats.markRead()`, which marks *their* inbound messages and
|
|
2015
|
+
* therefore resolves `direction: "inbound"`.
|
|
2016
|
+
*/
|
|
2017
|
+
const toReadReceiptMessages = async (client, cache, event, phone) => {
|
|
2018
|
+
if (!isAttributable(event)) {
|
|
2019
|
+
log$1.debug("read receipt dropped: reader could not be identified", {
|
|
2020
|
+
"spectrum.imessage.read.message_guid": event.messageGuid,
|
|
2021
|
+
"spectrum.imessage.read.sequence": event.sequence,
|
|
2022
|
+
"spectrum.imessage.read.chat_guid": event.chatGuid,
|
|
2023
|
+
"spectrum.imessage.read.has_actor": false
|
|
2024
|
+
});
|
|
2025
|
+
return [];
|
|
2026
|
+
}
|
|
2027
|
+
const target = await resolveTargetMessage(client, cache, event.chatGuid, event.messageGuid, phone);
|
|
2028
|
+
if (!target) {
|
|
2029
|
+
log$1.debug("read receipt dropped: target message could not be resolved", {
|
|
2030
|
+
"spectrum.imessage.read.message_guid": event.messageGuid,
|
|
2031
|
+
"spectrum.imessage.read.sequence": event.sequence
|
|
2032
|
+
});
|
|
2033
|
+
return [];
|
|
2034
|
+
}
|
|
2035
|
+
if (target.direction !== "outbound") {
|
|
2036
|
+
log$1.debug("read receipt dropped: target is not one of ours", {
|
|
2037
|
+
"spectrum.imessage.read.message_guid": event.messageGuid,
|
|
2038
|
+
"spectrum.imessage.read.sequence": event.sequence,
|
|
2039
|
+
"spectrum.imessage.read.target_direction": target.direction ?? "unset"
|
|
2040
|
+
});
|
|
2041
|
+
return [];
|
|
2042
|
+
}
|
|
2043
|
+
const reader = toReader(event, target, phone);
|
|
2044
|
+
if (!reader) {
|
|
2045
|
+
log$1.debug("read receipt dropped: reader could not be identified", {
|
|
2046
|
+
"spectrum.imessage.read.message_guid": event.messageGuid,
|
|
2047
|
+
"spectrum.imessage.read.sequence": event.sequence,
|
|
2048
|
+
"spectrum.imessage.read.chat_guid": event.chatGuid,
|
|
2049
|
+
"spectrum.imessage.read.has_actor": true
|
|
2050
|
+
});
|
|
2051
|
+
return [];
|
|
2052
|
+
}
|
|
2053
|
+
const readAt = event.readAt;
|
|
2054
|
+
log$1.debug("read receipt surfaced", {
|
|
2055
|
+
"spectrum.imessage.read.message_guid": event.messageGuid,
|
|
2056
|
+
"spectrum.imessage.read.sequence": event.sequence,
|
|
2057
|
+
"spectrum.imessage.read.reader": sanitizePhone(reader.id),
|
|
2058
|
+
"spectrum.imessage.read.used_read_at": readAt !== void 0
|
|
2059
|
+
});
|
|
2060
|
+
return [{
|
|
2061
|
+
id: readReceiptMessageId(event),
|
|
2062
|
+
content: asProviderRead(target),
|
|
2063
|
+
sender: reader,
|
|
2064
|
+
space: {
|
|
2065
|
+
id: event.chatGuid,
|
|
2066
|
+
type: chatTypeFromGuid(event.chatGuid),
|
|
2067
|
+
phone
|
|
2068
|
+
},
|
|
2069
|
+
timestamp: readAt ?? event.occurredAt
|
|
2070
|
+
}];
|
|
2071
|
+
};
|
|
2072
|
+
//#endregion
|
|
1785
2073
|
//#region src/remote/stream.ts
|
|
1786
2074
|
const isCursorRejectedIMessageError = (error) => error instanceof ValidationError;
|
|
1787
2075
|
const streamLabel = (kind, phone) => `imessage.${kind}:${phone === "shared" ? phone : sanitizePhone(phone)}`;
|
|
1788
|
-
const
|
|
2076
|
+
const isActorCurrentAccount = (actor, phone) => phone !== "shared" && actor?.address !== void 0 && actor.address === phone;
|
|
2077
|
+
const isEventFromCurrentAccount = (event, phone) => event.isFromMe || isActorCurrentAccount(event.actor, phone);
|
|
1789
2078
|
const streamLog = createLogger("spectrum.imessage.stream");
|
|
1790
2079
|
const isRetryableMappingError = (error) => typeof error === "object" && error !== null && error.retryable === true;
|
|
1791
2080
|
const skipUnmappable = async (label, cursor, map) => {
|
|
@@ -1834,6 +2123,26 @@ const toMessageItem = async (client, event, phone, cursor, onInbound) => {
|
|
|
1834
2123
|
values: await toReactionMessages(client, cache, event, phone)
|
|
1835
2124
|
};
|
|
1836
2125
|
}
|
|
2126
|
+
if (event.type === "message.read") {
|
|
2127
|
+
const id = readReceiptMessageId(event);
|
|
2128
|
+
streamLog.debug("received a read event", {
|
|
2129
|
+
"spectrum.imessage.read.message_guid": event.messageGuid,
|
|
2130
|
+
"spectrum.imessage.read.sequence": event.sequence,
|
|
2131
|
+
"spectrum.imessage.read.chat_guid": event.chatGuid,
|
|
2132
|
+
"spectrum.imessage.read.actor": event.actor?.address ? sanitizePhone(event.actor.address) : "none",
|
|
2133
|
+
"spectrum.imessage.read.is_from_me": event.isFromMe,
|
|
2134
|
+
"spectrum.imessage.read.line": phone
|
|
2135
|
+
});
|
|
2136
|
+
return {
|
|
2137
|
+
cursor,
|
|
2138
|
+
id,
|
|
2139
|
+
values: await toReadReceiptMessages(client, getMessageCache(client), event, phone)
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
streamLog.debug("message event consumed without mapping", {
|
|
2143
|
+
"spectrum.imessage.event_type": event.type,
|
|
2144
|
+
"spectrum.imessage.sequence": event.sequence
|
|
2145
|
+
});
|
|
1837
2146
|
return {
|
|
1838
2147
|
cursor,
|
|
1839
2148
|
id: `${event.type}:${"messageGuid" in event ? event.messageGuid : "unknown"}:${event.sequence}`,
|
|
@@ -1952,11 +2261,26 @@ const messages$1 = (clients, projectConfig, profileSyncGate) => {
|
|
|
1952
2261
|
const pollCache = getPollCache(clients);
|
|
1953
2262
|
const staticShareEnabled = projectConfig?.profile?.imessageSynced === true;
|
|
1954
2263
|
const recover = getCloudRecover(clients);
|
|
1955
|
-
const
|
|
1956
|
-
|
|
2264
|
+
const shared = isSharedMode(clients);
|
|
2265
|
+
const includeGroupEvents = !shared;
|
|
2266
|
+
const build = (entry) => () => {
|
|
1957
2267
|
const tracker = staticShareEnabled || profileSyncGate ? getContactShareTracker(entry.client) : void 0;
|
|
1958
2268
|
return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? contactShareHandler(tracker, profileSyncGate) : void 0, recover);
|
|
1959
|
-
}
|
|
2269
|
+
};
|
|
2270
|
+
const group = createStreamGroup({ label: "imessage.messages" });
|
|
2271
|
+
for (const entry of clients) group.add(lineKey(entry), build(entry));
|
|
2272
|
+
if (shared) return group;
|
|
2273
|
+
const disposeObserver = addLineObserver(clients, {
|
|
2274
|
+
attach: (entry) => {
|
|
2275
|
+
group.add(lineKey(entry), build(entry));
|
|
2276
|
+
},
|
|
2277
|
+
detach: (entry) => group.remove(lineKey(entry)).then(() => void 0)
|
|
2278
|
+
});
|
|
2279
|
+
const closeGroup = group.close.bind(group);
|
|
2280
|
+
return Object.assign(group, { close: async () => {
|
|
2281
|
+
disposeObserver();
|
|
2282
|
+
await closeGroup();
|
|
2283
|
+
} });
|
|
1960
2284
|
};
|
|
1961
2285
|
//#endregion
|
|
1962
2286
|
//#region src/remote/stream-text.ts
|