@mapier/imsg-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/dist/gateway/external-ids.d.ts +3 -0
  4. package/dist/gateway/external-ids.js +37 -0
  5. package/dist/gateway/external-ids.js.map +1 -0
  6. package/dist/gateway/fake.d.ts +161 -0
  7. package/dist/gateway/fake.js +870 -0
  8. package/dist/gateway/fake.js.map +1 -0
  9. package/dist/gateway/imsg.d.ts +77 -0
  10. package/dist/gateway/imsg.js +676 -0
  11. package/dist/gateway/imsg.js.map +1 -0
  12. package/dist/gateway/portable-chat.d.ts +65 -0
  13. package/dist/gateway/portable-chat.js +118 -0
  14. package/dist/gateway/portable-chat.js.map +1 -0
  15. package/dist/gateway/types.d.ts +169 -0
  16. package/dist/gateway/types.js +16 -0
  17. package/dist/gateway/types.js.map +1 -0
  18. package/dist/imsg/binary.d.ts +1 -0
  19. package/dist/imsg/binary.js +4 -0
  20. package/dist/imsg/binary.js.map +1 -0
  21. package/dist/imsg/react.d.ts +2 -0
  22. package/dist/imsg/react.js +24 -0
  23. package/dist/imsg/react.js.map +1 -0
  24. package/dist/imsg/rpc.d.ts +145 -0
  25. package/dist/imsg/rpc.js +227 -0
  26. package/dist/imsg/rpc.js.map +1 -0
  27. package/dist/imsg/status.d.ts +9 -0
  28. package/dist/imsg/status.js +37 -0
  29. package/dist/imsg/status.js.map +1 -0
  30. package/dist/index.d.ts +9 -0
  31. package/dist/index.js +9 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/interactions/catalog.d.ts +2 -0
  34. package/dist/interactions/catalog.js +98 -0
  35. package/dist/interactions/catalog.js.map +1 -0
  36. package/dist/interactions/harness.d.ts +25 -0
  37. package/dist/interactions/harness.js +69 -0
  38. package/dist/interactions/harness.js.map +1 -0
  39. package/dist/interactions/index.d.ts +5 -0
  40. package/dist/interactions/index.js +5 -0
  41. package/dist/interactions/index.js.map +1 -0
  42. package/dist/interactions/registry.d.ts +9 -0
  43. package/dist/interactions/registry.js +33 -0
  44. package/dist/interactions/registry.js.map +1 -0
  45. package/dist/interactions/types.d.ts +28 -0
  46. package/dist/interactions/types.js +5 -0
  47. package/dist/interactions/types.js.map +1 -0
  48. package/dist/polls.d.ts +12 -0
  49. package/dist/polls.js +102 -0
  50. package/dist/polls.js.map +1 -0
  51. package/dist/runtime-report.d.ts +3 -0
  52. package/dist/runtime-report.js +10 -0
  53. package/dist/runtime-report.js.map +1 -0
  54. package/dist/types.d.ts +73 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +59 -0
