@mapier/imsg-sdk 0.5.0 → 0.7.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.

Potentially problematic release.


This version of @mapier/imsg-sdk might be problematic. Click here for more details.

@@ -0,0 +1,34 @@
1
+ import type { ExtensionCard, ExtensionCardHandle, ExtensionCardResult, GatewayCapabilities } from './types.js';
2
+ export declare const EXTENSION_CARD_MAX_FIELD_LENGTH = 4096;
3
+ export declare const EXTENSION_CARD_PROVEN_REJECTIONS: readonly string[];
4
+ export declare function classifyExtensionCardFireFailure(err: unknown): ExtensionCardResult;
5
+ export interface ExtensionCardWireParams {
6
+ chat_id: number;
7
+ team_id: string;
8
+ extension_bundle_id: string;
9
+ app_name: string;
10
+ caption: string;
11
+ url: string;
12
+ live_layout: boolean;
13
+ subcaption?: string;
14
+ summary_text?: string;
15
+ app_store_id?: number;
16
+ session_id?: string;
17
+ updates_message_guid?: string;
18
+ }
19
+ export interface ExtensionCardSendResponse {
20
+ ok: boolean;
21
+ guid?: string;
22
+ message_id?: string;
23
+ session_id?: string;
24
+ balloon_bundle_id?: string;
25
+ live_layout?: boolean;
26
+ updated_message_guid?: string;
27
+ }
28
+ export declare function extensionCardRefusalReason(card: ExtensionCard, update?: ExtensionCardHandle): string | null;
29
+ export declare function extensionCardPreflight(capabilities: Pick<GatewayCapabilities, 'extensionCardSend' | 'extensionCardUpdate'>, card: ExtensionCard, update?: ExtensionCardHandle): {
30
+ result: ExtensionCardResult;
31
+ reason: string;
32
+ } | null;
33
+ export declare function extensionCardWireParams(chatId: number, card: ExtensionCard, update?: ExtensionCardHandle): ExtensionCardWireParams;
34
+ export declare function extensionCardResultFromResponse(response: ExtensionCardSendResponse, card: ExtensionCard, update?: ExtensionCardHandle): ExtensionCardResult;
@@ -0,0 +1,182 @@
1
+ import { bridgeEffectStarted } from './chat-background.js';
2
+ import { extensionCardFailure } from './types.js';
3
+ // Counted in UTF-16 code units, which is what a JS string's `.length` counts
4
+ // and what `[NSString length]` counts on the helper's side of the bridge —
5
+ // the same field cap ExtensionCardRequest.swift enforces.
6
+ export const EXTENSION_CARD_MAX_FIELD_LENGTH = 4096;
7
+ // handleSendExtensionCard's returns BEFORE `dispatchIMMessageInChat` /
8
+ // `[chat sendMessage:]` (IMsgInjected.m), plus the builder's own refusals
9
+ // (IMsgExtensionCard.m), matched against `error.data` exactly as the group
10
+ // verbs match theirs. Deliberately absent: `send-extension-card failed:` —
11
+ // that wraps an NSException caught around the whole build-and-send block, so
12
+ // the text cannot prove which side of the send it was raised on and the result
13
+ // stays ambiguous.
14
+ export const EXTENSION_CARD_PROVEN_REJECTIONS = [
15
+ 'Missing chatGuid',
16
+ 'Extension balloon IMMessage initializer unavailable on this macOS',
17
+ 'Associated-message IMMessage initializer unavailable on this macOS',
18
+ "teamId must be 10 characters of A-Z0-9 and bundleId reverse-DNS without ':'",
19
+ // Formatted with the guid appended, so the match has to be a prefix.
20
+ 'Chat not found',
21
+ 'Extension card spec is missing',
22
+ 'Extension card url is required',
23
+ 'Extension card appName is required',
24
+ 'Extension card caption is required',
25
+ 'Could not build extension card',
26
+ 'Could not construct extension card IMMessage',
27
+ ];
28
+ // The card call itself threw. `refused` only when the error proves the helper
29
+ // stopped before IMChat was invoked; everything else — a bridge timeout, an
30
+ // exception raised after the send, a dead rpc child — may already have put a
31
+ // card in the thread.
32
+ export function classifyExtensionCardFireFailure(err) {
33
+ return extensionCardFailure(bridgeEffectStarted(err, EXTENSION_CARD_PROVEN_REJECTIONS) ? 'fire-failed' : 'refused');
34
+ }
35
+ const TEAM_ID = /^[A-Z0-9]{10}$/;
36
+ // Reverse-DNS: the helper's own charset (letters, digits, '.', '-') plus at
37
+ // least one dot. A ':' would forge the `plugin:team:extension` structure of
38
+ // the balloon identifier and is excluded by the charset.
39
+ const EXTENSION_BUNDLE_ID = /^[A-Za-z0-9.-]+$/;
40
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
41
+ function requiredText(value, field) {
42
+ if (typeof value !== 'string' || !value)
43
+ return `${field} is required`;
44
+ return overlongText(value, field);
45
+ }
46
+ function overlongText(value, field) {
47
+ if (typeof value !== 'string')
48
+ return `${field} must be a string`;
49
+ // `.length` is UTF-16 code units, the unit the helper caps on.
50
+ if (value.length > EXTENSION_CARD_MAX_FIELD_LENGTH) {
51
+ return `${field} must be at most ${EXTENSION_CARD_MAX_FIELD_LENGTH} UTF-16 units`;
52
+ }
53
+ return null;
54
+ }
55
+ // Every field the native side would reject, checked here so a bad card is a
56
+ // proven no-op instead of a round trip. The one deliberate divergence is `url`:
57
+ // the helper accepts anything `URL(string:)` parses, which includes a
58
+ // scheme-relative string, while this requires an absolute URL. A card's URL is
59
+ // the extension's own state (a `data:` or `https:` URL in practice), never a
60
+ // relative path, so the stricter rule costs nothing real and keeps a caller
61
+ // from shipping a card whose URL the app cannot resolve.
62
+ export function extensionCardRefusalReason(card, update) {
63
+ if (card === null || typeof card !== 'object')
64
+ return 'card must be an object';
65
+ const teamIdProblem = requiredText(card.teamId, 'teamId');
66
+ if (teamIdProblem)
67
+ return teamIdProblem;
68
+ if (!TEAM_ID.test(card.teamId))
69
+ return 'teamId must be exactly 10 characters of A-Z and 0-9';
70
+ const bundleProblem = requiredText(card.extensionBundleId, 'extensionBundleId');
71
+ if (bundleProblem)
72
+ return bundleProblem;
73
+ if (!EXTENSION_BUNDLE_ID.test(card.extensionBundleId) || !card.extensionBundleId.includes('.')) {
74
+ return "extensionBundleId must be reverse-DNS (letters, digits, '-', at least one '.') and must not contain ':'";
75
+ }
76
+ for (const field of ['appName', 'caption', 'url']) {
77
+ const problem = requiredText(card[field], field);
78
+ if (problem)
79
+ return problem;
80
+ }
81
+ if (!URL.canParse(card.url))
82
+ return 'url must be an absolute URL';
83
+ for (const field of ['subcaption', 'summaryText']) {
84
+ const value = card[field];
85
+ if (value === undefined)
86
+ continue;
87
+ const problem = overlongText(value, field);
88
+ if (problem)
89
+ return problem;
90
+ }
91
+ if (card.appStoreId !== undefined && (!Number.isSafeInteger(card.appStoreId) || card.appStoreId <= 0)) {
92
+ return 'appStoreId must be a positive integer';
93
+ }
94
+ // Strict, like the native handler: a live_layout that cannot be read must
95
+ // not quietly become the default, because the two values render differently
96
+ // on the recipient's phone.
97
+ if (card.liveLayout !== undefined && typeof card.liveLayout !== 'boolean') {
98
+ return 'liveLayout must be a boolean';
99
+ }
100
+ if (update === undefined)
101
+ return null;
102
+ if (update === null || typeof update !== 'object')
103
+ return 'update must be an ExtensionCardHandle';
104
+ // Both halves or neither: the session id alone starts a second card in the
105
+ // same session rather than replacing anything, and a guid alone is refused
106
+ // by the helper.
107
+ if (typeof update.sessionId !== 'string' || !UUID.test(update.sessionId)) {
108
+ return 'update.sessionId must be the UUID a previous send returned';
109
+ }
110
+ if (typeof update.firstCardMessageGuid !== 'string' || !UUID.test(update.firstCardMessageGuid)) {
111
+ return "update.firstCardMessageGuid must be the FIRST card's message guid, as a UUID";
112
+ }
113
+ return null;
114
+ }
115
+ // The whole sendExtensionCard preflight, shared by both gateways so parity is
116
+ // structural: the capability gates, then the field rules. Returns the result
117
+ // to answer with plus a line to log, or null to fire. Nothing has been sent at
118
+ // any exit here, so every one of them is `effectStarted:false`.
119
+ export function extensionCardPreflight(capabilities, card, update) {
120
+ if (!capabilities.extensionCardSend) {
121
+ return {
122
+ result: extensionCardFailure('unsupported'),
123
+ reason: 'bridge does not advertise extensionCardSend',
124
+ };
125
+ }
126
+ // An update is a send plus the associated-message initializer, so it needs
127
+ // both markers; a host can advertise the first without the second.
128
+ if (update !== undefined && !capabilities.extensionCardUpdate) {
129
+ return {
130
+ result: extensionCardFailure('unsupported'),
131
+ reason: 'bridge does not advertise extensionCardUpdate',
132
+ };
133
+ }
134
+ const refusal = extensionCardRefusalReason(card, update);
135
+ return refusal ? { result: extensionCardFailure('refused'), reason: refusal } : null;
136
+ }
137
+ export function extensionCardWireParams(chatId, card, update) {
138
+ return {
139
+ chat_id: chatId,
140
+ team_id: card.teamId,
141
+ extension_bundle_id: card.extensionBundleId,
142
+ app_name: card.appName,
143
+ caption: card.caption,
144
+ url: card.url,
145
+ // Sent explicitly rather than left to the native default, so the value
146
+ // that shaped the send is the value that comes back in the result.
147
+ live_layout: card.liveLayout ?? true,
148
+ ...(card.subcaption !== undefined ? { subcaption: card.subcaption } : {}),
149
+ ...(card.summaryText !== undefined ? { summary_text: card.summaryText } : {}),
150
+ ...(card.appStoreId !== undefined ? { app_store_id: card.appStoreId } : {}),
151
+ ...(update ? { session_id: update.sessionId, updates_message_guid: update.firstCardMessageGuid } : {}),
152
+ };
153
+ }
154
+ // Build the caller's result out of the host's response. Shared by both
155
+ // gateways, which is what keeps the first-card rule from drifting: on an
156
+ // update `result.handle` is the one that was passed IN, never one built from
157
+ // this row's guid, so `handle = result.handle` after every update still points
158
+ // at the session's first card.
159
+ export function extensionCardResultFromResponse(response, card, update) {
160
+ // The native handler answers `ok:true` on every path that reaches a result
161
+ // and raises a JSON-RPC error otherwise, so a falsy `ok` is a host the SDK
162
+ // cannot read. It was invoked, so it is ambiguous, not a refusal.
163
+ if (!response.ok)
164
+ return extensionCardFailure('fire-failed');
165
+ const messageGuid = response.guid || response.message_id || undefined;
166
+ const sessionId = response.session_id || update?.sessionId || undefined;
167
+ // An update re-uses the handle it was given. A first send can only mint one
168
+ // when Messages actually exposed the new row's guid; `updatable:false` says
169
+ // that card is unreachable forever rather than handing back a guid we do not
170
+ // have.
171
+ const handle = update ?? (sessionId && messageGuid ? { sessionId, firstCardMessageGuid: messageGuid } : undefined);
172
+ return {
173
+ ok: true,
174
+ ...(sessionId ? { sessionId } : {}),
175
+ ...(messageGuid ? { messageGuid } : {}),
176
+ ...(handle ? { handle } : {}),
177
+ updatable: handle !== undefined,
178
+ ...(response.balloon_bundle_id ? { balloonBundleId: response.balloon_bundle_id } : {}),
179
+ liveLayout: response.live_layout ?? card.liveLayout ?? true,
180
+ };
181
+ }
182
+ //# sourceMappingURL=extension-card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extension-card.js","sourceRoot":"","sources":["../../src/gateway/extension-card.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAElD,6EAA6E;AAC7E,2EAA2E;AAC3E,0DAA0D;AAC1D,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAC;AAEpD,uEAAuE;AACvE,0EAA0E;AAC1E,2EAA2E;AAC3E,2EAA2E;AAC3E,6EAA6E;AAC7E,+EAA+E;AAC/E,mBAAmB;AACnB,MAAM,CAAC,MAAM,gCAAgC,GAAsB;IACjE,kBAAkB;IAClB,mEAAmE;IACnE,oEAAoE;IACpE,6EAA6E;IAC7E,qEAAqE;IACrE,gBAAgB;IAChB,gCAAgC;IAChC,gCAAgC;IAChC,oCAAoC;IACpC,oCAAoC;IACpC,gCAAgC;IAChC,8CAA8C;CAC/C,CAAC;AAEF,8EAA8E;AAC9E,4EAA4E;AAC5E,6EAA6E;AAC7E,sBAAsB;AACtB,MAAM,UAAU,gCAAgC,CAAC,GAAY;IAC3D,OAAO,oBAAoB,CAAC,mBAAmB,CAAC,GAAG,EAAE,gCAAgC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;AACtH,CAAC;AA+BD,MAAM,OAAO,GAAG,gBAAgB,CAAC;AACjC,4EAA4E;AAC5E,4EAA4E;AAC5E,yDAAyD;AACzD,MAAM,mBAAmB,GAAG,kBAAkB,CAAC;AAC/C,MAAM,IAAI,GAAG,+EAA+E,CAAC;AAE7F,SAAS,YAAY,CAAC,KAAc,EAAE,KAAa;IACjD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK;QAAE,OAAO,GAAG,KAAK,cAAc,CAAC;IACvE,OAAO,YAAY,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;AACpC,CAAC;AAED,SAAS,YAAY,CAAC,KAAc,EAAE,KAAa;IACjD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,GAAG,KAAK,mBAAmB,CAAC;IAClE,+DAA+D;IAC/D,IAAI,KAAK,CAAC,MAAM,GAAG,+BAA+B,EAAE,CAAC;QACnD,OAAO,GAAG,KAAK,oBAAoB,+BAA+B,eAAe,CAAC;IACpF,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,4EAA4E;AAC5E,gFAAgF;AAChF,sEAAsE;AACtE,+EAA+E;AAC/E,6EAA6E;AAC7E,4EAA4E;AAC5E,yDAAyD;AACzD,MAAM,UAAU,0BAA0B,CAAC,IAAmB,EAAE,MAA4B;IAC1F,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,wBAAwB,CAAC;IAE/E,MAAM,aAAa,GAAG,YAAY,CAAC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC1D,IAAI,aAAa;QAAE,OAAO,aAAa,CAAC;IACxC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;QAAE,OAAO,qDAAqD,CAAC;IAE7F,MAAM,aAAa,GAAG,YAAY,CAAC,IAAI,CAAC,iBAAiB,EAAE,mBAAmB,CAAC,CAAC;IAChF,IAAI,aAAa;QAAE,OAAO,aAAa,CAAC;IACxC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/F,OAAO,yGAAyG,CAAC;IACnH,CAAC;IAED,KAAK,MAAM,KAAK,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAU,EAAE,CAAC;QAC3D,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,CAAC;QACjD,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;IAC9B,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,6BAA6B,CAAC;IAElE,KAAK,MAAM,KAAK,IAAI,CAAC,YAAY,EAAE,aAAa,CAAU,EAAE,CAAC;QAC3D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,MAAM,OAAO,GAAG,YAAY,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC3C,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;IAC9B,CAAC;IAED,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS,IAAI,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC,EAAE,CAAC;QACtG,OAAO,uCAAuC,CAAC;IACjD,CAAC;IACD,0EAA0E;IAC1E,4EAA4E;IAC5E,4BAA4B;IAC5B,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS,IAAI,OAAO,IAAI,CAAC,UAAU,KAAK,SAAS,EAAE,CAAC;QAC1E,OAAO,8BAA8B,CAAC;IACxC,CAAC;IAED,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACtC,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,uCAAuC,CAAC;IAClG,2EAA2E;IAC3E,2EAA2E;IAC3E,iBAAiB;IACjB,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;QACzE,OAAO,4DAA4D,CAAC;IACtE,CAAC;IACD,IAAI,OAAO,MAAM,CAAC,oBAAoB,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,oBAAoB,CAAC,EAAE,CAAC;QAC/F,OAAO,8EAA8E,CAAC;IACxF,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,8EAA8E;AAC9E,6EAA6E;AAC7E,+EAA+E;AAC/E,gEAAgE;AAChE,MAAM,UAAU,sBAAsB,CACpC,YAAoF,EACpF,IAAmB,EACnB,MAA4B;IAE5B,IAAI,CAAC,YAAY,CAAC,iBAAiB,EAAE,CAAC;QACpC,OAAO;YACL,MAAM,EAAE,oBAAoB,CAAC,aAAa,CAAC;YAC3C,MAAM,EAAE,6CAA6C;SACtD,CAAC;IACJ,CAAC;IACD,2EAA2E;IAC3E,mEAAmE;IACnE,IAAI,MAAM,KAAK,SAAS,IAAI,CAAC,YAAY,CAAC,mBAAmB,EAAE,CAAC;QAC9D,OAAO;YACL,MAAM,EAAE,oBAAoB,CAAC,aAAa,CAAC;YAC3C,MAAM,EAAE,+CAA+C;SACxD,CAAC;IACJ,CAAC;IACD,MAAM,OAAO,GAAG,0BAA0B,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACzD,OAAO,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,oBAAoB,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACvF,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,MAAc,EACd,IAAmB,EACnB,MAA4B;IAE5B,OAAO;QACL,OAAO,EAAE,MAAM;QACf,OAAO,EAAE,IAAI,CAAC,MAAM;QACpB,mBAAmB,EAAE,IAAI,CAAC,iBAAiB;QAC3C,QAAQ,EAAE,IAAI,CAAC,OAAO;QACtB,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,GAAG,EAAE,IAAI,CAAC,GAAG;QACb,uEAAuE;QACvE,mEAAmE;QACnE,WAAW,EAAE,IAAI,CAAC,UAAU,IAAI,IAAI;QACpC,GAAG,CAAC,IAAI,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzE,GAAG,CAAC,IAAI,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7E,GAAG,CAAC,IAAI,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3E,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,SAAS,EAAE,oBAAoB,EAAE,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvG,CAAC;AACJ,CAAC;AAED,uEAAuE;AACvE,yEAAyE;AACzE,6EAA6E;AAC7E,+EAA+E;AAC/E,+BAA+B;AAC/B,MAAM,UAAU,+BAA+B,CAC7C,QAAmC,EACnC,IAAmB,EACnB,MAA4B;IAE5B,2EAA2E;IAC3E,2EAA2E;IAC3E,kEAAkE;IAClE,IAAI,CAAC,QAAQ,CAAC,EAAE;QAAE,OAAO,oBAAoB,CAAC,aAAa,CAAC,CAAC;IAE7D,MAAM,WAAW,GAAG,QAAQ,CAAC,IAAI,IAAI,QAAQ,CAAC,UAAU,IAAI,SAAS,CAAC;IACtE,MAAM,SAAS,GAAG,QAAQ,CAAC,UAAU,IAAI,MAAM,EAAE,SAAS,IAAI,SAAS,CAAC;IACxE,4EAA4E;IAC5E,4EAA4E;IAC5E,6EAA6E;IAC7E,QAAQ;IACR,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,IAAI,WAAW,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,oBAAoB,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACnH,OAAO;QACL,EAAE,EAAE,IAAI;QACR,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACnC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7B,SAAS,EAAE,MAAM,KAAK,SAAS;QAC/B,GAAG,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,QAAQ,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtF,UAAU,EAAE,QAAQ,CAAC,WAAW,IAAI,IAAI,CAAC,UAAU,IAAI,IAAI;KAC5D,CAAC;AACJ,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import type { MessageAttachment } from '../types.js';
2
- import type { ChatBackgroundPreset, ChatBackgroundRemoveResult, ChatBackgroundSetResult, ChatBackgroundStatus, EditMessageResult, Gateway, GatewayCapabilities, GatewayEvent, GatewayChat, GatewayHistoryRange, GroupChatResolution, GroupChatResolutionRequest, GroupMutationResult, HandleCheck, ImsgMessage, LocationUpdate, MessageService, NamePhotoShareResult, Reaction, ReactionNote, ReactResult, SendResult, SendStatus, SendTarget, SharedLocation, TextFormatRange } from './types.js';
2
+ import type { ChatBackgroundPreset, ChatBackgroundRemoveResult, ChatBackgroundSetResult, ChatBackgroundStatus, EditMessageResult, ExtensionCard, ExtensionCardHandle, ExtensionCardResult, Gateway, GatewayCapabilities, GatewayEvent, GatewayChat, GatewayHistoryRange, GroupChatResolution, GroupChatResolutionRequest, GroupMutationResult, HandleCheck, ImsgMessage, LocationUpdate, MessageRemovalResult, MessageService, MultipartPart, MultipartResult, NamePhotoShareResult, Reaction, ReactionNote, ReactResult, SendResult, SendStatus, SendTarget, SharedLocation, TextFormatRange } from './types.js';
3
3
  export interface FakeGatewayOptions {
4
4
  latencyMs?: number;
5
5
  capabilities?: Partial<GatewayCapabilities>;
@@ -76,8 +76,9 @@ export declare class FakeGateway implements Gateway {
76
76
  aliasType?: 'phone' | 'email';
77
77
  }): Promise<HandleCheck>;
