@spectrum-ts/imessage 12.2.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 +119 -28
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { NotFoundError, ValidationError, createGrpcClient } from "@photon-ai/advanced-imessage/grpc";
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
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";
@@ -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,9 +1503,10 @@ 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
+ const isPreconditionFailure = (error) => typeof error === "object" && error !== null && "code" in error && error.code === ErrorCode.preconditionFailed;
1509
1510
  /**
1510
1511
  * Tracks which chats this bot's line has already proactively pushed its contact
1511
1512
  * card to, so `im.chats.shareContactInfo` is fired at most once per chat per
@@ -1529,21 +1530,31 @@ var ContactShareTracker = class {
1529
1530
  this.client = client;
1530
1531
  }
1531
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
+ /**
1532
1541
  * Best-effort share. The cache is set eagerly so that a burst of inbound
1533
- * messages for the same chat coalesces to a single API call. On failure the
1534
- * entry is evicted so the next inbound retries — transient errors don't
1535
- * permanently mute the feature for a chat. Never awaits and never throws:
1536
- * the receive stream must not crash on share failures.
1542
+ * messages for the same chat coalesces to a single API call. A
1543
+ * `preconditionFailed` response remains cached for the normal 24-hour TTL,
1544
+ * avoiding repeated attempts when the account cannot currently share its
1545
+ * profile. Other failures evict the entry so the next inbound retries.
1546
+ * Never awaits and never throws: the receive stream must not crash on share
1547
+ * failures.
1537
1548
  */
