@mapier/imsg-sdk 0.5.0 → 0.6.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":"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"}
@@ -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, 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,12 @@ 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>;
98
102
  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
- }>;
103
+ unsendMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
104
+ deleteMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
105
105
  setTyping(chatId: number, _on: boolean): Promise<{
106
106
  ok: boolean;
107
107
  }>;
@@ -141,6 +141,7 @@ export declare class FakeGateway implements Gateway {
141
141
  reaction?: Reaction;
142
142
  emoji?: string;
143
143
  targetGuid?: string;
144
+ partIndex?: number;
144
145
  }): ImsgMessage;
145
146
  injectPollCreate(params: {
146
147
  chatId?: number;
@@ -1,5 +1,8 @@
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 { CHAT_BACKGROUND_PRESETS, decideEditMessagePreflight, extensionCardFailure, groupMutationFailure, messageRemovalFailure, normalizeChatBackgroundGuard, PATCHED_GATEWAY_CAPABILITIES, removalWouldBreakGroupMinimum, sendRichPreflight, tapbackFailure, TIER2_HISTORY_SCAN_WINDOW, } from './types.js';
5
+ import { reactionOnPart, tapbackPartRefusal, toReactionNote } from './tapback.js';
3
6
  import { locationUpdateFromWire, sharedLocationToWire } from './locations.js';
4
7
  import { classifyGroupChatResolution } from './portable-chat.js';
5
8
  // How far back recentReactions() scans, mirroring ImsgGateway's
@@ -145,12 +148,20 @@ export class FakeGateway {
145
148
  // Maintains the target message's reactions[] aggregate (the state the real
146
149
  // gateway reads from history) and emits the reaction event (the row the
147
150
  // real gateway sees on the watch stream).
148
- toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji) {
151
+ //
152
+ // `partIndex` (M2B-45) is part of a reaction's IDENTITY, not a detail on it:
153
+ // the same type on part 0 and part 1 is two separate reactions on the host,
154
+ // so the match below compares the part and the entry records it.
155
+ toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji, partIndex = 0) {
149
156
  const target = this.messages.find((m) => m.guid === targetGuid);
150
157
  if (!target)
151
158
  throw new Error(`toggleReaction: no message with guid ${targetGuid}`);
152
159
  target.reactions ??= [];
153
- const existingIdx = target.reactions.findIndex((r) => r.type === type && r.emoji === emoji && r.is_from_me === isFromMe && (isFromMe || r.sender === senderHandle));
160
+ const existingIdx = target.reactions.findIndex((r) => r.type === type &&
161
+ r.emoji === emoji &&
162
+ r.is_from_me === isFromMe &&
163
+ reactionOnPart(r, partIndex) &&
164
+ (isFromMe || r.sender === senderHandle));
154
165
  const nowActive = existingIdx === -1;
155
166
  const id = this.allocId();
156
167
  const createdAt = new Date().toISOString();
@@ -162,6 +173,9 @@ export class FakeGateway {
162
173
  is_from_me: isFromMe,
163
174
  sender: senderHandle,
164
175
  created_at: createdAt,
176
+ // Absent for part 0, exactly as the native emits it — a whole-message
177
+ // reaction carries no `target_part` and must not grow one here.
178
+ ...(partIndex ? { target_part: partIndex } : {}),
165
179
  };
166
180
  target.reactions.push(entry);
167
181
  }
@@ -183,6 +197,9 @@ export class FakeGateway {
183
197
  reaction_type: type,
184
198
  reaction_emoji: emoji,
185
199
  is_reaction_add: nowActive,
200
+ // The event row carries the part under its own name, and
201
+ // `reacted_to_guid` stays the bare message guid (contract §2).
202
+ ...(partIndex ? { reaction_target_part: partIndex } : {}),
186
203
  reacted_to_guid: targetGuid,
187
204
  created_at: createdAt,
188
205
  };
@@ -336,7 +353,7 @@ export class FakeGateway {
336
353
  continue;
337
354
  for (const r of m.reactions) {
338
355
  if (!r.is_from_me)
339
- notes.push({ id: r.id, reaction: toReactionNoteType(r.type), emoji: r.emoji });
356
+ notes.push(toReactionNote(r));
340
357
  }
341
358
  }
342
359
  return notes.sort((a, b) => b.id - a.id).slice(0, limit);
@@ -444,33 +461,59 @@ export class FakeGateway {
444
461
  .slice(-HISTORY_SCAN_WINDOW)
445
462
  .find((m) => m.guid === targetGuid);
446
463
  }
464
+ // How many parts a fake message has (M2B-45). The host counts the message's
465
+ // real chat items; the fake counts its attachments, so a two-photo message
466
+ // has parts 0 and 1 and anything else has the single part 0. That is
467
+ // deliberately CONSERVATIVE: a mixed text+photos message really has more
468
+ // parts than this says, so the fake refuses some parts the host would
469
+ // accept, and never the other way round (parity rule 2 — the fake must not
470
+ // succeed at something the real path refuses).
471
+ partCount(target) {
472
+ return Math.max(1, target.attachments?.length ?? 0);
473
+ }
447
474
  // Targeted tapback: any message guid (within the scan window above),
448
475
  // explicit add/remove, works in groups. Same closed Reaction set as
449
476
  // react(); the patched custom-emoji path is modeled separately below.
450
- async tapback(chatId, targetGuid, reaction, remove = false) {
477
+ async tapback(chatId, targetGuid, reaction, remove = false, partIndex) {
451
478
  await this.delay();
479
+ const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
480
+ if (partRefusal)
481
+ return partRefusal;
482
+ // Every failure the fake can reach is a proven no-op (same rule as the
483
+ // group verbs below): nothing fired, so it says so.
452
484
  const target = this.findInScanWindow(chatId, targetGuid);
453
485
  if (!target)
454
- return { ok: false };
455
- const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction));
486
+ return tapbackFailure('refused');
487
+ // The bridge refuses a part the message does not have before sending
488
+ // anything (`Message <guid> has no part <n>`), so the fake must too.
489
+ if (partIndex !== undefined && partIndex >= this.partCount(target))
490
+ return tapbackFailure('refused');
491
+ const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction && reactionOnPart(r, partIndex)));
456
492
  if (remove ? !active : active) {
457
493
  return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
458
494
  }