78
78
  private findInScanWindow;
79
- tapback(chatId: number, targetGuid: string, reaction: Reaction, remove?: boolean): Promise<ReactResult>;
80
- emojiTapback(chatId: number, targetGuid: string, emoji: string, remove?: boolean): Promise<ReactResult>;
79
+ private partCount;
80
+ tapback(chatId: number, targetGuid: string, reaction: Reaction, remove?: boolean, partIndex?: number): Promise<ReactResult>;
81
+ emojiTapback(chatId: number, targetGuid: string, emoji: string, remove?: boolean, partIndex?: number): Promise<ReactResult>;
81
82
  sendRich(chatId: number, text: string, opts?: {
82
83
  effect?: string;
83
84
  replyToGuid?: string;
@@ -95,13 +96,17 @@ export declare class FakeGateway implements Gateway {
95
96
  sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
96
97
  votePoll(chatId: number, pollGuid: string, optionId: string): Promise<ReactResult>;
97
98
  sendRichLink(chatId: number, _url: string): Promise<SendResult>;
99
+ sendExtensionCard(chatId: number, card: ExtensionCard, opts?: {
100
+ update?: ExtensionCardHandle;
101
+ }): Promise<ExtensionCardResult>;
102
+ sendMultipart(chatId: number, parts: readonly MultipartPart[], opts?: {
103
+ replyToGuid?: string;
104
+ effect?: string;
105
+ subject?: string;
106
+ }): Promise<MultipartResult>;
98
107
  editMessage(chatId: number, targetGuid: string, text: string): Promise<EditMessageResult>;
99
- unsendMessage(chatId: number, targetGuid: string): Promise<{
100
- ok: boolean;
101
- }>;
102
- deleteMessage(chatId: number, targetGuid: string): Promise<{
103
- ok: boolean;
104
- }>;
108
+ unsendMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
109
+ deleteMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
105
110
  setTyping(chatId: number, _on: boolean): Promise<{
106
111
  ok: boolean;
107
112
  }>;
@@ -141,6 +146,7 @@ export declare class FakeGateway implements Gateway {
141
146
  reaction?: Reaction;
142
147
  emoji?: string;
143
148
  targetGuid?: string;
149
+ partIndex?: number;
144
150
  }): ImsgMessage;
145
151
  injectPollCreate(params: {
146
152
  chatId?: number;
@@ -1,5 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { aggregatePolls } from '../polls.js';
2
- import { CHAT_BACKGROUND_PRESETS, decideEditMessagePreflight, groupMutationFailure, normalizeChatBackgroundGuard, PATCHED_GATEWAY_CAPABILITIES, removalWouldBreakGroupMinimum, sendRichPreflight, TIER2_HISTORY_SCAN_WINDOW, toReactionNoteType, } from './types.js';
3
+ import { extensionCardPreflight, extensionCardResultFromResponse } from './extension-card.js';
4
+ import { multipartPreflight, multipartResultFromResponse } from './multipart.js';
5
+ import { CHAT_BACKGROUND_PRESETS, decideEditMessagePreflight, extensionCardFailure, groupMutationFailure, messageRemovalFailure, multipartFailure, normalizeChatBackgroundGuard, PATCHED_GATEWAY_CAPABILITIES, removalWouldBreakGroupMinimum, sendRichPreflight, tapbackFailure, TIER2_HISTORY_SCAN_WINDOW, } from './types.js';
6
+ import { reactionOnPart, tapbackPartRefusal, toReactionNote } from './tapback.js';
3
7
  import { locationUpdateFromWire, sharedLocationToWire } from './locations.js';
4
8
  import { classifyGroupChatResolution } from './portable-chat.js';
5
9
  // How far back recentReactions() scans, mirroring ImsgGateway's
@@ -145,12 +149,20 @@ export class FakeGateway {
145
149
  // Maintains the target message's reactions[] aggregate (the state the real
146
150
  // gateway reads from history) and emits the reaction event (the row the
147
151
  // real gateway sees on the watch stream).
148
- toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji) {
152
+ //
153
+ // `partIndex` (M2B-45) is part of a reaction's IDENTITY, not a detail on it:
154
+ // the same type on part 0 and part 1 is two separate reactions on the host,
155
+ // so the match below compares the part and the entry records it.
156
+ toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji, partIndex = 0) {
149
157
  const target = this.messages.find((m) => m.guid === targetGuid);
150
158
  if (!target)
151
159
  throw new Error(`toggleReaction: no message with guid ${targetGuid}`);
152
160
  target.reactions ??= [];
153
- const existingIdx = target.reactions.findIndex((r) => r.type === type && r.emoji === emoji && r.is_from_me === isFromMe && (isFromMe || r.sender === senderHandle));
161
+ const existingIdx = target.reactions.findIndex((r) => r.type === type &&
162
+ r.emoji === emoji &&
163
+ r.is_from_me === isFromMe &&
164
+ reactionOnPart(r, partIndex) &&
165
+ (isFromMe || r.sender === senderHandle));
154
166
  const nowActive = existingIdx === -1;
155
167
  const id = this.allocId();
156
168
  const createdAt = new Date().toISOString();
@@ -162,6 +174,9 @@ export class FakeGateway {
162
174
  is_from_me: isFromMe,
163
175
  sender: senderHandle,
164
176
  created_at: createdAt,
177
+ // Absent for part 0, exactly as the native emits it — a whole-message
178
+ // reaction carries no `target_part` and must not grow one here.
179
+ ...(partIndex ? { target_part: partIndex } : {}),
165
180
  };
166
181
  target.reactions.push(entry);
167
182
  }
@@ -183,6 +198,9 @@ export class FakeGateway {
183
198
  reaction_type: type,
184
199
  reaction_emoji: emoji,
185
200
  is_reaction_add: nowActive,
201
+ // The event row carries the part under its own name, and
202
+ // `reacted_to_guid` stays the bare message guid (contract §2).
203
+ ...(partIndex ? { reaction_target_part: partIndex } : {}),
186
204
  reacted_to_guid: targetGuid,
187
205
  created_at: createdAt,
188
206
  };
@@ -336,7 +354,7 @@ export class FakeGateway {
336
354
  continue;
337
355
  for (const r of m.reactions) {
338
356
  if (!r.is_from_me)
339
- notes.push({ id: r.id, reaction: toReactionNoteType(r.type), emoji: r.emoji });
357
+ notes.push(toReactionNote(r));
340
358
  }
341
359
  }
342
360
  return notes.sort((a, b) => b.id - a.id).slice(0, limit);
@@ -444,33 +462,69 @@ export class FakeGateway {
444
462
  .slice(-HISTORY_SCAN_WINDOW)
445
463
  .find((m) => m.guid === targetGuid);
446
464
  }
465
+ // How many parts a fake message has (M2B-45). The host counts the message's
466
+ // real chat items; the fake counts its attachments, so a two-photo message
467
+ // has parts 0 and 1 and anything else has the single part 0. That is
468
+ // deliberately CONSERVATIVE: a mixed text+photos message really has more
469
+ // parts than this says, so the fake refuses some parts the host would
470
+ // accept, and never the other way round (parity rule 2 — the fake must not
471
+ // succeed at something the real path refuses).
472
+ //
473
+ // Left exactly as it was by M2B-55, deliberately. A row this gateway sent
474
+ // through sendMultipart carries no `attachments[]` — the send response
475
+ // reports none, and inventing them is what sendAttachment already refuses to
476
+ // do — so such a row counts 1 part however many it really had. That
477
+ // under-counts, which is the allowed direction: a part-level tapback on a
478
+ // fake-sent multipart message is refused where a host would accept it, and
479
+ // never the reverse. Counting `(text ? 1 : 0) + attachments.length` instead
480
+ // would be a contract change, and it needs the host run to prove the real
481
+ // part layout first.
482
+ partCount(target) {
483
+ return Math.max(1, target.attachments?.length ?? 0);
484
+ }
447
485
  // Targeted tapback: any message guid (within the scan window above),
448
486
  // explicit add/remove, works in groups. Same closed Reaction set as
449
487
  // react(); the patched custom-emoji path is modeled separately below.
450
- async tapback(chatId, targetGuid, reaction, remove = false) {
488
+ async tapback(chatId, targetGuid, reaction, remove = false, partIndex) {
451
489
  await this.delay();
490
+ const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
491
+ if (partRefusal)
492
+ return partRefusal;
493
+ // Every failure the fake can reach is a proven no-op (same rule as the
494
+ // group verbs below): nothing fired, so it says so.
452
495
  const target = this.findInScanWindow(chatId, targetGuid);
453
496
  if (!target)
454
- return { ok: false };
455
- const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction));
497
+ return tapbackFailure('refused');
498
+ // The bridge refuses a part the message does not have before sending
499
+ // anything (`Message <guid> has no part <n>`), so the fake must too.
500
+ if (partIndex !== undefined && partIndex >= this.partCount(target))
501
+ return tapbackFailure('refused');
502
+ const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction && reactionOnPart(r, partIndex)));
456
503
  if (remove ? !active : active) {
457
504
  return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
458
505
  }
459
- this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction]);
506
+ this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction], partIndex);
460
507
  return { ok: true };
