@photon-ai/chat-adapter-imessage 2.1.0 → 2.2.1

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 CHANGED
@@ -1,15 +1,115 @@
1
1
  // src/adapter.ts
2
- import { extractFiles, ValidationError as ValidationError4 } from "@chat-adapter/shared";
2
+ import { extractFiles, ValidationError as ValidationError8 } from "@chat-adapter/shared";
3
3
  import { NotImplementedError } from "chat";
4
4
  import {
5
+ app as appContent,
6
+ markdown as markdownContent,
5
7
  poll as pollContent,
6
8
  Spectrum,
7
9
  text as textContent
8
10
  } from "spectrum-ts";
9
- import { imessage } from "spectrum-ts/providers/imessage";
11
+ import {
12
+ customizedMiniApp,
13
+ effect as effectContent,
14
+ imessage as imessage2
15
+ } from "spectrum-ts/providers/imessage";
10
16
 
11
- // src/config.ts
17
+ // src/background.ts
12
18
  import { ValidationError } from "@chat-adapter/shared";
19
+ import { lookup as lookupMimeType } from "mime-types";
20
+ import { background as backgroundContent } from "spectrum-ts/providers/imessage";
21
+ var IMAGE_MIME_PATTERN = /^image\//i;
22
+ async function toBytes(input) {
23
+ if (input instanceof Uint8Array) {
24
+ return { bytes: Buffer.from(input) };
25
+ }
26
+ if (input instanceof ArrayBuffer) {
27
+ return { bytes: Buffer.from(input) };
28
+ }
29
+ if (input instanceof Blob) {
30
+ return {
31
+ bytes: Buffer.from(await input.arrayBuffer()),
32
+ mimeHint: input.type || void 0
33
+ };
34
+ }
35
+ const data = input.data;
36
+ if (data === void 0) {
37
+ throw new ValidationError(
38
+ "imessage",
39
+ "Chat background must be a Uint8Array, ArrayBuffer, Blob, or FileUpload"
40
+ );
41
+ }
42
+ const nested = await toBytes(data);
43
+ return {
44
+ bytes: nested.bytes,
45
+ mimeHint: input.mimeType || nested.mimeHint,
46
+ nameHint: input.filename || nested.nameHint
47
+ };
48
+ }
49
+ function toBackgroundUrl(input) {
50
+ if (input instanceof URL) {
51
+ return input;
52
+ }
53
+ if (typeof input !== "string") {
54
+ return;
55
+ }
56
+ let url;
57
+ try {
58
+ url = new URL(input);
59
+ } catch {
60
+ throw new ValidationError(
61
+ "imessage",
62
+ `Chat background string input must be an http(s) URL, got "${input}". Pass image bytes for local files.`
63
+ );
64
+ }
65
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
66
+ throw new ValidationError(
67
+ "imessage",
68
+ `Chat background URL must be http(s), got "${url.protocol}"`
69
+ );
70
+ }
71
+ return url;
72
+ }
73
+ function resolveBytesMime(hint, name) {
74
+ if (hint && IMAGE_MIME_PATTERN.test(hint)) {
75
+ return hint;
76
+ }
77
+ if (name) {
78
+ const inferred = lookupMimeType(name);
79
+ if (inferred && IMAGE_MIME_PATTERN.test(inferred)) {
80
+ return inferred;
81
+ }
82
+ }
83
+ throw new ValidationError(
84
+ "imessage",
85
+ hint ? `Chat background requires an image/* MIME type, got "${hint}"` : 'Chat background requires an image/* MIME type \u2014 pass options.mimeType (e.g. "image/jpeg") or a name with an image extension'
86
+ );
87
+ }
88
+ async function resolveBackground(input, options = {}) {
89
+ if (input === "clear") {
90
+ return backgroundContent("clear");
91
+ }
92
+ if (options.mimeType && !IMAGE_MIME_PATTERN.test(options.mimeType)) {
93
+ throw new ValidationError(
94
+ "imessage",
95
+ `Chat background requires an image/* MIME type, got "${options.mimeType}"`
96
+ );
97
+ }
98
+ const url = toBackgroundUrl(input);
99
+ if (url) {
100
+ return backgroundContent(
101
+ url,
102
+ options.mimeType ? { mimeType: options.mimeType } : void 0
103
+ );
104
+ }
105
+ const { bytes, mimeHint, nameHint } = await toBytes(input);
106
+ const name = options.name ?? nameHint;
107
+ const mimeType = resolveBytesMime(options.mimeType ?? mimeHint, name);
108
+ return backgroundContent(bytes, { mimeType });
109
+ }
110
+
111
+ // src/config.ts
112
+ import { ValidationError as ValidationError2 } from "@chat-adapter/shared";
13
113
  var SHARED_PHONE = "shared";
14
114
  var URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
15
115
  function deriveAddress(serverUrl) {
@@ -45,18 +145,47 @@ function resolveSpectrumConfig(local, auth) {
45
145
  }
46
146
  };
47
147
  }
48
- throw new ValidationError(
148
+ throw new ValidationError2(
49
149
  "imessage",
50
150
  "Remote mode requires Spectrum Cloud credentials (projectId + projectSecret), explicit clients, or serverUrl + apiKey."
51
151
  );
52
152
  }
53
153
 
