@spectrum-ts/imessage 9.3.0 → 10.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @spectrum-ts/imessage
2
2
 
3
- iMessage provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts), supporting local (imessage-kit) and remote (advanced-imessage) modes — including tapbacks, special effects, polls, and mini-apps.
3
+ iMessage provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts), including tapbacks, special effects, polls, and mini-apps.
4
4
 
5
5
  ## Install
6
6
 
@@ -15,7 +15,7 @@ import { Spectrum } from "spectrum-ts";
15
15
  import { imessage } from "@spectrum-ts/imessage";
16
16
 
17
17
  const spectrum = Spectrum({
18
- providers: [imessage.config({ /* ... */ })],
18
+ platforms: [imessage.config()],
19
19
  });
20
20
  ```
21
21
 
@@ -32,3 +32,11 @@ await space.shareContactCard();
32
32
  ```
33
33
 
34
34
  See the [spectrum-ts documentation](https://photon.codes/spectrum) for the full guide.
35
+
36
+ For direct access to the local macOS Messages database, install and import
37
+ `@spectrum-ts/imessage-local` separately. The local provider is intentionally
38
+ not included in the batteries-included `spectrum-ts` package.
39
+
40
+ ```sh
41
+ bun add @spectrum-ts/imessage-local
42
+ ```
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { AdvancedIMessage, ChatServiceType, MessageEffect } from "@photon-ai/advanced-imessage";
2
- import { IMessageSDK } from "@photon-ai/imessage-kit";
1
+ import { AdvancedIMessage, ChatServiceType } from "@photon-ai/advanced-imessage";
3
2
  import { Attachment, ContentBuilder, ContentInput, SchemaMessage, Space, read } from "@spectrum-ts/core";
4
3
  import { PhotoInput } from "@spectrum-ts/core/authoring";
5
4
  import z from "zod";
@@ -168,7 +167,22 @@ type CustomizedMiniAppInput = Omit<CustomizedMiniApp, "type" | "__platform">;
168
167
  declare function customizedMiniApp(input: CustomizedMiniAppInput): ContentBuilder;
169
168
  //#endregion
170
169
  //#region src/content/effect.d.ts
171
- type IMessageMessageEffect = MessageEffect;
170
+ declare const messageEffects: {
171
+ readonly balloons: "com.apple.messages.effect.CKBalloonEffect";
172
+ readonly celebration: "com.apple.messages.effect.CKHappyBirthdayEffect";
173
+ readonly confetti: "com.apple.messages.effect.CKConfettiEffect";
174
+ readonly echo: "com.apple.messages.effect.CKEchoEffect";
175
+ readonly fireworks: "com.apple.messages.effect.CKFireworksEffect";
176
+ readonly gentle: "com.apple.MobileSMS.expressivesend.gentle";
177
+ readonly heart: "com.apple.messages.effect.CKHeartEffect";
178
+ readonly invisible: "com.apple.MobileSMS.expressivesend.invisibleink";
179
+ readonly lasers: "com.apple.messages.effect.CKLasersEffect";
180
+ readonly loud: "com.apple.MobileSMS.expressivesend.loud";
181
+ readonly slam: "com.apple.MobileSMS.expressivesend.impact";
182
+ readonly sparkles: "com.apple.messages.effect.CKSparklesEffect";
183
+ readonly spotlight: "com.apple.messages.effect.CKSpotlightEffect";
184
+ };
185
+ type IMessageMessageEffect = (typeof messageEffects)[keyof typeof messageEffects];
172
186
  declare function effect(input: ContentInput, messageEffect: IMessageMessageEffect): ContentBuilder;
173
187
  //#endregion
174
188
  //#region src/types.d.ts
@@ -176,7 +190,7 @@ interface RemoteClient {
176
190
  client: AdvancedIMessage;
177
191
  phone: string;
178
192
  }
179
- type IMessageClient = IMessageSDK | RemoteClient[];
193
+ type IMessageClient = RemoteClient[];
180
194
  declare const userSchema: z.ZodObject<{
181
195
  address: z.ZodOptional<z.ZodString>;
182
196
  country: z.ZodOptional<z.ZodString>;
@@ -219,10 +233,7 @@ type IMessageMessage = SchemaMessage<typeof userSchema, typeof spaceSchema> & {
219
233
  };
220
234
  //#endregion
221
235
  //#region src/index.d.ts
222
- declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"iMessage", import("zod").ZodUnion<readonly [import("zod").ZodObject<{
223
- local: import("zod").ZodLiteral<true>;
224
- }, import("zod/v4/core").$strip>, import("zod").ZodObject<{
225
- local: import("zod").ZodDefault<import("zod").ZodOptional<import("zod").ZodLiteral<false>>>;
236
+ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"iMessage", import("zod").ZodObject<{
226
237
  clients: import("zod").ZodOptional<import("zod").ZodUnion<[import("zod").ZodObject<{
227
238
  address: import("zod").ZodString;
228
239
  token: import("zod").ZodString;
@@ -232,7 +243,7 @@ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-t
232
243
  token: import("zod").ZodString;
233
244
  phone: import("zod").ZodString;
234
245
  }, import("zod/v4/core").$strip>>]>>;
235
- }, import("zod/v4/core").$strip>]>, import("zod").ZodObject<{
246
+ }, import("zod/v4/core").$strict>, import("zod").ZodObject<{
236
247
  address: import("zod").ZodOptional<import("zod").ZodString>;
237
248
  country: import("zod").ZodOptional<import("zod").ZodString>;
238
249
  service: import("zod").ZodOptional<import("zod").ZodEnum<{
@@ -276,9 +287,6 @@ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-t
276
287
  }: {
277
288
  client: IMessageClient;
278
289
  config: {
279
- local: true;
280
- } | {
281
- local: false;
282
290
  clients?: {
283
291
  address: string;
284
292
  token: string;
@@ -303,9 +311,6 @@ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-t
303
311
  }: {
304
312
  client: IMessageClient;
305
313
  config: {
306
- local: true;
307
- } | {
308
- local: false;
309
314
  clients?: {
310
315
  address: string;
311
316
  token: string;
@@ -330,9 +335,6 @@ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-t
330
335
  }: {
331
336
  client: IMessageClient;
332
337
  config: {
333
- local: true;
334
- } | {
335
- local: false;
336
338
  clients?: {
337
339
  address: string;
338
340
  token: string;
@@ -357,9 +359,6 @@ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-t
357
359
  }: {
358
360
  client: IMessageClient;
359
361
  config: {
360
- local: true;
361
- } | {
362
- local: false;
363
362
  clients?: {
364
363
  address: string;
365
364
  token: string;
@@ -387,19 +386,19 @@ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-t
387
386
  }>> & Readonly<{
388
387
  effect: {
389
388
  message: {
390
- readonly slam: "com.apple.MobileSMS.expressivesend.impact";
391
- readonly loud: "com.apple.MobileSMS.expressivesend.loud";
392
- readonly gentle: "com.apple.MobileSMS.expressivesend.gentle";
393
- readonly invisible: "com.apple.MobileSMS.expressivesend.invisibleink";
389
+ readonly balloons: "com.apple.messages.effect.CKBalloonEffect";
390
+ readonly celebration: "com.apple.messages.effect.CKHappyBirthdayEffect";
394
391
  readonly confetti: "com.apple.messages.effect.CKConfettiEffect";
392
+ readonly echo: "com.apple.messages.effect.CKEchoEffect";
395
393
  readonly fireworks: "com.apple.messages.effect.CKFireworksEffect";
396
- readonly balloons: "com.apple.messages.effect.CKBalloonEffect";
394
+ readonly gentle: "com.apple.MobileSMS.expressivesend.gentle";
397
395
  readonly heart: "com.apple.messages.effect.CKHeartEffect";
396
+ readonly invisible: "com.apple.MobileSMS.expressivesend.invisibleink";
398
397
  readonly lasers: "com.apple.messages.effect.CKLasersEffect";
399
- readonly celebration: "com.apple.messages.effect.CKHappyBirthdayEffect";
398
+ readonly loud: "com.apple.MobileSMS.expressivesend.loud";
399
+ readonly slam: "com.apple.MobileSMS.expressivesend.impact";
400
400
  readonly sparkles: "com.apple.messages.effect.CKSparklesEffect";
401
401
  readonly spotlight: "com.apple.messages.effect.CKSpotlightEffect";
402
- readonly echo: "com.apple.messages.effect.CKEchoEffect";
403
402
  };
404
403
  };
405
404
  }>;
package/dist/index.js CHANGED
@@ -1,15 +1,8 @@
1
- import { MessageEffect, NotFoundError, ValidationError, createClient } from "@photon-ai/advanced-imessage";
2
- import { IMessageSDK } from "@photon-ai/imessage-kit";
1
+ import { NotFoundError, ValidationError, createClient } from "@photon-ai/advanced-imessage";
3
2
  import { sanitizePhone, withSpan } from "@photon-ai/otel";
4
- import { UnsupportedError, appLayoutSchema, cloud, definePlatform, fromVCard, mergeStreams, read, stream, text, toVCard } from "@spectrum-ts/core";
3
+ import { UnsupportedError, appLayoutSchema, cloud, definePlatform, fromVCard, mergeStreams, read, text, toVCard } from "@spectrum-ts/core";
5
4
  import { addMemberSchema, asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asReply, asText, avatarSchema, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, leaveSpaceSchema, messageEffectSchema, photoActionSchema, reactionSchema, removeMemberSchema, renameSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
6
5
  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
6
  import { Marked } from "marked";
14
7
  import { LRUCache } from "lru-cache";
15
8
  //#region src/content/background.ts
@@ -156,7 +149,22 @@ function customizedMiniApp(input) {
156
149
  }
157
150
  //#endregion
158
151
  //#region src/content/effect.ts
159
- const SUPPORTED_EFFECTS = new Set(Object.values(MessageEffect));
152
+ const messageEffects = {
153
+ balloons: "com.apple.messages.effect.CKBalloonEffect",
154
+ celebration: "com.apple.messages.effect.CKHappyBirthdayEffect",
155
+ confetti: "com.apple.messages.effect.CKConfettiEffect",
156
+ echo: "com.apple.messages.effect.CKEchoEffect",
157
+ fireworks: "com.apple.messages.effect.CKFireworksEffect",
158
+ gentle: "com.apple.MobileSMS.expressivesend.gentle",
159
+ heart: "com.apple.messages.effect.CKHeartEffect",
160
+ invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
161
+ lasers: "com.apple.messages.effect.CKLasersEffect",
162
+ loud: "com.apple.MobileSMS.expressivesend.loud",
163
+ slam: "com.apple.MobileSMS.expressivesend.impact",
164
+ sparkles: "com.apple.messages.effect.CKSparklesEffect",
165
+ spotlight: "com.apple.messages.effect.CKSpotlightEffect"
166
+ };
167
+ const SUPPORTED_EFFECTS = new Set(Object.values(messageEffects));
160
168
  const resolveContent = (input) => typeof input === "string" ? text(input).build() : input.build();
161
169
  function effect(input, messageEffect) {
162
170
  return { build: async () => {
@@ -178,16 +186,12 @@ function effect(input, messageEffect) {
178
186
  * no `from` parameter on send), so all routing through it tags this sentinel.
179
187
  */
180
188
  const SHARED_PHONE = "shared";
181
- const isLocal = (client) => client instanceof IMessageSDK;
182
189
  const clientEntry = z.object({
183
190
  address: z.string(),
184
191
  token: z.string(),
185
192
  phone: z.string()
186
193
  });
187
- const configSchema = z.union([z.object({ local: z.literal(true) }), z.object({
188
- local: z.literal(false).optional().default(false),
189
- clients: clientEntry.or(z.array(clientEntry)).optional()
190
- })]);
194
+ const configSchema = z.strictObject({ clients: clientEntry.or(z.array(clientEntry)).optional() });
191
195
  const userSchema = z.object({
192
196
  address: z.string().optional(),
193
197
  country: z.string().optional(),
@@ -436,10 +440,9 @@ var PollCache = class {
436
440
  const messageCaches = /* @__PURE__ */ new WeakMap();
437
441
  const pollCaches = /* @__PURE__ */ new WeakMap();
438
442
  /**
439
- * Returns a per-client message cache. Keyed by an object (the client array
440
- * for remote, or the IMessageSDK instance for local), so each iMessage
441
- * provider instance has its own cache and multiple providers don't share
442
- * state accidentally.
443
+ * Returns a per-client message cache. Keyed by the client array so each
444
+ * iMessage provider instance has its own cache and multiple providers don't
445
+ * share state accidentally.
443
446
  */
444
447
  const getMessageCache = (owner) => {
445
448
  let cache = messageCaches.get(owner);
@@ -457,257 +460,6 @@ const getPollCache = (owner) => {
457
460
  }
458
461
  return cache;
459
462
  };
460
- const addTextPart = (parts, text) => {
461
- const trimmed = text?.trim();
462
- if (trimmed) parts.push({
463
- type: "text",
464
- text: trimmed
465
- });
466
- };
467
- const hasUsableTextPart = (text) => text?.split("").some((segment) => segment.trim()) ?? false;
468
- const addAttachmentParts = (parts, attachments) => {
469
- for (const attachment of attachments) if (attachment) parts.push({
470
- type: "attachment",
471
- attachment
472
- });
473
- };
474
- const toOrderedParts = (text, attachments) => {
475
- const parts = [];
476
- if (!text) {
477
- addAttachmentParts(parts, attachments);
478
- return parts;
479
- }
480
- if (!text.includes("")) {
481
- addAttachmentParts(parts, attachments);
482
- addTextPart(parts, text);
483
- return parts;
484
- }
485
- const textSegments = text.split("");
486
- for (let i = 0; i < attachments.length; i++) {
487
- addTextPart(parts, textSegments[i]);
488
- const attachment = attachments[i];
489
- if (attachment) parts.push({
490
- type: "attachment",
491
- attachment
492
- });
493
- }
494
- addTextPart(parts, textSegments.slice(attachments.length).join(""));
495
- return parts;
496
- };
497
- //#endregion
498
- //#region src/shared/vcard.ts
499
- const VCARD_MIME_TYPES = new Set([
500
- "text/vcard",
501
- "text/x-vcard",
502
- "text/directory",
503
- "application/vcard",
504
- "application/x-vcard"
505
- ]);
506
- const normalizeMimeType = (mimeType) => (mimeType.split(";")[0] ?? "").trim().toLowerCase();
507
- const isVCardAttachment = (mimeType, fileName) => {
508
- if (mimeType && VCARD_MIME_TYPES.has(normalizeMimeType(mimeType))) return true;
509
- return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
510
- };
511
- const vcardFileName = (contact) => {
512
- return `${(contact.name?.formatted ?? contact.user?.id ?? "contact").replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
513
- };
514
- const readLocalAttachment = async (att) => {
515
- if (!att.localPath) throw new Error(`iMessage attachment ${att.id} has no local file available on disk`);
516
- return readFile(att.localPath);
517
- };
518
- const toAttachmentContent$1 = (att) => {
519
- const { localPath } = att;
520
- return asAttachment({
521
- id: att.id,
522
- name: att.fileName ?? "attachment",
523
- mimeType: att.mimeType,
524
- size: att.sizeBytes,
525
- read: () => readLocalAttachment(att),
526
- stream: localPath ? async () => Readable.toWeb(createReadStream(localPath)) : void 0
527
- });
528
- };
529
- const toVCardContent$1 = async (att) => {
530
- try {
531
- return asContact(fromVCard((await readLocalAttachment(att)).toString("utf8")));
532
- } catch {
533
- return toAttachmentContent$1(att);
534
- }
535
- };
536
- const localAttachmentContent = async (att) => isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent$1(att) : toAttachmentContent$1(att);
537
- //#endregion
538
- //#region src/local/inbound.ts
539
- const ATTACHMENT_JOIN_RETRY_DELAY_MS = 250;
540
- const ATTACHMENT_JOIN_RETRY_LIMIT = 8;
541
- const ATTACHMENT_JOIN_FETCH_LIMIT = 10;
542
- const hasAttachmentPlaceholder = (message) => message.text?.includes("") ?? false;
543
- const isPendingAttachmentJoin = (message) => message.attachments.length === 0 && (message.hasAttachments || hasAttachmentPlaceholder(message));
544
- const replyTargetId = (message) => message.threadRootMessageId ?? void 0;
545
- const stubReplyTarget$1 = (space, targetId) => ({
546
- id: targetId,
547
- content: asCustom({
548
- imessage_type: "reply-target",
549
- stub: true
550
- }),
551
- space
552
- });
553
- const asProviderReply$1 = (content, target) => asReply({
554
- content,
555
- target
556
- });
557
- const wrapReply = (messages, targetId) => {
558
- if (!targetId) return messages;
559
- return messages.map((message) => ({
560
- ...message,
561
- content: asProviderReply$1(message.content, stubReplyTarget$1(message.space, targetId))
562
- }));
563
- };
564
- const refetchUntilAttachmentsSettle = async (client, message) => {
565
- if (!message.chatId) return message;
566
- for (let attempt = 0; attempt < ATTACHMENT_JOIN_RETRY_LIMIT; attempt += 1) {
567
- await setTimeout$1(ATTACHMENT_JOIN_RETRY_DELAY_MS);
568
- let rows;
569
- try {
570
- rows = await client.getMessages({
571
- chatId: message.chatId,
572
- limit: ATTACHMENT_JOIN_FETCH_LIMIT,
573
- since: message.createdAt
574
- });
575
- } catch {
576
- continue;
577
- }
578
- const refreshed = rows.find((row) => row.id === message.id);
579
- if (refreshed && !isPendingAttachmentJoin(refreshed)) return refreshed;
580
- }
581
- return message;
582
- };
583
- const toMessages = async (message) => {
584
- const { chatId, chatKind } = message;
585
- if (!chatId || chatKind === "unknown") return [];
586
- if (message.reaction !== null || message.kind !== "text" || message.retractedAt !== null) return [];
587
- if (isPendingAttachmentJoin(message)) return [];
588
- const base = {
589
- sender: {
590
- id: message.participant ?? "",
591
- ...message.participant ? { address: message.participant } : {}
592
- },
593
- space: {
594
- id: chatId,
595
- type: chatKind === "group" ? "group" : "dm",
596
- phone: ""
597
- },
598
- timestamp: message.createdAt
599
- };
600
- const targetId = replyTargetId(message);
601
- if (message.attachments.length > 0) {
602
- if (!hasUsableTextPart(message.text)) return wrapReply(await Promise.all(message.attachments.map(async (att) => ({
603
- ...base,
604
- id: `${message.id}:${att.id}`,
605
- content: await localAttachmentContent(att)
606
- }))), targetId);
607
- const parts = toOrderedParts(message.text, message.attachments);
608
- const messages = [];
609
- for (let i = 0; i < parts.length; i++) {
610
- const part = parts[i];
611
- if (!part) continue;
612
- messages.push(part.type === "text" ? {
613
- ...base,
614
- id: `${message.id}:text:${i}`,
615
- content: asText(part.text),
616
- partIndex: i
617
- } : {
618
- ...base,
619
- id: `${message.id}:${part.attachment.id}`,
620
- content: await localAttachmentContent(part.attachment),
621
- partIndex: i
622
- });
623
- }
624
- return wrapReply(messages, targetId);
625
- }
626
- return wrapReply([{
627
- ...base,
628
- id: message.id,
629
- content: {
630
- type: "text",
631
- text: message.text ?? ""
632
- }
633
- }], targetId);
634
- };
635
- const messages$3 = (client) => stream((emit, end) => {
636
- let lastPromise = Promise.resolve();
637
- const handleIncoming = async (message) => {
638
- const ms = await toMessages(isPendingAttachmentJoin(message) ? await refetchUntilAttachmentsSettle(client, message) : message);
639
- for (const m of ms) await emit(m);
640
- };
641
- const startPromise = client.startWatching({
642
- onIncomingMessage: (message) => {
643
- lastPromise = lastPromise.then(() => handleIncoming(message)).catch(end);
644
- },
645
- onError: end
646
- }).catch(end);
647
- return async () => {
648
- await startPromise.catch(() => {});
649
- await client.stopWatching();
650
- await lastPromise.catch(() => {});
651
- };
652
- });
653
- //#endregion
654
- //#region src/shared/errors.ts
655
- const IMESSAGE_PLATFORM = "iMessage";
656
- const LOCAL_IMESSAGE_PLATFORM = "iMessage (local mode)";
657
- const unsupportedRemoteContent = (type, detail) => UnsupportedError.content(type, IMESSAGE_PLATFORM, detail);
658
- const unsupportedLocalContent = (type, detail) => UnsupportedError.content(type, LOCAL_IMESSAGE_PLATFORM, detail);
659
- //#endregion
660
- //#region src/local/send.ts
661
- const synthRecord = (spaceId, content) => ({
662
- id: crypto.randomUUID(),
663
- content,
664
- space: { id: spaceId },
665
- timestamp: /* @__PURE__ */ new Date()
666
- });
667
- const sendTempFile = async (client, spaceId, name, data) => {
668
- const safeName = basename(name) || "attachment";
669
- const dir = await mkdtemp(join(tmpdir(), "spectrum-"));
670
- const tmp = join(dir, safeName);
671
- await writeFile(tmp, data);
672
- try {
673
- await client.send({
674
- to: spaceId,
675
- attachments: [tmp]
676
- });
677
- } finally {
678
- await rm(dir, {
679
- recursive: true,
680
- force: true
681
- }).catch(() => {});
682
- }
683
- };
684
- const send$3 = async (client, spaceId, content) => {
685
- switch (content.type) {
686
- case "text":
687
- await client.send({
688
- to: spaceId,
689
- text: content.text
690
- });
691
- return synthRecord(spaceId, content);
692
- case "attachment":
693
- await sendTempFile(client, spaceId, content.name, await content.read());
694
- return synthRecord(spaceId, content);
695
- case "contact": {
696
- const vcf = await toVCard(content);
697
- await sendTempFile(client, spaceId, vcardFileName(content), Buffer.from(vcf, "utf8"));
698
- return synthRecord(spaceId, content);
699
- }
700
- case "effect": throw unsupportedLocalContent("effect", "message effects require remote iMessage");
701
- case "poll": throw unsupportedLocalContent("poll");
702
- default: throw unsupportedLocalContent(content.type);
703
- }
704
- };
705
- const getMessage$3 = async (_client, _id) => void 0;
706
- //#endregion
707
- //#region src/local/api.ts
708
- const messages$2 = (client) => messages$3(client);
709
- const send$2 = async (client, spaceId, content) => send$3(client, spaceId, content);
710
- const getMessage$2 = async (client, id) => getMessage$3(client, id);
711
463
  //#endregion
712
464
  //#region src/remote/ids.ts
713
465
  const PART_PREFIX = /^p:(\d+)\//;
@@ -824,6 +576,59 @@ const sendCustomizedMiniApp$1 = async (remote, spaceId, content) => {
824
576
  const updateCustomizedMiniApp$1 = async (remote, spaceId, session, content) => {
825
577
  return toProviderRecord(await remote.messages.updateCustomizedMiniApp(session, content), content, spaceId);
826
578
  };
579
+ const addTextPart = (parts, text) => {
580
+ const trimmed = text?.trim();
581
+ if (trimmed) parts.push({
582
+ type: "text",
583
+ text: trimmed
584
+ });
585
+ };
586
+ const addAttachmentParts = (parts, attachments) => {
587
+ for (const attachment of attachments) if (attachment) parts.push({
588
+ type: "attachment",
589
+ attachment
590
+ });
591
+ };
592
+ const toOrderedParts = (text, attachments) => {
593
+ const parts = [];
594
+ if (!text) {
595
+ addAttachmentParts(parts, attachments);
596
+ return parts;
597
+ }
598
+ if (!text.includes("")) {
599
+ addAttachmentParts(parts, attachments);
600
+ addTextPart(parts, text);
601
+ return parts;
602
+ }
603
+ const textSegments = text.split("");
604
+ for (let i = 0; i < attachments.length; i++) {
605
+ addTextPart(parts, textSegments[i]);
606
+ const attachment = attachments[i];
607
+ if (attachment) parts.push({
608
+ type: "attachment",
609
+ attachment
610
+ });
611
+ }
612
+ addTextPart(parts, textSegments.slice(attachments.length).join(""));
613
+ return parts;
614
+ };
615
+ //#endregion
616
+ //#region src/shared/vcard.ts
617
+ const VCARD_MIME_TYPES = new Set([
618
+ "text/vcard",
619
+ "text/x-vcard",
620
+ "text/directory",
621
+ "application/vcard",
622
+ "application/x-vcard"
623
+ ]);
624
+ const normalizeMimeType = (mimeType) => (mimeType.split(";")[0] ?? "").trim().toLowerCase();
625
+ const isVCardAttachment = (mimeType, fileName) => {
626
+ if (mimeType && VCARD_MIME_TYPES.has(normalizeMimeType(mimeType))) return true;
627
+ return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
628
+ };
629
+ const vcardFileName = (contact) => {
630
+ return `${(contact.name?.formatted ?? contact.user?.id ?? "contact").replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
631
+ };
827
632
  //#endregion
828
633
  //#region src/remote/attachments.ts
829
634
  /**
@@ -1273,6 +1078,10 @@ const getDisplayName$1 = async (remote, spaceId) => {
1273
1078
  return displayName || void 0;
1274
1079
  };
1275
1080
  //#endregion
1081
+ //#region src/shared/errors.ts
1082
+ const IMESSAGE_PLATFORM = "iMessage";
1083
+ const unsupportedRemoteContent = (type, detail) => UnsupportedError.content(type, IMESSAGE_PLATFORM, detail);
1084
+ //#endregion
1276
1085
  //#region src/remote/markdown.ts
1277
1086
  const markdownLexer = new Marked();
1278
1087
  const BULLET = "• ";
@@ -1701,6 +1510,30 @@ const unsendMessage$1 = async (remote, spaceId, msgId) => {
1701
1510
  await remote.messages.unsend(toChatGuid(spaceId), toMessageGuid(childRef?.parentGuid ?? msgId), childRef ? { partIndex: childRef.partIndex } : void 0);
1702
1511
  };
1703
1512
  //#endregion
1513
+ //#region src/remote/client.ts
1514
+ const isSharedMode = (clients) => clients.length === 1 && clients[0]?.phone === "shared";
1515
+ const availablePhones = (clients) => clients.map((c) => c.phone);
1516
+ const clientForPhone = (clients, phone) => {
1517
+ if (isSharedMode(clients)) {
1518
+ const entry = clients[0];
1519
+ if (!entry) throw new Error("No iMessage clients configured");
1520
+ return entry.client;
1521
+ }
1522
+ const entry = clients.find((c) => c.phone === phone);
1523
+ if (!entry) {
1524
+ const list = availablePhones(clients).join(", ") || "<none>";
1525
+ throw new Error(`No iMessage client serves phone ${phone}. Available: ${list}`);
1526
+ }
1527
+ return entry.client;
1528
+ };
1529
+ const randomPhone = (clients) => {
1530
+ if (clients.length === 0) throw new Error("No iMessage phones configured for this account");
1531
+ if (isSharedMode(clients)) return SHARED_PHONE;
1532
+ const entry = clients[Math.floor(Math.random() * clients.length)];
1533
+ if (!entry) throw new Error("No iMessage phones configured for this account");
1534
+ return entry.phone;
1535
+ };
1536
+ //#endregion
1704
1537
  //#region src/remote/contact-share.ts
1705
1538
  const log$2 = createLogger("spectrum.imessage.contact");
1706
1539
  const SHARE_TTL_MS = 1440 * 60 * 1e3;
@@ -2103,18 +1936,19 @@ const groupStream = (client, phone, recover) => resumableOrderedStream({
2103
1936
  processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toGroupItem(client, event, phone, String(event.sequence)),
2104
1937
  subscribeLive: (cursor) => withClose(client.groups.subscribeEvents(), cursor)
2105
1938
  });
2106
- const clientStream = (client, pollCache, phone, onInbound, recover) => mergeStreams([
2107
- messageStream(client, phone, onInbound, recover),
2108
- pollStream(client, pollCache, phone, recover),
2109
- groupStream(client, phone, recover)
2110
- ]);
1939
+ const clientStream = (client, pollCache, phone, includeGroupEvents, onInbound, recover) => {
1940
+ const streams = [messageStream(client, phone, onInbound, recover), pollStream(client, pollCache, phone, recover)];
1941
+ if (includeGroupEvents) streams.push(groupStream(client, phone, recover));
1942
+ return mergeStreams(streams);
1943
+ };
2111
1944
  const messages$1 = (clients, projectConfig) => {
2112
1945
  const pollCache = getPollCache(clients);
2113
1946
  const shareEnabled = projectConfig?.profile?.imessageSynced === true;
2114
1947
  const recover = getCloudRecover(clients);
1948
+ const includeGroupEvents = !isSharedMode(clients);
2115
1949
  return mergeStreams(clients.map((entry) => {
2116
1950
  const tracker = shareEnabled ? getContactShareTracker(entry.client) : void 0;
2117
- return clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(chatGuid) : void 0, recover);
1951
+ return clientStream(entry.client, pollCache, entry.phone, includeGroupEvents, tracker ? (chatGuid) => tracker.maybeShare(chatGuid) : void 0, recover);
2118
1952
  }));
2119
1953
  };
2120
1954
  //#endregion
@@ -2234,30 +2068,6 @@ const toSpectrumMiniApp = (url, layout, live) => asCustomizedMiniApp({
2234
2068
  ...live === void 0 ? {} : { live }
2235
2069
  });
2236
2070
  //#endregion
2237
- //#region src/remote/client.ts
2238
- const isSharedMode = (clients) => clients.length === 1 && clients[0]?.phone === "shared";
2239
- const availablePhones = (clients) => clients.map((c) => c.phone);
2240
- const clientForPhone = (clients, phone) => {
2241
- if (isSharedMode(clients)) {
2242
- const entry = clients[0];
2243
- if (!entry) throw new Error("No iMessage clients configured");
2244
- return entry.client;
2245
- }
2246
- const entry = clients.find((c) => c.phone === phone);
2247
- if (!entry) {
2248
- const list = availablePhones(clients).join(", ") || "<none>";
2249
- throw new Error(`No iMessage client serves phone ${phone}. Available: ${list}`);
2250
- }
2251
- return entry.client;
2252
- };
2253
- const randomPhone = (clients) => {
2254
- if (clients.length === 0) throw new Error("No iMessage phones configured for this account");
2255
- if (isSharedMode(clients)) return SHARED_PHONE;
2256
- const entry = clients[Math.floor(Math.random() * clients.length)];
2257
- if (!entry) throw new Error("No iMessage phones configured for this account");
2258
- return entry.phone;
2259
- };
2260
- //#endregion
2261
2071
  //#region src/index.ts
2262
2072
  const isPollContent = (content) => content.type === "poll" || content.type === "poll_option";
2263
2073
  const cacheRemoteOutbound = (remote, space, record) => {
@@ -2275,7 +2085,6 @@ const cacheRemoteOutbound = (remote, space, record) => {
2275
2085
  return record;
2276
2086
  };
2277
2087
  const handleEdit = async (client, space, content) => {
2278
- if (isLocal(client)) throw UnsupportedError.action("edit", "iMessage (local mode)");
2279
2088
  const miniAppCardSession = content.target.miniAppCardSession;
2280
2089
  const updateMiniAppCardSession = (record) => {
2281
2090
  const nextSession = record?.miniAppCardSession;
@@ -2299,7 +2108,6 @@ const handleEdit = async (client, space, content) => {
2299
2108
  await editMessage(clientForPhone(client, space.phone), space.id, content.target.id, content.content);
2300
2109
  };
2301
2110
  const handleUnsend = async (client, space, content) => {
2302
- if (isLocal(client)) throw UnsupportedError.action("unsend", "iMessage (local mode)");
2303
2111
  if (isPollContent(content.target.content)) throw UnsupportedError.action("unsend", "iMessage", "iMessage polls cannot be unsent");
2304
2112
  const remote = clientForPhone(client, space.phone);
2305
2113
  const targetContent = content.target.content;
@@ -2310,53 +2118,43 @@ const handleUnsend = async (client, space, content) => {
2310
2118
  await unsendMessage(remote, space.id, content.target.id);
2311
2119
  };
2312
2120
  const handleStreamText = async (client, space, content) => {
2313
- if (isLocal(client)) throw UnsupportedError.action("streamText", "iMessage (local mode)", "streaming text responses require remote iMessage");
2314
2121
  const remote = clientForPhone(client, space.phone);
2315
2122
  return cacheRemoteOutbound(remote, space, await sendStreamText(remote, space.id, content));
2316
2123
  };
2317
2124
  const handleBackground = async (client, space, content) => {
2318
- if (isLocal(client)) throw UnsupportedError.action("background", "iMessage (local mode)", "chat backgrounds require remote iMessage");
2319
2125
  await setBackground(clientForPhone(client, space.phone), space.id, content);
2320
2126
  };
2321
2127
  const handleCustomizedMiniApp = async (client, space, content) => {
2322
- if (isLocal(client)) throw UnsupportedError.action("customized-mini-app", "iMessage (local mode)", "mini app cards require remote iMessage");
2323
2128
  const remote = clientForPhone(client, space.phone);
2324
2129
  return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, content));
2325
2130
  };
2326
2131
  /**
2327
2132
  * Render the universal `app` content. On remote it becomes a native Spectrum
2328
2133
  * mini-app card (fixed `SPECTRUM_MINI_APP` identity + the URL + the layout
2329
- * already parsed from the URL's link metadata). Local mode cannot send cards,
2330
- * so it degrades to the bare URL as a text message.
2134
+ * already parsed from the URL's link metadata).
2331
2135
  */
2332
2136
  const handleApp = async (client, space, content) => {
2333
2137
  const url = await content.url();
2334
- if (isLocal(client)) return await send$2(client, space.id, await text(url).build());
2335
2138
  const layout = await content.layout();
2336
2139
  const remote = clientForPhone(client, space.phone);
2337
2140
  return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, toSpectrumMiniApp(url, layout, content.live)));
2338
2141
  };
2339
2142
  const handleRead = async (client, space) => {
2340
- if (isLocal(client)) throw UnsupportedError.action("read", "iMessage (local mode)", "marking chats as read requires remote iMessage");
2341
2143
  await markRead(clientForPhone(client, space.phone), space.id);
2342
2144
  };
2343
2145
  const handleShareContactCard = async (client, space) => {
2344
- if (isLocal(client)) throw UnsupportedError.action("shareContactCard", "iMessage (local mode)", "sharing the contact card requires remote iMessage");
2345
2146
  await shareContactCard(clientForPhone(client, space.phone), space.id);
2346
2147
  };
2347
2148
  const handleTyping = async (client, space, state) => {
2348
- if (isLocal(client)) return;
2349
2149
  const remote = clientForPhone(client, space.phone);
2350
2150
  if (state === "start") await startTyping(remote, space.id);
2351
2151
  else await stopTyping(remote, space.id);
2352
2152
  };
2353
2153
  const handleRename = async (client, space, content) => {
2354
- if (isLocal(client)) throw UnsupportedError.action("rename", "iMessage (local mode)", "renaming chats requires remote iMessage");
2355
2154
  if (space.type !== "group") throw UnsupportedError.action("rename", "iMessage", "only group chats can be renamed (this space is a DM)");
2356
2155
  await setDisplayName(clientForPhone(client, space.phone), space.id, content);
2357
2156
  };
2358
2157
  const handleAvatar = async (client, space, content) => {
2359
- if (isLocal(client)) throw UnsupportedError.action("avatar", "iMessage (local mode)", "setting group avatars requires remote iMessage");
2360
2158
  if (space.type !== "group") throw UnsupportedError.action("avatar", "iMessage", "only group chats have avatars (this space is a DM)");
2361
2159
  await setIcon(clientForPhone(client, space.phone), space.id, content);
2362
2160
  };
@@ -2366,27 +2164,17 @@ const handleAvatar = async (client, space, content) => {
2366
2164
  * guard sequence.
2367
2165
  */
2368
2166
  const remoteGroupClient = (client, space, action, detail) => {
2369
- if (isLocal(client)) throw UnsupportedError.action(action, "iMessage (local mode)", detail.local);
2370
- if (space.type !== "group") throw UnsupportedError.action(action, "iMessage", detail.dm);
2167
+ if (space.type !== "group") throw UnsupportedError.action(action, "iMessage", detail);
2371
2168
  return clientForPhone(client, space.phone);
2372
2169
  };
2373
2170
  const handleAddMember = async (client, space, content) => {
2374
- await addParticipants(remoteGroupClient(client, space, "addMember", {
2375
- dm: "only group chats can add members (this space is a DM — iMessage cannot convert a DM into a group; create a group via space.create instead)",
2376
- local: "adding members requires remote iMessage"
2377
- }), space.id, content);
2171
+ await addParticipants(remoteGroupClient(client, space, "addMember", "only group chats can add members (this space is a DM — iMessage cannot convert a DM into a group; create a group via space.create instead)"), space.id, content);
2378
2172
  };
2379
2173
  const handleRemoveMember = async (client, space, content) => {
2380
- await removeParticipants(remoteGroupClient(client, space, "removeMember", {
2381
- dm: "only group chats can remove members (this space is a DM — iMessage cannot convert a DM into a group; create a group via space.create instead)",
2382
- local: "removing members requires remote iMessage"
2383
- }), space.id, content);
2174
+ await removeParticipants(remoteGroupClient(client, space, "removeMember", "only group chats can remove members (this space is a DM — iMessage cannot convert a DM into a group; create a group via space.create instead)"), space.id, content);
2384
2175
  };
2385
2176
  const handleLeaveSpace = async (client, space) => {
2386
- await leaveGroup(remoteGroupClient(client, space, "leaveSpace", {
2387
- dm: "only group chats can be left (this space is a DM)",
2388
- local: "leaving chats requires remote iMessage"
2389
- }), space.id);
2177
+ await leaveGroup(remoteGroupClient(client, space, "leaveSpace", "only group chats can be left (this space is a DM)"), space.id);
2390
2178
  };
2391
2179
  /**
2392
2180
  * Dispatch the iMessage-only fire-and-forget control signals that live outside
@@ -2408,21 +2196,18 @@ const handleProviderControlSignal = async (client, space, content) => {
2408
2196
  };
2409
2197
  /**
2410
2198
  * Resolve the remote client for a `reply` / `reaction` whose target is another
2411
- * message. Both reject local mode and poll targets identically, so the guard
2412
- * lives here to keep the `send` dispatch flat. `action` labels the error and
2413
- * `pollNoun` is the plural used in the poll-unsupported message.
2199
+ * message. `action` labels the error and `pollNoun` is the plural used in the
2200
+ * poll-unsupported message.
2414
2201
  */
2415
2202
  const remoteForMessageTarget = (client, space, target, action, pollNoun) => {
2416
- if (isLocal(client)) throw UnsupportedError.action(action, "iMessage (local mode)");
2417
2203
  if (isPollContent(target.content)) throw UnsupportedError.action(action, "iMessage", `iMessage polls do not support ${pollNoun}`);
2418
2204
  return clientForPhone(client, space.phone);
2419
2205
  };
2420
2206
  const imessage = definePlatform("iMessage", {
2421
2207
  config: configSchema,
2422
- static: { effect: { message: MessageEffect } },
2208
+ static: { effect: { message: messageEffects } },
2423
2209
  lifecycle: {
2424
2210
  createClient: async ({ config, projectId, projectSecret }) => {
2425
- if (config.local) return new IMessageSDK();
2426
2211
  if (config.clients) return (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2427
2212
  phone: e.phone,
2428
2213
  client: createClient({
@@ -2433,14 +2218,10 @@ const imessage = definePlatform("iMessage", {
2433
2218
  token: e.token
2434
2219
  })
2435
2220
  }));
2436
- 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: [...] })");
2221
+ if (!(projectId && projectSecret)) throw new Error("Cloud iMessage requires projectId and projectSecret. Pass credentials to Spectrum() or provide explicit clients with imessage.config({ clients: [...] }). For local Messages access, install @spectrum-ts/imessage-local and use its imessage.config().");
2437
2222
  return await createCloudClients(projectId, projectSecret);
2438
2223
  },
2439
2224
  destroyClient: async ({ client }) => {
2440
- if (isLocal(client)) {
2441
- await client.close();
2442
- return;
2443
- }
2444
2225
  await disposeCloudAuth(client);
2445
2226
  await Promise.all(client.map((entry) => entry.client.close()));
2446
2227
  }
@@ -2454,14 +2235,6 @@ const imessage = definePlatform("iMessage", {
2454
2235
  params: spaceParamsSchema,
2455
2236
  create: async ({ input, client }) => {
2456
2237
  if (input.users.length === 0) throw new Error("iMessage space creation requires at least one user");
2457
- if (isLocal(client)) {
2458
- if (input.users.length > 1) throw UnsupportedError.action("space.create", "iMessage (local mode)", "local mode cannot create group chats — use space.get(chatGuid) for an existing group");
2459
- return {
2460
- id: dmChatGuid(input.users[0]?.id ?? ""),
2461
- type: "dm",
2462
- phone: ""
2463
- };
2464
- }
2465
2238
  if (client.length === 0) throw new Error("No iMessage clients configured");
2466
2239
  const addresses = input.users.map((u) => u.id);
2467
2240
  if (isSharedMode(client)) {
@@ -2481,11 +2254,6 @@ const imessage = definePlatform("iMessage", {
2481
2254
  };
2482
2255
  },
2483
2256
  get: async ({ input, client }) => {
2484
- if (isLocal(client)) return {
2485
- id: input.id,
2486
- type: chatTypeFromGuid(input.id),
2487
- phone: ""
2488
- };
2489
2257
  if (client.length === 0) throw new Error("No iMessage clients configured");
2490
2258
  const phone = isSharedMode(client) ? SHARED_PHONE : input.params?.phone ?? (client.length === 1 ? client[0]?.phone : void 0);
2491
2259
  if (!phone) throw new Error(`iMessage space.get requires params.phone when multiple clients are configured. Available: ${availablePhones(client).join(", ")}`);
@@ -2505,7 +2273,7 @@ const imessage = definePlatform("iMessage", {
2505
2273
  }
2506
2274
  },
2507
2275
  message: { schema: messageSchema },
2508
- messages: ({ client, projectConfig }) => isLocal(client) ? messages$2(client) : messages(client, projectConfig),
2276
+ messages: ({ client, projectConfig }) => messages(client, projectConfig),
2509
2277
  send: async ({ space, content, client }) => {
2510
2278
  if (content.type === "reply") {
2511
2279
  const remote = remoteForMessageTarget(client, space, content.target, "reply", "replies");
@@ -2555,35 +2323,23 @@ const imessage = definePlatform("iMessage", {
2555
2323
  if (content.type === "app") return await handleApp(client, space, content);
2556
2324
  if (await handleProviderControlSignal(client, space, content)) return;
2557
2325
  if (isCustomizedMiniApp(content)) return await handleCustomizedMiniApp(client, space, content);
2558
- if (isLocal(client)) return await send$2(client, space.id, content);
2559
2326
  const remote = clientForPhone(client, space.phone);
2560
2327
  return cacheRemoteOutbound(remote, space, await send(remote, space.id, content));
2561
2328
  },
2562
2329
  actions: {
2563
2330
  getMessage: async ({ client }, space, messageId) => {
2564
- if (isLocal(client)) return getMessage$2(client, messageId);
2565
2331
  return getMessage(clientForPhone(client, space.phone), space.id, messageId, space.phone);
2566
2332
  },
2567
2333
  getMembers: async ({ client }, space) => {
2568
- return await listParticipants(remoteGroupClient(client, space, "getMembers", {
2569
- dm: "only group chats support listing members (this space is a DM)",
2570
- local: "listing members requires remote iMessage"
2571
- }), space.id, space.phone);
2334
+ return await listParticipants(remoteGroupClient(client, space, "getMembers", "only group chats support listing members (this space is a DM)"), space.id, space.phone);
2572
2335
  },
2573
2336
  getAvatar: async ({ client }, space) => {
2574
- return await getIcon(remoteGroupClient(client, space, "getAvatar", {
2575
- dm: "only group chats have avatars (this space is a DM)",
2576
- local: "fetching group avatars requires remote iMessage"
2577
- }), space.id);
2337
+ return await getIcon(remoteGroupClient(client, space, "getAvatar", "only group chats have avatars (this space is a DM)"), space.id);
2578
2338
  },
2579
2339
  getDisplayName: async ({ client }, space) => {
2580
- return await getDisplayName(remoteGroupClient(client, space, "getDisplayName", {
2581
- dm: "only group chats have display names (this space is a DM)",
2582
- local: "reading chat display names requires remote iMessage"
2583
- }), space.id);
2340
+ return await getDisplayName(remoteGroupClient(client, space, "getDisplayName", "only group chats have display names (this space is a DM)"), space.id);
2584
2341
  },
2585
2342
  getAttachment: async ({ client }, guid, phone) => {
2586
- if (isLocal(client)) throw UnsupportedError.action("getAttachment", "iMessage (local mode)", "fetching attachments by GUID requires remote iMessage");
2587
2343
  if (client.length === 0) throw new Error("No iMessage clients configured");
2588
2344
  const routedPhone = (() => {
2589
2345
  if (isSharedMode(client)) return SHARED_PHONE;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectrum-ts/imessage",
3
- "version": "9.3.0",
4
- "description": "iMessage provider for spectrum-ts — local and remote (advanced) modes.",
3
+ "version": "10.0.0",
4
+ "description": "iMessage provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/photon-hq/spectrum-ts.git",
@@ -32,14 +32,13 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@photon-ai/advanced-imessage": "^1.0.0",
35
- "@photon-ai/imessage-kit": "^3.0.0",
36
35
  "@photon-ai/otel": "^3.1.0",
37
36
  "lru-cache": "^11.0.0",
38
37
  "marked": "^18.0.5",
39
38
  "zod": "^4.2.1"
40
39
  },
41
40
  "peerDependencies": {
42
- "@spectrum-ts/core": "^9.0.0",
41
+ "@spectrum-ts/core": "^10.0.0",
43
42
  "typescript": "^5 || ^6.0.0"
44
43
  },
45
44
  "license": "MIT"