461
508
  }
462
- async emojiTapback(chatId, targetGuid, emoji, remove = false) {
509
+ async emojiTapback(chatId, targetGuid, emoji, remove = false, partIndex) {
463
510
  await this.delay();
464
- if (!this.capabilities.emojiTapback || !emoji)
465
- return { ok: false };
511
+ if (!this.capabilities.emojiTapback)
512
+ return tapbackFailure('unsupported');
513
+ if (!emoji)
514
+ return tapbackFailure('refused');
515
+ const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
516
+ if (partRefusal)
517
+ return partRefusal;
466
518
  const target = this.findInScanWindow(chatId, targetGuid);
467
519
  if (!target)
468
- return { ok: false };
469
- const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji) ?? false;
520
+ return tapbackFailure('refused');
521
+ if (partIndex !== undefined && partIndex >= this.partCount(target))
522
+ return tapbackFailure('refused');
523
+ const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji && reactionOnPart(r, partIndex)) ?? false;
470
524
  if (remove ? !active : active) {
471
525
  return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
472
526
  }
473
- this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji);
527
+ this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji, partIndex);
474
528
  return { ok: true };
475
529
  }
476
530
  // Targets an EXISTING chat only — no find-or-create `to:` form (contract §2).
@@ -585,6 +639,89 @@ export class FakeGateway {
585
639
  this.emit(this.buildMessage(chat, { is_from_me: true }));
586
640
  return { ok: true };
587
641
  }