154
+ // src/effects.ts
155
+ import { ValidationError as ValidationError3 } from "@chat-adapter/shared";
156
+ import { imessage } from "spectrum-ts/providers/imessage";
157
+ var iMessageEffect = imessage.effect.message;
158
+ var EFFECT_IDS = new Set(Object.values(iMessageEffect));
159
+ var EFFECT_NAMES = Object.keys(iMessageEffect);
160
+ function resolveEffect(effect) {
161
+ if (effect in iMessageEffect) {
162
+ return iMessageEffect[effect];
163
+ }
164
+ if (EFFECT_IDS.has(effect)) {
165
+ return effect;
166
+ }
167
+ throw new ValidationError3(
168
+ "imessage",
169
+ `Unknown iMessage effect: "${effect}". Supported: ${EFFECT_NAMES.join(", ")}`
170
+ );
171
+ }
172
+
54
173
  // src/internal/cache.ts
55
174
  var DEFAULT_MAX_SPACES = 256;
56
175
  var DEFAULT_MAX_MESSAGES = 1024;
57
176
  var InboundCache = class {
58
177
  spaces = /* @__PURE__ */ new Map();
59
178
  messages = /* @__PURE__ */ new Map();
179
+ /** Sending line last seen per chat GUID — a hint for rebuilding an uncached
180
+ * Space on the right line when several are configured. */
181
+ phones = /* @__PURE__ */ new Map();
182
+ /**
183
+ * Reaction messages this session sent via `addReaction`, keyed by their
184
+ * target so `removeReaction` can `unsend()` them. spectrum-ts models a
185
+ * tapback as its own message, and the only handle to retract one is the
186
+ * `Message` that `react()` returns — there is no by-target removal API.
187
+ */
188
+ reactions = /* @__PURE__ */ new Map();
60
189
  maxSpaces;
61
190
  maxMessages;
62
191
  constructor(maxSpaces = DEFAULT_MAX_SPACES, maxMessages = DEFAULT_MAX_MESSAGES) {
@@ -84,10 +213,38 @@ var InboundCache = class {
84
213
  getSpace(chatGuid) {
85
214
  return this.spaces.get(chatGuid);
86
215
  }
216
+ /** Record the sending line seen for a chat GUID, if the delivery carried one. */
217
+ rememberPhone(chatGuid, phone) {
218
+ if (!phone) {
219
+ return;
220
+ }
221
+ this.phones.set(chatGuid, phone);
222
+ evict(this.phones, this.maxSpaces);
223
+ }
224
+ getPhone(chatGuid) {
225
+ return this.phones.get(chatGuid);
226
+ }
87
227
  getMessage(id) {
88
228
  return this.messages.get(id);
89
229
  }
230
+ /** Record the reaction message returned by `react()` for later removal. */
231
+ rememberReaction(targetMessageId, glyph, reaction) {
232
+ this.reactions.set(reactionKey(targetMessageId, glyph), reaction);
233
+ evict(this.reactions, this.maxMessages);
234
+ }
235
+ /** Look up (and forget) a tracked reaction so it can be `unsend()`-ed once. */
236
+ takeReaction(targetMessageId, glyph) {
237
+ const key = reactionKey(targetMessageId, glyph);
238
+ const reaction = this.reactions.get(key);
239
+ if (reaction) {
240
+ this.reactions.delete(key);
241
+ }
242
+ return reaction;
243
+ }
90
244
  };
245
+ function reactionKey(targetMessageId, glyph) {
246
+ return `${targetMessageId}::${glyph}`;
247
+ }
91
248
  function evict(map, max) {
92
249
  if (map.size <= max) {
93
250
  return;
@@ -168,26 +325,31 @@ var MessagePump = class {
168
325
  import { Message, parseMarkdown } from "chat";
169
326
 
170
327
  // src/internal/thread.ts
171
- import { ValidationError as ValidationError2 } from "@chat-adapter/shared";
328
+ import { ValidationError as ValidationError4 } from "@chat-adapter/shared";
172
329
  var THREAD_PREFIX = "imessage:";
330
+ var LINE_SEP = "~";
173
331
  function encodeThreadId(platformData) {
174
- return `${THREAD_PREFIX}${platformData.chatGuid}`;
332
+ const { chatGuid, phone } = platformData;
333
+ return phone ? `${THREAD_PREFIX}${chatGuid}${LINE_SEP}${phone}` : `${THREAD_PREFIX}${chatGuid}`;
175
334
  }
176
335
  function decodeThreadId(threadId) {
177
336
  if (!threadId.startsWith(THREAD_PREFIX)) {
178
- throw new ValidationError2(
337
+ throw new ValidationError4(
179
338
  "imessage",
180
339
  `Invalid iMessage thread ID: ${threadId}`
181
340
  );
182
341
  }
183
- const chatGuid = threadId.slice(THREAD_PREFIX.length);
342
+ const rest = threadId.slice(THREAD_PREFIX.length);
343
+ const sepIndex = rest.indexOf(LINE_SEP);
344
+ const chatGuid = sepIndex === -1 ? rest : rest.slice(0, sepIndex);
345
+ const phone = sepIndex === -1 ? void 0 : rest.slice(sepIndex + 1);
184
346
  if (!chatGuid) {
185
- throw new ValidationError2(
347
+ throw new ValidationError4(
186
348
  "imessage",
187
349
  `Invalid iMessage thread ID: ${threadId} (empty chat GUID)`
188
350
  );
189
351
  }
190
- return { chatGuid };
352
+ return phone ? { chatGuid, phone } : { chatGuid };
191
353
  }
192
354
  function isDMChatGuid(chatGuid) {
193
355
  return chatGuid.includes(";-;");
@@ -198,7 +360,7 @@ function buildChatMessageFromFields(fields) {
198
360
  const text = extractText(fields.content);
199
361
  return new Message({
200
362
  id: fields.id,
201
- threadId: encodeThreadId({ chatGuid: fields.chatGuid }),
363
+ threadId: encodeThreadId({ chatGuid: fields.chatGuid, phone: fields.phone }),
202
364
  text,
203
365
  formatted: parseMarkdown(text),
204
366
  author: {
@@ -226,6 +388,7 @@ function buildChatMessage(message, space) {
226
388
  content: message.content,
227
389
  senderId: message.sender?.id ?? "",
228
390
  isOutbound: message.direction === "outbound",
391
+ phone: space.phone,
229
392
  timestamp: message.timestamp,
230
393
  raw: message
231
394
  });
@@ -317,8 +480,8 @@ function titleKey(chatGuid, title) {
317
480
  }
318
481
 
319
482
  // src/internal/outbound.ts
320
- import { ValidationError as ValidationError3 } from "@chat-adapter/shared";
321
- import { lookup as lookupMimeType } from "mime-types";
483
+ import { ValidationError as ValidationError5 } from "@chat-adapter/shared";
484
+ import { lookup as lookupMimeType2 } from "mime-types";
322
485
  import { attachment } from "spectrum-ts";
323
486
  var EMOJI_GLYPHS = {
324
487
  heart: "\u2764\uFE0F",
@@ -336,7 +499,7 @@ function emojiToGlyph(emoji) {
336
499
  const name = typeof emoji === "string" ? emoji : emoji.name;
337
500
  const glyph = EMOJI_GLYPHS[name];
338
501
  if (!glyph) {
339
- throw new ValidationError3(
502
+ throw new ValidationError5(
340
503
  "imessage",
341
504
  `Unsupported iMessage tapback: "${name}". Supported: heart, thumbs_up, thumbs_down, laugh, emphasize, question`
342
505
  );
@@ -354,7 +517,7 @@ async function fileToAttachment(file) {
354
517
  buffer = Buffer.from(data);
355
518
  }
356
519
  const name = file.filename || "attachment";
357
- const mimeType = file.mimeType || lookupMimeType(name) || "application/octet-stream";
520
+ const mimeType = file.mimeType || lookupMimeType2(name) || "application/octet-stream";
358
521
  return attachment(buffer, { name, mimeType });
359
522
  }
360
523
 
@@ -390,6 +553,7 @@ function buildChatMessageFromWebhook(message, space) {
390
553
  content: message.content,
391
554
  senderId: message.sender?.id ?? "",
392
555
  isOutbound: message.direction === "outbound",
556
+ phone: space.phone ?? message.space?.phone,
393
557
  timestamp: message.timestamp ? new Date(message.timestamp) : /* @__PURE__ */ new Date(),
394
558
  raw: message
395
559
  });
@@ -411,7 +575,8 @@ import {
411
575
  isParagraphNode,
412
576
  isStrongNode,
413
577
  isTextNode,
414
- parseMarkdown as parseMarkdown2
578
+ parseMarkdown as parseMarkdown2,
579
+ stringifyMarkdown
415
580
  } from "chat";
416
581
  var iMessageFormatConverter = class extends BaseFormatConverter {
417
582
  /**
@@ -431,6 +596,30 @@ var iMessageFormatConverter = class extends BaseFormatConverter {
431
596
  toAst(text) {
432
597
  return parseMarkdown2(text);
433
598
  }
599
+ /**
600
+ * Decide how a postable message should reach spectrum-ts.
601
+ *
602
+ * Markdown-typed inputs -- `{ markdown }` and `{ ast }` -- carry CommonMark
603
+ * the caller wants styled, so their source is preserved verbatim and flagged
604
+ * `markdown: true`; the adapter sends it via spectrum's `markdown()` builder,
605
+ * which renders bold/italic/links/lists as native iMessage styled text.
606
+ *
607
+ * Everything else is pass-through-as-is by contract -- a plain `string` or
608
+ * `{ raw }` must not have stray `*`/`_` reinterpreted as formatting, and
609
+ * cards fall back to plain text -- so those are rendered to plain text and
610
+ * flagged `markdown: false`.
611
+ */
612
+ renderPostableContent(message) {
613
+ if (message && typeof message === "object") {
614
+ if ("markdown" in message && typeof message.markdown === "string") {
615
+ return { body: message.markdown, markdown: true };
616
+ }
617
+ if ("ast" in message && message.ast) {
618
+ return { body: stringifyMarkdown(message.ast), markdown: true };
619
+ }
620
+ }
621
+ return { body: this.renderPostable(message), markdown: false };
622
+ }
434
623
  nodeToPlainText(node) {
435
624
  if (isParagraphNode(node)) {
436
625
  return getNodeChildren(node).map((child) => this.nodeToPlainText(child)).join("");
@@ -478,6 +667,169 @@ var iMessageFormatConverter = class extends BaseFormatConverter {
478
667
  }
479
668
  };
480
669
 
670
+ // src/miniapp.ts
671
+ import { ValidationError as ValidationError6 } from "@chat-adapter/shared";
672
+ async function toImageBytes(image) {
673
+ if (image instanceof Uint8Array) {
674
+ return new Uint8Array(image);
675
+ }
676
+ if (image instanceof ArrayBuffer) {
677
+ return new Uint8Array(image);
678
+ }
679
+ if (image instanceof Blob) {
680
+ return new Uint8Array(await image.arrayBuffer());
681
+ }
682
+ const data = image.data;
683
+ if (data === void 0) {
684
+ throw new ValidationError6(
685
+ "imessage",
686
+ "Mini-app card image must be a Uint8Array, ArrayBuffer, Blob, or FileUpload"
687
+ );
688
+ }
689
+ return toImageBytes(data);
690
+ }
691
+ function requireField(value, field) {
692
+ if (!value || value.trim().length === 0) {
693
+ throw new ValidationError6(
694
+ "imessage",
695
+ `Mini-app card requires a non-empty "${field}"`
696
+ );
697
+ }
698
+ return value;
699
+ }
700
+ async function resolveMiniApp(card) {
701
+ const url = typeof card.url === "string" ? card.url : card.url.toString();
702
+ try {
703
+ new URL(url);
704
+ } catch {
705
+ throw new ValidationError6(
706
+ "imessage",
707
+ `Mini-app card has an invalid url: "${url}"`
708
+ );
709
+ }
710
+ const layout = card.layout ?? {};
711
+ const image = layout.image ? await toImageBytes(layout.image) : void 0;
712
+ return {
713
+ appName: requireField(card.appName, "appName"),
714
+ teamId: requireField(card.teamId, "teamId"),
715
+ extensionBundleId: requireField(
716
+ card.extensionBundleId,
717
+ "extensionBundleId"
718
+ ),
719
+ url,
720
+ ...card.appStoreId === void 0 ? {} : { appStoreId: card.appStoreId },
721
+ layout: {
722
+ caption: layout.caption,
723
+ subcaption: layout.subcaption,
724
+ trailingCaption: layout.trailingCaption,
725
+ trailingSubcaption: layout.trailingSubcaption,
726
+ imageTitle: layout.imageTitle,
727
+ imageSubtitle: layout.imageSubtitle,
728
+ summary: layout.summary,
729
+ ...image ? { image } : {}
730
+ }
731
+ };
732
+ }
733
+ function isAppUrl(input) {
734
+ return typeof input === "string" || typeof input === "function" || typeof input === "object" && input !== null && typeof input.then === "function";
735
+ }
736
+
737
+ // src/voice.ts
738
+ import { ValidationError as ValidationError7 } from "@chat-adapter/shared";
739
+ import { lookup as lookupMimeType3 } from "mime-types";
740
+ import { voice as voiceContent } from "spectrum-ts";
741
+ var AUDIO_MIME_PATTERN = /^audio\//i;
742
+ async function toBytes2(input) {
743
+ if (input instanceof Uint8Array) {
744
+ return { bytes: Buffer.from(input) };
745
+ }
746
+ if (input instanceof ArrayBuffer) {
747
+ return { bytes: Buffer.from(input) };
748
+ }
749
+ if (input instanceof Blob) {
750
+ return {
751
+ bytes: Buffer.from(await input.arrayBuffer()),
752
+ mimeHint: input.type || void 0
753
+ };
754
+ }
755
+ const data = input.data;
756
+ if (data === void 0) {
757
+ throw new ValidationError7(
758
+ "imessage",
759
+ "Voice message must be a Uint8Array, ArrayBuffer, Blob, or FileUpload"
760
+ );
761
+ }
762
+ const nested = await toBytes2(data);
763
+ return {
764
+ bytes: nested.bytes,
765
+ mimeHint: input.mimeType || nested.mimeHint,
766
+ nameHint: input.filename || nested.nameHint
767
+ };
768
+ }
769
+ function toVoiceUrl(input) {
770
+ if (input instanceof URL) {
771
+ return input;
772
+ }
773
+ if (typeof input !== "string") {
774
+ return;
775
+ }
776
+ let url;
777
+ try {
778
+ url = new URL(input);
779
+ } catch {
780
+ throw new ValidationError7(
781
+ "imessage",
782
+ `Voice message string input must be an http(s) URL, got "${input}". Pass audio bytes for local files.`
783
+ );
784
+ }
785
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
786
+ throw new ValidationError7(
787
+ "imessage",
788
+ `Voice message URL must be http(s), got "${url.protocol}"`
789
+ );
790
+ }
791
+ return url;
792
+ }
793
+ function resolveBytesMime2(hint, name) {
794
+ if (hint && AUDIO_MIME_PATTERN.test(hint)) {
795
+ return hint;
796
+ }
797
+ if (name) {
798
+ const inferred = lookupMimeType3(name);
799
+ if (inferred && AUDIO_MIME_PATTERN.test(inferred)) {
800
+ return inferred;
801
+ }
802
+ }
803
+ throw new ValidationError7(
804
+ "imessage",
805
+ hint ? `Voice message requires an audio/* MIME type, got "${hint}"` : 'Voice message requires an audio/* MIME type \u2014 pass options.mimeType (e.g. "audio/mp4") or a name with an audio extension'
806
+ );
807
+ }
808
+ async function resolveVoice(input, options = {}) {
809
+ if (options.mimeType && !AUDIO_MIME_PATTERN.test(options.mimeType)) {
810
+ throw new ValidationError7(
811
+ "imessage",
812
+ `Voice message requires an audio/* MIME type, got "${options.mimeType}"`
813
+ );
814
+ }
815
+ const url = toVoiceUrl(input);
816
+ if (url) {
817
+ return voiceContent(url, {
818
+ ...options.mimeType ? { mimeType: options.mimeType } : {},
819
+ ...options.name ? { name: options.name } : {},
820
+ ...options.duration === void 0 ? {} : { duration: options.duration }
821
+ });
822
+ }
823
+ const { bytes, mimeHint, nameHint } = await toBytes2(input);
824
+ const name = options.name ?? nameHint;
825
+ const mimeType = resolveBytesMime2(options.mimeType ?? mimeHint, name);
826
+ return voiceContent(bytes, {
827
+ mimeType,
828
+ ...name ? { name } : {},
829
+ ...options.duration === void 0 ? {} : { duration: options.duration }
830
+ });
831
+ }
832
+
481
833
  // src/adapter.ts
482
834
  var TYPING_DURATION_MS = 3e3;
483
835
  var iMessageAdapter = class {
@@ -491,8 +843,10 @@ var iMessageAdapter = class {
491
843
  clients;
492
844
  phone;
493
845
  webhookSecret;
494
- /** The spectrum-ts instance — null until `initialize()` runs. */
846
+ /** The spectrum-ts instance — null until `initialize()` or `ensureApp()` runs. */
495
847
  app = null;
848
+ /** In-flight app build, so concurrent callers share one construction. */
849
+ appBuild = null;
496
850
  chat = null;
497
851
  logger;
498
852
  formatConverter = new iMessageFormatConverter();
@@ -502,7 +856,7 @@ var iMessageAdapter = class {
502
856
  pump = null;
503
857
  constructor(config) {
504
858
  if (config.local && process.platform !== "darwin") {
505
- throw new ValidationError4(
859
+ throw new ValidationError8(
506
860
  "imessage",
507
861
  "iMessage adapter local mode requires macOS. Current platform: " + process.platform
508
862
  );
@@ -521,6 +875,34 @@ var iMessageAdapter = class {
521
875
  }
522
876
  async initialize(chat) {
523
877
  this.chat = chat;
878
+ await this.ensureApp();
879
+ this.pump = new MessagePump(
880
+ () => {
881
+ if (!this.app) {
882
+ throw new Error("Adapter not initialized");
883
+ }
884
+ return this.app.messages;
885
+ },
886
+ (space, message) => this.routeInbound(space, message, this.gatewayOptions),
887
+ this.logger
888
+ );
889
+ }
890
+ /**
891
+ * Build the spectrum-ts app on demand. eve may call the adapter in an
892
+ * invocation that never ran `initialize()` (e.g. a Vercel Workflow reply
893
+ * callback), leaving `this.app` null — every send funnels through here first.
894
+ */
895
+ async ensureApp() {
896
+ if (this.app) {
897
+ return;
898
+ }
899
+ this.appBuild ??= this.buildApp().catch((error) => {
900
+ this.appBuild = null;
901
+ throw error;
902
+ });
903
+ await this.appBuild;
904
+ }
905
+ async buildApp() {
524
906
  const { providerConfig, projectId, projectSecret } = resolveSpectrumConfig(
525
907
  this.local,
526
908
  {
@@ -532,18 +914,8 @@ var iMessageAdapter = class {
532
914
  serverUrl: this.serverUrl
533
915
  }
534
916
  );
535
- const providers = [imessage.config(providerConfig)];
917
+ const providers = [imessage2.config(providerConfig)];
536
918
  this.app = projectId && projectSecret ? await Spectrum({ providers, projectId, projectSecret }) : await Spectrum({ providers });
537
- this.pump = new MessagePump(
538
- () => {
539
- if (!this.app) {
540
- throw new Error("Adapter not initialized");
541
- }
542
- return this.app.messages;
543
- },
544
- (space, message) => this.routeInbound(space, message, this.gatewayOptions),
545
- this.logger
546
- );
547
919
  let mode;
548
920
  if (this.local) {
549
921
  mode = "local";
@@ -612,26 +984,152 @@ var iMessageAdapter = class {
612
984
  this.routeWebhookMessage(payload, options);
613
985
  return new Response(null, { status: 200 });
614
986
  }
987
+ /**
988
+ * Build the spectrum content for an outbound message. Markdown-typed inputs
989
+ * are sent via `markdown()` so remote iMessage renders them as native styled
990
+ * text; raw/string/card inputs stay plain `text()`. Returns the rendered
991
+ * `body` too so callers can skip an empty send.
992
+ */
993
+ toSpectrumContent(message) {
994
+ const { body, markdown } = this.formatConverter.renderPostableContent(message);
995
+ return {
996
+ body,
997
+ content: markdown ? markdownContent(body) : textContent(body)
998
+ };
999
+ }
615
1000
  async postMessage(threadId, message) {
616
1001
  const space = await this.requireSpace(threadId, "postMessage");
617
- const body = this.formatConverter.renderPostable(message);
1002
+ const { body, content } = this.toSpectrumContent(message);
618
1003
  const files = extractFiles(message);
619
1004
  let first;
620
1005
  if (body && body.trim().length > 0) {
621
- first = await space.send(textContent(body)) ?? first;
1006
+ first = await space.send(content) ?? first;
622
1007
  }
623
1008
  for (const file of files) {
624
1009
  const sent = await space.send(await fileToAttachment(file)) ?? void 0;
625
1010
  first ??= sent;
626
1011
  }
627
1012
  if (!first) {
628
- throw new ValidationError4(
1013
+ throw new ValidationError8(
629
1014
  "imessage",
630
1015
  "postMessage requires non-empty text or at least one attachment"
631
1016
  );
632
1017
  }
633
1018
  return { id: first.id, threadId, raw: first };
634
1019
  }
1020
+ /**
1021
+ * Send a message with an iMessage expressive-send effect — a bubble effect
1022
+ * (`slam`, `loud`, `gentle`, `invisible`) or a full-screen effect
1023
+ * (`confetti`, `fireworks`, `balloons`, `heart`, `lasers`, `celebration`,
1024
+ * `sparkles`, `spotlight`, `echo`). Not part of the Chat SDK `Adapter`
1025
+ * interface — exposed as an adapter-specific extra (e.g. celebratory confetti
1026
+ * on task completion). Remote only.
1027
+ *
1028
+ * The `effect` argument accepts a friendly name (`"confetti"`) or a value from
1029
+ * the re-exported `iMessageEffect` map. Effects attach to text content only,
1030
+ * so this requires non-empty text.
1031
+ */
1032
+ async sendEffect(threadId, message, effect) {
1033
+ if (this.local) {
1034
+ throw new NotImplementedError(
1035
+ "sendEffect is not supported in local mode",
1036
+ "sendEffect"
1037
+ );
1038
+ }
1039
+ const space = await this.requireSpace(threadId, "sendEffect");
1040
+ const { body, content } = this.toSpectrumContent(message);
1041
+ if (!body || body.trim().length === 0) {
1042
+ throw new ValidationError8(
1043
+ "imessage",
1044
+ "sendEffect requires non-empty text content"
1045
+ );
1046
+ }
1047
+ const effectId = resolveEffect(effect);
1048
+ const sent = await space.send(effectContent(content, effectId));
1049
+ if (!sent) {
1050
+ throw new ValidationError8(
1051
+ "imessage",
1052
+ "sendEffect could not send the message"
1053
+ );
1054
+ }
1055
+ return { id: sent.id, threadId, raw: sent };
1056
+ }
1057
+ async sendMiniApp(threadId, input) {
1058
+ if (this.local) {
1059
+ throw new NotImplementedError(
1060
+ "sendMiniApp is not supported in local mode",
1061
+ "sendMiniApp"
1062
+ );
1063
+ }
1064
+ const space = await this.requireSpace(threadId, "sendMiniApp");
1065
+ const content = isAppUrl(input) ? appContent(input) : customizedMiniApp(await resolveMiniApp(input));
1066
+ const sent = await space.send(content);
1067
+ if (!sent) {
1068
+ throw new ValidationError8(
1069
+ "imessage",
1070
+ "sendMiniApp could not send the card"
1071
+ );
1072
+ }
1073
+ return { id: sent.id, threadId, raw: sent };
1074
+ }
1075
+ /**
1076
+ * Send a native iMessage voice note — a real, playable waveform bubble (the
1077
+ * message renders with `isAudioMessage`), not an audio file dropped in as an
1078
+ * attachment. A natural fit for TTS-capable bots that reply with speech. Not
1079
+ * part of the Chat SDK `Adapter` interface — exposed as an adapter-specific
1080
+ * extra. Remote only.
1081
+ *
1082
+ * The `input` is either in-memory audio bytes (`Uint8Array` / `Buffer` /
1083
+ * `ArrayBuffer`, a `Blob`, or a Chat SDK `FileUpload`) or an `http(s)` URL (a
1084
+ * `URL` or a string) that is fetched at send time. Audio bytes need an
1085
+ * `audio/*` MIME type — supply `options.mimeType` (e.g. `"audio/mp4"`) or an
1086
+ * `options.name` with an audio extension when it can't be inferred.
1087
+ */
1088
+ async sendVoice(threadId, input, options) {
1089
+ if (this.local) {
1090
+ throw new NotImplementedError(
1091
+ "sendVoice is not supported in local mode",
1092
+ "sendVoice"
1093
+ );
1094
+ }
1095
+ const space = await this.requireSpace(threadId, "sendVoice");
1096
+ const content = await resolveVoice(input, options);
1097
+ const sent = await space.send(content);
1098
+ if (!sent) {
1099
+ throw new ValidationError8(
1100
+ "imessage",
1101
+ "sendVoice could not send the voice message"
1102
+ );
1103
+ }
1104
+ return { id: sent.id, threadId, raw: sent };
1105
+ }
1106
+ /**
1107
+ * Set or clear the chat background — the wallpaper behind a conversation, an
1108
+ * iMessage-only touch with no analog on the plain-text competitors. Not part
1109
+ * of the Chat SDK `Adapter` interface — exposed as an adapter-specific extra.
1110
+ * Remote only.
1111
+ *
1112
+ * The `input` is either the literal `"clear"` (to remove the current
1113
+ * background), in-memory image bytes (`Uint8Array` / `Buffer` / `ArrayBuffer`,
1114
+ * a `Blob`, or a Chat SDK `FileUpload`), or an `http(s)` URL (a `URL` or a
1115
+ * string) that is fetched at send time. Image bytes need an `image/*` MIME
1116
+ * type — supply `options.mimeType` (e.g. `"image/jpeg"`) or an `options.name`
1117
+ * with an image extension when it can't be inferred.
1118
+ *
1119
+ * Fire-and-forget: iMessage acknowledges the control signal without returning
1120
+ * a message, so this resolves to `void` rather than a {@link RawMessage}.
1121
+ */
1122
+ async setBackground(threadId, input, options) {
1123
+ if (this.local) {
1124
+ throw new NotImplementedError(
1125
+ "setBackground is not supported in local mode",
1126
+ "setBackground"
1127
+ );
1128
+ }
1129
+ const space = await this.requireSpace(threadId, "setBackground");
1130
+ const content = await resolveBackground(input, options);
1131
+ await space.send(content);
1132
+ }
635
1133
  async editMessage(threadId, messageId, message) {
636
1134
  if (this.local) {
637
1135
  throw new NotImplementedError(
@@ -646,21 +1144,42 @@ var iMessageAdapter = class {
646
1144
  "editMessage"
647
1145
  );
648
1146
  }
649
- await target.edit(
650
- textContent(this.formatConverter.renderPostable(message))
651
- );
1147
+ await target.edit(this.toSpectrumContent(message).content);
652
1148
  return { id: messageId, threadId, raw: target };
653
1149
  }
654
- async deleteMessage(_threadId, _messageId) {
655
- throw new NotImplementedError(
656
- "deleteMessage is not implemented",
657
- "deleteMessage"
658
- );
1150
+ async deleteMessage(threadId, messageId) {
1151
+ if (this.local) {
1152
+ throw new NotImplementedError(
1153
+ "deleteMessage is not supported in local mode",
1154
+ "deleteMessage"
1155
+ );
1156
+ }
1157
+ const target = await this.resolveMessage(threadId, messageId);
1158
+ if (!target) {
1159
+ throw new NotImplementedError(
1160
+ "deleteMessage requires the target message to have been sent or received in this session",
1161
+ "deleteMessage"
1162
+ );
1163
+ }
1164
+ await target.unsend();
659
1165
  }
660
1166
  parseMessage(raw) {
661
1167
  const message = raw;
662
1168
  return buildChatMessage(message, message.space);
663
1169
  }
1170
+ /**
1171
+ * Fetch a single message by id. spectrum-ts can resolve a message by id
1172
+ * (from the inbound cache or the provider's by-id lookup) even though it has
1173
+ * no paginated history API, so single-message reads work where
1174
+ * `fetchMessages` cannot. Returns `null` when the message can't be resolved.
1175
+ */
1176
+ async fetchMessage(threadId, messageId) {
1177
+ const target = await this.resolveMessage(threadId, messageId);
1178
+ if (!target) {
1179
+ return null;
1180
+ }
1181
+ return this.parseMessage(target);
1182
+ }
664
1183
  async fetchMessages(_threadId, _options) {
665
1184
  throw new NotImplementedError(
666
1185
  "fetchMessages (message history) is not supported by spectrum-ts",
@@ -691,13 +1210,27 @@ var iMessageAdapter = class {
691
1210
  "addReaction"
692
1211
  );
693
1212
  }
694
- await target.react(glyph);
1213
+ const reaction = await target.react(glyph);
1214
+ if (reaction) {
1215
+ this.cache.rememberReaction(messageId, glyph, reaction);
1216
+ }
695
1217
  }
696
- async removeReaction(_threadId, _messageId, _emoji) {
697
- throw new NotImplementedError(
698
- "removeReaction is not supported (spectrum-ts has no reaction-removal API)",
699
- "removeReaction"
700
- );
1218
+ async removeReaction(_threadId, messageId, emoji) {
1219
+ if (this.local) {
1220
+ throw new NotImplementedError(
1221
+ "removeReaction is not supported in local mode",
1222
+ "removeReaction"
1223
+ );
1224
+ }
1225
+ const glyph = emojiToGlyph(emoji);
1226
+ const reaction = this.cache.takeReaction(messageId, glyph);
1227
+ if (!reaction) {
1228
+ throw new NotImplementedError(
1229
+ "removeReaction requires the reaction to have been added via addReaction in this session",
1230
+ "removeReaction"
1231
+ );
1232
+ }
1233
+ await reaction.unsend();
701
1234
  }
702
1235
  async startTyping(threadId, _status) {
703
1236
  if (this.local) {
@@ -713,6 +1246,48 @@ var iMessageAdapter = class {
713
1246
  });
714
1247
  }, TYPING_DURATION_MS);
715
1248
  }
1249
+ /**
1250
+ * Cold-start a DM with a phone number / handle. spectrum-ts resolves (or
1251
+ * creates) the 1:1 conversation from the participant via `space.create`, so
1252
+ * the bot can message a user it has never received from. Returns the encoded
1253
+ * thread id, ready to pass to `postMessage`.
1254
+ */
1255
+ async openDM(userId) {
1256
+ await this.ensureApp();
1257
+ if (!this.app) {
1258
+ throw new NotImplementedError(
1259
+ "openDM requires the adapter to be initialized",
1260
+ "openDM"
1261
+ );
1262
+ }
1263
+ const space = await this.platformSpaces().create(userId);
1264
+ this.cache.rememberSpace(space);
1265
+ return encodeThreadId({
1266
+ chatGuid: space.id,
1267
+ phone: space.phone
1268
+ });
1269
+ }
1270
+ /**
1271
+ * Mark a received message (and the conversation up to it) as read, surfacing
1272
+ * a read receipt where iMessage supports one. Remote only; not part of the
1273
+ * Chat SDK `Adapter` interface — exposed as an adapter-specific extra.
1274
+ */
1275
+ async markRead(threadId, messageId) {
1276
+ if (this.local) {
1277
+ throw new NotImplementedError(
1278
+ "markRead is not supported in local mode",
1279
+ "markRead"
1280
+ );
1281
+ }
1282
+ const target = await this.resolveMessage(threadId, messageId);
1283
+ if (!target) {
1284
+ throw new NotImplementedError(
1285
+ "markRead requires the target message to have been received in this session",
1286
+ "markRead"
1287
+ );
1288
+ }
1289
+ await target.read();
1290
+ }
716
1291
  async openModal(triggerId, modal, contextId) {
717
1292
  if (this.local) {
718
1293
  throw new NotImplementedError(
@@ -724,14 +1299,14 @@ var iMessageAdapter = class {
724
1299
  (c) => c.type === "select"
725
1300
  );
726
1301
  if (!select) {
727
- throw new ValidationError4(
1302
+ throw new ValidationError8(
728
1303
  "imessage",
729
1304
  "openModal requires at least one Select child \u2014 iMessage modals map to native polls"
730
1305
  );
731
1306
  }
732
1307
  const labels = select.options.map((o) => o.label);
733
1308
  if (labels.length < 2 || labels.length > 10) {
734
- throw new ValidationError4(
1309
+ throw new ValidationError8(
735
1310
  "imessage",
736
1311
  `iMessage polls require between 2 and 10 options, received ${labels.length}`
737
1312
  );
@@ -813,6 +1388,7 @@ var iMessageAdapter = class {
813
1388
  return;
814
1389
  }
815
1390
  const { message, space } = payload;
1391
+ this.cache.rememberPhone(space.id, space.phone ?? message.space?.phone);
816
1392
  if (message.direction === "outbound") {
817
1393
  return;
818
1394
  }
@@ -887,29 +1463,25 @@ var iMessageAdapter = class {
887
1463
  );
888
1464
  }
889
1465
  /**
890
- * Resolve a sendable spectrum-ts `Space` for a thread.
891
- *
892
- * Prefers a live `Space` cached from the inbound stream (correct sending
893
- * line, no extra round-trip). On a miss — e.g. a webhook-only deployment, or
894
- * a cold send — it rebuilds the Space from the chat GUID via
895
- * `imessage(app).space.get(chatGuid)`, which works for DMs and groups alike.
896
- * The rebuild can still fail when several iMessage lines are configured and
897
- * spectrum-ts cannot infer which line the chat belongs to (`space.get`
898
- * requires `params.phone` there). Returns `undefined` when no Space can be
899
- * obtained.
1466
+ * Resolve a sendable spectrum-ts `Space` for a thread. Prefers a cached live
1467
+ * Space; otherwise rebuilds it from the chat GUID, passing the sending line
1468
+ * so `space.get` can pick it when multiple lines are configured. Returns
1469
+ * `undefined` when no Space can be obtained.
900
1470
  */
901
1471
  async resolveSpace(threadId) {
902
- const { chatGuid } = decodeThreadId(threadId);
1472
+ const { chatGuid, phone: threadPhone } = decodeThreadId(threadId);
903
1473
  const cached = this.cache.getSpace(chatGuid);
904
1474
  if (cached) {
905
1475
  return cached;
906
1476
  }
1477
+ await this.ensureApp();
907
1478
  if (!this.app) {
908
1479
  return;
909
1480
  }
910
- const platform = imessage(this.app);
911
1481
  try {
912
- const space = await platform.space.get(chatGuid);
1482
+ const phone = threadPhone ?? this.cache.getPhone(chatGuid);
1483
+ const spaces = this.platformSpaces();
1484
+ const space = phone ? await spaces.get(chatGuid, { phone }) : await spaces.get(chatGuid);
913
1485
  this.cache.rememberSpace(space);
914
1486
  return space;
915
1487
  } catch (error) {
@@ -920,6 +1492,17 @@ var iMessageAdapter = class {
920
1492
  return;
921
1493
  }
922
1494
  }
1495
+ /**
1496
+ * The iMessage provider's Space namespace (`get` / `create`). `HasProvider`
1497
+ * over the default provider tuple won't narrow to `true`, so `imessage(app)`
1498
+ * types as `never` — cast to the slice of the instance we use.
1499
+ */
1500
+ platformSpaces() {
1501
+ if (!this.app) {
1502
+ throw new Error("Adapter not initialized");
1503
+ }
1504
+ return imessage2(this.app).space;
1505
+ }
923
1506
  async requireSpace(threadId, action) {
924
1507
  const space = await this.resolveSpace(threadId);
925
1508
  if (!space) {
@@ -950,7 +1533,7 @@ function toClientArray(clients) {
950
1533
  }
951
1534
 
952
1535
  // src/factory.ts
953
- import { ValidationError as ValidationError5 } from "@chat-adapter/shared";
1536
+ import { ValidationError as ValidationError9 } from "@chat-adapter/shared";
954
1537
  import { ConsoleLogger } from "chat";
955
1538
  function resolveLocalMode(explicit, hasRemoteCreds) {
956
1539
  if (explicit !== void 0) {
@@ -970,13 +1553,13 @@ function assertRemoteCredentials(creds) {
970
1553
  return;
971
1554
  }
972
1555
  if (!creds.hasServerUrl) {
973
- throw new ValidationError5(
1556
+ throw new ValidationError9(
974
1557
  "imessage",
975
1558
  "serverUrl is required when local is false. Set IMESSAGE_SERVER_URL (or use IMESSAGE_PROJECT_ID/IMESSAGE_PROJECT_SECRET for Spectrum Cloud), or provide it in config."
976
1559
  );
977
1560
  }
978
1561
  if (!creds.hasApiKey) {
979
- throw new ValidationError5(
1562
+ throw new ValidationError9(
980
1563
  "imessage",
981
1564
  "apiKey is required when local is false. Set IMESSAGE_API_KEY or provide it in config."
982
1565
  );
@@ -1016,6 +1599,12 @@ export {
1016
1599
  createiMessageAdapter,
1017
1600
  deriveAddress,
1018
1601
  iMessageAdapter,
1019
- iMessageFormatConverter
1602
+ iMessageEffect,
1603
+ iMessageFormatConverter,
1604
+ isAppUrl,
1605
+ resolveBackground,
1606
+ resolveEffect,
1607
+ resolveMiniApp,
1608
+ resolveVoice
1020
1609
  };
1021
1610
  //# sourceMappingURL=index.js.map