@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.
- package/dist/index.js +94 -48
- 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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
280
|
+
if (disposed) return;
|
|
281
|
+
renewalTimer = setTimeout(runScheduledRefresh, RETRY_DELAY_MS);
|
|
266
282
|
renewalTimer?.unref?.();
|
|
267
|
-
}
|
|
268
|
-
}
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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,
|
|
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,
|
|
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
|
|
1512
|
-
* to, so `im.chats.shareContactInfo` is fired at most once per chat per
|
|
1513
|
-
* per
|
|
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(
|
|
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-
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1552
|
-
*
|
|
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 = (
|
|
1555
|
-
let tracker = trackers.get(
|
|
1592
|
+
const getContactShareTracker = (client) => {
|
|
1593
|
+
let tracker = trackers.get(client);
|
|
1556
1594
|
if (!tracker) {
|
|
1557
|
-
tracker = new ContactShareTracker();
|
|
1558
|
-
trackers.set(
|
|
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
|
|
1790
|
-
|
|
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": "
|
|
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": "^
|
|
42
|
+
"@spectrum-ts/core": "^6.0.0",
|
|
43
43
|
"typescript": "^5 || ^6.0.0"
|
|
44
44
|
},
|
|
45
45
|
"license": "MIT"
|