459
- this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction]);
495
+ this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction], partIndex);
460
496
  return { ok: true };
461
497
  }
462
- async emojiTapback(chatId, targetGuid, emoji, remove = false) {
498
+ async emojiTapback(chatId, targetGuid, emoji, remove = false, partIndex) {
463
499
  await this.delay();
464
- if (!this.capabilities.emojiTapback || !emoji)
465
- return { ok: false };
500
+ if (!this.capabilities.emojiTapback)
501
+ return tapbackFailure('unsupported');
502
+ if (!emoji)
503
+ return tapbackFailure('refused');
504
+ const partRefusal = tapbackPartRefusal(partIndex, this.capabilities);
505
+ if (partRefusal)
506
+ return partRefusal;
466
507
  const target = this.findInScanWindow(chatId, targetGuid);
467
508
  if (!target)
468
- return { ok: false };
469
- const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji) ?? false;
509
+ return tapbackFailure('refused');
510
+ if (partIndex !== undefined && partIndex >= this.partCount(target))
511
+ return tapbackFailure('refused');
512
+ const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji && reactionOnPart(r, partIndex)) ?? false;
470
513
  if (remove ? !active : active) {
471
514
  return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
472
515
  }
473
- this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji);
516
+ this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji, partIndex);
474
517
  return { ok: true };
475
518
  }
476
519
  // Targets an EXISTING chat only — no find-or-create `to:` form (contract §2).
@@ -585,6 +628,44 @@ export class FakeGateway {
585
628
  this.emit(this.buildMessage(chat, { is_from_me: true }));
586
629
  return { ok: true };
587
630
  }