@@ -0,0 +1,870 @@
1
+ import { aggregatePolls } from '../polls.js';
2
+ import { PATCHED_GATEWAY_CAPABILITIES, toReactionNoteType } from './types.js';
3
+ import { classifyGroupChatResolution } from './portable-chat.js';
4
+ // How far back recentReactions() scans, mirroring ImsgGateway's
5
+ // history(chatId, 30) read โ€” keep the two in lockstep (contract ยง2).
6
+ const HISTORY_SCAN_WINDOW = 30;
7
+ const REACTION_EMOJI_BY_TYPE = {
8
+ love: 'โค๏ธ',
9
+ like: '๐Ÿ‘',
10
+ dislike: '๐Ÿ‘Ž',
11
+ laugh: '๐Ÿ˜‚',
12
+ emphasis: 'โ€ผ๏ธ',
13
+ question: 'โ“',
14
+ };
15
+ // Only "Liked" is live-verified; the other verbs are extrapolated and the text
16
+ // is display-only, never parsed โ€” runtime.ts branches on is_reaction before
17
+ // text is ever read.
18
+ const REACTION_ADD_VERB_BY_TYPE = {
19
+ love: 'Loved',
20
+ like: 'Liked',
21
+ dislike: 'Disliked',
22
+ laugh: 'Laughed at',
23
+ emphasis: 'Emphasized',
24
+ question: 'Questioned',
25
+ };
26
+ function reactionEventText(type, emoji, isAdd, targetText) {
27
+ const quotedTarget = `"${targetText ?? ''}"`;
28
+ if (type === 'custom') {
29
+ // Custom emoji-picker summary copy is extrapolated and display-only; never
30
+ // parsed by runtime.ts because is_reaction is handled first.
31
+ return isAdd ? `Reacted ${emoji} to ${quotedTarget}` : `Removed ${emoji} from ${quotedTarget}`;
32
+ }
33
+ if (isAdd) {
34
+ return `${REACTION_ADD_VERB_BY_TYPE[type]} ${quotedTarget}`;
35
+ }
36
+ // Removal summary copy is extrapolated and display-only; never parsed by
37
+ // runtime.ts because is_reaction is handled first.
38
+ return `Removed a ${type} from ${quotedTarget}`;
39
+ }
40
+ // In-memory Gateway for unit tests and the simulator. No DB, no imsg binary.
41
+ // Must reproduce every invariant in docs/gateway-contract.md ยง2 exactly โ€”
42
+ // see that doc before changing behavior here.
43
+ export class FakeGateway {
44
+ capabilities;
45
+ messages = [];
46
+ chats = new Map();
47
+ // Group-directory fidelity knobs. Default to the healthy real-gateway shape;
48
+ // the drivers below reach the degraded states ImsgGateway can actually report.
49
+ groupDirectoryComplete = true;
50
+ groupParticipantsUnreported = new Set();
51
+ namePhotoSharedChats = new Set();
52
+ reachableAddresses = new Set();
53
+ sentGuids = new Set();
54
+ nextId = 1;
55
+ nextChatId = 1;
56
+ latencyMs;
57
+ subscribers = new Set();
58
+ activeSubscribers = 0;
59
+ forcedSendFailure = false;
60
+ constructor(opts = {}) {
61
+ this.latencyMs = opts.latencyMs ?? 0;
62
+ this.capabilities = { ...PATCHED_GATEWAY_CAPABILITIES, ...opts.capabilities };
63
+ }
64
+ async delay() {
65
+ if (this.latencyMs > 0)
66
+ await new Promise((r) => setTimeout(r, this.latencyMs));
67
+ }
68
+ allocId() {
69
+ return this.nextId++;
70
+ }
71
+ emit(msg) {
72
+ this.messages.push(msg);
73
+ if (msg.is_from_me && !msg.is_reaction)
74
+ this.sentGuids.add(msg.guid);
75
+ for (const sub of this.subscribers)
76
+ sub(msg);
77
+ }
78
+ ensureDmChat(handle) {
79
+ for (const chat of this.chats.values()) {
80
+ if (!chat.isGroup && chat.participants[0] === handle)
81
+ return chat;
82
+ }
83
+ const chat = {
84
+ chatId: this.nextChatId++,
85
+ guid: `any;-;${handle}`,
86
+ isGroup: false,
87
+ participants: [handle],
88
+ };
89
+ this.chats.set(chat.chatId, chat);
90
+ this.reachableAddresses.add(handle);
91
+ return chat;
92
+ }
93
+ latestMessage(chatId) {
94
+ for (let i = this.messages.length - 1; i >= 0; i--) {
95
+ const m = this.messages[i];
96
+ if (m && m.chat_id === chatId && !m.is_reaction)
97
+ return m;
98
+ }
99
+ return undefined;
100
+ }
101
+ // Mirrors the real react path: the CLI targets the most recent INCOMING
102
+ // message (imsg 0.12.0 help text) โ€” our own bubbles are never the target.
103
+ newestIncoming(chatId) {
104
+ for (let i = this.messages.length - 1; i >= 0; i--) {
105
+ const m = this.messages[i];
106
+ if (m && m.chat_id === chatId && !m.is_reaction && !m.is_from_me)
107
+ return m;
108
+ }
109
+ return undefined;
110
+ }
111
+ // Base message-row envelope (contract ยง2 "Message event shape") โ€” the one
112
+ // place to grow the shape. Everything but reaction rows builds on this;
113
+ // fields overrides/extends the defaults.
114
+ buildMessage(chat, fields) {
115
+ const id = this.allocId();
116
+ return {
117
+ id,
118
+ chat_id: chat.chatId,
119
+ chat_guid: chat.guid,
120
+ chat_name: chat.isGroup ? chat.name : undefined,
121
+ is_group: chat.isGroup,
122
+ guid: `fake-msg-${id}`,
123
+ reactions: [],
124
+ participants: chat.isGroup ? chat.participants : undefined,
125
+ created_at: new Date().toISOString(),
126
+ ...fields,
127
+ };
128
+ }
129
+ buildOutboundMessage(chat, text) {
130
+ return this.buildMessage(chat, { is_from_me: true, text });
131
+ }
132
+ // Toggle bookkeeping shared by react() (actor = us) and injectReaction
133
+ // (actor = the other party's handle) โ€” same primitive, different caller.
134
+ // Maintains the target message's reactions[] aggregate (the state the real
135
+ // gateway reads from history) and emits the reaction event (the row the
136
+ // real gateway sees on the watch stream).
137
+ toggleReaction(chatId, targetGuid, isFromMe, senderHandle, senderName, type, emoji) {
138
+ const target = this.messages.find((m) => m.guid === targetGuid);
139
+ if (!target)
140
+ throw new Error(`toggleReaction: no message with guid ${targetGuid}`);
141
+ target.reactions ??= [];
142
+ const existingIdx = target.reactions.findIndex((r) => r.type === type && r.emoji === emoji && r.is_from_me === isFromMe && (isFromMe || r.sender === senderHandle));
143
+ const nowActive = existingIdx === -1;
144
+ const id = this.allocId();
145
+ const createdAt = new Date().toISOString();
146
+ if (nowActive) {
147
+ const entry = {
148
+ id,
149
+ type,
150
+ emoji,
151
+ is_from_me: isFromMe,
152
+ sender: senderHandle,
153
+ created_at: createdAt,
154
+ };
155
+ target.reactions.push(entry);
156
+ }
157
+ else {
158
+ target.reactions.splice(existingIdx, 1);
159
+ }
160
+ const msg = {
161
+ id,
162
+ chat_id: chatId,
163
+ chat_guid: this.chats.get(chatId)?.guid,
164
+ is_group: this.chats.get(chatId)?.isGroup ?? false,
165
+ guid: `fake-reaction-${id}`,
166
+ sender: senderHandle,
167
+ sender_name: senderName,
168
+ is_from_me: isFromMe,
169
+ text: reactionEventText(type, emoji, nowActive, target.text),
170
+ reactions: [],
171
+ is_reaction: true,
172
+ reaction_type: type,
173
+ reaction_emoji: emoji,
174
+ is_reaction_add: nowActive,
175
+ reacted_to_guid: targetGuid,
176
+ created_at: createdAt,
177
+ };
178
+ this.emit(msg);
179
+ return msg;
180
+ }
181
+ // ---- Gateway interface ----
182
+ async *subscribe(sinceId = 0) {
183
+ // Single ACTIVE consumer, mirroring the real path (contract ยง1) โ€” the
184
+ // real gateway's event queue is instance-shared and refuses a second
185
+ // subscriber, so the fake must not offer concurrent subscriptions either.
186
+ // Sequential re-subscription (subscribe โ†’ break โ†’ subscribe) stays legal:
187
+ // it models reconnect-after-downtime.
188
+ if (this.activeSubscribers > 0) {
189
+ throw new Error('FakeGateway.subscribe: already subscribed โ€” one active consumer per gateway');
190
+ }
191
+ this.activeSubscribers++;
192
+ const queue = [];
193
+ let wake = null;
194
+ let lastDelivered = sinceId;
195
+ const handler = (msg) => {
196
+ if (msg.id > lastDelivered) {
197
+ queue.push(msg);
198
+ wake?.();
199
+ }
200
+ };
201
+ try {
202
+ // Catch-up: replay anything already stored beyond the cursor before
203
+ // going live โ€” mirrors reconnect-after-downtime (contract ยง2).
204
+ for (const m of this.messages) {
205
+ if (m.id > lastDelivered) {
206
+ lastDelivered = m.id;
207
+ yield m;
208
+ }
209
+ }
210
+ this.subscribers.add(handler);
211
+ for (;;) {
212
+ const next = queue.shift();
213
+ if (next) {
214
+ lastDelivered = next.id;
215
+ yield next;
216
+ }
217
+ else {
218
+ await new Promise((resolve) => {
219
+ wake = resolve;
220
+ });
221
+ wake = null;
222
+ }
223
+ }
224
+ }
225
+ finally {
226
+ this.subscribers.delete(handler);
227
+ this.activeSubscribers--;
228
+ }
229
+ }
230
+ async history(chatId, limit = 30) {
231
+ await this.delay();
232
+ // Mirrors the real path: history hides reaction ROWS but each message
233
+ // carries its current reactions[] aggregate (contract ยง2).
234
+ const msgs = this.messages.filter((m) => m.chat_id === chatId && !m.is_reaction);
235
+ return msgs.slice(-limit);
236
+ }
237
+ async listChats(limit = 10_000) {
238
+ await this.delay();
239
+ return [...this.chats.values()]
240
+ .map((chat) => {
241
+ const last = [...this.messages].reverse().find((message) => message.chat_id === chat.chatId);
242
+ return {
243
+ chatId: chat.chatId,
244
+ conversationExternalId: chat.guid,
245
+ isGroup: chat.isGroup,
246
+ participantExternalIds: [...chat.participants].sort(),
247
+ lastMessageAt: last?.created_at ?? null,
248
+ lastMessageId: last?.id ?? 0,
249
+ };
250
+ })
251
+ .sort((left, right) => right.lastMessageId - left.lastMessageId)
252
+ .slice(0, limit)
253
+ .map(({ lastMessageId: _, ...chat }) => chat);
254
+ }
255
+ async historyRange(chatId, range) {
256
+ await this.delay();
257
+ const startMs = range.startAt ? new Date(range.startAt).getTime() : null;
258
+ const endMs = range.endBefore ? new Date(range.endBefore).getTime() : null;
259
+ const messages = this.messages.filter((message) => {
260
+ if (message.chat_id !== chatId || message.is_reaction)
261
+ return false;
262
+ if (!message.created_at)
263
+ return startMs === null && endMs === null;
264
+ const occurredAt = new Date(message.created_at).getTime();
265
+ return (startMs === null || occurredAt >= startMs) && (endMs === null || occurredAt < endMs);
266
+ });
267
+ return structuredClone(messages.slice(-(range.limit ?? 500)));
268
+ }
269
+ async send(target, text) {
270
+ await this.delay();
271
+ if (this.forcedSendFailure) {
272
+ this.forcedSendFailure = false;
273
+ return { ok: false };
274
+ }
275
+ const chat = 'chatId' in target ? this.chats.get(target.chatId) : this.ensureDmChat(target.to);
276
+ if (!chat)
277
+ return { ok: false };
278
+ const msg = this.buildOutboundMessage(chat, text);
279
+ this.emit(msg);
280
+ return { ok: true, id: msg.id, guid: msg.guid };
281
+ }
282
+ async react(chatId, reaction, expectedGuid) {
283
+ await this.delay();
284
+ // DM-only, mirroring the real path (contract ยง2): group-thread titles
285
+ // defeat the automation's focused-chat verification.
286
+ if (this.chats.get(chatId)?.isGroup)
287
+ return { ok: false };
288
+ const target = this.newestIncoming(chatId);
289
+ if (!target || (expectedGuid !== undefined && target.guid !== expectedGuid)) {
290
+ return { ok: true, skipped: 'stale-target' };
291
+ }
292
+ if (target.reactions?.some((r) => r.is_from_me && r.type === reaction)) {
293
+ return { ok: true, skipped: 'already-reacted' };
294
+ }
295
+ this.toggleReaction(chatId, target.guid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction]);
296
+ return { ok: true };
297
+ }
298
+ async createGroup(handles, firstMessage) {
299
+ await this.delay();
300
+ const chatId = this.nextChatId++;
301
+ const chat = {
302
+ chatId,
303
+ guid: `any;+;${chatId.toString(16).padStart(32, '0')}`,
304
+ isGroup: true,
305
+ participants: [...handles],
306
+ };
307
+ this.chats.set(chat.chatId, chat);
308
+ for (const handle of handles)
309
+ this.reachableAddresses.add(handle);
310
+ this.emit(this.buildOutboundMessage(chat, firstMessage));
311
+ return { chatId: chat.chatId };
312
+ }
313
+ // Inbound tapbacks on OUR messages, newest-first โ€” read from message
314
+ // reactions[] state, mirroring the real gateway's history-derived path
315
+ // INCLUDING its ceiling: only the chat's most recent HISTORY_SCAN_WINDOW
316
+ // messages are scanned (the real path reads history(chatId, 30), so a
317
+ // tapback on an older message is invisible there โ€” the fake must not
318
+ // surface what the real gateway can't; contract ยง2, capability rule 2).
319
+ async recentReactions(chatId, limit = 10) {
320
+ await this.delay();
321
+ const window = await this.history(chatId, HISTORY_SCAN_WINDOW);
322
+ const notes = [];
323
+ for (const m of window) {
324
+ if (!m.is_from_me || !m.reactions)
325
+ continue;
326
+ for (const r of m.reactions) {
327
+ if (!r.is_from_me)
328
+ notes.push({ id: r.id, reaction: toReactionNoteType(r.type), emoji: r.emoji });
329
+ }
330
+ }
331
+ return notes.sort((a, b) => b.id - a.id).slice(0, limit);
332
+ }
333
+ // Read-only DM lookup by handle (contract: ImsgGateway reads it from
334
+ // chat.db). Unlike ensureDm this must NOT create โ€” the real path returns
335
+ // null for a thread that doesn't exist, and FakeGateway may not exceed it.
336
+ async resolveDmChat(handle) {
337
+ await this.delay();
338
+ for (const chat of this.chats.values()) {
339
+ if (!chat.isGroup && chat.participants[0] === handle)
340
+ return chat.chatId;
341
+ }
342
+ return null;
343
+ }
344
+ async resolveGroupChat(request) {
345
+ await this.delay();
346
+ return classifyGroupChatResolution({
347
+ request,
348
+ // The real gateway derives this from scan-limit truncation and per-chat
349
+ // history readability. The fake cannot truncate, but it must still be able
350
+ // to REACH the state, or it silently exceeds the real gateway's guarantees
351
+ // and no test can cover the branch (rule 2).
352
+ directoryComplete: this.groupDirectoryComplete,
353
+ groups: [...this.chats.values()]
354
+ .filter((chat) => chat.isGroup)
355
+ .map((chat) => ({
356
+ chatId: chat.chatId,
357
+ conversationExternalId: chat.guid,
358
+ participantExternalIds: this.groupParticipantsUnreported.has(chat.chatId) ? null : chat.participants,
359
+ })),
360
+ });
361
+ }
362
+ async sendStatus(guid) {
363
+ await this.delay();
364
+ if (!this.sentGuids.has(guid)) {
365
+ return {
366
+ ok: true,
367
+ guid,
368
+ send_state: 'pending',
369
+ service: 'iMessage',
370
+ delivered_at: null,
371
+ date_read: null,
372
+ is_read: false,
373
+ status_fields: null,
374
+ };
375
+ }
376
+ // The fake knows it dispatched this guid, but has no recipient-side
377
+ // evidence for delivery or reading. Never over-claim either.
378
+ return {
379
+ ok: true,
380
+ guid,
381
+ send_state: 'sent',
382
+ service: 'iMessage',
383
+ delivered_at: null,
384
+ date_read: null,
385
+ is_read: false,
386
+ status_fields: {
387
+ is_sent: true,
388
+ is_delivered: false,
389
+ is_finished: true,
390
+ error: 0,
391
+ date_delivered: null,
392
+ date_read: null,
393
+ is_delayed: false,
394
+ is_prepared: false,
395
+ is_pending_satellite_send: false,
396
+ was_downgraded: false,
397
+ },
398
+ };
399
+ }
400
+ async checkHandle(address, opts) {
401
+ await this.delay();
402
+ const aliasType = opts?.aliasType ?? (address.includes('@') ? 'email' : 'phone');
403
+ const valid = aliasType === 'phone' ? /^\+[1-9]\d{6,14}$/.test(address) : /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address);
404
+ const destination = aliasType === 'phone' ? `tel:${address}` : `mailto:${address}`;
405
+ if (!valid) {
406
+ return { ok: false, available: false, idStatus: 0, destination, service: 'iMessage', aliasType, address };
407
+ }
408
+ const available = this.reachableAddresses.has(address);
409
+ return {
410
+ ok: true,
411
+ available,
412
+ idStatus: available ? 1 : 0,
413
+ destination,
414
+ service: 'iMessage',
415
+ aliasType,
416
+ address,
417
+ };
418
+ }
419
+ // ---- Tier 2 (bridge-backed) methods โ€” see gateway/types.ts and
420
+ // docs/gateway-contract.md ยง2 for what each one may and may not do. Unlike
421
+ // react()/createGroup(), none of these are DM-only or newest-incoming-only;
422
+ // they mirror the real bridge's arbitrary message/chat targeting.
423
+ // tapback/editMessage/unsendMessage/deleteMessage can target ANY message,
424
+ // but ImsgGateway locates it by scanning history(chatId, 30) BEFORE firing
425
+ // (contract ยง2 "Tier-2 guid-targeting window") โ€” a guid older than that is
426
+ // invisible to the real path even though it's still in chat.db. This must
427
+ // return the same `undefined` the real path effectively gets in that case
428
+ // (parity rule 2: `this.messages` unconditionally would let the fake target
429
+ // a guid the real path can't reach).
430
+ findInScanWindow(chatId, targetGuid) {
431
+ return this.messages
432
+ .filter((m) => m.chat_id === chatId && !m.is_reaction)
433
+ .slice(-HISTORY_SCAN_WINDOW)
434
+ .find((m) => m.guid === targetGuid);
435
+ }
436
+ // Targeted tapback: any message guid (within the scan window above),
437
+ // explicit add/remove, works in groups. Same closed Reaction set as
438
+ // react(); the patched custom-emoji path is modeled separately below.
439
+ async tapback(chatId, targetGuid, reaction, remove = false) {
440
+ await this.delay();
441
+ const target = this.findInScanWindow(chatId, targetGuid);
442
+ if (!target)
443
+ return { ok: false };
444
+ const active = Boolean(target.reactions?.some((r) => r.is_from_me && r.type === reaction));
445
+ if (remove ? !active : active) {
446
+ return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
447
+ }
448
+ this.toggleReaction(chatId, targetGuid, true, undefined, undefined, reaction, REACTION_EMOJI_BY_TYPE[reaction]);
449
+ return { ok: true };
450
+ }
451
+ async emojiTapback(chatId, targetGuid, emoji, remove = false) {
452
+ await this.delay();
453
+ if (!this.capabilities.emojiTapback || !emoji)
454
+ return { ok: false };
455
+ const target = this.findInScanWindow(chatId, targetGuid);
456
+ if (!target)
457
+ return { ok: false };
458
+ const active = target.reactions?.some((r) => r.is_from_me && r.type === 'custom' && r.emoji === emoji) ?? false;
459
+ if (remove ? !active : active) {
460
+ return { ok: true, skipped: remove ? 'not-reacted' : 'already-reacted' };
461
+ }
462
+ this.toggleReaction(chatId, targetGuid, true, undefined, undefined, 'custom', emoji);
463
+ return { ok: true };
464
+ }
465
+ // Targets an EXISTING chat only โ€” no find-or-create `to:` form (contract ยง2).
466
+ async sendRich(chatId, text, opts = {}) {
467
+ await this.delay();
468
+ const chat = this.chats.get(chatId);
469
+ if (!chat)
470
+ return { ok: false };
471
+ // `effect` and `subject` have no fields in imsg's JSON message shape
472
+ // (docs/json.md) โ€” they are write-only on the real path too, so the fake
473
+ // has nothing to store.
474
+ const msg = this.buildMessage(chat, { is_from_me: true, text, reply_to_guid: opts.replyToGuid });
475
+ this.emit(msg);
476
+ return { ok: true, id: msg.id, guid: msg.guid };
477
+ }
478
+ async sendAttachment(chatId, _filePath, opts = {}) {
479
+ await this.delay();
480
+ const chat = this.chats.get(chatId);
481
+ if (!chat)
482
+ return { ok: false };
483
+ // The send response does not carry attachment metadata. Do not invent
484
+ // readback fields before a later history/watch row supplies them.
485
+ const msg = this.buildMessage(chat, { is_from_me: true, reply_to_guid: opts.replyToGuid });
486
+ this.emit(msg);
487
+ return { ok: true, id: msg.id, guid: msg.guid };
488
+ }
489
+ async sendSticker(chatId, _filePath, _opts = {}) {
490
+ await this.delay();
491
+ if (!this.capabilities.stickerSend)
492
+ return { ok: false };
493
+ const chat = this.chats.get(chatId);
494
+ if (!chat)
495
+ return { ok: false };
496
+ // File validity and sticker rendering have no synchronous postcondition
497
+ // in the Gateway message shape; conservatively emit only the outbound row.
498
+ const msg = this.buildMessage(chat, { is_from_me: true });
499
+ this.emit(msg);
500
+ return { ok: true, id: msg.id, guid: msg.guid };
501
+ }
502
+ async sendPoll(chatId, question, options) {
503
+ await this.delay();
504
+ if (options.length < 2 || !this.chats.has(chatId))
505
+ return { ok: false };
506
+ const { balloon } = this.injectPollCreate({ chatId, fromMe: true, question, options });
507
+ return { ok: true, guid: balloon.guid };
508
+ }
509
+ async sendRichLink(chatId, _url) {
510
+ await this.delay();
511
+ const chat = this.chats.get(chatId);
512
+ if (!chat)
513
+ return { ok: false };
514
+ // The real send.rich url submode is queued and returns NO guid; the URL
515
+ // balloon lands as its own row later. Emit the row for stream parity but
516
+ // return only `{ ok }` โ€” the fake must never hand back a guid the real
517
+ // path does not provide (rule 2). ImsgMessage has no rich-link field, so
518
+ // the preview itself is not observable through this interface either.
519
+ this.emit(this.buildMessage(chat, { is_from_me: true }));
520
+ return { ok: true };
521
+ }
522
+ // Edits mutate the target row's text IN PLACE โ€” no new row, no subscribe()
523
+ // event (contract ยง2: this is what breaks the "same guid = immutable
524
+ // content" assumption elsewhere in the codebase).
525
+ async editMessage(chatId, targetGuid, text) {
526
+ await this.delay();
527
+ const target = this.findInScanWindow(chatId, targetGuid);
528
+ if (!target)
529
+ return { ok: false };
530
+ target.text = text;
531
+ return { ok: true };
532
+ }
533
+ // Live parity: unsend leaves a tombstone row whose text is cleared.
534
+ async unsendMessage(chatId, targetGuid) {
535
+ await this.delay();
536
+ const target = this.findInScanWindow(chatId, targetGuid);
537
+ if (!target)
538
+ return { ok: false };
539
+ target.text = undefined;
540
+ return { ok: true };
541
+ }
542
+ // Real deleteChatItems: has no stable history postcondition or deletion
543
+ // marker. Preserve the row here as the conservative no-queryable-mutation
544
+ // model so the fake never promises a readback capability the real path lacks.
545
+ async deleteMessage(chatId, targetGuid) {
546
+ await this.delay();
547
+ const target = this.findInScanWindow(chatId, targetGuid);
548
+ if (!target)
549
+ return { ok: false };
550
+ return { ok: true };
551
+ }
552
+ // Fire-and-forget: no observable effect through history() or this
553
+ // interface (contract ยง2) โ€” the fake does not track a
554
+ // queryable typing/read state, matching real capability (parity rule 2).
555
+ async setTyping(chatId, _on) {
556
+ await this.delay();
557
+ return { ok: this.chats.has(chatId) };
558
+ }
559
+ async markRead(chatId) {
560
+ await this.delay();
561
+ return { ok: this.chats.has(chatId) };
562
+ }
563
+ async renameGroup(chatId, name) {
564
+ await this.delay();
565
+ const chat = this.chats.get(chatId);
566
+ if (!chat?.isGroup)
567
+ return { ok: false };
568
+ chat.name = name;
569
+ return { ok: true };
570
+ }
571
+ // No photo field exists anywhere in imsg's JSON output โ€” fire-and-forget,
572
+ // same as setTyping/markRead (contract ยง2).
573
+ async setGroupPhoto(chatId, _filePath) {
574
+ await this.delay();
575
+ if (!this.capabilities.groupPhoto)
576
+ return { ok: false };
577
+ return { ok: Boolean(this.chats.get(chatId)?.isGroup) };
578
+ }
579
+ async addParticipant(chatId, handle) {
580
+ await this.delay();
581
+ if (!this.capabilities.groupParticipants)
582
+ return { ok: false };
583
+ const chat = this.chats.get(chatId);
584
+ if (!chat?.isGroup)
585
+ return { ok: false };
586
+ if (!chat.participants.includes(handle))
587
+ chat.participants.push(handle);
588
+ this.reachableAddresses.add(handle);
589
+ return { ok: true };
590
+ }
591
+ async removeParticipant(chatId, handle) {
592
+ await this.delay();
593
+ if (!this.capabilities.groupParticipants)
594
+ return { ok: false };
595
+ const chat = this.chats.get(chatId);
596
+ if (!chat?.isGroup)
597
+ return { ok: false };
598
+ chat.participants = chat.participants.filter((h) => h !== handle);
599
+ return { ok: true };
600
+ }
601
+ // participants[] excludes the local user always (contract ยง2 "Participants
602
+ // exclude the local user" โ€” imsg docs/groups.md), so leaving never changes
603
+ // it โ€” no observable field, fire-and-forget like setGroupPhoto.
604
+ async leaveGroup(chatId) {
605
+ await this.delay();
606
+ return { ok: Boolean(this.chats.get(chatId)?.isGroup) };
607
+ }
608
+ async shareNamePhoto(chatId) {
609
+ await this.delay();
610
+ if (!this.capabilities.namePhotoSharing || !this.chats.has(chatId))
611
+ return { ok: false, effectStarted: false };
612
+ if (this.namePhotoSharedChats.has(chatId))
613
+ return { ok: true, skipped: 'not-offered' };
614
+ this.namePhotoSharedChats.add(chatId);
615
+ return { ok: true };
616
+ }
617
+ // ---- fixture/test driver API (not part of Gateway) ----
618
+ // Reproduce the real gateway's bounded-directory failure. ImsgGateway sets
619
+ // directoryComplete=false when the chat scan hits GROUP_CHAT_SCAN_LIMIT or a
620
+ // group's history is unreadable; the fake has no scan limit, so without this
621
+ // it could never reach the state and rule 2's "never exceed the real
622
+ // gateway's capability" would be violated by omission.
623
+ setGroupDirectoryComplete(complete) {
624
+ this.groupDirectoryComplete = complete;
625
+ }
626
+ // Reproduce an imsg directory that reports a group without its `participants`
627
+ // list, which the wire type permits.
628
+ setGroupParticipantsUnreported(chatId, unreported) {
629
+ if (unreported)
630
+ this.groupParticipantsUnreported.add(chatId);
631
+ else
632
+ this.groupParticipantsUnreported.delete(chatId);
633
+ }
634
+ // Simulate an inbound text from another participant. Creates the DM chat
635
+ // if chatId is omitted (find-or-create by sender), or appends to an
636
+ // existing chat (DM or group) when chatId is given.
637
+ injectInbound(params) {
638
+ const chat = params.chatId !== undefined ? this.chats.get(params.chatId) : this.ensureDmChat(params.sender);
639
+ if (!chat)
640
+ throw new Error(`injectInbound: unknown chatId ${params.chatId}`);
641
+ const msg = this.buildMessage(chat, {
642
+ sender: params.sender,
643
+ sender_name: params.senderName,
644
+ is_from_me: false,
645
+ text: params.text,
646
+ attachments: params.attachments,
647
+ });
648
+ this.emit(msg);
649
+ return msg;
650
+ }
651
+ // Simulate another participant tapback-reacting (their side can target any
652
+ // message, same as real Messages.app โ€” only OUR react() is most-recent-
653
+ // incoming-only).
654
+ injectReaction(params) {
655
+ if ((params.reaction === undefined) === (params.emoji === undefined)) {
656
+ throw new Error('injectReaction: provide exactly one of reaction or emoji');
657
+ }
658
+ const target = params.targetGuid ?? this.latestMessage(params.chatId)?.guid;
659
+ if (!target)
660
+ throw new Error('injectReaction: chat has no message to react to');
661
+ const type = params.reaction ?? 'custom';
662
+ const emoji = params.emoji ?? REACTION_EMOJI_BY_TYPE[params.reaction];
663
+ return this.toggleReaction(params.chatId, target, false, params.sender, params.senderName, type, emoji);
664
+ }
665
+ // ---- native poll fixtures (contract ยง2 "Native poll readback") ----
666
+ // Polls are created through sendPoll(), a manual host-Mac `imsg poll send`
667
+ // CLI op (fromMe: true here), or another participant's device. All three
668
+ // fixtures mirror the live wire shapes verified 2026-07-07 (docs/imsg-polls.md).
669
+ // A poll landing in a chat is TWO rows: the balloon row carrying the poll
670
+ // payload (display text "Sent a poll"), then a plain caption row with the
671
+ // question โ€” Messages never renders the poll title on the balloon, so imsg
672
+ // always sends the caption.
673
+ injectPollCreate(params) {
674
+ if (params.options.length < 2)
675
+ throw new Error('injectPollCreate: pass at least two options');
676
+ const chat = params.chatId !== undefined
677
+ ? this.chats.get(params.chatId)
678
+ : params.sender !== undefined
679
+ ? this.ensureDmChat(params.sender)
680
+ : undefined;
681
+ if (!chat)
682
+ throw new Error(`injectPollCreate: unknown chatId ${params.chatId}`);
683
+ const fromMe = params.fromMe ?? false;
684
+ const balloon = this.buildMessage(chat, {
685
+ sender: fromMe ? undefined : params.sender,
686
+ sender_name: fromMe ? undefined : params.senderName,
687
+ is_from_me: fromMe,
688
+ text: 'Sent a poll',
689
+ });
690
+ balloon.poll = {
691
+ kind: 'created',
692
+ event: 'imessage.poll.created',
693
+ question: params.question,
694
+ options: params.options.map((text, i) => ({ id: `fake-opt-${balloon.id}-${i}`, text })),
695
+ creator: fromMe ? 'mapi' : params.sender,
696
+ poll_guid: balloon.guid,
697
+ metadata: { associated_message_type: 3 },
698
+ };
699
+ this.emit(balloon);
700
+ const caption = this.buildMessage(chat, {
701
+ sender: fromMe ? undefined : params.sender,
702
+ sender_name: fromMe ? undefined : params.senderName,
703
+ is_from_me: fromMe,
704
+ text: params.question,
705
+ reply_to_guid: balloon.guid,
706
+ });
707
+ this.emit(caption);
708
+ return { balloon, caption };
709
+ }
710
+ pollSnapshot(chatId, pollGuid) {
711
+ const snap = aggregatePolls(this.messages.filter((m) => m.chat_id === chatId)).find((p) => p.originGuid === pollGuid);
712
+ if (!snap)
713
+ throw new Error(`no poll with guid ${pollGuid} in chat ${chatId}`);
714
+ return snap;
715
+ }
716
+ // Simulate another participant voting. votes[] is that participant's
717
+ // CURRENT selections, not a delta โ€” re-voting emits a new row carrying the
718
+ // new full selection. An empty selection is refused: bare vote retraction
719
+ // is unverified on the real path (docs/imsg-polls.md, open questions).
720
+ injectPollVote(params) {
721
+ if (!params.optionIds.length) {
722
+ throw new Error('injectPollVote: empty selection โ€” vote retraction is unverified on the real path');
723
+ }
724
+ const chat = this.chats.get(params.chatId);
725
+ if (!chat)
726
+ throw new Error(`injectPollVote: unknown chatId ${params.chatId}`);
727
+ const snap = this.pollSnapshot(params.chatId, params.pollGuid);
728
+ const textById = new Map(snap.options.map((o) => [o.id, o.text]));
729
+ const votes = params.optionIds.map((optionId) => {
730
+ const optionText = textById.get(optionId);
731
+ if (optionText === undefined)
732
+ throw new Error(`injectPollVote: unknown option ${optionId}`);
733
+ return {
734
+ event_type: 'selected',
735
+ option_id: optionId,
736
+ option_text: optionText,
737
+ participant: params.sender,
738
+ server_time: `${Date.now() / 1000}`,
739
+ };
740
+ });
741
+ const msg = this.buildMessage(chat, {
742
+ sender: params.sender,
743
+ sender_name: params.senderName,
744
+ is_from_me: false,
745
+ // Live vote rows carry a single-space display text โ€” the payload is
746
+ // the poll object.
747
+ text: ' ',
748
+ poll: {
749
+ kind: 'vote',
750
+ event: 'imessage.poll.voted',
751
+ vote: votes[0],
752
+ votes,
753
+ poll_guid: params.pollGuid,
754
+ original_guid: params.pollGuid,
755
+ metadata: { associated_message_type: 4000 },
756
+ },
757
+ });
758
+ this.emit(msg);
759
+ return msg;
760
+ }
761
+ // Simulate another participant using "Add Choice". Mirrors the live wire
762
+ // hazards on purpose: the row re-carries the FULL option list, is
763
+ // mislabeled kind:"created", and its question field is junk โ€” consumers
764
+ // must classify via pollEventKind and join original_guid (src/polls.ts).
765
+ injectPollAddOption(params) {
766
+ const chat = this.chats.get(params.chatId);
767
+ if (!chat)
768
+ throw new Error(`injectPollAddOption: unknown chatId ${params.chatId}`);
769
+ const snap = this.pollSnapshot(params.chatId, params.pollGuid);
770
+ const msg = this.buildMessage(chat, {
771
+ sender: params.sender,
772
+ sender_name: params.senderName,
773
+ is_from_me: false,
774
+ // Live option-update rows display as U+FFFD.
775
+ text: '๏ฟฝ',
776
+ });
777
+ msg.poll = {
778
+ kind: 'created',
779
+ event: 'imessage.poll.created',
780
+ question: '',
781
+ options: [...snap.options, { id: `fake-opt-${msg.id}`, text: params.optionText }],
782
+ creator: params.sender,
783
+ poll_guid: msg.guid,
784
+ original_guid: params.pollGuid,
785
+ metadata: { associated_message_type: 2 },
786
+ };
787
+ this.emit(msg);
788
+ return msg;
789
+ }
790
+ // Force the next send() to fail (contract ยง2 send-failure mode). Resets
791
+ // itself after firing once.
792
+ failNextSend() {
793
+ this.forcedSendFailure = true;
794
+ }
795
+ // Evidence override for handle-check tests/sim. Unknown well-formed
796
+ // addresses remain conservatively unavailable; this never queries Apple.
797
+ setHandleReachable(address, reachable) {
798
+ if (reachable)
799
+ this.reachableAddresses.add(address);
800
+ else
801
+ this.reachableAddresses.delete(address);
802
+ }
803
+ // Find-or-create the DM chat for a handle without sending anything โ€” the
804
+ // simulator seeds empty DM panes with this before the first message.
805
+ ensureDm(handle) {
806
+ return this.ensureDmChat(handle).chatId;
807
+ }
808
+ // Read-only mirror of the whole store for the simulator UI. Deep-copied so
809
+ // callers can't mutate reaction aggregates behind the gateway's back.
810
+ snapshot() {
811
+ return {
812
+ chats: [...this.chats.values()].map((c) => ({
813
+ chatId: c.chatId,
814
+ chatGuid: c.guid,
815
+ isGroup: c.isGroup,
816
+ participants: [...c.participants],
817
+ })),
818
+ messages: structuredClone(this.messages),
819
+ };
820
+ }
821
+ // Hosted-Dev persistence only. This is not a Gateway capability: it restores
822
+ // a previously validated synthetic snapshot before the isolated world starts.
823
+ devRestoreSnapshot(input) {
824
+ const chats = new Map();
825
+ const messageIds = new Set();
826
+ for (const source of input.chats) {
827
+ if (!Number.isSafeInteger(source.chatId) || source.chatId < 1 || chats.has(source.chatId)) {
828
+ throw new Error('synthetic snapshot has an invalid chat identity');
829
+ }
830
+ const messageGuid = input.messages.find((message) => message.chat_id === source.chatId)?.chat_guid;
831
+ const guid = source.isGroup ? messageGuid : `any;-;${source.participants[0] ?? ''}`;
832
+ if (!guid || (source.isGroup ? !guid.startsWith('any;+;') : !guid.startsWith('any;-;'))) {
833
+ throw new Error('synthetic snapshot has no portable conversation identity');
834
+ }
835
+ chats.set(source.chatId, {
836
+ chatId: source.chatId,
837
+ guid,
838
+ isGroup: source.isGroup,
839
+ participants: [...source.participants],
840
+ });
841
+ }
842
+ const messages = input.messages.map((message) => structuredClone(message));
843
+ for (const message of messages) {
844
+ const chat = chats.get(message.chat_id);
845
+ if (!Number.isSafeInteger(message.id) ||
846
+ message.id < 1 ||
847
+ messageIds.has(message.id) ||
848
+ !chat ||
849
+ message.chat_guid !== chat.guid ||
850
+ message.is_group !== chat.isGroup) {
851
+ throw new Error('synthetic snapshot message conflicts with its chat');
852
+ }
853
+ messageIds.add(message.id);
854
+ }
855
+ this.chats = chats;
856
+ this.messages = messages;
857
+ this.nextChatId = Math.max(0, ...chats.keys()) + 1;
858
+ this.nextId = Math.max(0, ...messages.map((message) => message.id)) + 1;
859
+ }
860
+ // Tap every emitted event (messages AND reaction rows) without holding the
861
+ // single-consumer subscribe slot โ€” the simulator uses this to know when to
862
+ // rebroadcast state. Returns an unsubscribe function.
863
+ onEvent(listener) {
864
+ this.subscribers.add(listener);
865
+ return () => {
866
+ this.subscribers.delete(listener);
867
+ };
868
+ }
869
+ }
870
+ //# sourceMappingURL=fake.js.map