@spectrum-ts/imessage 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2153 @@
1
+ import { MessageEffect, NotFoundError, ValidationError, createClient } from "@photon-ai/advanced-imessage";
2
+ import { IMessageSDK } from "@photon-ai/imessage-kit";
3
+ import { sanitizePhone, withSpan } from "@photon-ai/otel";
4
+ import { UnsupportedError, appLayoutSchema, cloud, definePlatform, fromVCard, mergeStreams, read, stream, text, toVCard } from "@spectrum-ts/core";
5
+ import { asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asRichlink, asText, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, messageEffectSchema, photoActionSchema, reactionSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
6
+ import z from "zod";
7
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
+ import { createReadStream } from "node:fs";
9
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
10
+ import { Readable } from "node:stream";
11
+ import { tmpdir } from "node:os";
12
+ import { basename, join } from "node:path";
13
+ import { Marked } from "marked";
14
+ import { LRUCache } from "lru-cache";
15
+ //#region src/content/background.ts
16
+ /**
17
+ * iMessage-only chat background content. Lives entirely under the iMessage
18
+ * provider — never enters the universal `Content` discriminated union. The
19
+ * framework recognizes it via two generic content-level contracts:
20
+ *
21
+ * 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
22
+ * `platform/build.ts` reads this tag and warns-and-skips when a different
23
+ * platform receives it.
24
+ * 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check
25
+ * treats this as a side-effecting send that returns no message id, the
26
+ * same way it treats `reaction` / `typing` / `edit`.
27
+ *
28
+ * iMessage's `send` handler narrows back to `Background` via the `isBackground`
29
+ * type guard before dispatching to `chats.setBackground` / `removeBackground`.
30
+ */
31
+ const backgroundSchema = z.object({
32
+ type: z.literal("background"),
33
+ __platform: z.literal("iMessage"),
34
+ __fireAndForget: z.literal(true),
35
+ action: photoActionSchema
36
+ });
37
+ const isBackground = (v) => backgroundSchema.safeParse(v).success;
38
+ function background(input, options) {
39
+ const action = buildPhotoAction(input, options, "background");
40
+ return { build: async () => backgroundSchema.parse({
41
+ type: "background",
42
+ __platform: "iMessage",
43
+ __fireAndForget: true,
44
+ action
45
+ }) };
46
+ }
47
+ //#endregion
48
+ //#region src/content/contact-card.ts
49
+ /**
50
+ * iMessage-only "share contact card" control signal. Pushes the *local
51
+ * account's native contact card* (the name + photo a recipient sees in their
52
+ * Messages app) to a chat via the SDK's `chats.shareContactInfo`.
53
+ *
54
+ * This is Apple's "Share Name and Photo" mechanism — distinct from the
55
+ * universal `contact(...)` content, which uploads an arbitrary person's vCard
56
+ * as a *file* attachment. There is no payload: the card shared is always the
57
+ * bot account's own.
58
+ *
59
+ * Like `background`, it lives entirely under the iMessage provider and never
60
+ * enters the universal `Content` discriminated union. The framework recognizes
61
+ * it via two generic content-level contracts:
62
+ *
63
+ * 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
64
+ * `platform/build.ts` reads this tag and warns-and-skips when a different
65
+ * platform receives it.
66
+ * 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check treats
67
+ * this as a side-effecting send that returns no message id, the same way it
68
+ * treats `read` / `typing`.
69
+ *
70
+ * iMessage's `send` handler narrows back via the `isContactCard` type guard
71
+ * before dispatching to `chats.shareContactInfo`.
72
+ */
73
+ const contactCardSchema = z.object({
74
+ type: z.literal("contactCard"),
75
+ __platform: z.literal("iMessage"),
76
+ __fireAndForget: z.literal(true)
77
+ });
78
+ const isContactCard = (v) => contactCardSchema.safeParse(v).success;
79
+ /**
80
+ * Share the bot account's native iMessage contact card (name + photo) with the
81
+ * chat. iMessage-only, remote-only.
82
+ *
83
+ * `space.send(nativeContactCard())` is the canonical form; `space.shareContactCard()`
84
+ * is sugar attached via `PlatformDef.space.actions` (only typed on
85
+ * `PlatformSpace<IMessageDef>`).
86
+ *
87
+ * This is an explicit, on-demand share and always fires — unlike the automatic
88
+ * best-effort share gated behind the `imessageSynced` project profile, which
89
+ * dedupes to once per chat per 24h (see `remote/contact-share.ts`). Works in
90
+ * both DMs and group chats; the recipient chooses whether to accept the card.
91
+ *
92
+ * `ContactCard` is intentionally not a member of the universal `Content`
93
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
94
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
95
+ * The framework treats it as a fire-and-forget control signal at runtime.
96
+ */
97
+ function nativeContactCard() {
98
+ return { build: async () => contactCardSchema.parse({
99
+ type: "contactCard",
100
+ __platform: "iMessage",
101
+ __fireAndForget: true
102
+ }) };
103
+ }
104
+ //#endregion
105
+ //#region src/content/customized-mini-app.ts
106
+ const layoutSchema = appLayoutSchema;
107
+ /**
108
+ * iMessage-only mini-app card content. Lives entirely under the iMessage
109
+ * provider — never enters the universal `Content` discriminated union. The
110
+ * framework recognizes it via the generic content-level platform contract:
111
+ *
112
+ * - `__platform: "iMessage"` — `findUnsupportedPlatformContent` reads this tag
113
+ * and warns-and-skips when a different platform receives it.
114
+ *
115
+ * Unlike `background` / `read`, this content is **not** `__fireAndForget`: it
116
+ * produces a real outbound message, so the iMessage `send` handler narrows
117
+ * back to `CustomizedMiniApp` via the `isCustomizedMiniApp` guard and returns
118
+ * the resulting `ProviderMessageRecord` (rather than `void`).
119
+ */
120
+ const customizedMiniAppSchema = z.object({
121
+ type: z.literal("customized-mini-app"),
122
+ __platform: z.literal("iMessage"),
123
+ appName: z.string().nonempty(),
124
+ appStoreId: z.number().int().positive().optional(),
125
+ extensionBundleId: z.string().nonempty(),
126
+ layout: layoutSchema,
127
+ teamId: z.string(),
128
+ url: z.url()
129
+ });
130
+ const isCustomizedMiniApp = (v) => customizedMiniAppSchema.safeParse(v).success;
131
+ const asCustomizedMiniApp = (input) => customizedMiniAppSchema.parse({
132
+ type: "customized-mini-app",
133
+ __platform: "iMessage",
134
+ ...input
135
+ });
136
+ /**
137
+ * Construct a `customized-mini-app` content value. iMessage-only, remote-only.
138
+ *
139
+ * The layout is what recipients see in the bubble. `teamId` and
140
+ * `extensionBundleId` identify the iMessage extension that receives `url` when
141
+ * the recipient taps the card; the server constructs the matching
142
+ * `MSMessageExtensionBalloonPlugin` plugin id from these values. `appStoreId`
143
+ * is optional and only points recipients without the extension at its App
144
+ * Store entry.
145
+ *
146
+ * `space.send(customizedMiniApp(...))` is the canonical form.
147
+ *
148
+ * `CustomizedMiniApp` is intentionally not a member of the universal `Content`
149
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
150
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
151
+ */
152
+ function customizedMiniApp(input) {
153
+ return { build: async () => asCustomizedMiniApp(input) };
154
+ }
155
+ //#endregion
156
+ //#region src/content/effect.ts
157
+ const SUPPORTED_EFFECTS = new Set(Object.values(MessageEffect));
158
+ const resolveContent = (input) => typeof input === "string" ? text(input).build() : input.build();
159
+ function effect(input, messageEffect) {
160
+ return { build: async () => {
161
+ if (!SUPPORTED_EFFECTS.has(messageEffect)) throw new Error(`Unsupported iMessage message effect "${messageEffect}"`);
162
+ const inner = await resolveContent(input);
163
+ if (inner.type !== "text" && inner.type !== "markdown" && inner.type !== "attachment") throw new Error(`imessage effect() only supports text, markdown, and attachment content, got "${inner.type}"`);
164
+ return messageEffectSchema.parse({
165
+ type: "effect",
166
+ content: inner,
167
+ effect: messageEffect
168
+ });
169
+ } };
170
+ }
171
+ //#endregion
172
+ //#region src/types.ts
173
+ /**
174
+ * Sentinel phone for shared-token mode. The single shared client serves an
175
+ * unknown set of numbers (the SDK exposes no recipient field on inbound and
176
+ * no `from` parameter on send), so all routing through it tags this sentinel.
177
+ */
178
+ const SHARED_PHONE = "shared";
179
+ const isLocal = (client) => client instanceof IMessageSDK;
180
+ const clientEntry = z.object({
181
+ address: z.string(),
182
+ token: z.string(),
183
+ phone: z.string()
184
+ });
185
+ const configSchema = z.union([z.object({ local: z.literal(true) }), z.object({
186
+ local: z.literal(false).optional().default(false),
187
+ clients: clientEntry.or(z.array(clientEntry)).optional()
188
+ })]);
189
+ z.object({});
190
+ const spaceSchema = z.object({
191
+ id: z.string(),
192
+ type: z.enum(["dm", "group"]),
193
+ phone: z.string()
194
+ });
195
+ const spaceParamsSchema = z.object({ phone: z.string().optional() });
196
+ /**
197
+ * iMessage-specific per-message metadata surfaced on `IMessageMessage`.
198
+ * - `partIndex`: attachment index within a multi-part message (0 for bare
199
+ * or single-attachment messages; 0..N-1 for a group's sub-items).
200
+ * - `parentId`: guid of the parent message for a group sub-item. Undefined
201
+ * when the message itself is the parent.
202
+ */
203
+ const messageSchema = z.object({
204
+ partIndex: z.number().int().nonnegative().optional(),
205
+ parentId: z.string().optional()
206
+ });
207
+ //#endregion
208
+ //#region src/auth.ts
209
+ const log$3 = createLogger("spectrum.imessage.auth");
210
+ const RENEWAL_RATIO = .8;
211
+ const EXPIRY_BUFFER_MS = 3e4;
212
+ const RETRY_DELAY_MS = 3e4;
213
+ const cloudAuthState = /* @__PURE__ */ new WeakMap();
214
+ const requirePhone = (data, instanceId) => {
215
+ const phone = data.numbers?.[instanceId];
216
+ if (!phone) throw new Error(`iMessage instance ${instanceId} has no phone assigned`);
217
+ return phone;
218
+ };
219
+ async function createCloudClients(projectId, projectSecret) {
220
+ let tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
221
+ let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
222
+ let disposed = false;
223
+ let renewalTimer;
224
+ let refreshFailures = 0;
225
+ const records = [];
226
+ const syncPhones = (data) => {
227
+ for (const { entry, instanceId } of records) entry.phone = requirePhone(data, instanceId);
228
+ };
229
+ const onRefreshSuccess = () => {
230
+ if (refreshFailures > 0) {
231
+ log$3.info("imessage token refresh recovered", { "spectrum.imessage.auth.attempt": refreshFailures });
232
+ refreshFailures = 0;
233
+ }
234
+ };
235
+ const onRefreshFailure = (error) => {
236
+ refreshFailures += 1;
237
+ log$3.warn("imessage token refresh failed; retrying", {
238
+ "spectrum.imessage.auth.attempt": refreshFailures,
239
+ "spectrum.imessage.auth.retry_in_ms": RETRY_DELAY_MS,
240
+ ...errorAttrs(error)
241
+ }, error);
242
+ };
243
+ const scheduleRenewal = () => {
244
+ if (disposed) return;
245
+ const ttlMs = tokenData.expiresIn * 1e3;
246
+ const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
247
+ renewalTimer = setTimeout(async () => {
248
+ try {
249
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
250
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
251
+ if (tokenData.type === "dedicated") syncPhones(tokenData);
252
+ onRefreshSuccess();
253
+ scheduleRenewal();
254
+ } catch (error) {
255
+ onRefreshFailure(error);
256
+ renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
257
+ renewalTimer?.unref?.();
258
+ }
259
+ }, renewInMs);
260
+ renewalTimer?.unref?.();
261
+ };
262
+ scheduleRenewal();
263
+ const refreshIfNeeded = async () => {
264
+ if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
265
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
266
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
267
+ if (tokenData.type === "dedicated") syncPhones(tokenData);
268
+ onRefreshSuccess();
269
+ scheduleRenewal();
270
+ };
271
+ if (tokenData.type === "shared") {
272
+ const entries = [{
273
+ phone: SHARED_PHONE,
274
+ client: createClient({
275
+ address: process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "imessage.spectrum.photon.codes:443",
276
+ tls: true,
277
+ token: async () => {
278
+ await refreshIfNeeded();
279
+ return tokenData.token;
280
+ }
281
+ })
282
+ }];
283
+ cloudAuthState.set(entries, { dispose: () => {
284
+ disposed = true;
285
+ if (renewalTimer !== void 0) {
286
+ clearTimeout(renewalTimer);
287
+ renewalTimer = void 0;
288
+ }
289
+ } });
290
+ return entries;
291
+ }
292
+ const dedicated = tokenData;
293
+ for (const [instanceId, token] of Object.entries(dedicated.auth)) {
294
+ const entry = {
295
+ phone: requirePhone(dedicated, instanceId),
296
+ client: createClient({
297
+ address: `${instanceId}.imsg.photon.codes:443`,
298
+ tls: true,
299
+ token: async () => {
300
+ await refreshIfNeeded();
301
+ return tokenData.auth[instanceId] ?? token;
302
+ }
303
+ })
304
+ };
305
+ records.push({
306
+ entry,
307
+ instanceId
308
+ });
309
+ }
310
+ const entries = records.map((r) => r.entry);
311
+ cloudAuthState.set(entries, { dispose: () => {
312
+ disposed = true;
313
+ if (renewalTimer !== void 0) {
314
+ clearTimeout(renewalTimer);
315
+ renewalTimer = void 0;
316
+ }
317
+ } });
318
+ return entries;
319
+ }
320
+ async function disposeCloudAuth(clients) {
321
+ const auth = cloudAuthState.get(clients);
322
+ if (auth) {
323
+ auth.dispose();
324
+ cloudAuthState.delete(clients);
325
+ }
326
+ }
327
+ //#endregion
328
+ //#region src/cache.ts
329
+ const DEFAULT_MAX = 1e3;
330
+ /**
331
+ * Bounded insertion-order cache of recently-seen iMessage messages, keyed by
332
+ * guid. Provides O(1) lookup for reaction target resolution. When capacity is
333
+ * exceeded, the oldest entry is evicted. Access does not promote recency —
334
+ * this is a bounded FIFO, not an LRU. The workload (reactions arriving shortly
335
+ * after the message they target) doesn't benefit from LRU semantics, and FIFO
336
+ * avoids a dependency.
337
+ */
338
+ var MessageCache = class {
339
+ map = /* @__PURE__ */ new Map();
340
+ max;
341
+ constructor(max = DEFAULT_MAX) {
342
+ this.max = max;
343
+ }
344
+ get(id) {
345
+ return this.map.get(id);
346
+ }
347
+ set(id, message) {
348
+ if (this.map.has(id)) this.map.delete(id);
349
+ this.map.set(id, message);
350
+ if (this.map.size > this.max) {
351
+ const first = this.map.keys().next().value;
352
+ if (first !== void 0) this.map.delete(first);
353
+ }
354
+ }
355
+ clear() {
356
+ this.map.clear();
357
+ }
358
+ };
359
+ /**
360
+ * Bounded insertion-order cache of recently-seen iMessage polls, keyed by
361
+ * poll message guid. The public poll shape deliberately hides provider ids;
362
+ * `optionsByIdentifier` keeps the private lookup table needed to correlate
363
+ * vote events back to public `PollChoice` objects.
364
+ */
365
+ var PollCache = class {
366
+ map = /* @__PURE__ */ new Map();
367
+ max;
368
+ constructor(max = DEFAULT_MAX) {
369
+ this.max = max;
370
+ }
371
+ get(id) {
372
+ return this.map.get(id);
373
+ }
374
+ set(id, poll) {
375
+ if (this.map.has(id)) this.map.delete(id);
376
+ this.map.set(id, poll);
377
+ if (this.map.size > this.max) {
378
+ const first = this.map.keys().next().value;
379
+ if (first !== void 0) this.map.delete(first);
380
+ }
381
+ }
382
+ clear() {
383
+ this.map.clear();
384
+ }
385
+ };
386
+ const messageCaches = /* @__PURE__ */ new WeakMap();
387
+ const pollCaches = /* @__PURE__ */ new WeakMap();
388
+ /**
389
+ * Returns a per-client message cache. Keyed by an object (the client array
390
+ * for remote, or the IMessageSDK instance for local), so each iMessage
391
+ * provider instance has its own cache and multiple providers don't share
392
+ * state accidentally.
393
+ */
394
+ const getMessageCache = (owner) => {
395
+ let cache = messageCaches.get(owner);
396
+ if (!cache) {
397
+ cache = new MessageCache();
398
+ messageCaches.set(owner, cache);
399
+ }
400
+ return cache;
401
+ };
402
+ const getPollCache = (owner) => {
403
+ let cache = pollCaches.get(owner);
404
+ if (!cache) {
405
+ cache = new PollCache();
406
+ pollCaches.set(owner, cache);
407
+ }
408
+ return cache;
409
+ };
410
+ //#endregion
411
+ //#region src/shared/vcard.ts
412
+ const VCARD_MIME_TYPES = new Set([
413
+ "text/vcard",
414
+ "text/x-vcard",
415
+ "text/directory",
416
+ "application/vcard",
417
+ "application/x-vcard"
418
+ ]);
419
+ const normalizeMimeType = (mimeType) => (mimeType.split(";")[0] ?? "").trim().toLowerCase();
420
+ const isVCardAttachment = (mimeType, fileName) => {
421
+ if (mimeType && VCARD_MIME_TYPES.has(normalizeMimeType(mimeType))) return true;
422
+ return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
423
+ };
424
+ const vcardFileName = (contact) => {
425
+ return `${(contact.name?.formatted ?? contact.user?.id ?? "contact").replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
426
+ };
427
+ const readLocalAttachment = async (att) => {
428
+ if (!att.localPath) throw new Error(`iMessage attachment ${att.id} has no local file available on disk`);
429
+ return readFile(att.localPath);
430
+ };
431
+ const toAttachmentContent$1 = (att) => {
432
+ const { localPath } = att;
433
+ return asAttachment({
434
+ id: att.id,
435
+ name: att.fileName ?? "attachment",
436
+ mimeType: att.mimeType,
437
+ size: att.sizeBytes,
438
+ read: () => readLocalAttachment(att),
439
+ stream: localPath ? async () => Readable.toWeb(createReadStream(localPath)) : void 0
440
+ });
441
+ };
442
+ const toVCardContent$1 = async (att) => {
443
+ try {
444
+ return asContact(fromVCard((await readLocalAttachment(att)).toString("utf8")));
445
+ } catch {
446
+ return toAttachmentContent$1(att);
447
+ }
448
+ };
449
+ const localAttachmentContent = async (att) => isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent$1(att) : toAttachmentContent$1(att);
450
+ //#endregion
451
+ //#region src/local/inbound.ts
452
+ const ATTACHMENT_PLACEHOLDER = "";
453
+ const ATTACHMENT_JOIN_RETRY_DELAY_MS = 250;
454
+ const ATTACHMENT_JOIN_RETRY_LIMIT = 8;
455
+ const ATTACHMENT_JOIN_FETCH_LIMIT = 10;
456
+ const hasAttachmentPlaceholder = (message) => message.text?.includes(ATTACHMENT_PLACEHOLDER) ?? false;
457
+ const isPendingAttachmentJoin = (message) => message.attachments.length === 0 && (message.hasAttachments || hasAttachmentPlaceholder(message));
458
+ const refetchUntilAttachmentsSettle = async (client, message) => {
459
+ if (!message.chatId) return message;
460
+ for (let attempt = 0; attempt < ATTACHMENT_JOIN_RETRY_LIMIT; attempt += 1) {
461
+ await setTimeout$1(ATTACHMENT_JOIN_RETRY_DELAY_MS);
462
+ let rows;
463
+ try {
464
+ rows = await client.getMessages({
465
+ chatId: message.chatId,
466
+ limit: ATTACHMENT_JOIN_FETCH_LIMIT,
467
+ since: message.createdAt
468
+ });
469
+ } catch {
470
+ continue;
471
+ }
472
+ const refreshed = rows.find((row) => row.id === message.id);
473
+ if (refreshed && !isPendingAttachmentJoin(refreshed)) return refreshed;
474
+ }
475
+ return message;
476
+ };
477
+ const toMessages = async (message) => {
478
+ const { chatId, chatKind } = message;
479
+ if (!chatId || chatKind === "unknown") return [];
480
+ if (message.reaction !== null || message.kind !== "text" || message.retractedAt !== null) return [];
481
+ if (isPendingAttachmentJoin(message)) return [];
482
+ const base = {
483
+ sender: { id: message.participant ?? "" },
484
+ space: {
485
+ id: chatId,
486
+ type: chatKind === "group" ? "group" : "dm",
487
+ phone: ""
488
+ },
489
+ timestamp: message.createdAt
490
+ };
491
+ if (message.attachments.length > 0) return Promise.all(message.attachments.map(async (att) => ({
492
+ ...base,
493
+ id: `${message.id}:${att.id}`,
494
+ content: await localAttachmentContent(att)
495
+ })));
496
+ return [{
497
+ ...base,
498
+ id: message.id,
499
+ content: {
500
+ type: "text",
501
+ text: message.text ?? ""
502
+ }
503
+ }];
504
+ };
505
+ const messages$3 = (client) => stream((emit, end) => {
506
+ let lastPromise = Promise.resolve();
507
+ const handleIncoming = async (message) => {
508
+ const ms = await toMessages(isPendingAttachmentJoin(message) ? await refetchUntilAttachmentsSettle(client, message) : message);
509
+ for (const m of ms) await emit(m);
510
+ };
511
+ const startPromise = client.startWatching({
512
+ onIncomingMessage: (message) => {
513
+ lastPromise = lastPromise.then(() => handleIncoming(message)).catch(end);
514
+ },
515
+ onError: end
516
+ }).catch(end);
517
+ return async () => {
518
+ await startPromise.catch(() => {});
519
+ await client.stopWatching();
520
+ await lastPromise.catch(() => {});
521
+ };
522
+ });
523
+ //#endregion
524
+ //#region src/shared/errors.ts
525
+ const IMESSAGE_PLATFORM = "iMessage";
526
+ const LOCAL_IMESSAGE_PLATFORM = "iMessage (local mode)";
527
+ const unsupportedRemoteContent = (type, detail) => UnsupportedError.content(type, IMESSAGE_PLATFORM, detail);
528
+ const unsupportedLocalContent = (type, detail) => UnsupportedError.content(type, LOCAL_IMESSAGE_PLATFORM, detail);
529
+ //#endregion
530
+ //#region src/local/send.ts
531
+ const synthRecord = (spaceId, content) => ({
532
+ id: crypto.randomUUID(),
533
+ content,
534
+ space: { id: spaceId },
535
+ timestamp: /* @__PURE__ */ new Date()
536
+ });
537
+ const sendTempFile = async (client, spaceId, name, data) => {
538
+ const safeName = basename(name) || "attachment";
539
+ const dir = await mkdtemp(join(tmpdir(), "spectrum-"));
540
+ const tmp = join(dir, safeName);
541
+ await writeFile(tmp, data);
542
+ try {
543
+ await client.send({
544
+ to: spaceId,
545
+ attachments: [tmp]
546
+ });
547
+ } finally {
548
+ await rm(dir, {
549
+ recursive: true,
550
+ force: true
551
+ }).catch(() => {});
552
+ }
553
+ };
554
+ const send$3 = async (client, spaceId, content) => {
555
+ switch (content.type) {
556
+ case "text":
557
+ await client.send({
558
+ to: spaceId,
559
+ text: content.text
560
+ });
561
+ return synthRecord(spaceId, content);
562
+ case "attachment":
563
+ await sendTempFile(client, spaceId, content.name, await content.read());
564
+ return synthRecord(spaceId, content);
565
+ case "contact": {
566
+ const vcf = await toVCard(content);
567
+ await sendTempFile(client, spaceId, vcardFileName(content), Buffer.from(vcf, "utf8"));
568
+ return synthRecord(spaceId, content);
569
+ }
570
+ case "effect": throw unsupportedLocalContent("effect", "message effects require remote iMessage");
571
+ case "poll": throw unsupportedLocalContent("poll");
572
+ default: throw unsupportedLocalContent(content.type);
573
+ }
574
+ };
575
+ const getMessage$3 = async (_client, _id) => void 0;
576
+ //#endregion
577
+ //#region src/local/api.ts
578
+ const messages$2 = (client) => messages$3(client);
579
+ const send$2 = async (client, spaceId, content) => send$3(client, spaceId, content);
580
+ const getMessage$2 = async (client, id) => getMessage$3(client, id);
581
+ //#endregion
582
+ //#region src/remote/ids.ts
583
+ const PART_PREFIX = /^p:(\d+)\//;
584
+ const dmChatGuid = (address) => `any;-;${address}`;
585
+ const chatTypeFromGuid = (guid) => guid.includes(";+;") ? "group" : "dm";
586
+ const toChatGuid = (value) => value;
587
+ const toMessageGuid = (value) => value;
588
+ const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
589
+ const parseChildId = (id) => {
590
+ const match = id.match(PART_PREFIX);
591
+ if (!match) return null;
592
+ return {
593
+ parentGuid: id.replace(PART_PREFIX, ""),
594
+ partIndex: Number(match[1])
595
+ };
596
+ };
597
+ //#endregion
598
+ //#region src/remote/avatar.ts
599
+ /**
600
+ * Apply an `Avatar` content value to a remote iMessage group chat.
601
+ *
602
+ * `set` uploads the icon bytes via `groups.setIcon`; `clear` removes the
603
+ * current icon via `groups.removeIcon`. Both surfaces are fire-and-forget —
604
+ * no message id is produced. The caller (`handleAvatar` in the iMessage
605
+ * provider) is responsible for the group-only / remote-only guards.
606
+ */
607
+ const setIcon$1 = async (remote, spaceId, content) => {
608
+ const chat = toChatGuid(spaceId);
609
+ if (content.action.kind === "clear") {
610
+ await remote.groups.removeIcon(chat);
611
+ return;
612
+ }
613
+ const buffer = await content.action.read();
614
+ await remote.groups.setIcon(chat, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
615
+ };
616
+ //#endregion
617
+ //#region src/remote/background.ts
618
+ /**
619
+ * Apply a `Background` content value to a remote iMessage chat.
620
+ *
621
+ * `set` uploads the photo bytes via `chats.setBackground`; `clear` removes
622
+ * any current background via `chats.removeBackground`. Both surfaces are
623
+ * fire-and-forget — no message id is produced.
624
+ */
625
+ const setBackground$1 = async (remote, spaceId, content) => {
626
+ const chat = toChatGuid(spaceId);
627
+ if (content.action.kind === "clear") {
628
+ await remote.chats.removeBackground(chat);
629
+ return;
630
+ }
631
+ const buffer = await content.action.read();
632
+ await remote.chats.setBackground(chat, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
633
+ };
634
+ //#endregion
635
+ //#region src/remote/contact-card.ts
636
+ /**
637
+ * Share the local account's native contact card (name + photo) with the chat.
638
+ *
639
+ * The SDK exposes a single chat-level `chats.shareContactInfo(chatGuid)` — the
640
+ * card shared is always the bot account's own, so there is no payload beyond
641
+ * the chat. `send` has already resolved the space into `spaceId` by the time
642
+ * the dispatcher reaches here.
643
+ *
644
+ * On-demand and unconditional: unlike the proactive `ContactShareTracker` in
645
+ * `contact-share.ts` (24h dedupe, gated behind the `imessageSynced` profile),
646
+ * this fires every time the caller asks.
647
+ */
648
+ const shareContactCard$1 = async (remote, spaceId) => {
649
+ await remote.chats.shareContactInfo(toChatGuid(spaceId));
650
+ };
651
+ //#endregion
652
+ //#region src/remote/customized-mini-app.ts
653
+ /**
654
+ * Send a `CustomizedMiniApp` card to a remote iMessage chat.
655
+ *
656
+ * Unlike `setBackground`, this produces a real outbound message, so it returns
657
+ * a `ProviderMessageRecord`. The `content` carries extra `type` / `__platform`
658
+ * tags the SDK ignores; it is passed as a variable (not an object literal) so
659
+ * no excess-property check applies, and the wire serializer reads only the
660
+ * fields it knows.
661
+ */
662
+ const sendCustomizedMiniApp$1 = async (remote, spaceId, content) => {
663
+ const chat = toChatGuid(spaceId);
664
+ const message = await remote.messages.sendCustomizedMiniApp(chat, content);
665
+ return {
666
+ id: message.guid,
667
+ content,
668
+ direction: "outbound",
669
+ space: { id: spaceId },
670
+ timestamp: message.dateCreated
671
+ };
672
+ };
673
+ //#endregion
674
+ //#region src/remote/attachments.ts
675
+ /**
676
+ * Stream the primary file bytes of an attachment as a `ReadableStream`.
677
+ * Skips header and Live Photo companion frames; emits only `primaryChunk`
678
+ * payloads. Cleans up the underlying gRPC iterator on cancel and on error.
679
+ */
680
+ const downloadPrimaryAttachmentStream = (client, attachmentGuid) => {
681
+ const frames = client.attachments.downloadStream(attachmentGuid);
682
+ const iterator = frames[Symbol.asyncIterator]();
683
+ let closed = false;
684
+ const closeFrames = async () => {
685
+ if (closed) return;
686
+ closed = true;
687
+ try {
688
+ await iterator.return?.();
689
+ } finally {
690
+ await frames.close();
691
+ }
692
+ };
693
+ return new ReadableStream({
694
+ async cancel() {
695
+ await closeFrames();
696
+ },
697
+ async pull(controller) {
698
+ try {
699
+ while (true) {
700
+ const result = await iterator.next();
701
+ if (result.done) {
702
+ controller.close();
703
+ await closeFrames();
704
+ return;
705
+ }
706
+ if (result.value.type === "primaryChunk") {
707
+ controller.enqueue(result.value.data);
708
+ return;
709
+ }
710
+ }
711
+ } catch (error) {
712
+ await closeFrames();
713
+ throw error;
714
+ }
715
+ }
716
+ });
717
+ };
718
+ /**
719
+ * Collect the primary file bytes of an attachment into a single `Buffer`.
720
+ * Skips header and Live Photo companion frames.
721
+ */
722
+ const downloadPrimaryAttachment = async (client, attachmentGuid) => {
723
+ const chunks = [];
724
+ const frames = client.attachments.downloadStream(attachmentGuid);
725
+ try {
726
+ for await (const frame of frames) if (frame.type === "primaryChunk") chunks.push(Buffer.from(frame.data));
727
+ } finally {
728
+ await frames.close();
729
+ }
730
+ return Buffer.concat(chunks);
731
+ };
732
+ /**
733
+ * Fetch an attachment by GUID and wrap it as a spectrum `Attachment`. The
734
+ * returned object is lazy: `.read()` triggers a Buffer download, `.stream()`
735
+ * opens a fresh byte stream. Calling both issues two independent gRPC
736
+ * downloads — cache `.read()` if you need the bytes more than once.
737
+ *
738
+ * Returns `undefined` when the GUID is unknown to the server.
739
+ */
740
+ const getRemoteAttachment = async (client, guid) => {
741
+ let info;
742
+ try {
743
+ info = await client.attachments.get(guid);
744
+ } catch (err) {
745
+ if (err instanceof NotFoundError) return;
746
+ throw err;
747
+ }
748
+ return asAttachment({
749
+ id: info.guid,
750
+ name: info.fileName,
751
+ mimeType: info.mimeType,
752
+ size: info.totalBytes,
753
+ read: () => downloadPrimaryAttachment(client, info.guid),
754
+ stream: async () => downloadPrimaryAttachmentStream(client, info.guid)
755
+ });
756
+ };
757
+ //#endregion
758
+ //#region src/remote/inbound.ts
759
+ const log$2 = createLogger("spectrum.imessage.inbound");
760
+ const URL_BALLOON_BUNDLE_ID = "com.apple.messages.URLBalloonProvider";
761
+ const getBalloonBundleId = (message) => message.content.balloonBundleId;
762
+ const messageAttachments = (message) => message.content.attachments;
763
+ const resolveChatGuid = (message, hint) => {
764
+ if (hint) return hint;
765
+ return message.chatGuids?.[0] ?? "";
766
+ };
767
+ const resolveSenderId = (message) => message.sender?.address ?? "";
768
+ const isIMessageMessage = (value) => {
769
+ if (typeof value !== "object" || value === null) return false;
770
+ const record = value;
771
+ return typeof record.id === "string" && record.id.length > 0 && typeof record.content === "object" && record.content !== null && typeof record.space === "object" && record.space !== null;
772
+ };
773
+ const asProviderGroup = (items) => groupSchema.parse({
774
+ type: "group",
775
+ items
776
+ });
777
+ const buildMessageBase = (message, chatGuidHint, timestamp, phone) => {
778
+ const chat = resolveChatGuid(message, chatGuidHint);
779
+ return {
780
+ direction: message.isFromMe ? "outbound" : "inbound",
781
+ sender: { id: resolveSenderId(message) },
782
+ space: {
783
+ id: chat,
784
+ type: chatTypeFromGuid(chat),
785
+ phone
786
+ },
787
+ timestamp
788
+ };
789
+ };
790
+ const toAttachmentContent = (client, info) => asAttachment({
791
+ id: info.guid,
792
+ name: info.fileName,
793
+ mimeType: info.mimeType,
794
+ size: info.totalBytes,
795
+ read: async () => await downloadPrimaryAttachment(client, info.guid),
796
+ stream: async () => downloadPrimaryAttachmentStream(client, info.guid)
797
+ });
798
+ const toVCardContent = async (client, info) => {
799
+ try {
800
+ return asContact(fromVCard((await downloadPrimaryAttachment(client, info.guid)).toString("utf8")));
801
+ } catch (err) {
802
+ log$2.warn("failed to parse vCard attachment; falling back to attachment content", {
803
+ "spectrum.imessage.attachment.guid": info.guid,
804
+ ...errorAttrs(err)
805
+ }, err);
806
+ return toAttachmentContent(client, info);
807
+ }
808
+ };
809
+ const attachmentContent = async (client, info) => isVCardAttachment(info.mimeType, info.fileName) ? await toVCardContent(client, info) : toAttachmentContent(client, info);
810
+ const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
811
+ const content = await attachmentContent(client, info);
812
+ const msg = {
813
+ ...base,
814
+ id,
815
+ content,
816
+ partIndex
817
+ };
818
+ if (parentId !== void 0) msg.parentId = parentId;
819
+ return msg;
820
+ };
821
+ const toRichlinkMessage = (message, base, id) => {
822
+ const url = message.content.text ?? "";
823
+ try {
824
+ return {
825
+ ...base,
826
+ id,
827
+ content: asRichlink({ url })
828
+ };
829
+ } catch (err) {
830
+ log$2.warn("failed to convert message to rich link; falling back to text/custom content", {
831
+ "spectrum.imessage.message.id": id,
832
+ ...errorAttrs(err)
833
+ }, err);
834
+ return {
835
+ ...base,
836
+ id,
837
+ content: url ? asText(url) : asCustom(message)
838
+ };
839
+ }
840
+ };
841
+ const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
842
+ const messageGuidStr = message.guid;
843
+ const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
844
+ const attachments = messageAttachments(message);
845
+ if (attachments.length === 1) {
846
+ const info = attachments[0];
847
+ if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
848
+ return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
849
+ }
850
+ if (attachments.length > 1) {
851
+ const items = [];
852
+ for (let i = 0; i < attachments.length; i++) {
853
+ const info = attachments[i];
854
+ if (!info) continue;
855
+ items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
856
+ }
857
+ return {
858
+ ...base,
859
+ id: messageGuidStr,
860
+ content: asProviderGroup(items)
861
+ };
862
+ }
863
+ if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) return toRichlinkMessage(message, base, messageGuidStr);
864
+ const text = message.content.text;
865
+ return {
866
+ ...base,
867
+ id: messageGuidStr,
868
+ content: text ? asText(text) : asCustom(message)
869
+ };
870
+ };
871
+ const cacheMessage = (cache, message) => {
872
+ cache.set(message.id, message);
873
+ if (message.content.type === "group") {
874
+ for (const item of message.content.items) if (isIMessageMessage(item)) cache.set(item.id, item);
875
+ }
876
+ };
877
+ const toInboundMessages = async (client, cache, event, phone) => {
878
+ const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
879
+ const messageGuidStr = event.message.guid;
880
+ if (getBalloonBundleId(event.message) === URL_BALLOON_BUNDLE_ID) {
881
+ const msg = toRichlinkMessage(event.message, base, messageGuidStr);
882
+ cacheMessage(cache, msg);
883
+ return [msg];
884
+ }
885
+ const attachments = messageAttachments(event.message);
886
+ if (attachments.length === 1) {
887
+ const info = attachments[0];
888
+ if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
889
+ const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
890
+ cacheMessage(cache, msg);
891
+ return [msg];
892
+ }
893
+ if (attachments.length > 1) {
894
+ const items = [];
895
+ for (let i = 0; i < attachments.length; i++) {
896
+ const info = attachments[i];
897
+ if (!info) continue;
898
+ items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
899
+ }
900
+ const parent = {
901
+ ...base,
902
+ id: messageGuidStr,
903
+ content: asProviderGroup(items)
904
+ };
905
+ cacheMessage(cache, parent);
906
+ return [parent];
907
+ }
908
+ const text = event.message.content.text;
909
+ const msg = {
910
+ ...base,
911
+ id: messageGuidStr,
912
+ content: text ? asText(text) : asCustom(event.message)
913
+ };
914
+ cacheMessage(cache, msg);
915
+ return [msg];
916
+ };
917
+ const getMessage$1 = async (remote, spaceId, msgId, phone) => {
918
+ const cache = getMessageCache(remote);
919
+ const cached = cache.get(msgId);
920
+ if (cached) return cached;
921
+ const childRef = parseChildId(msgId);
922
+ if (childRef) try {
923
+ const parent = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(childRef.parentGuid)), phone, spaceId);
924
+ cacheMessage(cache, parent);
925
+ if (parent.content.type !== "group") return;
926
+ const item = parent.content.items[childRef.partIndex];
927
+ return isIMessageMessage(item) ? item : void 0;
928
+ } catch (err) {
929
+ if (err instanceof NotFoundError) return;
930
+ throw err;
931
+ }
932
+ try {
933
+ const rebuilt = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(msgId)), phone, spaceId);
934
+ cacheMessage(cache, rebuilt);
935
+ return rebuilt;
936
+ } catch (err) {
937
+ if (err instanceof NotFoundError) return;
938
+ throw err;
939
+ }
940
+ };
941
+ //#endregion
942
+ //#region src/remote/reactions.ts
943
+ const EMOJI_TO_TAPBACK = {
944
+ "❤️": "love",
945
+ "👍": "like",
946
+ "👎": "dislike",
947
+ "😂": "laugh",
948
+ "‼️": "emphasize",
949
+ "❓": "question"
950
+ };
951
+ const TAPBACK_TO_EMOJI = Object.fromEntries(Object.entries(EMOJI_TO_TAPBACK).map(([emoji, kind]) => [kind, emoji]));
952
+ const reactionEmoji = (reaction) => reaction.kind === "emoji" ? reaction.emoji : TAPBACK_TO_EMOJI[reaction.kind];
953
+ const asProviderReaction = (emoji, target) => reactionSchema.parse({
954
+ emoji,
955
+ target,
956
+ type: "reaction"
957
+ });
958
+ const resolveReactionTarget = async (client, cache, chat, targetGuid, partIndex, phone) => {
959
+ let candidate = cache.get(targetGuid);
960
+ if (!candidate) try {
961
+ candidate = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), phone, chat);
962
+ cacheMessage(cache, candidate);
963
+ } catch {
964
+ return;
965
+ }
966
+ if (candidate.content.type === "group") {
967
+ const items = candidate.content.items;
968
+ if (!Array.isArray(items)) return candidate;
969
+ const item = items[partIndex ?? 0];
970
+ return isIMessageMessage(item) ? item : candidate;
971
+ }
972
+ return candidate;
973
+ };
974
+ const toReactionMessages = async (client, cache, event, phone) => {
975
+ const emoji = reactionEmoji(event.reaction);
976
+ if (!emoji) return [];
977
+ const senderAddress = event.actor?.address;
978
+ if (!senderAddress) return [];
979
+ const resolved = await resolveReactionTarget(client, cache, event.chatGuid, event.messageGuid, event.targetPartIndex, phone);
980
+ if (!resolved) return [];
981
+ const partSuffix = typeof event.targetPartIndex === "number" ? `:${event.targetPartIndex}` : "";
982
+ return [{
983
+ sender: { id: senderAddress },
984
+ space: {
985
+ id: event.chatGuid,
986
+ type: chatTypeFromGuid(event.chatGuid),
987
+ phone
988
+ },
989
+ timestamp: event.occurredAt,
990
+ id: `${event.messageGuid}:reaction:${event.sequence}${partSuffix}`,
991
+ content: asProviderReaction(emoji, resolved)
992
+ }];
993
+ };
994
+ const toSettableReaction = (emoji) => {
995
+ const native = EMOJI_TO_TAPBACK[emoji];
996
+ return native ? { kind: native } : {
997
+ kind: "emoji",
998
+ emoji
999
+ };
1000
+ };
1001
+ const tapbackTarget = (target) => ({
1002
+ guid: toMessageGuid(target.parentId ?? target.id),
1003
+ opts: typeof target.partIndex === "number" ? { partIndex: target.partIndex } : void 0
1004
+ });
1005
+ const reactToMessage$1 = async (remote, spaceId, target, reaction) => {
1006
+ const { guid, opts } = tapbackTarget(target);
1007
+ const sent = await remote.messages.setReaction(toChatGuid(spaceId), guid, toSettableReaction(reaction), true, opts);
1008
+ return {
1009
+ id: sent.guid,
1010
+ content: asProviderReaction(reaction, target),
1011
+ direction: "outbound",
1012
+ space: { id: spaceId },
1013
+ timestamp: sent.dateCreated
1014
+ };
1015
+ };
1016
+ const unsendReaction$1 = async (remote, spaceId, target, reaction) => {
1017
+ const { guid, opts } = tapbackTarget(target);
1018
+ await remote.messages.setReaction(toChatGuid(spaceId), guid, toSettableReaction(reaction), false, opts);
1019
+ };
1020
+ //#endregion
1021
+ //#region src/remote/read.ts
1022
+ /**
1023
+ * Mark every unread message in the chat as read.
1024
+ *
1025
+ * The SDK exposes only a chat-level `chats.markRead(chatGuid)` — there is no
1026
+ * per-message API. The `Read` content's `target` is used by the caller to
1027
+ * derive the chat, which `send` has already resolved into `spaceId` by the
1028
+ * time the dispatcher reaches here.
1029
+ */
1030
+ const markRead$1 = async (remote, spaceId) => {
1031
+ await remote.chats.markRead(toChatGuid(spaceId));
1032
+ };
1033
+ //#endregion
1034
+ //#region src/remote/rename.ts
1035
+ /**
1036
+ * Apply a `Rename` content value to a remote iMessage group chat.
1037
+ * Fire-and-forget — the `Chat` returned by `setDisplayName` is discarded.
1038
+ */
1039
+ const setDisplayName$1 = async (remote, spaceId, content) => {
1040
+ await remote.groups.setDisplayName(toChatGuid(spaceId), content.displayName);
1041
+ };
1042
+ //#endregion
1043
+ //#region src/remote/markdown.ts
1044
+ const markdownLexer = new Marked();
1045
+ const BULLET = "• ";
1046
+ const HR_LINE = "———";
1047
+ const NESTED_LIST_INDENT = " ";
1048
+ const BLOCK_SEPARATOR = "\n\n";
1049
+ const TABLE_CELL_SEPARATOR = " | ";
1050
+ const DEFAULT_LIST_START = 1;
1051
+ const LEADING_WHITESPACE = /^\s+/;
1052
+ const TRAILING_WHITESPACE = /\s+$/;
1053
+ const MONOSPACE_UPPER_A = 120432;
1054
+ const MONOSPACE_LOWER_A = 120458;
1055
+ const MONOSPACE_DIGIT_ZERO = 120822;
1056
+ const UPPER_A = 65;
1057
+ const UPPER_Z = 90;
1058
+ const LOWER_A = 97;
1059
+ const LOWER_Z = 122;
1060
+ const DIGIT_ZERO = 48;
1061
+ const DIGIT_NINE = 57;
1062
+ const monospaceCodePoint = (codePoint) => {
1063
+ if (codePoint >= UPPER_A && codePoint <= UPPER_Z) return MONOSPACE_UPPER_A + (codePoint - UPPER_A);
1064
+ if (codePoint >= LOWER_A && codePoint <= LOWER_Z) return MONOSPACE_LOWER_A + (codePoint - LOWER_A);
1065
+ if (codePoint >= DIGIT_ZERO && codePoint <= DIGIT_NINE) return MONOSPACE_DIGIT_ZERO + (codePoint - DIGIT_ZERO);
1066
+ return codePoint;
1067
+ };
1068
+ const toMonospace = (text) => {
1069
+ let out = "";
1070
+ for (const char of text) {
1071
+ const codePoint = char.codePointAt(0);
1072
+ out += codePoint === void 0 ? char : String.fromCodePoint(monospaceCodePoint(codePoint));
1073
+ }
1074
+ return out;
1075
+ };
1076
+ const STYLE_ORDER = [
1077
+ "bold",
1078
+ "italic",
1079
+ "strikethrough"
1080
+ ];
1081
+ const plain = (text) => ({
1082
+ text,
1083
+ styles: []
1084
+ });
1085
+ const withStyle = (spans, style) => spans.map((span) => span.styles.includes(style) ? span : {
1086
+ ...span,
1087
+ styles: [...span.styles, style]
1088
+ });
1089
+ const asLink = (spans) => spans.map((span) => ({
1090
+ ...span,
1091
+ link: true
1092
+ }));
1093
+ const spanText = (spans) => {
1094
+ let out = "";
1095
+ for (const span of spans) out += span.text;
1096
+ return out;
1097
+ };
1098
+ const joinSpans = (blocks, separator) => {
1099
+ const out = [];
1100
+ for (const [index, block] of blocks.entries()) {
1101
+ if (index > 0) out.push(plain(separator));
1102
+ out.push(...block);
1103
+ }
1104
+ return out;
1105
+ };
1106
+ const splitSpanLines = (spans) => {
1107
+ let current = [];
1108
+ const lines = [current];
1109
+ for (const span of spans) {
1110
+ const parts = span.text.split("\n");
1111
+ for (const [index, part] of parts.entries()) {
1112
+ if (index > 0) {
1113
+ current = [];
1114
+ lines.push(current);
1115
+ }
1116
+ if (part) current.push({
1117
+ ...span,
1118
+ text: part
1119
+ });
1120
+ }
1121
+ }
1122
+ return lines;
1123
+ };
1124
+ const asMarkedToken = (token) => token;
1125
+ const checkboxPrefix = (item) => {
1126
+ if (!item.task) return "";
1127
+ return item.checked ? "[x] " : "[ ] ";
1128
+ };
1129
+ const listMarker = (list, index) => {
1130
+ if (!list.ordered) return BULLET;
1131
+ return `${(list.start === "" ? DEFAULT_LIST_START : list.start) + index}. `;
1132
+ };
1133
+ const renderLink = (token) => {
1134
+ if (token.text === token.href) return [{
1135
+ text: token.href,
1136
+ styles: [],
1137
+ link: true
1138
+ }];
1139
+ return [...asLink(renderInlineTokens(token.tokens)), {
1140
+ text: ` (${token.href})`,
1141
+ styles: [],
1142
+ link: true
1143
+ }];
1144
+ };
1145
+ const renderImage = (token) => [{
1146
+ text: token.text ? `${token.text} (${token.href})` : token.href,
1147
+ styles: [],
1148
+ link: true
1149
+ }];
1150
+ const renderInlineToken = (token) => {
1151
+ switch (token.type) {
1152
+ case "strong": return withStyle(renderInlineTokens(token.tokens), "bold");
1153
+ case "em": return withStyle(renderInlineTokens(token.tokens), "italic");
1154
+ case "del": return withStyle(renderInlineTokens(token.tokens), "strikethrough");
1155
+ case "codespan": return [plain(toMonospace(token.text))];
1156
+ case "br": return [plain("\n")];
1157
+ case "link": return renderLink(token);
1158
+ case "image": return renderImage(token);
1159
+ case "escape": return [plain(token.text)];
1160
+ case "text": return token.tokens ? renderInlineTokens(token.tokens) : [plain(token.text)];
1161
+ case "html": return [plain(token.text)];
1162
+ case "checkbox": return [];
1163
+ default: return "raw" in token ? [plain(String(token.raw))] : [];
1164
+ }
1165
+ };
1166
+ const renderInlineTokens = (tokens) => {
1167
+ const out = [];
1168
+ for (const token of tokens) out.push(...renderInlineToken(asMarkedToken(token)));
1169
+ return out;
1170
+ };
1171
+ const renderBlockquote = (quote) => {
1172
+ const lines = splitSpanLines(renderBlockTokens(quote.tokens));
1173
+ const out = [];
1174
+ for (const [index, line] of lines.entries()) {
1175
+ if (index > 0) out.push(plain("\n"));
1176
+ out.push(plain(line.length > 0 ? "> " : ">"), ...line);
1177
+ }
1178
+ return out;
1179
+ };
1180
+ const renderList = (list) => {
1181
+ const out = [];
1182
+ for (const [index, item] of list.items.entries()) {
1183
+ const prefix = `${listMarker(list, index)}${checkboxPrefix(item)}`;
1184
+ const blocks = [];
1185
+ for (const token of item.tokens) {
1186
+ const rendered = renderBlockToken(asMarkedToken(token));
1187
+ if (spanText(rendered)) blocks.push(rendered);
1188
+ }
1189
+ const [first = [], ...rest] = splitSpanLines(joinSpans(blocks, "\n"));
1190
+ if (out.length > 0) out.push(plain("\n"));
1191
+ out.push(plain(prefix), ...first);
1192
+ for (const line of rest) out.push(plain(`\n${NESTED_LIST_INDENT}`), ...line);
1193
+ }
1194
+ return out;
1195
+ };
1196
+ const renderTable = (table) => {
1197
+ const out = [];
1198
+ const pushRow = (cells, rowIndex) => {
1199
+ if (rowIndex > 0) out.push(plain("\n"));
1200
+ for (const [cellIndex, cell] of cells.entries()) {
1201
+ if (cellIndex > 0) out.push(plain(TABLE_CELL_SEPARATOR));
1202
+ out.push(...renderInlineTokens(cell.tokens));
1203
+ }
1204
+ };
1205
+ pushRow(table.header, 0);
1206
+ for (const [index, row] of table.rows.entries()) pushRow(row, index + 1);
1207
+ return out;
1208
+ };
1209
+ const renderBlockToken = (token) => {
1210
+ switch (token.type) {
1211
+ case "heading": return withStyle(renderInlineTokens(token.tokens), "bold");
1212
+ case "paragraph": return renderInlineTokens(token.tokens);
1213
+ case "code": return [plain(toMonospace(token.text))];
1214
+ case "blockquote": return renderBlockquote(token);
1215
+ case "list": return renderList(token);
1216
+ case "table": return renderTable(token);
1217
+ case "hr": return [plain(HR_LINE)];
1218
+ case "space":
1219
+ case "def": return [];
1220
+ default: return renderInlineToken(token);
1221
+ }
1222
+ };
1223
+ const renderBlockTokens = (tokens) => {
1224
+ const blocks = [];
1225
+ for (const token of tokens) {
1226
+ const rendered = renderBlockToken(asMarkedToken(token));
1227
+ if (spanText(rendered)) blocks.push(rendered);
1228
+ }
1229
+ return joinSpans(blocks, BLOCK_SEPARATOR);
1230
+ };
1231
+ const trimSpans = (spans) => {
1232
+ const trimmed = [...spans];
1233
+ while (trimmed.length > 0) {
1234
+ const first = trimmed.at(0);
1235
+ const text = first?.text.replace(LEADING_WHITESPACE, "");
1236
+ if (first && text) {
1237
+ trimmed[0] = {
1238
+ ...first,
1239
+ text
1240
+ };
1241
+ break;
1242
+ }
1243
+ trimmed.shift();
1244
+ }
1245
+ while (trimmed.length > 0) {
1246
+ const last = trimmed.at(-1);
1247
+ const text = last?.text.replace(TRAILING_WHITESPACE, "");
1248
+ if (last && text) {
1249
+ trimmed[trimmed.length - 1] = {
1250
+ ...last,
1251
+ text
1252
+ };
1253
+ break;
1254
+ }
1255
+ trimmed.pop();
1256
+ }
1257
+ return trimmed;
1258
+ };
1259
+ const finalize = (spans) => {
1260
+ let text = "";
1261
+ let hasLinks = false;
1262
+ const open = /* @__PURE__ */ new Map();
1263
+ const ranges = [];
1264
+ const close = (style, end) => {
1265
+ const start = open.get(style);
1266
+ open.delete(style);
1267
+ if (start !== void 0 && end > start) ranges.push({
1268
+ type: style,
1269
+ start,
1270
+ length: end - start
1271
+ });
1272
+ };
1273
+ for (const span of spans) {
1274
+ if (!span.text) continue;
1275
+ hasLinks ||= span.link === true;
1276
+ const offset = text.length;
1277
+ for (const style of STYLE_ORDER) if (span.styles.includes(style)) {
1278
+ if (!open.has(style)) open.set(style, offset);
1279
+ } else close(style, offset);
1280
+ text += span.text;
1281
+ }
1282
+ for (const style of STYLE_ORDER) close(style, text.length);
1283
+ ranges.sort((a, b) => a.start - b.start || STYLE_ORDER.indexOf(a.type) - STYLE_ORDER.indexOf(b.type));
1284
+ return {
1285
+ text,
1286
+ formatting: ranges,
1287
+ hasLinks
1288
+ };
1289
+ };
1290
+ /**
1291
+ * Render standard markdown (CommonMark + GFM) to iMessage styled text: a
1292
+ * plain string plus UTF-16 formatting ranges for `messages.sendText`'s
1293
+ * `formatting` option. Block layout matches the plain-text renderer (list
1294
+ * bullets, `label (url)` links); inline emphasis becomes native
1295
+ * bold/italic/strikethrough ranges instead of being stripped, headings
1296
+ * render as bold, and code maps to Unicode mathematical monospace
1297
+ * characters. `hasLinks` reports whether any link or image put a URL into
1298
+ * the text, so the sender can enable Apple's data-detector pass.
1299
+ */
1300
+ const markdownToIMessageText = (markdown) => finalize(trimSpans(renderBlockTokens(markdownLexer.lexer(markdown))));
1301
+ //#endregion
1302
+ //#region src/remote/send.ts
1303
+ const GROUP_ITEM_ALLOWED = new Set([
1304
+ "text",
1305
+ "markdown",
1306
+ "attachment",
1307
+ "contact",
1308
+ "voice"
1309
+ ]);
1310
+ const GROUP_TEXT_TYPES = new Set(["text", "markdown"]);
1311
+ const MAX_GROUP_TEXT_ITEMS = 1;
1312
+ const outboundRecord = (spaceId, id, content, timestamp, extras) => ({
1313
+ id,
1314
+ content,
1315
+ direction: "outbound",
1316
+ space: { id: spaceId },
1317
+ timestamp,
1318
+ ...extras
1319
+ });
1320
+ const outboundGroupItem = (spaceId, id, content, timestamp, partIndex, parentId) => outboundRecord(spaceId, id, content, timestamp, {
1321
+ partIndex,
1322
+ parentId
1323
+ });
1324
+ const providerGroup = (items) => asGroup({ items });
1325
+ const withReply = (options, replyTo) => replyTo ? {
1326
+ ...options,
1327
+ replyTo
1328
+ } : options;
1329
+ const replyOptions = (replyTo) => replyTo ? { replyTo } : void 0;
1330
+ const effectOption = (effect) => effect ? { effect } : {};
1331
+ const formattingOption = (formatting) => formatting.length > 0 ? { formatting } : {};
1332
+ const dataDetectionOption = (hasLinks) => hasLinks ? { enableDataDetection: true } : {};
1333
+ const renderMarkdown = (markdown) => {
1334
+ const rendered = markdownToIMessageText(markdown);
1335
+ if (!rendered.text) throw unsupportedRemoteContent("markdown", "renders to empty text — nothing to send");
1336
+ return rendered;
1337
+ };
1338
+ const replyTargetFromId = (messageId) => {
1339
+ const childRef = parseChildId(messageId);
1340
+ if (childRef) return {
1341
+ guid: toMessageGuid(childRef.parentGuid),
1342
+ partIndex: childRef.partIndex
1343
+ };
1344
+ return toMessageGuid(messageId);
1345
+ };
1346
+ const outboundMessage = (spaceId, message, content) => outboundRecord(spaceId, message.guid, content, message.dateCreated);
1347
+ const outboundPoll = (spaceId, poll, content) => outboundRecord(spaceId, poll.pollMessageGuid, content, /* @__PURE__ */ new Date());
1348
+ const sendVCardAttachment = (remote, name, vcf) => remote.attachments.upload({
1349
+ data: Buffer.from(vcf, "utf8"),
1350
+ fileName: name
1351
+ });
1352
+ const sendContactAttachment = async (remote, content) => {
1353
+ const vcf = await toVCard(content);
1354
+ const name = vcardFileName(content);
1355
+ return {
1356
+ guid: (await sendVCardAttachment(remote, name, vcf)).attachment.guid,
1357
+ name
1358
+ };
1359
+ };
1360
+ const uploadAttachment = async (remote, content) => {
1361
+ return {
1362
+ guid: (await remote.attachments.upload({
1363
+ data: await content.read(),
1364
+ fileName: content.name
1365
+ })).attachment.guid,
1366
+ name: content.name
1367
+ };
1368
+ };
1369
+ const uploadVoice = async (remote, content) => {
1370
+ const { buffer } = await ensureM4a(await content.read(), content.mimeType);
1371
+ const name = content.name ?? "voice.m4a";
1372
+ return {
1373
+ guid: (await remote.attachments.upload({
1374
+ data: buffer,
1375
+ fileName: name
1376
+ })).attachment.guid,
1377
+ name
1378
+ };
1379
+ };
1380
+ const sendContent = async (remote, spaceId, chat, content, replyTo, effect) => {
1381
+ switch (content.type) {
1382
+ case "effect": return sendContent(remote, spaceId, chat, content.content, replyTo, content.effect);
1383
+ case "text": return outboundMessage(spaceId, await remote.messages.sendText(chat, content.text, withReply(effectOption(effect), replyTo)), content);
1384
+ case "markdown": {
1385
+ const rendered = renderMarkdown(content.markdown);
1386
+ return outboundMessage(spaceId, await remote.messages.sendText(chat, rendered.text, withReply({
1387
+ ...effectOption(effect),
1388
+ ...formattingOption(rendered.formatting),
1389
+ ...dataDetectionOption(rendered.hasLinks)
1390
+ }, replyTo)), content);
1391
+ }
1392
+ case "richlink": return outboundMessage(spaceId, await remote.messages.sendText(chat, content.url, withReply({ enableLinkPreview: true }, replyTo)), content);
1393
+ case "attachment": {
1394
+ const { guid } = await uploadAttachment(remote, content);
1395
+ return outboundMessage(spaceId, await remote.messages.sendAttachment(chat, guid, withReply(effectOption(effect), replyTo)), content);
1396
+ }
1397
+ case "contact": {
1398
+ const { guid } = await sendContactAttachment(remote, content);
1399
+ return outboundMessage(spaceId, await remote.messages.sendAttachment(chat, guid, replyOptions(replyTo)), content);
1400
+ }
1401
+ case "voice": {
1402
+ const { guid } = await uploadVoice(remote, content);
1403
+ return outboundMessage(spaceId, await remote.messages.sendAttachment(chat, guid, {
1404
+ isAudioMessage: true,
1405
+ ...replyOptions(replyTo)
1406
+ }), content);
1407
+ }
1408
+ case "poll":
1409
+ if (replyTo) throw unsupportedRemoteContent("poll", "polls cannot be sent as replies");
1410
+ return outboundPoll(spaceId, await remote.polls.create(chat, content.title, content.options.map((option) => option.title)), content);
1411
+ default: throw unsupportedRemoteContent(content.type);
1412
+ }
1413
+ };
1414
+ const validateGroupContent = (content) => {
1415
+ let textCount = 0;
1416
+ for (const sub of content.items) {
1417
+ const itemType = sub.content.type;
1418
+ if (!GROUP_ITEM_ALLOWED.has(itemType)) throw unsupportedRemoteContent("group", `"${itemType}" items are not supported inside a group`);
1419
+ if (GROUP_TEXT_TYPES.has(itemType) && ++textCount > MAX_GROUP_TEXT_ITEMS) throw unsupportedRemoteContent("group", `groups can contain at most ${MAX_GROUP_TEXT_ITEMS} text item`);
1420
+ }
1421
+ };
1422
+ const resolvePart = async (remote, content) => {
1423
+ switch (content.type) {
1424
+ case "text": return { text: content.text };
1425
+ case "markdown": {
1426
+ const rendered = renderMarkdown(content.markdown);
1427
+ return {
1428
+ text: rendered.text,
1429
+ ...formattingOption(rendered.formatting)
1430
+ };
1431
+ }
1432
+ case "attachment": {
1433
+ const { guid, name } = await uploadAttachment(remote, content);
1434
+ return {
1435
+ attachmentGuid: guid,
1436
+ attachmentName: name
1437
+ };
1438
+ }
1439
+ case "contact": {
1440
+ const { guid, name } = await sendContactAttachment(remote, content);
1441
+ return {
1442
+ attachmentGuid: guid,
1443
+ attachmentName: name
1444
+ };
1445
+ }
1446
+ case "voice": {
1447
+ const { guid, name } = await uploadVoice(remote, content);
1448
+ return {
1449
+ attachmentGuid: guid,
1450
+ attachmentName: name
1451
+ };
1452
+ }
1453
+ default: throw unsupportedRemoteContent(content.type);
1454
+ }
1455
+ };
1456
+ const send$1 = async (remote, spaceId, content) => {
1457
+ const chat = toChatGuid(spaceId);
1458
+ if (content.type === "group") {
1459
+ validateGroupContent(content);
1460
+ const resolved = await Promise.all(content.items.map((sub) => resolvePart(remote, sub.content)));
1461
+ const message = await remote.messages.sendMultipart(chat, resolved.map((part, idx) => ({
1462
+ ...part,
1463
+ bubbleIndex: idx
1464
+ })));
1465
+ const parentGuid = message.guid;
1466
+ const timestamp = message.dateCreated;
1467
+ return outboundRecord(spaceId, parentGuid, providerGroup(content.items.map((sub, idx) => outboundGroupItem(spaceId, formatChildId(idx, parentGuid), sub.content, timestamp, idx, parentGuid))), timestamp);
1468
+ }
1469
+ return sendContent(remote, spaceId, chat, content);
1470
+ };
1471
+ const replyToMessage$1 = async (remote, spaceId, msgId, content) => {
1472
+ return sendContent(remote, spaceId, toChatGuid(spaceId), content, replyTargetFromId(msgId));
1473
+ };
1474
+ const editMessage$1 = async (remote, spaceId, msgId, content) => {
1475
+ if (content.type !== "text") throw unsupportedRemoteContent(content.type, "only text content can be edited");
1476
+ const childRef = parseChildId(msgId);
1477
+ await remote.messages.edit(toChatGuid(spaceId), toMessageGuid(childRef?.parentGuid ?? msgId), content.text, childRef ? { partIndex: childRef.partIndex } : void 0);
1478
+ };
1479
+ const unsendMessage$1 = async (remote, spaceId, msgId) => {
1480
+ const childRef = parseChildId(msgId);
1481
+ await remote.messages.unsend(toChatGuid(spaceId), toMessageGuid(childRef?.parentGuid ?? msgId), childRef ? { partIndex: childRef.partIndex } : void 0);
1482
+ };
1483
+ //#endregion
1484
+ //#region src/remote/contact-share.ts
1485
+ const log$1 = createLogger("spectrum.imessage.contact");
1486
+ const SHARE_TTL_MS = 1440 * 60 * 1e3;
1487
+ const MAX_TRACKED_CHATS = 1e4;
1488
+ /**
1489
+ * Tracks which chats this bot has already proactively pushed its contact card
1490
+ * to, so `im.chats.shareContactInfo` is fired at most once per chat per 24h
1491
+ * per iMessage provider instance.
1492
+ *
1493
+ * Backed by `lru-cache` for TTL + bounded memory. `ttlAutopurge: false`
1494
+ * keeps eviction lazy (on access) — there is no background timer to leak
1495
+ * across Spectrum lifecycles.
1496
+ */
1497
+ var ContactShareTracker = class {
1498
+ cache = new LRUCache({
1499
+ max: MAX_TRACKED_CHATS,
1500
+ ttl: SHARE_TTL_MS,
1501
+ ttlAutopurge: false
1502
+ });
1503
+ /**
1504
+ * Best-effort share. The cache is set eagerly so that a burst of inbound
1505
+ * messages for the same chat coalesces to a single API call. On failure the
1506
+ * entry is evicted so the next inbound retries — transient errors don't
1507
+ * permanently mute the feature for a chat. Never awaits and never throws:
1508
+ * the receive stream must not crash on share failures.
1509
+ */
1510
+ maybeShare(client, chatGuid) {
1511
+ if (this.cache.has(chatGuid)) return;
1512
+ this.cache.set(chatGuid, true);
1513
+ const safeChatGuid = sanitizeErrorMessage(chatGuid);
1514
+ client.chats.shareContactInfo(chatGuid).then(() => {
1515
+ log$1.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
1516
+ }).catch((error) => {
1517
+ this.cache.delete(chatGuid);
1518
+ log$1.warn("failed to share contact card", {
1519
+ "spectrum.imessage.contact.chat": safeChatGuid,
1520
+ ...errorAttrs(error)
1521
+ }, error);
1522
+ });
1523
+ }
1524
+ };
1525
+ const trackers = /* @__PURE__ */ new WeakMap();
1526
+ /**
1527
+ * Returns a per-owner tracker. Mirrors `getMessageCache`/`getPollCache` in
1528
+ * ../cache.ts — keyed by an object (the `RemoteClient[]` array for remote
1529
+ * mode), so each iMessage provider instance has its own tracker and multiple
1530
+ * providers don't share state accidentally.
1531
+ */
1532
+ const getContactShareTracker = (owner) => {
1533
+ let tracker = trackers.get(owner);
1534
+ if (!tracker) {
1535
+ tracker = new ContactShareTracker();
1536
+ trackers.set(owner, tracker);
1537
+ }
1538
+ return tracker;
1539
+ };
1540
+ //#endregion
1541
+ //#region src/remote/polls.ts
1542
+ const log = createLogger("spectrum.imessage.poll");
1543
+ const isVotedPollEvent = (event) => event.delta.type === "voted";
1544
+ const isUnvotedPollEvent = (event) => event.delta.type === "unvoted";
1545
+ const toCachedPoll = (input) => {
1546
+ const poll = asPoll({
1547
+ title: input.title,
1548
+ options: input.options.map((optionInfo) => ({ title: optionInfo.text }))
1549
+ });
1550
+ const optionsByIdentifier = /* @__PURE__ */ new Map();
1551
+ for (const [index, optionInfo] of input.options.entries()) {
1552
+ const option = poll.options[index];
1553
+ if (option && optionInfo.optionIdentifier) optionsByIdentifier.set(optionInfo.optionIdentifier, option);
1554
+ }
1555
+ return {
1556
+ poll,
1557
+ optionsByIdentifier
1558
+ };
1559
+ };
1560
+ const cachePollInfo = (cache, info) => {
1561
+ const cached = toCachedPoll(info);
1562
+ cache.set(info.pollMessageGuid, cached);
1563
+ return cached;
1564
+ };
1565
+ const cachePollEvent = (cache, event) => {
1566
+ if (event.delta.type === "created" || event.delta.type === "optionAdded") try {
1567
+ const cached = toCachedPoll({
1568
+ title: event.delta.title,
1569
+ options: event.delta.options
1570
+ });
1571
+ cache.set(event.pollMessageGuid, cached);
1572
+ return cached;
1573
+ } catch (e) {
1574
+ log.error("failed to cache poll", {
1575
+ "spectrum.imessage.poll.guid": event.pollMessageGuid,
1576
+ ...errorAttrs(e)
1577
+ }, e);
1578
+ }
1579
+ };
1580
+ const fetchPollInfo = async (client, cache, event) => {
1581
+ try {
1582
+ const info = await client.polls.get(event.pollMessageGuid);
1583
+ cachePollInfo(cache, info);
1584
+ return info;
1585
+ } catch (e) {
1586
+ log.error("failed to fetch poll", {
1587
+ "spectrum.imessage.poll.guid": event.pollMessageGuid,
1588
+ ...errorAttrs(e)
1589
+ }, e);
1590
+ return;
1591
+ }
1592
+ };
1593
+ const resolvePoll = async (client, cache, event) => {
1594
+ const cached = cache.get(event.pollMessageGuid);
1595
+ if (cached) return cached;
1596
+ try {
1597
+ return cachePollInfo(cache, await client.polls.get(event.pollMessageGuid));
1598
+ } catch (e) {
1599
+ log.error("failed to resolve poll", {
1600
+ "spectrum.imessage.poll.guid": event.pollMessageGuid,
1601
+ ...errorAttrs(e)
1602
+ }, e);
1603
+ return;
1604
+ }
1605
+ };
1606
+ const buildPollOptionMessage = (input) => {
1607
+ const option = input.cached.optionsByIdentifier.get(input.optionId);
1608
+ if (!option) return;
1609
+ const action = input.selected ? "selected" : "deselected";
1610
+ const eventTime = input.event.occurredAt.getTime();
1611
+ return {
1612
+ id: `${input.event.pollMessageGuid}:${input.senderAddress}:${input.optionId}:${action}:${eventTime}`,
1613
+ sender: { id: input.senderAddress },
1614
+ space: {
1615
+ id: input.chatGuid,
1616
+ type: chatTypeFromGuid(input.chatGuid),
1617
+ phone: input.phone
1618
+ },
1619
+ timestamp: input.event.occurredAt,
1620
+ content: asPollOption({
1621
+ option,
1622
+ poll: input.cached.poll,
1623
+ selected: input.selected
1624
+ })
1625
+ };
1626
+ };
1627
+ const refreshPollMetadata = async (client, pollCache, event) => {
1628
+ const info = await fetchPollInfo(client, pollCache, event);
1629
+ if (!info) return;
1630
+ return pollCache.get(info.pollMessageGuid);
1631
+ };
1632
+ const toPollOptionMessage = async (client, pollCache, event, phone) => {
1633
+ const senderAddress = event.actor?.address;
1634
+ const optionId = event.delta.optionIdentifier;
1635
+ if (!(senderAddress && optionId)) return [];
1636
+ let cached = await resolvePoll(client, pollCache, event);
1637
+ if (!cached) return [];
1638
+ if (!cached.optionsByIdentifier.has(optionId)) {
1639
+ const refreshed = await refreshPollMetadata(client, pollCache, event);
1640
+ if (refreshed) cached = refreshed;
1641
+ }
1642
+ const message = buildPollOptionMessage({
1643
+ cached,
1644
+ chatGuid: event.chatGuid,
1645
+ event,
1646
+ optionId,
1647
+ phone,
1648
+ selected: event.delta.type === "voted",
1649
+ senderAddress
1650
+ });
1651
+ return message ? [message] : [];
1652
+ };
1653
+ const toPollDeltaMessages = async (client, pollCache, event, phone) => {
1654
+ if (isVotedPollEvent(event)) return toPollOptionMessage(client, pollCache, event, phone);
1655
+ if (isUnvotedPollEvent(event)) return toPollOptionMessage(client, pollCache, event, phone);
1656
+ return [];
1657
+ };
1658
+ //#endregion
1659
+ //#region src/remote/stream.ts
1660
+ const isCursorRejectedIMessageError = (error) => error instanceof ValidationError;
1661
+ const streamLabel = (kind, phone) => `imessage.${kind}:${phone === "shared" ? phone : sanitizePhone(phone)}`;
1662
+ const isEventFromCurrentAccount = (event, phone) => event.isFromMe || phone !== "shared" && event.actor?.address !== void 0 && event.actor.address === phone;
1663
+ const toMessageItem = async (client, event, phone, cursor, onInbound) => {
1664
+ if (event.type === "message.received") {
1665
+ if (event.message.isFromMe) return {
1666
+ cursor,
1667
+ id: event.message.guid,
1668
+ values: []
1669
+ };
1670
+ const inboundChatGuid = event.message.chatGuids?.[0];
1671
+ if (inboundChatGuid) onInbound?.(inboundChatGuid);
1672
+ const cache = getMessageCache(client);
1673
+ return {
1674
+ cursor,
1675
+ id: event.message.guid,
1676
+ values: await toInboundMessages(client, cache, event, phone)
1677
+ };
1678
+ }
1679
+ if (event.type === "message.reactionAdded") {
1680
+ if (isEventFromCurrentAccount(event, phone)) return {
1681
+ cursor,
1682
+ id: `${event.messageGuid}:reaction:${event.sequence}`,
1683
+ values: []
1684
+ };
1685
+ const cache = getMessageCache(client);
1686
+ return {
1687
+ cursor,
1688
+ id: `${event.messageGuid}:reaction:${event.sequence}`,
1689
+ values: await toReactionMessages(client, cache, event, phone)
1690
+ };
1691
+ }
1692
+ return {
1693
+ cursor,
1694
+ id: `${event.type}:${"messageGuid" in event ? event.messageGuid : "unknown"}:${event.sequence}`,
1695
+ values: []
1696
+ };
1697
+ };
1698
+ const toPollItem = async (client, pollCache, event, phone, cursor) => {
1699
+ cachePollEvent(pollCache, event);
1700
+ if (isEventFromCurrentAccount(event, phone)) return {
1701
+ cursor,
1702
+ id: `${event.pollMessageGuid}:poll:${event.sequence}`,
1703
+ values: []
1704
+ };
1705
+ return {
1706
+ cursor,
1707
+ id: `${event.pollMessageGuid}:poll:${event.sequence}`,
1708
+ values: await toPollDeltaMessages(client, pollCache, event, phone)
1709
+ };
1710
+ };
1711
+ const toCatchUpCompleteItem = (event) => ({
1712
+ cursor: String(event.headSequence),
1713
+ id: `${event.type}:${event.headSequence}`,
1714
+ values: []
1715
+ });
1716
+ const isMessageEvent = (event) => event.type.startsWith("message.");
1717
+ const isPollEvent = (event) => event.type === "poll.changed";
1718
+ async function* catchUpEvents(client, cursor, isWanted) {
1719
+ const since = toResumeAfter(cursor);
1720
+ if (since === void 0) return;
1721
+ for await (const event of client.events.catchUp(since)) {
1722
+ if (event.type === "catchup.complete") {
1723
+ yield event;
1724
+ return;
1725
+ }
1726
+ if (isWanted(event)) yield event;
1727
+ }
1728
+ }
1729
+ const toResumeAfter = (cursor) => {
1730
+ if (!cursor) return;
1731
+ const sequence = Number(cursor);
1732
+ return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : void 0;
1733
+ };
1734
+ async function* afterCursor(stream, cursor) {
1735
+ const resumeAfter = toResumeAfter(cursor);
1736
+ try {
1737
+ for await (const event of stream) {
1738
+ if (resumeAfter !== void 0 && event.sequence <= resumeAfter) continue;
1739
+ yield event;
1740
+ }
1741
+ } finally {
1742
+ await stream.close?.();
1743
+ }
1744
+ }
1745
+ const withClose = (source, cursor) => Object.assign(afterCursor(source, cursor), { close: async () => {
1746
+ await source.close?.();
1747
+ } });
1748
+ const messageStream = (client, phone, onInbound) => resumableOrderedStream({
1749
+ fetchMissed: (cursor) => catchUpEvents(client, cursor, isMessageEvent),
1750
+ isCursorRejectedError: isCursorRejectedIMessageError,
1751
+ label: streamLabel("messages", phone),
1752
+ processLive: (event) => toMessageItem(client, event, phone, String(event.sequence), onInbound),
1753
+ processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toMessageItem(client, event, phone, String(event.sequence), onInbound),
1754
+ subscribeLive: (cursor) => withClose(client.messages.subscribeEvents(), cursor)
1755
+ });
1756
+ const pollStream = (client, pollCache, phone) => resumableOrderedStream({
1757
+ fetchMissed: (cursor) => catchUpEvents(client, cursor, isPollEvent),
1758
+ isCursorRejectedError: isCursorRejectedIMessageError,
1759
+ label: streamLabel("polls", phone),
1760
+ processLive: (event) => toPollItem(client, pollCache, event, phone, String(event.sequence)),
1761
+ processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toPollItem(client, pollCache, event, phone, String(event.sequence)),
1762
+ subscribeLive: (cursor) => withClose(client.polls.subscribeEvents(), cursor)
1763
+ });
1764
+ const clientStream = (client, pollCache, phone, onInbound) => mergeStreams([messageStream(client, phone, onInbound), pollStream(client, pollCache, phone)]);
1765
+ const messages$1 = (clients, projectConfig) => {
1766
+ const pollCache = getPollCache(clients);
1767
+ const tracker = projectConfig?.profile?.imessageSynced === true ? getContactShareTracker(clients) : void 0;
1768
+ return mergeStreams(clients.map((entry) => clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(entry.client, chatGuid) : void 0)));
1769
+ };
1770
+ //#endregion
1771
+ //#region src/remote/stream-text.ts
1772
+ const INITIAL_THROTTLE_MS = 1e3;
1773
+ const BACKOFF_FACTOR = 2;
1774
+ const MAX_EDITS = 5;
1775
+ /**
1776
+ * Deliver a `streamText` content by sending the first chunk as a real message
1777
+ * and editing it in place as more text arrives. The stream materializes into a
1778
+ * normal text message: the returned record carries `asText(fullText)` with the
1779
+ * first send's id and timestamp.
1780
+ */
1781
+ const sendStreamText$1 = async (remote, spaceId, content) => {
1782
+ if (content.format === "markdown") throw unsupportedRemoteContent("streamText", "markdown-formatted streams have no native iMessage delivery");
1783
+ const chat = toChatGuid(spaceId);
1784
+ let sent;
1785
+ let full = "";
1786
+ let lastSentText = "";
1787
+ let lastEditAt = 0;
1788
+ let editCount = 0;
1789
+ const flushEdit = async (text) => {
1790
+ if (!sent || text === lastSentText) return;
1791
+ await remote.messages.edit(chat, toMessageGuid(sent.guid), text);
1792
+ lastSentText = text;
1793
+ lastEditAt = Date.now();
1794
+ editCount += 1;
1795
+ };
1796
+ for await (const delta of content.stream()) {
1797
+ full += delta;
1798
+ if (!sent) {
1799
+ sent = await remote.messages.sendText(chat, full);
1800
+ lastSentText = full;
1801
+ lastEditAt = Date.now();
1802
+ continue;
1803
+ }
1804
+ const hasBudgetForInterimEdit = editCount < MAX_EDITS - 1;
1805
+ const requiredGap = INITIAL_THROTTLE_MS * BACKOFF_FACTOR ** editCount;
1806
+ if (hasBudgetForInterimEdit && Date.now() - lastEditAt >= requiredGap) await flushEdit(full);
1807
+ }
1808
+ if (!sent) throw unsupportedRemoteContent("streamText", "stream produced no text — nothing to send");
1809
+ await flushEdit(full);
1810
+ return {
1811
+ id: sent.guid,
1812
+ content: asText(full),
1813
+ direction: "outbound",
1814
+ space: { id: spaceId },
1815
+ timestamp: sent.dateCreated
1816
+ };
1817
+ };
1818
+ //#endregion
1819
+ //#region src/remote/typing.ts
1820
+ const startTyping$1 = async (remote, spaceId) => {
1821
+ await remote.chats.setTyping(toChatGuid(spaceId), true);
1822
+ };
1823
+ const stopTyping$1 = async (remote, spaceId) => {
1824
+ await remote.chats.setTyping(toChatGuid(spaceId), false);
1825
+ };
1826
+ //#endregion
1827
+ //#region src/remote/api.ts
1828
+ const messages = (clients, projectConfig) => messages$1(clients, projectConfig);
1829
+ const setBackground = async (remote, spaceId, content) => setBackground$1(remote, spaceId, content);
1830
+ const sendCustomizedMiniApp = async (remote, spaceId, content) => sendCustomizedMiniApp$1(remote, spaceId, content);
1831
+ const setDisplayName = async (remote, spaceId, content) => setDisplayName$1(remote, spaceId, content);
1832
+ const setIcon = async (remote, spaceId, content) => setIcon$1(remote, spaceId, content);
1833
+ const markRead = async (remote, spaceId) => {
1834
+ await markRead$1(remote, spaceId);
1835
+ };
1836
+ const shareContactCard = async (remote, spaceId) => {
1837
+ await shareContactCard$1(remote, spaceId);
1838
+ };
1839
+ const startTyping = async (remote, spaceId) => {
1840
+ await startTyping$1(remote, spaceId);
1841
+ };
1842
+ const stopTyping = async (remote, spaceId) => {
1843
+ await stopTyping$1(remote, spaceId);
1844
+ };
1845
+ const send = async (remote, spaceId, content) => send$1(remote, spaceId, content);
1846
+ const sendStreamText = async (remote, spaceId, content) => sendStreamText$1(remote, spaceId, content);
1847
+ const replyToMessage = async (remote, spaceId, msgId, content) => replyToMessage$1(remote, spaceId, msgId, content);
1848
+ const editMessage = async (remote, spaceId, msgId, content) => editMessage$1(remote, spaceId, msgId, content);
1849
+ const reactToMessage = async (remote, spaceId, target, reaction) => reactToMessage$1(remote, spaceId, target, reaction);
1850
+ const unsendMessage = async (remote, spaceId, msgId) => unsendMessage$1(remote, spaceId, msgId);
1851
+ const unsendReaction = async (remote, spaceId, target, reaction) => unsendReaction$1(remote, spaceId, target, reaction);
1852
+ const getMessage = async (remote, spaceId, msgId, phone) => getMessage$1(remote, spaceId, msgId, phone);
1853
+ //#endregion
1854
+ //#region src/remote/app.ts
1855
+ /**
1856
+ * Fixed identity of Spectrum's own iMessage extension. The universal `app`
1857
+ * content renders through this extension, so callers never supply (or even see)
1858
+ * these constants — they pass only a URL and the card opens it inside the
1859
+ * Spectrum mini app on tap. Callers shipping their *own* extension use the
1860
+ * low-level `customizedMiniApp()` instead.
1861
+ */
1862
+ const SPECTRUM_MINI_APP = {
1863
+ appName: "Spectrum",
1864
+ extensionBundleId: "codes.photon.Spectrum.MessagesExtension",
1865
+ teamId: "P8XT6232SL",
1866
+ appStoreId: 6777616651
1867
+ };
1868
+ /**
1869
+ * Build the iMessage mini-app card for an `app` content: Spectrum's fixed
1870
+ * identity plus the per-message `url` and the `layout` already derived from the
1871
+ * URL's link metadata.
1872
+ */
1873
+ const toSpectrumMiniApp = (url, layout) => asCustomizedMiniApp({
1874
+ ...SPECTRUM_MINI_APP,
1875
+ url,
1876
+ layout
1877
+ });
1878
+ //#endregion
1879
+ //#region src/remote/client.ts
1880
+ const isSharedMode = (clients) => clients.length === 1 && clients[0]?.phone === "shared";
1881
+ const availablePhones = (clients) => clients.map((c) => c.phone);
1882
+ const clientForPhone = (clients, phone) => {
1883
+ if (isSharedMode(clients)) {
1884
+ const entry = clients[0];
1885
+ if (!entry) throw new Error("No iMessage clients configured");
1886
+ return entry.client;
1887
+ }
1888
+ const entry = clients.find((c) => c.phone === phone);
1889
+ if (!entry) {
1890
+ const list = availablePhones(clients).join(", ") || "<none>";
1891
+ throw new Error(`No iMessage client serves phone ${phone}. Available: ${list}`);
1892
+ }
1893
+ return entry.client;
1894
+ };
1895
+ const randomPhone = (clients) => {
1896
+ if (clients.length === 0) throw new Error("No iMessage phones configured for this account");
1897
+ if (isSharedMode(clients)) return SHARED_PHONE;
1898
+ const entry = clients[Math.floor(Math.random() * clients.length)];
1899
+ if (!entry) throw new Error("No iMessage phones configured for this account");
1900
+ return entry.phone;
1901
+ };
1902
+ //#endregion
1903
+ //#region src/index.ts
1904
+ const isPollContent = (content) => content.type === "poll" || content.type === "poll_option";
1905
+ const cacheRemoteOutbound = (remote, space, record) => {
1906
+ if (!record) return record;
1907
+ cacheMessage(getMessageCache(remote), {
1908
+ ...record,
1909
+ direction: record.direction ?? "outbound",
1910
+ space: {
1911
+ ...record.space,
1912
+ id: record.space.id,
1913
+ phone: space.phone,
1914
+ type: space.type
1915
+ }
1916
+ });
1917
+ return record;
1918
+ };
1919
+ const handleEdit = async (client, space, content) => {
1920
+ if (isLocal(client)) throw UnsupportedError.action("edit", "iMessage (local mode)");
1921
+ if (content.content.type !== "text") throw UnsupportedError.content("edit", "iMessage", `only text content can be edited (got "${content.content.type}")`);
1922
+ await editMessage(clientForPhone(client, space.phone), space.id, content.target.id, content.content);
1923
+ };
1924
+ const handleUnsend = async (client, space, content) => {
1925
+ if (isLocal(client)) throw UnsupportedError.action("unsend", "iMessage (local mode)");
1926
+ if (isPollContent(content.target.content)) throw UnsupportedError.action("unsend", "iMessage", "iMessage polls cannot be unsent");
1927
+ const remote = clientForPhone(client, space.phone);
1928
+ const targetContent = content.target.content;
1929
+ if (targetContent.type === "reaction") {
1930
+ await unsendReaction(remote, space.id, targetContent.target, targetContent.emoji);
1931
+ return;
1932
+ }
1933
+ await unsendMessage(remote, space.id, content.target.id);
1934
+ };
1935
+ const handleStreamText = async (client, space, content) => {
1936
+ if (isLocal(client)) throw UnsupportedError.action("streamText", "iMessage (local mode)", "streaming text responses require remote iMessage");
1937
+ const remote = clientForPhone(client, space.phone);
1938
+ return cacheRemoteOutbound(remote, space, await sendStreamText(remote, space.id, content));
1939
+ };
1940
+ const handleBackground = async (client, space, content) => {
1941
+ if (isLocal(client)) throw UnsupportedError.action("background", "iMessage (local mode)", "chat backgrounds require remote iMessage");
1942
+ await setBackground(clientForPhone(client, space.phone), space.id, content);
1943
+ };
1944
+ const handleCustomizedMiniApp = async (client, space, content) => {
1945
+ if (isLocal(client)) throw UnsupportedError.action("customized-mini-app", "iMessage (local mode)", "mini app cards require remote iMessage");
1946
+ const remote = clientForPhone(client, space.phone);
1947
+ return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, content));
1948
+ };
1949
+ /**
1950
+ * Render the universal `app` content. On remote it becomes a native Spectrum
1951
+ * mini-app card (fixed `SPECTRUM_MINI_APP` identity + the URL + the layout
1952
+ * already parsed from the URL's link metadata). Local mode cannot send cards,
1953
+ * so it degrades to the bare URL as a text message.
1954
+ */
1955
+ const handleApp = async (client, space, content) => {
1956
+ const url = await content.url();
1957
+ if (isLocal(client)) return await send$2(client, space.id, await text(url).build());
1958
+ const layout = await content.layout();
1959
+ const remote = clientForPhone(client, space.phone);
1960
+ return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, toSpectrumMiniApp(url, layout)));
1961
+ };
1962
+ const handleRead = async (client, space) => {
1963
+ if (isLocal(client)) throw UnsupportedError.action("read", "iMessage (local mode)", "marking chats as read requires remote iMessage");
1964
+ await markRead(clientForPhone(client, space.phone), space.id);
1965
+ };
1966
+ const handleShareContactCard = async (client, space) => {
1967
+ if (isLocal(client)) throw UnsupportedError.action("shareContactCard", "iMessage (local mode)", "sharing the contact card requires remote iMessage");
1968
+ await shareContactCard(clientForPhone(client, space.phone), space.id);
1969
+ };
1970
+ const handleTyping = async (client, space, state) => {
1971
+ if (isLocal(client)) return;
1972
+ const remote = clientForPhone(client, space.phone);
1973
+ if (state === "start") await startTyping(remote, space.id);
1974
+ else await stopTyping(remote, space.id);
1975
+ };
1976
+ const handleRename = async (client, space, content) => {
1977
+ if (isLocal(client)) throw UnsupportedError.action("rename", "iMessage (local mode)", "renaming chats requires remote iMessage");
1978
+ if (space.type !== "group") throw UnsupportedError.action("rename", "iMessage", "only group chats can be renamed (this space is a DM)");
1979
+ await setDisplayName(clientForPhone(client, space.phone), space.id, content);
1980
+ };
1981
+ const handleAvatar = async (client, space, content) => {
1982
+ if (isLocal(client)) throw UnsupportedError.action("avatar", "iMessage (local mode)", "setting group avatars requires remote iMessage");
1983
+ if (space.type !== "group") throw UnsupportedError.action("avatar", "iMessage", "only group chats have avatars (this space is a DM)");
1984
+ await setIcon(clientForPhone(client, space.phone), space.id, content);
1985
+ };
1986
+ /**
1987
+ * Dispatch the iMessage-only fire-and-forget control signals that live outside
1988
+ * the universal `Content` union (`background`, `contactCard`). Each is narrowed
1989
+ * via a runtime guard rather than a `content.type ===` check — the literals
1990
+ * aren't members of `Content["type"]`. Returns `true` when it consumed the
1991
+ * content so `send` can return early, keeping its dispatch chain flat.
1992
+ */
1993
+ const handleProviderControlSignal = async (client, space, content) => {
1994
+ if (isBackground(content)) {
1995
+ await handleBackground(client, space, content);
1996
+ return true;
1997
+ }
1998
+ if (isContactCard(content)) {
1999
+ await handleShareContactCard(client, space);
2000
+ return true;
2001
+ }
2002
+ return false;
2003
+ };
2004
+ /**
2005
+ * Resolve the remote client for a `reply` / `reaction` whose target is another
2006
+ * message. Both reject local mode and poll targets identically, so the guard
2007
+ * lives here to keep the `send` dispatch flat. `action` labels the error and
2008
+ * `pollNoun` is the plural used in the poll-unsupported message.
2009
+ */
2010
+ const remoteForMessageTarget = (client, space, target, action, pollNoun) => {
2011
+ if (isLocal(client)) throw UnsupportedError.action(action, "iMessage (local mode)");
2012
+ if (isPollContent(target.content)) throw UnsupportedError.action(action, "iMessage", `iMessage polls do not support ${pollNoun}`);
2013
+ return clientForPhone(client, space.phone);
2014
+ };
2015
+ const imessage = definePlatform("iMessage", {
2016
+ config: configSchema,
2017
+ static: { effect: { message: MessageEffect } },
2018
+ lifecycle: {
2019
+ createClient: async ({ config, projectId, projectSecret }) => {
2020
+ if (config.local) return new IMessageSDK();
2021
+ if (config.clients) return (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2022
+ phone: e.phone,
2023
+ client: createClient({
2024
+ address: e.address,
2025
+ tls: true,
2026
+ token: e.token
2027
+ })
2028
+ }));
2029
+ if (!(projectId && projectSecret)) throw new Error("iMessage requires projectId and projectSecret. Either pass credentials to Spectrum(), use local mode: imessage.config({ local: true }), or provide explicit client config: imessage.config({ clients: [...] })");
2030
+ return await createCloudClients(projectId, projectSecret);
2031
+ },
2032
+ destroyClient: async ({ client }) => {
2033
+ if (isLocal(client)) {
2034
+ await client.close();
2035
+ return;
2036
+ }
2037
+ await disposeCloudAuth(client);
2038
+ await Promise.all(client.map((entry) => entry.client.close()));
2039
+ }
2040
+ },
2041
+ user: { resolve: async ({ input }) => ({ id: input.userID }) },
2042
+ space: {
2043
+ schema: spaceSchema,
2044
+ params: spaceParamsSchema,
2045
+ create: async ({ input, client }) => {
2046
+ if (isLocal(client)) throw UnsupportedError.action("space.create", "iMessage (local mode)", "local mode only supports replying to existing messages");
2047
+ if (input.users.length === 0) throw new Error("iMessage space creation requires at least one user");
2048
+ if (client.length === 0) throw new Error("No iMessage clients configured");
2049
+ const addresses = input.users.map((u) => u.id);
2050
+ if (isSharedMode(client)) {
2051
+ if (addresses.length > 1) throw UnsupportedError.action("space.create", "iMessage (shared mode)", "shared mode cannot create group chats — use a dedicated number, or space.get(chatGuid) for an existing group");
2052
+ return {
2053
+ id: dmChatGuid(addresses[0] ?? ""),
2054
+ type: "dm",
2055
+ phone: SHARED_PHONE
2056
+ };
2057
+ }
2058
+ const phone = input.params?.phone ?? randomPhone(client);
2059
+ const { chat } = await clientForPhone(client, phone).chats.create(addresses);
2060
+ return {
2061
+ id: chat.guid,
2062
+ type: chat.isGroup ? "group" : "dm",
2063
+ phone
2064
+ };
2065
+ },
2066
+ get: async ({ input, client }) => {
2067
+ if (isLocal(client)) throw UnsupportedError.action("space.get", "iMessage (local mode)", "local mode only supports replying to existing messages");
2068
+ if (client.length === 0) throw new Error("No iMessage clients configured");
2069
+ const phone = isSharedMode(client) ? SHARED_PHONE : input.params?.phone ?? (client.length === 1 ? client[0]?.phone : void 0);
2070
+ if (!phone) throw new Error(`iMessage space.get requires params.phone when multiple clients are configured. Available: ${availablePhones(client).join(", ")}`);
2071
+ return {
2072
+ id: input.id,
2073
+ type: chatTypeFromGuid(input.id),
2074
+ phone
2075
+ };
2076
+ },
2077
+ actions: {
2078
+ background: async (space, input, opts) => {
2079
+ await space.send(background(input, opts));
2080
+ },
2081
+ shareContactCard: async (space) => {
2082
+ await space.send(nativeContactCard());
2083
+ }
2084
+ }
2085
+ },
2086
+ message: { schema: messageSchema },
2087
+ messages: ({ client, projectConfig }) => isLocal(client) ? messages$2(client) : messages(client, projectConfig),
2088
+ send: async ({ space, content, client }) => {
2089
+ if (content.type === "reply") {
2090
+ const remote = remoteForMessageTarget(client, space, content.target, "reply", "replies");
2091
+ return cacheRemoteOutbound(remote, space, await replyToMessage(remote, space.id, content.target.id, content.content));
2092
+ }
2093
+ if (content.type === "reaction") {
2094
+ const remote = remoteForMessageTarget(client, space, content.target, "react", "reactions");
2095
+ return cacheRemoteOutbound(remote, space, await reactToMessage(remote, space.id, content.target, content.emoji));
2096
+ }
2097
+ if (content.type === "typing") {
2098
+ await handleTyping(client, space, content.state);
2099
+ return;
2100
+ }
2101
+ if (content.type === "edit") {
2102
+ await handleEdit(client, space, content);
2103
+ return;
2104
+ }
2105
+ if (content.type === "unsend") {
2106
+ await handleUnsend(client, space, content);
2107
+ return;
2108
+ }
2109
+ if (content.type === "streamText") return await handleStreamText(client, space, content);
2110
+ if (content.type === "rename") {
2111
+ await handleRename(client, space, content);
2112
+ return;
2113
+ }
2114
+ if (content.type === "avatar") {
2115
+ await handleAvatar(client, space, content);
2116
+ return;
2117
+ }
2118
+ if (content.type === "read") {
2119
+ await handleRead(client, space);
2120
+ return;
2121
+ }
2122
+ if (content.type === "app") return await handleApp(client, space, content);
2123
+ if (await handleProviderControlSignal(client, space, content)) return;
2124
+ if (isCustomizedMiniApp(content)) return await handleCustomizedMiniApp(client, space, content);
2125
+ if (isLocal(client)) return await send$2(client, space.id, content);
2126
+ const remote = clientForPhone(client, space.phone);
2127
+ return cacheRemoteOutbound(remote, space, await send(remote, space.id, content));
2128
+ },
2129
+ actions: {
2130
+ getMessage: async ({ client }, space, messageId) => {
2131
+ if (isLocal(client)) return getMessage$2(client, messageId);
2132
+ return getMessage(clientForPhone(client, space.phone), space.id, messageId, space.phone);
2133
+ },
2134
+ getAttachment: async ({ client }, guid, phone) => {
2135
+ if (isLocal(client)) throw UnsupportedError.action("getAttachment", "iMessage (local mode)", "fetching attachments by GUID requires remote iMessage");
2136
+ if (client.length === 0) throw new Error("No iMessage clients configured");
2137
+ const routedPhone = (() => {
2138
+ if (isSharedMode(client)) return SHARED_PHONE;
2139
+ if (phone) return phone;
2140
+ if (client.length === 1) return client[0].phone;
2141
+ throw new Error(`imessage.getAttachment requires a phone in multi-phone mode. Available: ${availablePhones(client).join(", ")}`);
2142
+ })();
2143
+ const remote = clientForPhone(client, routedPhone);
2144
+ return withSpan("spectrum.imessage.getAttachment", {
2145
+ "spectrum.provider": "iMessage",
2146
+ "spectrum.imessage.attachment.guid": guid,
2147
+ "spectrum.imessage.phone": routedPhone
2148
+ }, () => getRemoteAttachment(remote, guid));
2149
+ }
2150
+ }
2151
+ });
2152
+ //#endregion
2153
+ export { background, customizedMiniApp, effect, imessage, nativeContactCard, read };