@spectrum-ts/imessage 12.5.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 +180 -37
- 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, readSchema, 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();
|
|
@@ -2133,11 +2261,26 @@ const messages$1 = (clients, projectConfig, profileSyncGate) => {
|
|
|
2133
2261
|
const pollCache = getPollCache(clients);
|
|
2134
2262
|
const staticShareEnabled = projectConfig?.profile?.imessageSynced === true;
|
|
2135
2263
|
const recover = getCloudRecover(clients);
|
|
2136
|
-
const
|
|
2137
|
-
|
|
2264
|
+
const shared = isSharedMode(clients);
|
|
2265
|
+
const includeGroupEvents = !shared;
|
|
2266
|
+
const build = (entry) => () => {
|
|
2138
2267
|
const tracker = staticShareEnabled || profileSyncGate ? getContactShareTracker(entry.client) : void 0;
|
|
2139
2268
|
return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? contactShareHandler(tracker, profileSyncGate) : void 0, recover);
|
|
2140
|
-
}
|
|
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
|
+
} });
|
|
2141
2284
|
};
|
|
2142
2285
|
//#endregion
|
|
2143
2286
|
//#region src/remote/stream-text.ts
|