642
+ async sendExtensionCard(chatId, card, opts = {}) {
643
+ await this.delay();
644
+ // The same shared preflight ImsgGateway runs, so the capability gates and
645
+ // every field rule answer identically on both implementations.
646
+ const refusal = extensionCardPreflight(this.capabilities, card, opts.update);
647
+ if (refusal)
648
+ return refusal.result;
649
+ const chat = this.chats.get(chatId);
650
+ // No find-or-create, like every other tier-2 send. On the host this is the
651
+ // helper's "Chat not found", a proven pre-invoke rejection.
652
+ if (!chat)
653
+ return extensionCardFailure('refused');
654
+ // NOT a UUID by the fake's usual `fake-msg-<id>` convention: a real card's
655
+ // guid is a Messages UUID and the update path validates it as one, so a
656
+ // fake guid would make the update leg unreachable here while it works on a
657
+ // host. The fake must be neither easier nor harder than the Mac.
658
+ const guid = randomUUID().toUpperCase();
659
+ // The card lands as its own balloon row; ImsgMessage models nothing of the
660
+ // payload, so the fake emits the bare outbound row like sendLocationRequest
661
+ // and lets the shared result builder do the rest. An update emits a row
662
+ // too — on a host it is a real associated-message row, and the card it
663
+ // replaces is restamped rather than removed.
664
+ this.emit(this.buildMessage(chat, { is_from_me: true, guid }));
665
+ // Deliberately NOT checked: that `update` names a card this gateway sent,
666
+ // that it is the FIRST card of its session, or that the session exists.
667
+ // The Mac cannot check any of it — an update aimed at a previous update's
668
+ // guid arrives as a separate card with `ok:true` and a real guid — and a
669
+ // fake that caught it would hide the one mistake this verb is shaped
670
+ // around (see ExtensionCardHandle).
671
+ return extensionCardResultFromResponse({
672
+ ok: true,
673
+ guid,
674
+ session_id: opts.update?.sessionId ?? randomUUID(),
675
+ balloon_bundle_id: `com.apple.messages.MSMessageExtensionBalloonPlugin:${card.teamId}:${card.extensionBundleId}`,
676
+ live_layout: card.liveLayout ?? true,
677
+ ...(opts.update ? { updated_message_guid: opts.update.firstCardMessageGuid } : {}),
678
+ }, card, opts.update);
679
+ }
680
+ // Several parts as ONE message (M2B-55) — one row, not one row per part,
681
+ // which is the whole point of the verb.
682
+ async sendMultipart(chatId, parts, opts = {}) {
683
+ await this.delay();
684
+ // The same shared preflight ImsgGateway runs, so the capability gates and
685
+ // every list rule answer identically on both implementations. A file part
686
+ // whose path does not exist is NOT checked here or there: the path belongs
687
+ // to the Mac, so a missing file is the helper's refusal.
688
+ const refusal = multipartPreflight(this.capabilities, parts);
689
+ if (refusal)
690
+ return refusal.result;
691
+ const chat = this.chats.get(chatId);
692
+ // No find-or-create, like every other tier-2 send. On the host this is the
693
+ // helper's "Chat not found", a proven pre-invoke rejection.
694
+ if (!chat)
695
+ return multipartFailure('refused');
696
+ // The helper validates every text part's mentions against the LIVE
697
+ // participant list before a single transfer is staged, and refuses them all
698
+ // when that list is unreadable — the same rule sendRich models above, so a
699
+ // mention that the Mac would refuse must not go through here either.
700
+ const members = this.groupParticipantsUnreported.has(chatId) ? [] : chat.participants;
701
+ const mentionsAStranger = parts.some((part) => part.kind === 'text' &&
702
+ part.textFormatting?.some((r) => r.mention !== undefined && !members.includes(r.mention)));
703
+ if (mentionsAStranger)
704
+ return multipartFailure('refused');
705
+ // ONE bare outbound row. The real row's text is Apple's own concatenation
706
+ // of the parts, object-replacement placeholders for the files included, and
707
+ // its `attachments[]` only appears on a later history/watch row — the send
708
+ // response reports neither, so neither is invented here (the rule
709
+ // sendAttachment states). Effect, subject and formatting have no fields in
710
+ // imsg's JSON message shape at all: write-only on the real path too.
711
+ const text = parts
712
+ .filter((part) => part.kind === 'text')
713
+ .map((part) => part.text)
714
+ .join('\n');
715
+ const msg = this.buildMessage(chat, {
716
+ is_from_me: true,
717
+ ...(text ? { text } : {}),
718
+ reply_to_guid: opts.replyToGuid,
719
+ });
720
+ this.emit(msg);
721
+ // Minted like the host's own response and mapped by the shared builder, so
722
+ // neither implementation can grow a field the other cannot report.
723
+ return multipartResultFromResponse({ ok: true, guid: msg.guid, parts_count: parts.length });
724
+ }
588
725
  // Edits mutate the target row's text IN PLACE — no new row, no subscribe()
