@spectrum-ts/imessage 9.3.1 → 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 = "• ";
@@ -2276,7 +2085,6 @@ const cacheRemoteOutbound = (remote, space, record) => {
2276
2085
  return record;
2277
2086
  };
2278
2087
  const handleEdit = async (client, space, content) => {
2279
- if (isLocal(client)) throw UnsupportedError.action("edit", "iMessage (local mode)");
2280
2088
  const miniAppCardSession = content.target.miniAppCardSession;
2281
2089
  const updateMiniAppCardSession = (record) => {
2282
2090
  const nextSession = record?.miniAppCardSession;
@@ -2300,7 +2108,6 @@ const handleEdit = async (client, space, content) => {
2300
2108
  await editMessage(clientForPhone(client, space.phone), space.id, content.target.id, content.content);
2301
2109
  };
2302
2110
  const handleUnsend = async (client, space, content) => {
2303
- if (isLocal(client)) throw UnsupportedError.action("unsend", "iMessage (local mode)");
2304
2111
  if (isPollContent(content.target.content)) throw UnsupportedError.action("unsend", "iMessage", "iMessage polls cannot be unsent");
2305
2112
  const remote = clientForPhone(client, space.phone);
2306
2113
  const targetContent = content.target.content;
@@ -2311,53 +2118,43 @@ const handleUnsend = async (client, space, content) => {
2311
2118
  await unsendMessage(remote, space.id, content.target.id);
2312
2119
  };
2313
2120
  const handleStreamText = async (client, space, content) => {
2314
- if (isLocal(client)) throw UnsupportedError.action("streamText", "iMessage (local mode)", "streaming text responses require remote iMessage");
2315
2121
  const remote = clientForPhone(client, space.phone);
2316
2122
  return cacheRemoteOutbound(remote, space, await sendStreamText(remote, space.id, content));
2317
2123
  };
2318
2124
  const handleBackground = async (client, space, content) => {
2319
- if (isLocal(client)) throw UnsupportedError.action("background", "iMessage (local mode)", "chat backgrounds require remote iMessage");
2320
2125
  await setBackground(clientForPhone(client, space.phone), space.id, content);
2321
2126
  };
2322
2127
  const handleCustomizedMiniApp = async (client, space, content) => {
2323
- if (isLocal(client)) throw UnsupportedError.action("customized-mini-app", "iMessage (local mode)", "mini app cards require remote iMessage");
2324
2128
  const remote = clientForPhone(client, space.phone);
2325
2129
  return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, content));
2326
2130
  };
2327
2131
  /**
2328
2132
  * Render the universal `app` content. On remote it becomes a native Spectrum
2329
2133
  * mini-app card (fixed `SPECTRUM_MINI_APP` identity + the URL + the layout
2330
- * already parsed from the URL's link metadata). Local mode cannot send cards,
2331
- * so it degrades to the bare URL as a text message.
2134
+ * already parsed from the URL's link metadata).
2332
2135
  */
2333
2136
  const handleApp = async (client, space, content) => {
2334
2137
  const url = await content.url();
2335
- if (isLocal(client)) return await send$2(client, space.id, await text(url).build());
2336
2138
  const layout = await content.layout();
2337
2139
  const remote = clientForPhone(client, space.phone);
2338
2140
  return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, toSpectrumMiniApp(url, layout, content.live)));
2339
2141
  };
2340
2142
  const handleRead = async (client, space) => {
2341
- if (isLocal(client)) throw UnsupportedError.action("read", "iMessage (local mode)", "marking chats as read requires remote iMessage");
2342
2143
  await markRead(clientForPhone(client, space.phone), space.id);
2343
2144
  };
2344
2145
  const handleShareContactCard = async (client, space) => {
2345
- if (isLocal(client)) throw UnsupportedError.action("shareContactCard", "iMessage (local mode)", "sharing the contact card requires remote iMessage");
2346
2146
  await shareContactCard(clientForPhone(client, space.phone), space.id);
2347
2147
  };
2348
2148
  const handleTyping = async (client, space, state) => {
2349
- if (isLocal(client)) return;
2350
2149
  const remote = clientForPhone(client, space.phone);
2351
2150
  if (state === "start") await startTyping(remote, space.id);
2352
2151
  else await stopTyping(remote, space.id);
2353
2152
  };
