@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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/gateway/external-ids.d.ts +3 -0
- package/dist/gateway/external-ids.js +37 -0
- package/dist/gateway/external-ids.js.map +1 -0
- package/dist/gateway/fake.d.ts +161 -0
- package/dist/gateway/fake.js +870 -0
- package/dist/gateway/fake.js.map +1 -0
- package/dist/gateway/imsg.d.ts +77 -0
- package/dist/gateway/imsg.js +676 -0
- package/dist/gateway/imsg.js.map +1 -0
- package/dist/gateway/portable-chat.d.ts +65 -0
- package/dist/gateway/portable-chat.js +118 -0
- package/dist/gateway/portable-chat.js.map +1 -0
- package/dist/gateway/types.d.ts +169 -0
- package/dist/gateway/types.js +16 -0
- package/dist/gateway/types.js.map +1 -0
- package/dist/imsg/binary.d.ts +1 -0
- package/dist/imsg/binary.js +4 -0
- package/dist/imsg/binary.js.map +1 -0
- package/dist/imsg/react.d.ts +2 -0
- package/dist/imsg/react.js +24 -0
- package/dist/imsg/react.js.map +1 -0
- package/dist/imsg/rpc.d.ts +145 -0
- package/dist/imsg/rpc.js +227 -0
- package/dist/imsg/rpc.js.map +1 -0
- package/dist/imsg/status.d.ts +9 -0
- package/dist/imsg/status.js +37 -0
- package/dist/imsg/status.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions/catalog.d.ts +2 -0
- package/dist/interactions/catalog.js +98 -0
- package/dist/interactions/catalog.js.map +1 -0
- package/dist/interactions/harness.d.ts +25 -0
- package/dist/interactions/harness.js +69 -0
- package/dist/interactions/harness.js.map +1 -0
- package/dist/interactions/index.d.ts +5 -0
- package/dist/interactions/index.js +5 -0
- package/dist/interactions/index.js.map +1 -0
- package/dist/interactions/registry.d.ts +9 -0
- package/dist/interactions/registry.js +33 -0
- package/dist/interactions/registry.js.map +1 -0
- package/dist/interactions/types.d.ts +28 -0
- package/dist/interactions/types.js +5 -0
- package/dist/interactions/types.js.map +1 -0
- package/dist/polls.d.ts +12 -0
- package/dist/polls.js +102 -0
- package/dist/polls.js.map +1 -0
- package/dist/runtime-report.d.ts +3 -0
- package/dist/runtime-report.js +10 -0
- package/dist/runtime-report.js.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|