1538
1549
  maybeShare(chatGuid) {
1539
1550
  if (this.cache.has(chatGuid)) return;
1540
1551
  this.cache.set(chatGuid, true);
1541
1552
  const safeChatGuid = sanitizeErrorMessage(chatGuid);
1542
1553
  this.client.chats.shareContactInfo(chatGuid).then(() => {
1543
- log$2.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1554
+ log$3.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1544
1555
  }).catch((error) => {
1545
- this.cache.delete(chatGuid);
1546
- log$2.warn("failed to share contact card", {
1556
+ if (!isPreconditionFailure(error)) this.cache.delete(chatGuid);
1557
+ log$3.warn("failed to share contact card", {
1547
1558
  "spectrum.imessage.contact.chat": safeChatGuid,
1548
1559
  ...errorAttrs(error)
1549
1560
  }, error);
@@ -1569,7 +1580,7 @@ const getContactShareTracker = (client) => {
1569
1580
  };
1570
1581
  //#endregion
1571
1582
  //#region src/remote/group-events.ts
1572
- const log$1 = createLogger("spectrum.imessage.group");
1583
+ const log$2 = createLogger("spectrum.imessage.group");
1573
1584
  /**
1574
1585
  * Synthetic id for a `group.changed` event — shared between the stream item
1575
1586
  * (the dedup key across live/catch-up) and the surfaced message. `sequence`
@@ -1599,7 +1610,7 @@ const fetchIconContent = async (client, event) => {
1599
1610
  }
1600
1611
  });
1601
1612
  } catch (e) {
1602
- log$1.error("failed to fetch changed group icon", {
1613
+ log$2.error("failed to fetch changed group icon", {
1603
1614
  "spectrum.imessage.group.chat": event.chatGuid,
1604
1615
  ...errorAttrs(e)
1605
1616
  }, e);
@@ -1654,7 +1665,7 @@ const toGroupEventMessages = async (client, event, phone) => {
1654
1665
  };
1655
1666
  //#endregion
1656
1667
  //#region src/remote/polls.ts
1657
- const log = createLogger("spectrum.imessage.poll");
1668
+ const log$1 = createLogger("spectrum.imessage.poll");
1658
1669
  const isVotedPollEvent = (event) => event.delta.type === "voted";
1659
1670
  const isUnvotedPollEvent = (event) => event.delta.type === "unvoted";
1660
1671
  const toCachedPoll = (input) => {
@@ -1686,7 +1697,7 @@ const cachePollEvent = (cache, event) => {
1686
1697
  cache.set(event.pollMessageGuid, cached);
1687
1698
  return cached;
1688
1699
  } catch (e) {
1689
- log.error("failed to cache poll", {
1700
+ log$1.error("failed to cache poll", {
1690
1701
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1691
1702
  ...errorAttrs(e)
1692
1703
  }, e);
@@ -1698,7 +1709,7 @@ const fetchPollInfo = async (client, cache, event) => {
1698
1709
  cachePollInfo(cache, info);
1699
1710
  return info;
1700
1711
  } catch (e) {
1701
- log.error("failed to fetch poll", {
1712
+ log$1.error("failed to fetch poll", {
1702
1713
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1703
1714
  ...errorAttrs(e)
1704
1715
  }, e);
@@ -1711,7 +1722,7 @@ const resolvePoll = async (client, cache, event) => {
1711
1722
  try {
1712
1723
  return cachePollInfo(cache, await client.polls.get(event.pollMessageGuid));
1713
1724
  } catch (e) {
1714
- log.error("failed to resolve poll", {
1725
+ log$1.error("failed to resolve poll", {
1715
1726
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1716
1727
  ...errorAttrs(e)
1717
1728
  }, e);
@@ -1928,14 +1939,23 @@ const clientStream = (client, pollCache, phone, includeGroupEvents, onInbound, r
1928
1939
  if (includeGroupEvents) streams.push(groupStream(client, phone, recover));
1929
1940
  return mergeStreams(streams);
1930
1941
  };
1931
- 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) => {
1932
1952
  const pollCache = getPollCache(clients);
1933
- const shareEnabled = projectConfig?.profile?.imessageSynced === true;
1953
+ const staticShareEnabled = projectConfig?.profile?.imessageSynced === true;
1934
1954
  const recover = getCloudRecover(clients);
1935
1955
  const includeGroupEvents = !isSharedMode(clients);
1936
1956
  return mergeStreams(clients.map((entry) => {
1937
- const tracker = shareEnabled ? getContactShareTracker(entry.client) : void 0;
1938
- 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);
1939
1959
  }));
1940
1960
  };
1941
1961
  //#endregion
@@ -1996,7 +2016,7 @@ const stopTyping$1 = async (remote, spaceId) => {
1996
2016
  };
1997
2017
  //#endregion
1998
2018
  //#region src/remote/api.ts
1999
- const messages = (clients, projectConfig) => messages$1(clients, projectConfig);
2019
+ const messages = (clients, projectConfig, profileSyncGate) => messages$1(clients, projectConfig, profileSyncGate);
2000
2020
  const setBackground = async (remote, spaceId, content) => setBackground$1(remote, spaceId, content);
2001
2021
  const sendCustomizedMiniApp = async (remote, spaceId, content) => sendCustomizedMiniApp$1(remote, spaceId, content);
2002
2022
  const updateCustomizedMiniApp = async (remote, spaceId, session, content) => updateCustomizedMiniApp$1(remote, spaceId, session, content);
@@ -2055,6 +2075,73 @@ const toSpectrumMiniApp = (url, layout, live) => asCustomizedMiniApp({
2055
2075
  ...live === void 0 ? {} : { live }
2056
2076
  });
2057
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
2058
2145
  //#region src/index.ts
2059
2146
  const isPollContent = (content) => content.type === "poll" || content.type === "poll_option";
2060
2147
  const cacheRemoteOutbound = (remote, space, record) => {
@@ -2194,8 +2281,9 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2194
2281
  config: configSchema,
2195
2282
  static: { effect: { message: messageEffects } },
2196
2283
  lifecycle: {
2197
- createClient: async ({ config, projectId, projectSecret }) => {
2198
- 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) => ({
2199
2287
  phone: e.phone,
2200
2288
  client: createGrpcClient({
2201
2289
  address: e.address,
@@ -2205,10 +2293,13 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2205
2293
  token: e.token
2206
2294
  })
2207
2295
  }));
2208
- 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().");
2209
- 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;
2210
2300
  },
2211
2301
  destroyClient: async ({ client }) => {
2302
+ disposeProfileSyncGate(client);
2212
2303
  await disposeCloudAuth(client);
2213
2304
  await Promise.all(client.map((entry) => entry.client.close()));
2214
2305
  }
@@ -2260,7 +2351,7 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2260
2351
  }
2261
2352
  },
2262
2353
  message: { schema: messageSchema },
2263
- messages: ({ client, projectConfig }) => messages(client, projectConfig),
2354
+ messages: ({ client, projectConfig }) => messages(client, projectConfig, getProfileSyncGate(client)),
2264
2355
  send: async ({ space, content, client }) => {
2265
2356
  if (content.type === "reply") {
2266
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.2.0",
3
+ "version": "12.4.0",
4
4
  "description": "iMessage provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@grpc/grpc-js": "^1.14.4",
35
35
  "@photon-ai/advanced-imessage": "^2.0.2",
36
- "@photon-ai/otel": "^3.1.0",
36
+ "@photon-ai/otel": "^3.3.0",
37
37
  "lru-cache": "^11.0.0",
38
38
  "marked": "^18.0.5",
39
39
  "nice-grpc": "^2.1.16",