589
726
  // event (contract §2: this is what breaks the "same guid = immutable
590
727
  // content" assumption elsewhere in the codebase).
@@ -603,11 +740,18 @@ export class FakeGateway {
603
740
  return { ok: true };
604
741
  }
605
742
  // Live parity: unsend leaves a tombstone row whose text is cleared.
743
+ //
744
+ // The fake has no bridge to time out on, so it can only ever reach the
745
+ // proven half of the vocabulary. A guid outside the scan window is the one
746
+ // failure it can model — and it is `refused` here, not the `unverified` the
747
+ // real path reports for the same input: the real verb fires before it
748
+ // discovers the ceiling, the fake never fires at all. That is the fake
749
+ // staying INSIDE the real gateway's certainty, not exceeding it.
606
750
  async unsendMessage(chatId, targetGuid) {
607
751
  await this.delay();
608
752
  const target = this.findInScanWindow(chatId, targetGuid);
609
753
  if (!target)
610
- return { ok: false };
754
+ return messageRemovalFailure('refused');
611
755
  target.text = undefined;
612
756
  return { ok: true };
613
757
  }
@@ -618,7 +762,7 @@ export class FakeGateway {
618
762
  await this.delay();
619
763
  const target = this.findInScanWindow(chatId, targetGuid);
620
764
  if (!target)
621
- return { ok: false };
765
+ return messageRemovalFailure('refused');
622
766
  return { ok: true };
623
767
  }
