@spectrum-ts/imessage 12.3.0 → 12.5.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 +300 -31
  2. 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, removeMemberSchema, renameSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
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";
5
5
  import z from "zod";
6
6
  import { Marked } from "marked";
7
7
  import { LRUCache } from "lru-cache";
@@ -409,6 +409,12 @@ const IMESSAGE_PLATFORM = "imessage";
409
409
  const PART_PREFIX = /^p:(\d+)\//;
410
410
  const dmChatGuid = (address) => `any;-;${address}`;
411
411
  const chatTypeFromGuid = (guid) => guid.includes(";+;") ? "group" : "dm";
412
+ const dmPeerFromChatGuid = (guid) => {
413
+ if (chatTypeFromGuid(guid) !== "dm") return;
414
+ const separator = guid.indexOf(";-;");
415
+ if (separator < 0) return;
416
+ return guid.slice(separator + 3) || void 0;
417
+ };
412
418
  const toChatGuid = (value) => value;
413
419
  const toMessageGuid = (value) => value;
414
420
  const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
@@ -671,7 +677,7 @@ const getRemoteAttachment = async (client, guid) => {
671
677
  };
672
678
  //#endregion
673
679
  //#region src/remote/inbound.ts
674
- const log$3 = createLogger("spectrum.imessage.inbound");
680
+ const log$5 = createLogger("spectrum.imessage.inbound");
675
681
  const messageAttachments = (message) => message.content.attachments;