2354
2153
  const handleRename = async (client, space, content) => {
2355
- if (isLocal(client)) throw UnsupportedError.action("rename", "iMessage (local mode)", "renaming chats requires remote iMessage");
2356
2154
  if (space.type !== "group") throw UnsupportedError.action("rename", "iMessage", "only group chats can be renamed (this space is a DM)");
2357
2155
  await setDisplayName(clientForPhone(client, space.phone), space.id, content);
2358
2156
  };
2359
2157
  const handleAvatar = async (client, space, content) => {
2360
- if (isLocal(client)) throw UnsupportedError.action("avatar", "iMessage (local mode)", "setting group avatars requires remote iMessage");
2361
2158
  if (space.type !== "group") throw UnsupportedError.action("avatar", "iMessage", "only group chats have avatars (this space is a DM)");
2362
2159
  await setIcon(clientForPhone(client, space.phone), space.id, content);
2363
2160
  };
@@ -2367,27 +2164,17 @@ const handleAvatar = async (client, space, content) => {
2367
2164
  * guard sequence.
2368
2165
  */
2369
2166
  const remoteGroupClient = (client, space, action, detail) => {
2370
- if (isLocal(client)) throw UnsupportedError.action(action, "iMessage (local mode)", detail.local);
2371
- if (space.type !== "group") throw UnsupportedError.action(action, "iMessage", detail.dm);
2167
+ if (space.type !== "group") throw UnsupportedError.action(action, "iMessage", detail);
2372
2168
  return clientForPhone(client, space.phone);
2373
2169
  };
2374
2170
  const handleAddMember = async (client, space, content) => {
2375
- await addParticipants(remoteGroupClient(client, space, "addMember", {
2376
- 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)",
2377
- local: "adding members requires remote iMessage"
2378
- }), 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);
2379
2172
  };
2380
2173
  const handleRemoveMember = async (client, space, content) => {
2381
- await removeParticipants(remoteGroupClient(client, space, "removeMember", {
2382
- 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)",
2383
- local: "removing members requires remote iMessage"
2384
- }), 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);
2385
2175
  };
2386
2176
  const handleLeaveSpace = async (client, space) => {
2387
- await leaveGroup(remoteGroupClient(client, space, "leaveSpace", {
2388
- dm: "only group chats can be left (this space is a DM)",
2389
- local: "leaving chats requires remote iMessage"
2390
- }), space.id);
2177
+ await leaveGroup(remoteGroupClient(client, space, "leaveSpace", "only group chats can be left (this space is a DM)"), space.id);
2391
2178
  };
2392
2179
  /**
2393
2180
  * Dispatch the iMessage-only fire-and-forget control signals that live outside
@@ -2409,21 +2196,18 @@ const handleProviderControlSignal = async (client, space, content) => {
2409
2196
  };
2410
2197
  /**
2411
2198
  * Resolve the remote client for a `reply` / `reaction` whose target is another
2412
- * message. Both reject local mode and poll targets identically, so the guard
2413
- * lives here to keep the `send` dispatch flat. `action` labels the error and
2414
- * `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.
2415
2201
  */
2416
2202
  const remoteForMessageTarget = (client, space, target, action, pollNoun) => {
2417
- if (isLocal(client)) throw UnsupportedError.action(action, "iMessage (local mode)");
2418
2203
  if (isPollContent(target.content)) throw UnsupportedError.action(action, "iMessage", `iMessage polls do not support ${pollNoun}`);
2419
2204
  return clientForPhone(client, space.phone);
2420
2205
  };
