@spectrum-ts/imessage 12.3.0 → 12.4.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 +110 -22
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -671,7 +671,7 @@ const getRemoteAttachment = async (client, guid) => {
671
671
  };
672
672
  //#endregion
673
673
  //#region src/remote/inbound.ts
674
- const log$3 = createLogger("spectrum.imessage.inbound");
674
+ const log$4 = createLogger("spectrum.imessage.inbound");
675
675
  const messageAttachments = (message) => message.content.attachments;
676
676
  const resolveChatGuid = (message, hint) => {
677
677
  if (hint) return hint;
@@ -735,7 +735,7 @@ const toVCardContent = async (client, info) => {
735
735
  try {
736
736
  return asContact(fromVCard((await downloadPrimaryAttachment(client, info.guid)).toString("utf8")));
737
737
  } catch (err) {
738
- log$3.warn("failed to parse vCard attachment; falling back to attachment content", {
738
+ log$4.warn("failed to parse vCard attachment; falling back to attachment content", {
739
739
  "spectrum.imessage.attachment.guid": info.guid,
740
740
  ...errorAttrs(err)
741
741
  }, err);
@@ -823,7 +823,7 @@ const resolveReplyTarget = async (client, base, targetGuid, currentGuid, options
823
823
  if (options.cache) cacheMessage(options.cache, rebuilt);
824
824
  return rebuilt;
825
825
  } catch (err) {
826
- if (!(err instanceof NotFoundError)) log$3.warn("failed to resolve iMessage reply target; falling back to stub target", {
826
+ if (!(err instanceof NotFoundError)) log$4.warn("failed to resolve iMessage reply target; falling back to stub target", {
827
827
  "spectrum.imessage.message.guid": currentGuid,
828
828
  "spectrum.imessage.reply.target_guid": targetGuid,
829
829
  ...errorAttrs(err)
@@ -1503,7 +1503,7 @@ const randomPhone = (clients) => {
1503
1503
  };
1504
1504
  //#endregion
1505
1505
  //#region src/remote/contact-share.ts
1506
- const log$2 = createLogger("spectrum.imessage.contact");
1506
+ const log$3 = createLogger("spectrum.imessage.contact");
1507
1507
  const SHARE_TTL_MS = 1440 * 60 * 1e3;
1508
1508
  const MAX_TRACKED_CHATS = 1e4;
1509
1509
  const isPreconditionFailure = (error) => typeof error === "object" && error !== null && "code" in error && error.code === ErrorCode.preconditionFailed;
@@ -1530,6 +1530,14 @@ var ContactShareTracker = class {
1530
1530
  this.client = client;
1531
1531
  }
1532
1532
  /**
1533
+ * Whether this line has already attempted to share with the chat during the
1534
+ * 24-hour dedupe window. Callers use this before consulting any remote gate,
1535
+ * so repeat inbound messages stay entirely local.
1536
+ */
1537
+ hasRecentlyShared(chatGuid) {
1538
+ return this.cache.has(chatGuid);
1539
+ }
1540
+ /**
1533
1541
  * Best-effort share. The cache is set eagerly so that a burst of inbound
1534
1542
  * messages for the same chat coalesces to a single API call. A
1535
1543
  * `preconditionFailed` response remains cached for the normal 24-hour TTL,
@@ -1543,10 +1551,10 @@ var ContactShareTracker = class {
1543
1551
  this.cache.set(chatGuid, true);
1544
1552
  const safeChatGuid = sanitizeErrorMessage(chatGuid);
1545
1553
  this.client.chats.shareContactInfo(chatGuid).then(() => {
1546
- log$2.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1554
+ log$3.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1547
1555
  }).catch((error) => {
1548
1556
  if (!isPreconditionFailure(error)) this.cache.delete(chatGuid);
1549
- log$2.warn("failed to share contact card", {
1557
+ log$3.warn("failed to share contact card", {
1550
1558
  "spectrum.imessage.contact.chat": safeChatGuid,
1551
1559
  ...errorAttrs(error)
1552
1560
  }, error);
@@ -1572,7 +1580,7 @@ const getContactShareTracker = (client) => {
1572
1580
  };
1573
1581
  //#endregion
1574
1582
  //#region src/remote/group-events.ts
1575
- const log$1 = createLogger("spectrum.imessage.group");
1583
+ const log$2 = createLogger("spectrum.imessage.group");
1576
1584
  /**
1577
1585
  * Synthetic id for a `group.changed` event — shared between the stream item
1578
1586
  * (the dedup key across live/catch-up) and the surfaced message. `sequence`
@@ -1602,7 +1610,7 @@ const fetchIconContent = async (client, event) => {
1602
1610
  }
1603
1611
  });
1604
1612
  } catch (e) {
1605
- log$1.error("failed to fetch changed group icon", {
1613
+ log$2.error("failed to fetch changed group icon", {
1606
1614
  "spectrum.imessage.group.chat": event.chatGuid,
1607
1615
  ...errorAttrs(e)
1608
1616
  }, e);
@@ -1657,7 +1665,7 @@ const toGroupEventMessages = async (client, event, phone) => {
1657
1665
  };
1658
1666
  //#endregion
1659
1667
  //#region src/remote/polls.ts
1660
- const log = createLogger("spectrum.imessage.poll");
1668
+ const log$1 = createLogger("spectrum.imessage.poll");
1661
1669
  const isVotedPollEvent = (event) => event.delta.type === "voted";
1662
1670
  const isUnvotedPollEvent = (event) => event.delta.type === "unvoted";
1663
1671
  const toCachedPoll = (input) => {
@@ -1689,7 +1697,7 @@ const cachePollEvent = (cache, event) => {
1689
1697
  cache.set(event.pollMessageGuid, cached);
1690
1698
  return cached;
1691
1699
  } catch (e) {
1692
- log.error("failed to cache poll", {
1700
+ log$1.error("failed to cache poll", {
1693
1701
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1694
1702
  ...errorAttrs(e)
1695
1703
  }, e);
@@ -1701,7 +1709,7 @@ const fetchPollInfo = async (client, cache, event) => {
1701
1709
  cachePollInfo(cache, info);
1702
1710
  return info;
1703
1711
  } catch (e) {
1704
- log.error("failed to fetch poll", {
1712
+ log$1.error("failed to fetch poll", {
1705
1713
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1706
1714
  ...errorAttrs(e)
1707
1715
  }, e);
@@ -1714,7 +1722,7 @@ const resolvePoll = async (client, cache, event) => {
1714
1722
  try {
1715
1723
  return cachePollInfo(cache, await client.polls.get(event.pollMessageGuid));
1716
1724
  } catch (e) {
1717
- log.error("failed to resolve poll", {
1725
+ log$1.error("failed to resolve poll", {
1718
1726
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1719
1727
  ...errorAttrs(e)
1720
1728
  }, e);
@@ -1931,14 +1939,23 @@ const clientStream = (client, pollCache, phone, includeGroupEvents, onInbound, r
1931
1939
  if (includeGroupEvents) streams.push(groupStream(client, phone, recover));
1932
1940
  return mergeStreams(streams);
1933
1941
  };
1934
- const messages$1 = (clients, projectConfig) => {
1942
+ const shareWhenProfileSynced = (tracker, gate, chatGuid) => {
1943
+ if (tracker.hasRecentlyShared(chatGuid)) return;
1944
+ gate.isEnabled().then((enabled) => {
1945
+ if (enabled) tracker.maybeShare(chatGuid);
1946
+ }).catch((error) => {
1947
+ streamLog.warn("profile sync gate failed; skipping automatic contact sharing", errorAttrs(error), error instanceof Error ? error : void 0);
1948
+ });
1949
+ };
1950
+ const contactShareHandler = (tracker, profileSyncGate) => profileSyncGate ? (chatGuid) => shareWhenProfileSynced(tracker, profileSyncGate, chatGuid) : (chatGuid) => tracker.maybeShare(chatGuid);
1951
+ const messages$1 = (clients, projectConfig, profileSyncGate) => {
1935
1952
  const pollCache = getPollCache(clients);
1936
- const shareEnabled = projectConfig?.profile?.imessageSynced === true;
1953
+ const staticShareEnabled = projectConfig?.profile?.imessageSynced === true;
1937
1954
  const recover = getCloudRecover(clients);
1938
1955
  const includeGroupEvents = !isSharedMode(clients);
1939
1956
  return mergeStreams(clients.map((entry) => {
1940
- const tracker = shareEnabled ? getContactShareTracker(entry.client) : void 0;
1941
- return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? (chatGuid) => tracker.maybeShare(chatGuid) : void 0, recover);
1957
+ const tracker = staticShareEnabled || profileSyncGate ? getContactShareTracker(entry.client) : void 0;
1958
+ return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? contactShareHandler(tracker, profileSyncGate) : void 0, recover);
1942
1959
  }));
1943
1960
  };
1944
1961
  //#endregion
@@ -1999,7 +2016,7 @@ const stopTyping$1 = async (remote, spaceId) => {
1999
2016
  };
2000
2017
  //#endregion
2001
2018
  //#region src/remote/api.ts
2002
- const messages = (clients, projectConfig) => messages$1(clients, projectConfig);
2019
+ const messages = (clients, projectConfig, profileSyncGate) => messages$1(clients, projectConfig, profileSyncGate);
2003
2020
  const setBackground = async (remote, spaceId, content) => setBackground$1(remote, spaceId, content);
2004
2021
  const sendCustomizedMiniApp = async (remote, spaceId, content) => sendCustomizedMiniApp$1(remote, spaceId, content);
2005
2022
  const updateCustomizedMiniApp = async (remote, spaceId, session, content) => updateCustomizedMiniApp$1(remote, spaceId, session, content);
@@ -2058,6 +2075,73 @@ const toSpectrumMiniApp = (url, layout, live) => asCustomizedMiniApp({
2058
2075
  ...live === void 0 ? {} : { live }
2059
2076
  });
2060
2077
  //#endregion
2078
+ //#region src/remote/profile-sync-gate.ts
2079
+ const PROFILE_SYNC_CACHE_TTL_MS = 6e4;
2080
+ const PROFILE_SYNC_RETRY_DELAY_MS = 3e4;
2081
+ const log = createLogger("spectrum.imessage.profile-sync");
2082
+ const createProfileSyncGate = (options) => {
2083
+ const cacheTtlMs = options.cacheTtlMs ?? PROFILE_SYNC_CACHE_TTL_MS;
2084
+ const retryDelayMs = options.retryDelayMs ?? PROFILE_SYNC_RETRY_DELAY_MS;
2085
+ let cachedEnabled = options.initialEnabled;
2086
+ let disposed = false;
2087
+ let refreshInFlight;
2088
+ let retryAfter = 0;
2089
+ let validUntil = Date.now() + cacheTtlMs;
2090
+ const refreshNow = async () => {
2091
+ try {
2092
+ const enabled = await options.refresh();
2093
+ if (disposed) return false;
2094
+ cachedEnabled = enabled;
2095
+ retryAfter = 0;
2096
+ validUntil = Date.now() + cacheTtlMs;
2097
+ return enabled;
2098
+ } catch (error) {
2099
+ cachedEnabled = false;
2100
+ retryAfter = Date.now() + retryDelayMs;
2101
+ validUntil = 0;
2102
+ log.warn("failed to refresh profile sync state; automatic contact sharing remains disabled", {
2103
+ "spectrum.imessage.profile_sync.retry_in_ms": retryDelayMs,
2104
+ ...errorAttrs(error)
2105
+ }, error);
2106
+ return false;
2107
+ }
2108
+ };
2109
+ const coalescedRefresh = () => {
2110
+ if (!refreshInFlight) refreshInFlight = refreshNow().finally(() => {
2111
+ refreshInFlight = void 0;
2112
+ });
2113
+ return refreshInFlight;
2114
+ };
2115
+ return {
2116
+ dispose() {
2117
+ disposed = true;
2118
+ cachedEnabled = false;
2119
+ validUntil = 0;
2120
+ },
2121
+ async isEnabled() {
2122
+ if (disposed) return false;
2123
+ const now = Date.now();
2124
+ if (now < validUntil) return cachedEnabled;
2125
+ if (now < retryAfter) return false;
2126
+ return await coalescedRefresh();
2127
+ }
2128
+ };
2129
+ };
2130
+ const gates = /* @__PURE__ */ new WeakMap();
2131
+ const isProfileSynced = (projectConfig) => projectConfig.profile?.imessageSynced === true;
2132
+ const registerProfileSyncGate = (clients, projectId, projectSecret, projectConfig) => {
2133
+ gates.get(clients)?.dispose();
2134
+ gates.set(clients, createProfileSyncGate({
2135
+ initialEnabled: isProfileSynced(projectConfig),
2136
+ refresh: async () => isProfileSynced(await cloud.getProject(projectId, projectSecret))
2137
+ }));
2138
+ };
2139
+ const getProfileSyncGate = (clients) => gates.get(clients);
2140
+ const disposeProfileSyncGate = (clients) => {
2141
+ gates.get(clients)?.dispose();
2142
+ gates.delete(clients);
2143
+ };
2144
+ //#endregion
2061
2145
  //#region src/index.ts
2062
2146
  const isPollContent = (content) => content.type === "poll" || content.type === "poll_option";
2063
2147
  const cacheRemoteOutbound = (remote, space, record) => {
@@ -2197,8 +2281,9 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2197
2281
  config: configSchema,
2198
2282
  static: { effect: { message: messageEffects } },
2199
2283
  lifecycle: {
2200
- createClient: async ({ config, projectId, projectSecret }) => {
2201
- if (config.clients) return (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2284
+ createClient: async ({ config, projectConfig, projectId, projectSecret }) => {
2285
+ let clients;
2286
+ if (config.clients) clients = (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2202
2287
  phone: e.phone,
2203
2288
  client: createGrpcClient({
2204
2289
  address: e.address,
@@ -2208,10 +2293,13 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2208
2293
  token: e.token
2209
2294
  })
2210
2295
  }));
2211
- if (!(projectId && projectSecret)) throw new Error("Cloud iMessage requires projectId and projectSecret. Pass credentials to Spectrum() or provide explicit clients with imessage.config({ clients: [...] }). For local Messages access, install @spectrum-ts/imessage-local and use localIMessage.config().");
2212
- return await createCloudClients(projectId, projectSecret);
2296
+ else if (projectId && projectSecret) clients = await createCloudClients(projectId, projectSecret);
2297
+ else throw new Error("Cloud iMessage requires projectId and projectSecret. Pass credentials to Spectrum() or provide explicit clients with imessage.config({ clients: [...] }). For local Messages access, install @spectrum-ts/imessage-local and use localIMessage.config().");
2298
+ if (projectId && projectSecret && projectConfig) registerProfileSyncGate(clients, projectId, projectSecret, projectConfig);
2299
+ return clients;
2213
2300
  },
2214
2301
  destroyClient: async ({ client }) => {
2302
+ disposeProfileSyncGate(client);
2215
2303
  await disposeCloudAuth(client);
2216
2304
  await Promise.all(client.map((entry) => entry.client.close()));
2217
2305
  }
@@ -2263,7 +2351,7 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2263
2351
  }
2264
2352
  },
2265
2353
  message: { schema: messageSchema },
2266
- messages: ({ client, projectConfig }) => messages(client, projectConfig),
2354
+ messages: ({ client, projectConfig }) => messages(client, projectConfig, getProfileSyncGate(client)),
2267
2355
  send: async ({ space, content, client }) => {
2268
2356
  if (content.type === "reply") {
2269
2357
  const remote = remoteForMessageTarget(client, space, content.target, "reply", "replies");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectrum-ts/imessage",
3
- "version": "12.3.0",
3
+ "version": "12.4.0",
4
4
  "description": "iMessage provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",