@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.
- package/dist/index.js +300 -31
- 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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
959
|
-
if (!candidate)
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1941
|
-
return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? (
|
|
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
|
-
|
|
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 (
|
|
2212
|
-
|
|
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");
|