@spectrum-ts/imessage 5.2.0 → 6.1.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.
Files changed (2) hide show
  1. package/dist/index.js +94 -48
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -219,6 +219,7 @@ const log$3 = createLogger("spectrum.imessage.auth");
219
219
  const RENEWAL_RATIO = .8;
220
220
  const EXPIRY_BUFFER_MS = 3e4;
221
221
  const RETRY_DELAY_MS = 3e4;
222
+ const FORCE_REFRESH_MIN_INTERVAL_MS = 5e3;
222
223
  const cloudAuthState = /* @__PURE__ */ new WeakMap();
223
224
  const requirePhone = (data, instanceId) => {
224
225
  const phone = data.numbers?.[instanceId];
@@ -231,6 +232,8 @@ async function createCloudClients(projectId, projectSecret) {
231
232
  let disposed = false;
232
233
  let renewalTimer;
233
234
  let refreshFailures = 0;
235
+ let refreshInFlight;
236
+ let lastRefreshAt = Date.now();
234
237
  const records = [];
235
238
  const syncPhones = (data) => {
236
239
  for (const { entry, instanceId } of records) entry.phone = requirePhone(data, instanceId);
@@ -249,39 +252,66 @@ async function createCloudClients(projectId, projectSecret) {
249
252
  ...errorAttrs(error)
250
253
  }, error);
251
254
  };
255
+ const refreshNow = async () => {
256
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
257
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
258
+ lastRefreshAt = Date.now();
259
+ if (tokenData.type === "dedicated") syncPhones(tokenData);
260
+ onRefreshSuccess();
261
+ scheduleRenewal();
262
+ };
263
+ const coalescedRefresh = () => {
264
+ if (!refreshInFlight) refreshInFlight = refreshNow().finally(() => {
265
+ refreshInFlight = void 0;
266
+ });
267
+ return refreshInFlight;
268
+ };
252
269
  const scheduleRenewal = () => {
253
270
  if (disposed) return;
271
+ if (renewalTimer !== void 0) {
272
+ clearTimeout(renewalTimer);
273
+ renewalTimer = void 0;
274
+ }
254
275
  const ttlMs = tokenData.expiresIn * 1e3;
255
276
  const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
256
- renewalTimer = setTimeout(async () => {
257
- try {
258
- tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
259
- tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
260
- if (tokenData.type === "dedicated") syncPhones(tokenData);
261
- onRefreshSuccess();
262
- scheduleRenewal();
263
- } catch (error) {
277
+ const runScheduledRefresh = () => {
278
+ coalescedRefresh().catch((error) => {
264
279
  onRefreshFailure(error);
265
- renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
280
+ if (disposed) return;
281
+ renewalTimer = setTimeout(runScheduledRefresh, RETRY_DELAY_MS);
266
282
  renewalTimer?.unref?.();
267
- }
268
- }, renewInMs);
283
+ });
284
+ };
285
+ renewalTimer = setTimeout(runScheduledRefresh, renewInMs);
269
286
  renewalTimer?.unref?.();
270
287
  };
271
288
  scheduleRenewal();
272
289
  const refreshIfNeeded = async () => {
273
290
  if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
274
- tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
275
- tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
276
- if (tokenData.type === "dedicated") syncPhones(tokenData);
277
- onRefreshSuccess();
278
- scheduleRenewal();
291
+ await coalescedRefresh();
292
+ };
293
+ const forceRefresh = async () => {
294
+ if (Date.now() - lastRefreshAt < FORCE_REFRESH_MIN_INTERVAL_MS) return;
295
+ await coalescedRefresh();
296
+ };
297
+ const dispose = () => {
298
+ disposed = true;
299
+ if (renewalTimer !== void 0) {
300
+ clearTimeout(renewalTimer);
301
+ renewalTimer = void 0;
302
+ }
303
+ };
304
+ const cloudAuth = {
305
+ dispose,
306
+ forceRefresh
279
307
  };
280
308
  if (tokenData.type === "shared") {
281
309
  const entries = [{
282
310
  phone: SHARED_PHONE,
283
311
  client: createClient({
284
312
  address: process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "imessage.spectrum.photon.codes:443",
313
+ autoIdempotency: true,
314
+ retry: true,
285
315
  tls: true,
286
316
  token: async () => {
287
317
  await refreshIfNeeded();
@@ -289,13 +319,7 @@ async function createCloudClients(projectId, projectSecret) {
289
319
  }
290
320
  })
291
321
  }];
292
- cloudAuthState.set(entries, { dispose: () => {
293
- disposed = true;
294
- if (renewalTimer !== void 0) {
295
- clearTimeout(renewalTimer);
296
- renewalTimer = void 0;
297
- }
298
- } });
322
+ cloudAuthState.set(entries, cloudAuth);
299
323
  return entries;
300
324
  }
301
325
  const dedicated = tokenData;
@@ -304,6 +328,8 @@ async function createCloudClients(projectId, projectSecret) {
304
328
  phone: requirePhone(dedicated, instanceId),
305
329
  client: createClient({
306
330
  address: `${instanceId}.imsg.photon.codes:443`,
331
+ autoIdempotency: true,
332
+ retry: true,
307
333
  tls: true,
308
334
  token: async () => {
309
335
  await refreshIfNeeded();
@@ -317,13 +343,7 @@ async function createCloudClients(projectId, projectSecret) {
317
343
  });
318
344
  }
319
345
  const entries = records.map((r) => r.entry);
320
- cloudAuthState.set(entries, { dispose: () => {
321
- disposed = true;
322
- if (renewalTimer !== void 0) {
323
- clearTimeout(renewalTimer);
324
- renewalTimer = void 0;
325
- }
326
- } });
346
+ cloudAuthState.set(entries, cloudAuth);
327
347
  return entries;
328
348
  }
329
349
  async function disposeCloudAuth(clients) {
@@ -333,6 +353,15 @@ async function disposeCloudAuth(clients) {
333
353
  cloudAuthState.delete(clients);
334
354
  }
335
355
  }
356
+ /**
357
+ * The recover hook for a cloud-backed client array: forces a token re-mint so a
358
+ * persistently-failing stream (server rejecting an unexpired token after a
359
+ * restart) gets a fresh bearer on its next reconnect. Returns undefined for
360
+ * explicitly-configured (static-token) clients, which have nothing to re-mint.
361
+ */
362
+ function getCloudRecover(clients) {
363
+ return cloudAuthState.get(clients)?.forceRefresh;
364
+ }
336
365
  //#endregion
337
366
  //#region src/cache.ts
338
367
  const DEFAULT_MAX = 1e3;
@@ -1508,9 +1537,12 @@ const log$1 = createLogger("spectrum.imessage.contact");
1508
1537
  const SHARE_TTL_MS = 1440 * 60 * 1e3;
1509
1538
  const MAX_TRACKED_CHATS = 1e4;
1510
1539
  /**
1511
- * Tracks which chats this bot has already proactively pushed its contact card
1512
- * to, so `im.chats.shareContactInfo` is fired at most once per chat per 24h
1513
- * per iMessage provider instance.
1540
+ * Tracks which chats this bot's line has already proactively pushed its contact
1541
+ * card to, so `im.chats.shareContactInfo` is fired at most once per chat per
1542
+ * line per 24h. One tracker is created per `AdvancedIMessage` client (see
1543
+ * `getContactShareTracker`), so the dedupe is naturally scoped to the line: a
1544
+ * DM `chatGuid` encodes the peer, not the receiving bot line, so the same guid
1545
+ * arriving on a different line shares independently.
1514
1546
  *
1515
1547
  * Backed by `lru-cache` for TTL + bounded memory. `ttlAutopurge: false`
1516
1548
  * keeps eviction lazy (on access) — there is no background timer to leak
@@ -1522,6 +1554,10 @@ var ContactShareTracker = class {
1522
1554
  ttl: SHARE_TTL_MS,
1523
1555
  ttlAutopurge: false
1524
1556
  });
1557
+ client;
1558
+ constructor(client) {
1559
+ this.client = client;
1560
+ }
1525
1561
  /**
1526
1562
  * Best-effort share. The cache is set eagerly so that a burst of inbound
1527
1563
  * messages for the same chat coalesces to a single API call. On failure the
@@ -1529,11 +1565,11 @@ var ContactShareTracker = class {
1529
1565
  * permanently mute the feature for a chat. Never awaits and never throws:
1530
1566
  * the receive stream must not crash on share failures.
1531
1567
  */
1532
- maybeShare(client, chatGuid) {
1568
+ maybeShare(chatGuid) {
1533
1569
  if (this.cache.has(chatGuid)) return;
1534
1570
  this.cache.set(chatGuid, true);
1535
1571
  const safeChatGuid = sanitizeErrorMessage(chatGuid);
1536
- client.chats.shareContactInfo(chatGuid).then(() => {
1572
+ this.client.chats.shareContactInfo(chatGuid).then(() => {
1537
1573
  log$1.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1538
1574
  }).catch((error) => {
1539
1575
  this.cache.delete(chatGuid);
@@ -1546,16 +1582,18 @@ var ContactShareTracker = class {
1546
1582
  };
1547
1583
  const trackers = /* @__PURE__ */ new WeakMap();
1548
1584
  /**
1549
- * Returns a per-owner tracker. Mirrors `getMessageCache`/`getPollCache` in
1550
- * ../cache.ts keyed by an object (the `RemoteClient[]` array for remote
1551
- * mode), so each iMessage provider instance has its own tracker and multiple
1552
- * providers don't share state accidentally.
1585
+ * Returns a per-line tracker. Mirrors `getMessageCache` in ../cache.ts — keyed
1586
+ * by the individual `AdvancedIMessage` client, so each line has its own dedupe
1587
+ * state and multiple lines/providers don't share state accidentally. The
1588
+ * WeakMap holds the client weakly, so a torn-down line's tracker is collected
1589
+ * with its client (the tracker's own reference back to the client doesn't pin
1590
+ * it — the entry is a collectible cycle).
1553
1591
  */
1554
- const getContactShareTracker = (owner) => {
1555
- let tracker = trackers.get(owner);
1592
+ const getContactShareTracker = (client) => {
1593
+ let tracker = trackers.get(client);
1556
1594
  if (!tracker) {
1557
- tracker = new ContactShareTracker();
1558
- trackers.set(owner, tracker);
1595
+ tracker = new ContactShareTracker(client);
1596
+ trackers.set(client, tracker);
1559
1597
  }
1560
1598
  return tracker;
1561
1599
  };
@@ -1767,27 +1805,33 @@ async function* afterCursor(stream, cursor) {
1767
1805
  const withClose = (source, cursor) => Object.assign(afterCursor(source, cursor), { close: async () => {
1768
1806
  await source.close?.();
1769
1807
  } });
1770
- const messageStream = (client, phone, onInbound) => resumableOrderedStream({
1808
+ const messageStream = (client, phone, onInbound, recover) => resumableOrderedStream({
1771
1809
  fetchMissed: (cursor) => catchUpEvents(client, cursor, isMessageEvent),
1772
1810
  isCursorRejectedError: isCursorRejectedIMessageError,
1773
1811
  label: streamLabel("messages", phone),
1812
+ recover,
1774
1813
  processLive: (event) => toMessageItem(client, event, phone, String(event.sequence), onInbound),
1775
1814
  processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toMessageItem(client, event, phone, String(event.sequence), onInbound),
1776
1815
  subscribeLive: (cursor) => withClose(client.messages.subscribeEvents(), cursor)
1777
1816
  });
1778
- const pollStream = (client, pollCache, phone) => resumableOrderedStream({
1817
+ const pollStream = (client, pollCache, phone, recover) => resumableOrderedStream({
1779
1818
  fetchMissed: (cursor) => catchUpEvents(client, cursor, isPollEvent),
1780
1819
  isCursorRejectedError: isCursorRejectedIMessageError,
1781
1820
  label: streamLabel("polls", phone),
1821
+ recover,
1782
1822
  processLive: (event) => toPollItem(client, pollCache, event, phone, String(event.sequence)),
1783
1823
  processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toPollItem(client, pollCache, event, phone, String(event.sequence)),
1784
1824
  subscribeLive: (cursor) => withClose(client.polls.subscribeEvents(), cursor)
1785
1825
  });
1786
- const clientStream = (client, pollCache, phone, onInbound) => mergeStreams([messageStream(client, phone, onInbound), pollStream(client, pollCache, phone)]);
1826
+ const clientStream = (client, pollCache, phone, onInbound, recover) => mergeStreams([messageStream(client, phone, onInbound, recover), pollStream(client, pollCache, phone, recover)]);
1787
1827
  const messages$1 = (clients, projectConfig) => {
1788
1828
  const pollCache = getPollCache(clients);
1789
- const tracker = projectConfig?.profile?.imessageSynced === true ? getContactShareTracker(clients) : void 0;
1790
- return mergeStreams(clients.map((entry) => clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(entry.client, chatGuid) : void 0)));
1829
+ const shareEnabled = projectConfig?.profile?.imessageSynced === true;
1830
+ const recover = getCloudRecover(clients);
1831
+ return mergeStreams(clients.map((entry) => {
1832
+ const tracker = shareEnabled ? getContactShareTracker(entry.client) : void 0;
1833
+ return clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(chatGuid) : void 0, recover);
1834
+ }));
1791
1835
  };
1792
1836
  //#endregion
1793
1837
  //#region src/remote/stream-text.ts
@@ -2044,6 +2088,8 @@ const imessage = definePlatform("iMessage", {
2044
2088
  phone: e.phone,
2045
2089
  client: createClient({
2046
2090
  address: e.address,
2091
+ autoIdempotency: true,
2092
+ retry: true,
2047
2093
  tls: true,
2048
2094
  token: e.token
2049
2095
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectrum-ts/imessage",
3
- "version": "5.2.0",
3
+ "version": "6.1.0",
4
4
  "description": "iMessage provider for spectrum-ts — local and remote (advanced) modes.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,7 @@
39
39
  "zod": "^4.2.1"
40
40
  },
41
41
  "peerDependencies": {
42
- "@spectrum-ts/core": "^5.0.0",
42
+ "@spectrum-ts/core": "^6.0.0",
43
43
  "typescript": "^5 || ^6.0.0"
44
44
  },
45
45
  "license": "MIT"