2421
2206
  const imessage = definePlatform("iMessage", {
2422
2207
  config: configSchema,
2423
- static: { effect: { message: MessageEffect } },
2208
+ static: { effect: { message: messageEffects } },
2424
2209
  lifecycle: {
2425
2210
  createClient: async ({ config, projectId, projectSecret }) => {
2426
- if (config.local) return new IMessageSDK();
2427
2211
  if (config.clients) return (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
2428
2212
  phone: e.phone,
2429
2213
  client: createClient({
@@ -2434,14 +2218,10 @@ const imessage = definePlatform("iMessage", {
2434
2218
  token: e.token
2435
2219
  })
2436
2220
  }));
2437
- 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().");
2438
2222
  return await createCloudClients(projectId, projectSecret);
2439
2223
  },
2440
2224
  destroyClient: async ({ client }) => {
2441
- if (isLocal(client)) {
2442
- await client.close();
2443
- return;
2444
- }
2445
2225
  await disposeCloudAuth(client);
2446
2226
  await Promise.all(client.map((entry) => entry.client.close()));
2447
2227
  }
@@ -2455,14 +2235,6 @@ const imessage = definePlatform("iMessage", {
2455
2235
  params: spaceParamsSchema,
2456
2236
  create: async ({ input, client }) => {
2457
2237
  if (input.users.length === 0) throw new Error("iMessage space creation requires at least one user");
2458
- if (isLocal(client)) {
2459
- 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");
2460
- return {
2461
- id: dmChatGuid(input.users[0]?.id ?? ""),
2462
- type: "dm",
2463
- phone: ""
2464
- };
2465
- }
2466
2238
  if (client.length === 0) throw new Error("No iMessage clients configured");
2467
2239
  const addresses = input.users.map((u) => u.id);
2468
2240
  if (isSharedMode(client)) {
@@ -2482,11 +2254,6 @@ const imessage = definePlatform("iMessage", {
2482
2254
  };
2483
2255
  },
2484
2256
  get: async ({ input, client }) => {
2485
- if (isLocal(client)) return {
2486
- id: input.id,
2487
- type: chatTypeFromGuid(input.id),
2488
- phone: ""
2489
- };
2490
2257
  if (client.length === 0) throw new Error("No iMessage clients configured");
2491
2258
  const phone = isSharedMode(client) ? SHARED_PHONE : input.params?.phone ?? (client.length === 1 ? client[0]?.phone : void 0);
2492
2259
  if (!phone) throw new Error(`iMessage space.get requires params.phone when multiple clients are configured. Available: ${availablePhones(client).join(", ")}`);
@@ -2506,7 +2273,7 @@ const imessage = definePlatform("iMessage", {
2506
2273
  }
2507
2274
  },
2508
2275
  message: { schema: messageSchema },
2509
- messages: ({ client, projectConfig }) => isLocal(client) ? messages$2(client) : messages(client, projectConfig),
2276
+ messages: ({ client, projectConfig }) => messages(client, projectConfig),
2510
2277
  send: async ({ space, content, client }) => {
2511
2278
  if (content.type === "reply") {
2512
2279
  const remote = remoteForMessageTarget(client, space, content.target, "reply", "replies");
@@ -2556,35 +2323,23 @@ const imessage = definePlatform("iMessage", {
2556
2323
  if (content.type === "app") return await handleApp(client, space, content);
2557
2324
  if (await handleProviderControlSignal(client, space, content)) return;
2558
2325
  if (isCustomizedMiniApp(content)) return await handleCustomizedMiniApp(client, space, content);
2559
- if (isLocal(client)) return await send$2(client, space.id, content);
2560
2326
  const remote = clientForPhone(client, space.phone);
2561
2327
  return cacheRemoteOutbound(remote, space, await send(remote, space.id, content));
2562
2328
  },
2563
2329
  actions: {
2564
2330
  getMessage: async ({ client }, space, messageId) => {
2565
- if (isLocal(client)) return getMessage$2(client, messageId);
2566
2331
  return getMessage(clientForPhone(client, space.phone), space.id, messageId, space.phone);
2567
2332
  },
2568
2333
  getMembers: async ({ client }, space) => {
2569
- return await listParticipants(remoteGroupClient(client, space, "getMembers", {
2570
- dm: "only group chats support listing members (this space is a DM)",
2571
- local: "listing members requires remote iMessage"
2572
- }), 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);
2573
2335
  },
2574
2336
  getAvatar: async ({ client }, space) => {
2575
- return await getIcon(remoteGroupClient(client, space, "getAvatar", {
2576
- dm: "only group chats have avatars (this space is a DM)",
2577
- local: "fetching group avatars requires remote iMessage"
2578
- }), space.id);
2337
+ return await getIcon(remoteGroupClient(client, space, "getAvatar", "only group chats have avatars (this space is a DM)"), space.id);
2579
2338
  },
2580
2339
  getDisplayName: async ({ client }, space) => {
2581
- return await getDisplayName(remoteGroupClient(client, space, "getDisplayName", {
2582
- dm: "only group chats have display names (this space is a DM)",
2583
- local: "reading chat display names requires remote iMessage"
2584
- }), space.id);
2340
+ return await getDisplayName(remoteGroupClient(client, space, "getDisplayName", "only group chats have display names (this space is a DM)"), space.id);
2585
2341
  },
2586
2342
  getAttachment: async ({ client }, guid, phone) => {
2587
- if (isLocal(client)) throw UnsupportedError.action("getAttachment", "iMessage (local mode)", "fetching attachments by GUID requires remote iMessage");
2588
2343
  if (client.length === 0) throw new Error("No iMessage clients configured");
2589
2344
  const routedPhone = (() => {
2590
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.1",
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"