624
768
  // Fire-and-forget: no observable effect through history() or this
@@ -855,6 +999,11 @@ export class FakeGateway {
855
999
  // Simulate another participant tapback-reacting (their side can target any
856
1000
  // message, same as real Messages.app — only OUR react() is most-recent-
857
1001
  // incoming-only).
1002
+ // `partIndex` reacts to one part of the target (M2B-45), the way a phone
1003
+ // reacts to the second photo of two. Unlike our own tapback() this is a
1004
+ // fixture, not a verb: it models what the other side's device did, so it is
1005
+ // not capability-gated — the reaction exists in the thread whatever THIS
1006
+ // host can send.
858
1007
  injectReaction(params) {
859
1008
  if ((params.reaction === undefined) === (params.emoji === undefined)) {
860
1009
  throw new Error('injectReaction: provide exactly one of reaction or emoji');
@@ -864,7 +1013,7 @@ export class FakeGateway {
864
1013
  throw new Error('injectReaction: chat has no message to react to');
865
1014
  const type = params.reaction ?? 'custom';
866
1015
  const emoji = params.emoji ?? REACTION_EMOJI_BY_TYPE[params.reaction];
867
- return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji);
1016
+ return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji, params.partIndex);
868
1017
  }
869
1018
  // ---- native poll fixtures (contract §2 "Native poll readback") ----
870
1019
  // Polls are created through sendPoll(), a manual host-Mac `imsg poll send`