631
+ async sendExtensionCard(chatId, card, opts = {}) {
632
+ await this.delay();
633
+ // The same shared preflight ImsgGateway runs, so the capability gates and
634
+ // every field rule answer identically on both implementations.
635
+ const refusal = extensionCardPreflight(this.capabilities, card, opts.update);
636
+ if (refusal)
637
+ return refusal.result;
638
+ const chat = this.chats.get(chatId);
639
+ // No find-or-create, like every other tier-2 send. On the host this is the
640
+ // helper's "Chat not found", a proven pre-invoke rejection.
641
+ if (!chat)
642
+ return extensionCardFailure('refused');
643
+ // NOT a UUID by the fake's usual `fake-msg-<id>` convention: a real card's
644
+ // guid is a Messages UUID and the update path validates it as one, so a
645
+ // fake guid would make the update leg unreachable here while it works on a
646
+ // host. The fake must be neither easier nor harder than the Mac.
647
+ const guid = randomUUID().toUpperCase();
648
+ // The card lands as its own balloon row; ImsgMessage models nothing of the
649
+ // payload, so the fake emits the bare outbound row like sendLocationRequest
650
+ // and lets the shared result builder do the rest. An update emits a row
651
+ // too — on a host it is a real associated-message row, and the card it
652
+ // replaces is restamped rather than removed.
653
+ this.emit(this.buildMessage(chat, { is_from_me: true, guid }));
654
+ // Deliberately NOT checked: that `update` names a card this gateway sent,
655
+ // that it is the FIRST card of its session, or that the session exists.
656
+ // The Mac cannot check any of it — an update aimed at a previous update's
657
+ // guid arrives as a separate card with `ok:true` and a real guid — and a
658
+ // fake that caught it would hide the one mistake this verb is shaped
659
+ // around (see ExtensionCardHandle).
660
+ return extensionCardResultFromResponse({
661
+ ok: true,
662
+ guid,
663
+ session_id: opts.update?.sessionId ?? randomUUID(),
664
+ balloon_bundle_id: `com.apple.messages.MSMessageExtensionBalloonPlugin:${card.teamId}:${card.extensionBundleId}`,
665
+ live_layout: card.liveLayout ?? true,
666
+ ...(opts.update ? { updated_message_guid: opts.update.firstCardMessageGuid } : {}),
667
+ }, card, opts.update);
668
+ }
588
669
  // Edits mutate the target row's text IN PLACE — no new row, no subscribe()
589
670
  // event (contract §2: this is what breaks the "same guid = immutable
590
671
  // content" assumption elsewhere in the codebase).
@@ -603,11 +684,18 @@ export class FakeGateway {
603
684
  return { ok: true };
604
685
  }
605
686
  // Live parity: unsend leaves a tombstone row whose text is cleared.
687
+ //
688
+ // The fake has no bridge to time out on, so it can only ever reach the
689
+ // proven half of the vocabulary. A guid outside the scan window is the one
690
+ // failure it can model — and it is `refused` here, not the `unverified` the
691
+ // real path reports for the same input: the real verb fires before it
692
+ // discovers the ceiling, the fake never fires at all. That is the fake
693
+ // staying INSIDE the real gateway's certainty, not exceeding it.
606
694
  async unsendMessage(chatId, targetGuid) {
607
695
  await this.delay();
608
696
  const target = this.findInScanWindow(chatId, targetGuid);
609
697
  if (!target)
610
- return { ok: false };
698
+ return messageRemovalFailure('refused');
611
699
  target.text = undefined;
612
700
  return { ok: true };
613
701
  }
@@ -618,7 +706,7 @@ export class FakeGateway {
618
706
  await this.delay();
619
707
  const target = this.findInScanWindow(chatId, targetGuid);
620
708
  if (!target)
621
- return { ok: false };
709
+ return messageRemovalFailure('refused');
622
710
  return { ok: true };
623
711
  }
624
712
  // Fire-and-forget: no observable effect through history() or this
@@ -855,6 +943,11 @@ export class FakeGateway {
855
943
  // Simulate another participant tapback-reacting (their side can target any
856
944
  // message, same as real Messages.app — only OUR react() is most-recent-
857
945
  // incoming-only).
946
+ // `partIndex` reacts to one part of the target (M2B-45), the way a phone
947
+ // reacts to the second photo of two. Unlike our own tapback() this is a
948
+ // fixture, not a verb: it models what the other side's device did, so it is
949
+ // not capability-gated — the reaction exists in the thread whatever THIS
950
+ // host can send.
858
951
  injectReaction(params) {
859
952
  if ((params.reaction === undefined) === (params.emoji === undefined)) {
860
953
  throw new Error('injectReaction: provide exactly one of reaction or emoji');
@@ -864,7 +957,7 @@ export class FakeGateway {
864
957
  throw new Error('injectReaction: chat has no message to react to');
865
958
  const type = params.reaction ?? 'custom';
866
959
  const emoji = params.emoji ?? REACTION_EMOJI_BY_TYPE[params.reaction];
867
- return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji);
960
+ return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji, params.partIndex);
868
961
  }
869
962
  // ---- native poll fixtures (contract §2 "Native poll readback") ----
870
963
  // Polls are created through sendPoll(), a manual host-Mac `imsg poll send`