676
682
  const resolveChatGuid = (message, hint) => {
677
683
  if (hint) return hint;
@@ -735,7 +741,7 @@ const toVCardContent = async (client, info) => {
735
741
  try {
736
742
  return asContact(fromVCard((await downloadPrimaryAttachment(client, info.guid)).toString("utf8")));
737
743
  } catch (err) {
738
- log$3.warn("failed to parse vCard attachment; falling back to attachment content", {
744
+ log$5.warn("failed to parse vCard attachment; falling back to attachment content", {
739
745
  "spectrum.imessage.attachment.guid": info.guid,
740
746
  ...errorAttrs(err)
741
747
  }, err);
@@ -823,7 +829,7 @@ const resolveReplyTarget = async (client, base, targetGuid, currentGuid, options
823
829
  if (options.cache) cacheMessage(options.cache, rebuilt);
824
830
  return rebuilt;
825
831
  } catch (err) {
826
- if (!(err instanceof NotFoundError)) log$3.warn("failed to resolve iMessage reply target; falling back to stub target", {
832
+ if (!(err instanceof NotFoundError)) log$5.warn("failed to resolve iMessage reply target; falling back to stub target", {
827
833
  "spectrum.imessage.message.guid": currentGuid,
828
834
  "spectrum.imessage.reply.target_guid": targetGuid,
829
835
  ...errorAttrs(err)
@@ -860,6 +866,36 @@ const cacheMessage = (cache, message) => {
860
866
  for (const item of group.items) if (isIMessageMessage(item)) cache.set(item.id, item);
861
867
  }
862
868
  };
869
+ /**
870
+ * Resolve a guid to the spectrum message it maps to, for events that reference
871
+ * their target only by guid (reactions, read receipts). Cache first — outbound
872
+ * sends are cached at send time by `cacheRemoteOutbound`, inbound messages when
873
+ * they are converted — then one `messages.get` + rebuild, which also warms the
874
+ * cache so sibling events for the same guid (a group's other participants) are
875
+ * free.
876
+ *
877
+ * Any failure drops the event: these are secondary signals and must never wedge
878
+ * the stream. Deliberately unlike `getMessage` below (the public
879
+ * `space.getMessage` action), which rethrows non-`NotFoundError` failures; and
880
+ * unlike it this never resolves `p:N/guid` child ids, because event payloads
881
+ * always carry the parent guid.
882
+ */
883
+ const resolveTargetMessage = async (client, cache, chatGuid, targetGuid, phone) => {
884
+ const cached = cache.get(targetGuid);
885
+ if (cached) return cached;
886
+ try {
887
+ const rebuilt = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), phone, chatGuid);
888
+ cacheMessage(cache, rebuilt);
889
+ return rebuilt;
890
+ } catch (error) {
891
+ log$5.debug("event target could not be resolved; dropping the event", {
892
+ "spectrum.imessage.target_guid": targetGuid,
893
+ "spectrum.imessage.chat_guid": chatGuid,
894
+ ...errorAttrs(error)
895
+ }, error instanceof Error ? error : void 0);
896
+ return;
897
+ }
898
+ };
863
899
  const toInboundMessages = async (client, cache, event, phone) => {
864
900
  const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
865
901
  const messageGuidStr = event.message.guid;
@@ -955,13 +991,8 @@ const asProviderReaction = (emoji, target) => reactionSchema.parse({
955
991
  type: "reaction"
956
992
  });
957
993
  const resolveReactionTarget = async (client, cache, chat, targetGuid, partIndex, phone) => {
958
- let candidate = cache.get(targetGuid);
959
- if (!candidate) try {
960
- candidate = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), phone, chat);
961
- cacheMessage(cache, candidate);
962
- } catch {
963
- return;
964
- }
994
+ const candidate = await resolveTargetMessage(client, cache, chat, targetGuid, phone);
995
+ if (!candidate) return;
965
996
  if (candidate.content.type === "group") {
966
997
  const items = candidate.content.items;
967
998
  if (!Array.isArray(items)) return candidate;
@@ -1503,7 +1534,7 @@ const randomPhone = (clients) => {
1503
1534
  };
1504
1535
  //#endregion
1505
1536
  //#region src/remote/contact-share.ts
1506
- const log$2 = createLogger("spectrum.imessage.contact");
1537
+ const log$4 = createLogger("spectrum.imessage.contact");
1507
1538
  const SHARE_TTL_MS = 1440 * 60 * 1e3;
1508
1539
  const MAX_TRACKED_CHATS = 1e4;
1509
1540
  const isPreconditionFailure = (error) => typeof error === "object" && error !== null && "code" in error && error.code === ErrorCode.preconditionFailed;
@@ -1530,6 +1561,14 @@ var ContactShareTracker = class {
1530
1561
  this.client = client;
1531
1562
  }
1532
1563
  /**
1564
+ * Whether this line has already attempted to share with the chat during the
1565
+ * 24-hour dedupe window. Callers use this before consulting any remote gate,
1566
+ * so repeat inbound messages stay entirely local.
1567
+ */
1568
+ hasRecentlyShared(chatGuid) {
1569
+ return this.cache.has(chatGuid);
1570
+ }
1571
+ /**
1533
1572
  * Best-effort share. The cache is set eagerly so that a burst of inbound
1534
1573
  * messages for the same chat coalesces to a single API call. A
1535
1574
  * `preconditionFailed` response remains cached for the normal 24-hour TTL,
@@ -1543,10 +1582,10 @@ var ContactShareTracker = class {
1543
1582
  this.cache.set(chatGuid, true);
1544
1583
  const safeChatGuid = sanitizeErrorMessage(chatGuid);
1545
1584
  this.client.chats.shareContactInfo(chatGuid).then(() => {
1546
- log$2.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1585
+ log$4.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1547
1586
  }).catch((error) => {
1548
1587
  if (!isPreconditionFailure(error)) this.cache.delete(chatGuid);
1549
- log$2.warn("failed to share contact card", {
1588
+ log$4.warn("failed to share contact card", {
1550
1589
  "spectrum.imessage.contact.chat": safeChatGuid,
1551
1590
  ...errorAttrs(error)
1552
1591
  }, error);
@@ -1572,7 +1611,7 @@ const getContactShareTracker = (client) => {
1572
1611
  };
1573
1612
  //#endregion
1574
1613
  //#region src/remote/group-events.ts
1575
- const log$1 = createLogger("spectrum.imessage.group");
1614
+ const log$3 = createLogger("spectrum.imessage.group");
1576
1615
  /**
1577
1616
  * Synthetic id for a `group.changed` event — shared between the stream item
1578
1617
  * (the dedup key across live/catch-up) and the surfaced message. `sequence`
@@ -1602,7 +1641,7 @@ const fetchIconContent = async (client, event) => {
1602
1641
  }
1603
1642
  });
1604
1643
  } catch (e) {
1605
- log$1.error("failed to fetch changed group icon", {
1644
+ log$3.error("failed to fetch changed group icon", {
1606
1645
  "spectrum.imessage.group.chat": event.chatGuid,
1607
1646
  ...errorAttrs(e)
1608
1647
  }, e);
@@ -1657,7 +1696,7 @@ const toGroupEventMessages = async (client, event, phone) => {
1657
1696
  };
1658
1697
  //#endregion
1659
1698
  //#region src/remote/polls.ts
1660
- const log = createLogger("spectrum.imessage.poll");
1699
+ const log$2 = createLogger("spectrum.imessage.poll");
1661
1700
  const isVotedPollEvent = (event) => event.delta.type === "voted";
1662
1701
  const isUnvotedPollEvent = (event) => event.delta.type === "unvoted";
1663
1702
  const toCachedPoll = (input) => {
@@ -1689,7 +1728,7 @@ const cachePollEvent = (cache, event) => {
1689
1728
  cache.set(event.pollMessageGuid, cached);
1690
1729
  return cached;
1691
1730
  } catch (e) {
1692
- log.error("failed to cache poll", {
1731
+ log$2.error("failed to cache poll", {
1693
1732
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1694
1733
  ...errorAttrs(e)
1695
1734
  }, e);
@@ -1701,7 +1740,7 @@ const fetchPollInfo = async (client, cache, event) => {
1701
1740
  cachePollInfo(cache, info);
1702
1741
  return info;
1703
1742
  } catch (e) {
1704
- log.error("failed to fetch poll", {
1743
+ log$2.error("failed to fetch poll", {
1705
1744
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1706
1745
  ...errorAttrs(e)
1707
1746
  }, e);
@@ -1714,7 +1753,7 @@ const resolvePoll = async (client, cache, event) => {
1714
1753
  try {
1715
1754
  return cachePollInfo(cache, await client.polls.get(event.pollMessageGuid));
1716
1755
  } catch (e) {
1717
- log.error("failed to resolve poll", {
1756
+ log$2.error("failed to resolve poll", {
1718
1757
  "spectrum.imessage.poll.guid": event.pollMessageGuid,
1719
1758
  ...errorAttrs(e)
1720
1759
  }, e);
@@ -1774,10 +1813,140 @@ const toPollDeltaMessages = async (client, pollCache, event, phone) => {
1774
1813
  return [];
1775
1814
  };
1776
1815
  //#endregion
1816
+ //#region src/remote/read-receipts.ts
1817
+ const log$1 = createLogger("spectrum.imessage.read");
1818
+ /**
1819
+ * Synthetic id for a `message.read` event — shared between the stream item
1820
+ * (the dedup key across live/catch-up) and the surfaced message. `sequence` is
1821
+ * monotonic per line, so N participants reading the same message produce N
1822
+ * distinct ids, and the same event replayed through catch-up produces the id
1823
+ * it produced live.
1824
+ */
1825
+ const readReceiptMessageId = (event) => `${event.messageGuid}:read:${event.sequence}`;
1826
+ const asProviderRead = (target) => readSchema.parse({
1827
+ target,
1828
+ type: "read"
1829
+ });
1830
+ /**
1831
+ * Whether the reader could be identified at all, decided without resolving
1832
+ * the target. A DM always can (the peer is in the guid); a group only if the
1833
+ * platform named an actor. Lets an unattributable group receipt drop before
1834
+ * paying for an RPC.
1835
+ */
1836
+ const isAttributable = (event) => dmPeerFromChatGuid(event.chatGuid) !== void 0 || event.actor?.address !== void 0;
1837
+ /**
1838
+ * Identify the reader.
1839
+ *
1840
+ * `event.actor` is **not** the reader on this arm. Verified against a live
1841
+ * line: in a DM where the peer read our message, `actor` came back as *our
1842
+ * own* line's address, not theirs.
1843
+ *
1844
+ * So the chat guid comes first. In a DM there are exactly two participants
1845
+ * and the target is already known to be ours, which makes the reader
1846
+ * definitionally the other one — no address comparison needed, and therefore
1847
+ * correct on pooled lines too, where `phone` is the `"shared"` sentinel and
1848
+ * comparing it against a real address is meaningless.
1849
+ *
1850
+ * A group guid carries no participant list, so there `actor` is the only
1851
+ * possible attribution. It is trusted only when it names neither this line
1852
+ * nor the target's own sender; the latter is what catches the shared-mode
1853
+ * case, since the sentinel never equals a real address. An unattributable
1854
+ * receipt is dropped rather than surfaced with a wrong reader — the reader's
1855
+ * identity is the entire payload.
1856
+ */
1857
+ const toReader = (event, target, phone) => {
1858
+ const peer = dmPeerFromChatGuid(event.chatGuid);
1859
+ if (peer) return {
1860
+ id: peer,
1861
+ address: peer
1862
+ };
1863
+ const actor = event.actor;
1864
+ if (actor?.address && actor.address !== phone && actor.address !== target.sender?.address) return toSenderRef(actor);
1865
+ };
1866
+ /**
1867
+ * Convert a `message.read` event into an inbound `read` message — someone read
1868
+ * a message the agent sent. `sender` is the reader; `content.target` is ours.
1869
+ *
1870
+ * Two guards, cheapest first:
1871
+ *
1872
+ * 1. **The reader must be identifiable -> else drop.** Unlike a membership
1873
+ * change (where the state change itself is the payload and
1874
+ * `sender: undefined` still carries meaning), the reader's identity *is*
1875
+ * the payload of a receipt. An unattributed receipt in a group is
1876
+ * indistinguishable noise and would corrupt any "N of M have read" tally.
1877
+ * Same call as `toReactionMessages`. See `toReader`.
1878
+ *
1879
+ * 2. **The target must be one of ours.** `event.isFromMe` is not trustworthy
1880
+ * here: the proto carries no comment for it on this arm, and it most
1881
+ * plausibly describes the *underlying message* — which for a genuine
1882
+ * receipt is ours, i.e. `true` — so keying self-suppression off it would
1883
+ * suppress every receipt worth surfacing. The durable invariant is that a
1884
+ * peer's receipt always points at a message we sent, so the resolved target
1885
+ * must be `direction: "outbound"`. That one check also suppresses the echo
1886
+ * of our own `chats.markRead()`, which marks *their* inbound messages and
1887
+ * therefore resolves `direction: "inbound"`.
1888
+ */
1889
+ const toReadReceiptMessages = async (client, cache, event, phone) => {
1890
+ if (!isAttributable(event)) {
1891
+ log$1.debug("read receipt dropped: reader could not be identified", {
1892
+ "spectrum.imessage.read.message_guid": event.messageGuid,
1893
+ "spectrum.imessage.read.sequence": event.sequence,
1894
+ "spectrum.imessage.read.chat_guid": event.chatGuid,
1895
+ "spectrum.imessage.read.has_actor": false
1896
+ });
1897
+ return [];
1898
+ }
1899
+ const target = await resolveTargetMessage(client, cache, event.chatGuid, event.messageGuid, phone);
1900
+ if (!target) {
1901
+ log$1.debug("read receipt dropped: target message could not be resolved", {
1902
+ "spectrum.imessage.read.message_guid": event.messageGuid,
1903
+ "spectrum.imessage.read.sequence": event.sequence
1904
+ });
1905
+ return [];
1906
+ }
1907
+ if (target.direction !== "outbound") {
1908
+ log$1.debug("read receipt dropped: target is not one of ours", {
1909
+ "spectrum.imessage.read.message_guid": event.messageGuid,
1910
+ "spectrum.imessage.read.sequence": event.sequence,
1911
+ "spectrum.imessage.read.target_direction": target.direction ?? "unset"
1912
+ });
1913
+ return [];
1914
+ }
1915
+ const reader = toReader(event, target, phone);
1916
+ if (!reader) {
1917
+ log$1.debug("read receipt dropped: reader could not be identified", {
1918
+ "spectrum.imessage.read.message_guid": event.messageGuid,
1919
+ "spectrum.imessage.read.sequence": event.sequence,
1920
+ "spectrum.imessage.read.chat_guid": event.chatGuid,
1921
+ "spectrum.imessage.read.has_actor": true
1922
+ });
1923
+ return [];
1924
+ }
1925
+ const readAt = event.readAt;
1926
+ log$1.debug("read receipt surfaced", {
1927
+ "spectrum.imessage.read.message_guid": event.messageGuid,
1928
+ "spectrum.imessage.read.sequence": event.sequence,
1929
+ "spectrum.imessage.read.reader": sanitizePhone(reader.id),
1930
+ "spectrum.imessage.read.used_read_at": readAt !== void 0
1931
+ });
1932
+ return [{
1933
+ id: readReceiptMessageId(event),
1934
+ content: asProviderRead(target),
1935
+ sender: reader,
1936
+ space: {
1937
+ id: event.chatGuid,
1938
+ type: chatTypeFromGuid(event.chatGuid),
1939
+ phone
1940
+ },
1941
+ timestamp: readAt ?? event.occurredAt
1942
+ }];
1943
+ };
1944
+ //#endregion
1777
1945
  //#region src/remote/stream.ts
1778
1946
  const isCursorRejectedIMessageError = (error) => error instanceof ValidationError;
1779
1947
  const streamLabel = (kind, phone) => `imessage.${kind}:${phone === "shared" ? phone : sanitizePhone(phone)}`;
1780
- const isEventFromCurrentAccount = (event, phone) => event.isFromMe || phone !== "shared" && event.actor?.address !== void 0 && event.actor.address === phone;
1948
+ const isActorCurrentAccount = (actor, phone) => phone !== "shared" && actor?.address !== void 0 && actor.address === phone;
1949
+ const isEventFromCurrentAccount = (event, phone) => event.isFromMe || isActorCurrentAccount(event.actor, phone);
1781
1950
  const streamLog = createLogger("spectrum.imessage.stream");
1782
1951
  const isRetryableMappingError = (error) => typeof error === "object" && error !== null && error.retryable === true;
1783
1952
  const skipUnmappable = async (label, cursor, map) => {
@@ -1826,6 +1995,26 @@ const toMessageItem = async (client, event, phone, cursor, onInbound) => {
1826
1995
  values: await toReactionMessages(client, cache, event, phone)
1827
1996
  };
1828
1997
  }
1998
+ if (event.type === "message.read") {
1999
+ const id = readReceiptMessageId(event);
2000
+ streamLog.debug("received a read event", {
2001
+ "spectrum.imessage.read.message_guid": event.messageGuid,
2002
+ "spectrum.imessage.read.sequence": event.sequence,
2003
+ "spectrum.imessage.read.chat_guid": event.chatGuid,
2004
+ "spectrum.imessage.read.actor": event.actor?.address ? sanitizePhone(event.actor.address) : "none",
2005
+ "spectrum.imessage.read.is_from_me": event.isFromMe,
2006
+ "spectrum.imessage.read.line": phone
2007
+ });
2008
+ return {
2009
+ cursor,
2010
+ id,
2011
+ values: await toReadReceiptMessages(client, getMessageCache(client), event, phone)
2012
+ };
2013
+ }
2014
+ streamLog.debug("message event consumed without mapping", {
2015
+ "spectrum.imessage.event_type": event.type,
2016
+ "spectrum.imessage.sequence": event.sequence
2017
+ });
1829
2018
  return {
1830
2019
  cursor,
1831
2020
  id: `${event.type}:${"messageGuid" in event ? event.messageGuid : "unknown"}:${event.sequence}`,
@@ -1931,14 +2120,23 @@ const clientStream = (client, pollCache, phone, includeGroupEvents, onInbound, r
1931
2120
  if (includeGroupEvents) streams.push(groupStream(client, phone, recover));
1932
2121
  return mergeStreams(streams);
1933
2122
  };
1934
- const messages$1 = (clients, projectConfig) => {
2123
+ const shareWhenProfileSynced = (tracker, gate, chatGuid) => {
2124
+ if (tracker.hasRecentlyShared(chatGuid)) return;
2125
+ gate.isEnabled().then((enabled) => {
2126
+ if (enabled) tracker.maybeShare(chatGuid);
2127
+ }).catch((error) => {
2128
+ streamLog.warn("profile sync gate failed; skipping automatic contact sharing", errorAttrs(error), error instanceof Error ? error : void 0);
2129
+ });
2130
+ };
2131
+ const contactShareHandler = (tracker, profileSyncGate) => profileSyncGate ? (chatGuid) => shareWhenProfileSynced(tracker, profileSyncGate, chatGuid) : (chatGuid) => tracker.maybeShare(chatGuid);
2132
+ const messages$1 = (clients, projectConfig, profileSyncGate) => {
1935
2133
  const pollCache = getPollCache(clients);
1936
- const shareEnabled = projectConfig?.profile?.imessageSynced === true;
2134
+ const staticShareEnabled = projectConfig?.profile?.imessageSynced === true;
1937
2135
  const recover = getCloudRecover(clients);
1938
2136
  const includeGroupEvents = !isSharedMode(clients);
1939
2137
  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);
2138
+ const tracker = staticShareEnabled || profileSyncGate ? getContactShareTracker(entry.client) : void 0;
2139
+ return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? contactShareHandler(tracker, profileSyncGate) : void 0, recover);
1942
2140
  }));
1943
2141
  };
1944
2142
  //#endregion
@@ -1999,7 +2197,7 @@ const stopTyping$1 = async (remote, spaceId) => {
1999
2197
  };
2000
2198
  //#endregion
2001
2199
  //#region src/remote/api.ts
2002
- const messages = (clients, projectConfig) => messages$1(clients, projectConfig);
2200
+ const messages = (clients, projectConfig, profileSyncGate) => messages$1(clients, projectConfig, profileSyncGate);
2003
2201
  const setBackground = async (remote, spaceId, content) => setBackground$1(remote, spaceId, content);
2004
2202
  const sendCustomizedMiniApp = async (remote, spaceId, content) => sendCustomizedMiniApp$1(remote, spaceId, content);
2005
2203
  const updateCustomizedMiniApp = async (remote, spaceId, session, content) => updateCustomizedMiniApp$1(remote, spaceId, session, content);
@@ -2058,6 +2256,73 @@ const toSpectrumMiniApp = (url, layout, live) => asCustomizedMiniApp({
2058
2256
  ...live === void 0 ? {} : { live }
2059
2257
  });
2060
2258
  //#endregion
2259
+ //#region src/remote/profile-sync-gate.ts
2260
+ const PROFILE_SYNC_CACHE_TTL_MS = 6e4;
2261
+ const PROFILE_SYNC_RETRY_DELAY_MS = 3e4;
2262
+ const log = createLogger("spectrum.imessage.profile-sync");
2263
+ const createProfileSyncGate = (options) => {
2264
+ const cacheTtlMs = options.cacheTtlMs ?? PROFILE_SYNC_CACHE_TTL_MS;
2265
+ const retryDelayMs = options.retryDelayMs ?? PROFILE_SYNC_RETRY_DELAY_MS;
2266
+ let cachedEnabled = options.initialEnabled;
2267
+ let disposed = false;
2268
+ let refreshInFlight;
2269
+ let retryAfter = 0;
2270
+ let validUntil = Date.now() + cacheTtlMs;
2271
+ const refreshNow = async () => {
2272
+ try {
2273
+ const enabled = await options.refresh();
2274
+ if (disposed) return false;
2275
+ cachedEnabled = enabled;
2276
+ retryAfter = 0;
2277
+ validUntil = Date.now() + cacheTtlMs;
2278
+ return enabled;
2279
+ } catch (error) {
2280
+ cachedEnabled = false;
2281
+ retryAfter = Date.now() + retryDelayMs;
2282
+ validUntil = 0;
2283
+ log.warn("failed to refresh profile sync state; automatic contact sharing remains disabled", {
2284
+ "spectrum.imessage.profile_sync.retry_in_ms": retryDelayMs,
2285
+ ...errorAttrs(error)
2286
+ }, error);
2287
+ return false;
2288
+ }
2289
+ };
2290
+ const coalescedRefresh = () => {
2291
+ if (!refreshInFlight) refreshInFlight = refreshNow().finally(() => {
2292
+ refreshInFlight = void 0;
2293
+ });
2294
+ return refreshInFlight;
2295
+ };
2296
+ return {
2297
+ dispose() {
2298
+ disposed = true;
2299
+ cachedEnabled = false;
2300
+ validUntil = 0;
2301
+ },
2302
+ async isEnabled() {
2303
+ if (disposed) return false;
2304
+ const now = Date.now();
2305
+ if (now < validUntil) return cachedEnabled;
2306
+ if (now < retryAfter) return false;
2307
+ return await coalescedRefresh();
2308
+ }
2309
+ };
2310
+ };
2311
+ const gates = /* @__PURE__ */ new WeakMap();
2312
+ const isProfileSynced = (projectConfig) => projectConfig.profile?.imessageSynced === true;
2313
+ const registerProfileSyncGate = (clients, projectId, projectSecret, projectConfig) => {
2314
+ gates.get(clients)?.dispose();
2315
+ gates.set(clients, createProfileSyncGate({
2316
+ initialEnabled: isProfileSynced(projectConfig),
2317
+ refresh: async () => isProfileSynced(await cloud.getProject(projectId, projectSecret))
2318
+ }));
2319
+ };
2320
+ const getProfileSyncGate = (clients) => gates.get(clients);
2321
+ const disposeProfileSyncGate = (clients) => {
2322
+ gates.get(clients)?.dispose();
2323
+ gates.delete(clients);
2324
+ };
2325
+ //#endregion
2061
2326
  //#region src/index.ts
2062
2327
  const isPollContent = (content) => content.type === "poll" || content.type === "poll_option";
2063
2328
  const cacheRemoteOutbound = (remote, space, record) => {
@@ -2197,8 +2462,9 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2197
2462
  config: configSchema,
2198
2463
  static: { effect: { message: messageEffects } },
2199
2464
  lifecycle: {
2200
- createClient: async ({ config, projectId, projectSecret }) => {
2201
- if (config.clients) return (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2465
+ createClient: async ({ config, projectConfig, projectId, projectSecret }) => {
2466
+ let clients;
2467
+ if (config.clients) clients = (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2202
2468
  phone: e.phone,
2203
2469
  client: createGrpcClient({
2204
2470
  address: e.address,
@@ -2208,10 +2474,13 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2208
2474
  token: e.token
2209
2475
  })
2210
2476
  }));
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);
2477
+ else if (projectId && projectSecret) clients = await createCloudClients(projectId, projectSecret);
2478
+ 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().");
2479
+ if (projectId && projectSecret && projectConfig) registerProfileSyncGate(clients, projectId, projectSecret, projectConfig);
2480
+ return clients;
2213
2481
  },
2214
2482
  destroyClient: async ({ client }) => {
2483
+ disposeProfileSyncGate(client);
2215
2484
  await disposeCloudAuth(client);
2216
2485
  await Promise.all(client.map((entry) => entry.client.close()));
2217
2486
  }
@@ -2263,7 +2532,7 @@ const imessage = definePlatform(IMESSAGE_PLATFORM, {
2263
2532
  }
2264
2533
  },
2265
2534
  message: { schema: messageSchema },
2266
- messages: ({ client, projectConfig }) => messages(client, projectConfig),
2535
+ messages: ({ client, projectConfig }) => messages(client, projectConfig, getProfileSyncGate(client)),
2267
2536
  send: async ({ space, content, client }) => {
2268
2537
  if (content.type === "reply") {
2269
2538
  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.5.0",
4
4
  "description": "iMessage provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",