@photon-ai/chat-adapter-imessage 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js 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,44 @@ 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
+ /**
180
+ * Reaction messages this session sent via `addReaction`, keyed by their
181
+ * target so `removeReaction` can `unsend()` them. spectrum-ts models a
182
+ * tapback as its own message, and the only handle to retract one is the
183
+ * `Message` that `react()` returns — there is no by-target removal API.
184
+ */
185
+ reactions = /* @__PURE__ */ new Map();
60
186
  maxSpaces;
61
187
  maxMessages;
62
188
  constructor(maxSpaces = DEFAULT_MAX_SPACES, maxMessages = DEFAULT_MAX_MESSAGES) {
@@ -87,7 +213,24 @@ var InboundCache = class {
87
213
  getMessage(id) {
88
214
  return this.messages.get(id);
89
215
  }
216
+ /** Record the reaction message returned by `react()` for later removal. */
217
+ rememberReaction(targetMessageId, glyph, reaction) {
218
+ this.reactions.set(reactionKey(targetMessageId, glyph), reaction);
219
+ evict(this.reactions, this.maxMessages);
220
+ }
221
+ /** Look up (and forget) a tracked reaction so it can be `unsend()`-ed once. */
222
+ takeReaction(targetMessageId, glyph) {
223
+ const key = reactionKey(targetMessageId, glyph);
224
+ const reaction = this.reactions.get(key);
225
+ if (reaction) {
226
+ this.reactions.delete(key);
227
+ }
228
+ return reaction;
229
+ }
90
230
  };
231
+ function reactionKey(targetMessageId, glyph) {
232
+ return `${targetMessageId}::${glyph}`;
233
+ }
91
234
  function evict(map, max) {
92
235
  if (map.size <= max) {
93
236
  return;
@@ -168,21 +311,21 @@ var MessagePump = class {
168
311
  import { Message, parseMarkdown } from "chat";
169
312
 
170
313
  // src/internal/thread.ts
171
- import { ValidationError as ValidationError2 } from "@chat-adapter/shared";
314
+ import { ValidationError as ValidationError4 } from "@chat-adapter/shared";
172
315
  var THREAD_PREFIX = "imessage:";
173
316
  function encodeThreadId(platformData) {
174
317
  return `${THREAD_PREFIX}${platformData.chatGuid}`;
175
318
  }
176
319
  function decodeThreadId(threadId) {
177
320
  if (!threadId.startsWith(THREAD_PREFIX)) {
178
- throw new ValidationError2(
321
+ throw new ValidationError4(
179
322
  "imessage",
180
323
  `Invalid iMessage thread ID: ${threadId}`
181
324
  );
182
325
  }
183
326
  const chatGuid = threadId.slice(THREAD_PREFIX.length);
184
327
  if (!chatGuid) {
185
- throw new ValidationError2(
328
+ throw new ValidationError4(
186
329
  "imessage",
187
330
  `Invalid iMessage thread ID: ${threadId} (empty chat GUID)`
188
331
  );
@@ -192,11 +335,6 @@ function decodeThreadId(threadId) {
192
335
  function isDMChatGuid(chatGuid) {
193
336
  return chatGuid.includes(";-;");
194
337
  }
195
- var DM_SEPARATOR = ";-;";
196
- function dmAddressFromChatGuid(chatGuid) {
197
- const idx = chatGuid.indexOf(DM_SEPARATOR);
198
- return idx === -1 ? "" : chatGuid.slice(idx + DM_SEPARATOR.length);
199
- }
200
338
 
201
339
  // src/internal/inbound.ts
202
340
  function buildChatMessageFromFields(fields) {
@@ -322,8 +460,8 @@ function titleKey(chatGuid, title) {
322
460
  }
323
461
 
324
462
  // src/internal/outbound.ts
325
- import { ValidationError as ValidationError3 } from "@chat-adapter/shared";
326
- import { lookup as lookupMimeType } from "mime-types";
463
+ import { ValidationError as ValidationError5 } from "@chat-adapter/shared";
464
+ import { lookup as lookupMimeType2 } from "mime-types";
327
465
  import { attachment } from "spectrum-ts";
328
466
  var EMOJI_GLYPHS = {
329
467
  heart: "\u2764\uFE0F",
@@ -341,7 +479,7 @@ function emojiToGlyph(emoji) {
341
479
  const name = typeof emoji === "string" ? emoji : emoji.name;
342
480
  const glyph = EMOJI_GLYPHS[name];
343
481
  if (!glyph) {
344
- throw new ValidationError3(
482
+ throw new ValidationError5(
345
483
  "imessage",
346
484
  `Unsupported iMessage tapback: "${name}". Supported: heart, thumbs_up, thumbs_down, laugh, emphasize, question`
347
485
  );
@@ -359,7 +497,7 @@ async function fileToAttachment(file) {
359
497
  buffer = Buffer.from(data);
360
498
  }
361
499
  const name = file.filename || "attachment";
362
- const mimeType = file.mimeType || lookupMimeType(name) || "application/octet-stream";
500
+ const mimeType = file.mimeType || lookupMimeType2(name) || "application/octet-stream";
363
501
  return attachment(buffer, { name, mimeType });
364
502
  }
365
503
 
@@ -416,7 +554,8 @@ import {
416
554
  isParagraphNode,
417
555
  isStrongNode,
418
556
  isTextNode,
419
- parseMarkdown as parseMarkdown2
557
+ parseMarkdown as parseMarkdown2,
558
+ stringifyMarkdown
420
559
  } from "chat";
421
560
  var iMessageFormatConverter = class extends BaseFormatConverter {
422
561
  /**
@@ -436,6 +575,30 @@ var iMessageFormatConverter = class extends BaseFormatConverter {
436
575
  toAst(text) {
437
576
  return parseMarkdown2(text);
438
577
  }
578
+ /**
579
+ * Decide how a postable message should reach spectrum-ts.
580
+ *
581
+ * Markdown-typed inputs -- `{ markdown }` and `{ ast }` -- carry CommonMark
582
+ * the caller wants styled, so their source is preserved verbatim and flagged
583
+ * `markdown: true`; the adapter sends it via spectrum's `markdown()` builder,
584
+ * which renders bold/italic/links/lists as native iMessage styled text.
585
+ *
586
+ * Everything else is pass-through-as-is by contract -- a plain `string` or
587
+ * `{ raw }` must not have stray `*`/`_` reinterpreted as formatting, and
588
+ * cards fall back to plain text -- so those are rendered to plain text and
589
+ * flagged `markdown: false`.
590
+ */
591
+ renderPostableContent(message) {
592
+ if (message && typeof message === "object") {
593
+ if ("markdown" in message && typeof message.markdown === "string") {
594
+ return { body: message.markdown, markdown: true };
595
+ }
596
+ if ("ast" in message && message.ast) {
597
+ return { body: stringifyMarkdown(message.ast), markdown: true };
598
+ }
599
+ }
600
+ return { body: this.renderPostable(message), markdown: false };
601
+ }
439
602
  nodeToPlainText(node) {
440
603
  if (isParagraphNode(node)) {
441
604
  return getNodeChildren(node).map((child) => this.nodeToPlainText(child)).join("");
@@ -483,6 +646,169 @@ var iMessageFormatConverter = class extends BaseFormatConverter {
483
646
  }
484
647
  };
485
648
 
649
+ // src/miniapp.ts
650
+ import { ValidationError as ValidationError6 } from "@chat-adapter/shared";
651
+ async function toImageBytes(image) {
652
+ if (image instanceof Uint8Array) {
653
+ return new Uint8Array(image);
654
+ }
655
+ if (image instanceof ArrayBuffer) {
656
+ return new Uint8Array(image);
657
+ }
658
+ if (image instanceof Blob) {
659
+ return new Uint8Array(await image.arrayBuffer());
660
+ }
661
+ const data = image.data;
662
+ if (data === void 0) {
663
+ throw new ValidationError6(
664
+ "imessage",
665
+ "Mini-app card image must be a Uint8Array, ArrayBuffer, Blob, or FileUpload"
666
+ );
667
+ }
668
+ return toImageBytes(data);
669
+ }
670
+ function requireField(value, field) {
671
+ if (!value || value.trim().length === 0) {
672
+ throw new ValidationError6(
673
+ "imessage",
674
+ `Mini-app card requires a non-empty "${field}"`
675
+ );
676
+ }
677
+ return value;
678
+ }
679
+ async function resolveMiniApp(card) {
680
+ const url = typeof card.url === "string" ? card.url : card.url.toString();
681
+ try {
682
+ new URL(url);
683
+ } catch {
684
+ throw new ValidationError6(
685
+ "imessage",
686
+ `Mini-app card has an invalid url: "${url}"`
687
+ );
688
+ }
689
+ const layout = card.layout ?? {};
690
+ const image = layout.image ? await toImageBytes(layout.image) : void 0;
691
+ return {
692
+ appName: requireField(card.appName, "appName"),
693
+ teamId: requireField(card.teamId, "teamId"),
694
+ extensionBundleId: requireField(
695
+ card.extensionBundleId,
696
+ "extensionBundleId"
697
+ ),
698
+ url,
699
+ ...card.appStoreId === void 0 ? {} : { appStoreId: card.appStoreId },
700
+ layout: {
701
+ caption: layout.caption,
702
+ subcaption: layout.subcaption,
703
+ trailingCaption: layout.trailingCaption,
704
+ trailingSubcaption: layout.trailingSubcaption,
705
+ imageTitle: layout.imageTitle,
706
+ imageSubtitle: layout.imageSubtitle,
707
+ summary: layout.summary,
708
+ ...image ? { image } : {}
709
+ }
710
+ };
711
+ }
712
+ function isAppUrl(input) {
713
+ return typeof input === "string" || typeof input === "function" || typeof input === "object" && input !== null && typeof input.then === "function";
714
+ }
715
+
716
+ // src/voice.ts
717
+ import { ValidationError as ValidationError7 } from "@chat-adapter/shared";
718
+ import { lookup as lookupMimeType3 } from "mime-types";
719
+ import { voice as voiceContent } from "spectrum-ts";
720
+ var AUDIO_MIME_PATTERN = /^audio\//i;
721
+ async function toBytes2(input) {
722
+ if (input instanceof Uint8Array) {
723
+ return { bytes: Buffer.from(input) };
724
+ }
725
+ if (input instanceof ArrayBuffer) {
726
+ return { bytes: Buffer.from(input) };
727
+ }
728
+ if (input instanceof Blob) {
729
+ return {
730
+ bytes: Buffer.from(await input.arrayBuffer()),
731
+ mimeHint: input.type || void 0
732
+ };
733
+ }
734
+ const data = input.data;
735
+ if (data === void 0) {
736
+ throw new ValidationError7(
737
+ "imessage",
738
+ "Voice message must be a Uint8Array, ArrayBuffer, Blob, or FileUpload"
739
+ );
740
+ }
741
+ const nested = await toBytes2(data);
742
+ return {
743
+ bytes: nested.bytes,
744
+ mimeHint: input.mimeType || nested.mimeHint,
745
+ nameHint: input.filename || nested.nameHint
746
+ };
747
+ }
748
+ function toVoiceUrl(input) {
749
+ if (input instanceof URL) {
750
+ return input;
751
+ }
752
+ if (typeof input !== "string") {
753
+ return;
754
+ }
755
+ let url;
756
+ try {
757
+ url = new URL(input);
758
+ } catch {
759
+ throw new ValidationError7(
760
+ "imessage",
761
+ `Voice message string input must be an http(s) URL, got "${input}". Pass audio bytes for local files.`
762
+ );
763
+ }
764
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
765
+ throw new ValidationError7(
766
+ "imessage",
767
+ `Voice message URL must be http(s), got "${url.protocol}"`
768
+ );
769
+ }
770
+ return url;
771
+ }
772
+ function resolveBytesMime2(hint, name) {
773
+ if (hint && AUDIO_MIME_PATTERN.test(hint)) {
774
+ return hint;
775
+ }
776
+ if (name) {
777
+ const inferred = lookupMimeType3(name);
778
+ if (inferred && AUDIO_MIME_PATTERN.test(inferred)) {
779
+ return inferred;
780
+ }
781
+ }
782
+ throw new ValidationError7(
783
+ "imessage",
784
+ 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'
785
+ );
786
+ }
787
+ async function resolveVoice(input, options = {}) {
788
+ if (options.mimeType && !AUDIO_MIME_PATTERN.test(options.mimeType)) {
789
+ throw new ValidationError7(
790
+ "imessage",
791
+ `Voice message requires an audio/* MIME type, got "${options.mimeType}"`
792
+ );
793
+ }
794
+ const url = toVoiceUrl(input);
795
+ if (url) {
796
+ return voiceContent(url, {
797
+ ...options.mimeType ? { mimeType: options.mimeType } : {},
798
+ ...options.name ? { name: options.name } : {},
799
+ ...options.duration === void 0 ? {} : { duration: options.duration }
800
+ });
801
+ }
802
+ const { bytes, mimeHint, nameHint } = await toBytes2(input);
803
+ const name = options.name ?? nameHint;
804
+ const mimeType = resolveBytesMime2(options.mimeType ?? mimeHint, name);
805
+ return voiceContent(bytes, {
806
+ mimeType,
807
+ ...name ? { name } : {},
808
+ ...options.duration === void 0 ? {} : { duration: options.duration }
809
+ });
810
+ }
811
+
486
812
  // src/adapter.ts
487
813
  var TYPING_DURATION_MS = 3e3;
488
814
  var iMessageAdapter = class {
@@ -507,7 +833,7 @@ var iMessageAdapter = class {
507
833
  pump = null;
508
834
  constructor(config) {
509
835
  if (config.local && process.platform !== "darwin") {
510
- throw new ValidationError4(
836
+ throw new ValidationError8(
511
837
  "imessage",
512
838
  "iMessage adapter local mode requires macOS. Current platform: " + process.platform
513
839
  );
@@ -537,7 +863,7 @@ var iMessageAdapter = class {
537
863
  serverUrl: this.serverUrl
538
864
  }
539
865
  );
540
- const providers = [imessage.config(providerConfig)];
866
+ const providers = [imessage2.config(providerConfig)];
541
867
  this.app = projectId && projectSecret ? await Spectrum({ providers, projectId, projectSecret }) : await Spectrum({ providers });
542
868
  this.pump = new MessagePump(
543
869
  () => {
@@ -566,9 +892,9 @@ var iMessageAdapter = class {
566
892
  * Handle a Spectrum Cloud webhook delivery (signed JSON `messages` event).
567
893
  *
568
894
  * Verifies the `X-Spectrum-Signature` HMAC, then routes the message into the
569
- * Chat SDK. Webhook deliveries are receive-only: a delivered thread has no
570
- * live spectrum-ts `Space`, so replying/reacting still requires the gateway
571
- * stream (see `startGatewayListener`).
895
+ * Chat SDK. A delivered thread has no live spectrum-ts `Space`, but the
896
+ * adapter rebuilds one from the chat GUID on demand (see `resolveSpace`), so
897
+ * replying works directly from a webhook delivery.
572
898
  *
573
899
  * @see https://photon.codes/docs/webhooks/overview
574
900
  */
@@ -617,26 +943,152 @@ var iMessageAdapter = class {
617
943
  this.routeWebhookMessage(payload, options);
618
944
  return new Response(null, { status: 200 });
619
945
  }
946
+ /**
947
+ * Build the spectrum content for an outbound message. Markdown-typed inputs
948
+ * are sent via `markdown()` so remote iMessage renders them as native styled
949
+ * text; raw/string/card inputs stay plain `text()`. Returns the rendered
950
+ * `body` too so callers can skip an empty send.
951
+ */
952
+ toSpectrumContent(message) {
953
+ const { body, markdown } = this.formatConverter.renderPostableContent(message);
954
+ return {
955
+ body,
956
+ content: markdown ? markdownContent(body) : textContent(body)
957
+ };
958
+ }
620
959
  async postMessage(threadId, message) {
621
960
  const space = await this.requireSpace(threadId, "postMessage");
622
- const body = this.formatConverter.renderPostable(message);
961
+ const { body, content } = this.toSpectrumContent(message);
623
962
  const files = extractFiles(message);
624
963
  let first;
625
964
  if (body && body.trim().length > 0) {
626
- first = await space.send(textContent(body)) ?? first;
965
+ first = await space.send(content) ?? first;
627
966
  }
628
967
  for (const file of files) {
629
968
  const sent = await space.send(await fileToAttachment(file)) ?? void 0;
630
969
  first ??= sent;
631
970
  }
632
971
  if (!first) {
633
- throw new ValidationError4(
972
+ throw new ValidationError8(
634
973
  "imessage",
635
974
  "postMessage requires non-empty text or at least one attachment"
636
975
  );
637
976
  }
638
977
  return { id: first.id, threadId, raw: first };
639
978
  }
979
+ /**
980
+ * Send a message with an iMessage expressive-send effect — a bubble effect
981
+ * (`slam`, `loud`, `gentle`, `invisible`) or a full-screen effect
982
+ * (`confetti`, `fireworks`, `balloons`, `heart`, `lasers`, `celebration`,
983
+ * `sparkles`, `spotlight`, `echo`). Not part of the Chat SDK `Adapter`
984
+ * interface — exposed as an adapter-specific extra (e.g. celebratory confetti
985
+ * on task completion). Remote only.
986
+ *
987
+ * The `effect` argument accepts a friendly name (`"confetti"`) or a value from
988
+ * the re-exported `iMessageEffect` map. Effects attach to text content only,
989
+ * so this requires non-empty text.
990
+ */
991
+ async sendEffect(threadId, message, effect) {
992
+ if (this.local) {
993
+ throw new NotImplementedError(
994
+ "sendEffect is not supported in local mode",
995
+ "sendEffect"
996
+ );
997
+ }
998
+ const space = await this.requireSpace(threadId, "sendEffect");
999
+ const { body, content } = this.toSpectrumContent(message);
1000
+ if (!body || body.trim().length === 0) {
1001
+ throw new ValidationError8(
1002
+ "imessage",
1003
+ "sendEffect requires non-empty text content"
1004
+ );
1005
+ }
1006
+ const effectId = resolveEffect(effect);
1007
+ const sent = await space.send(effectContent(content, effectId));
1008
+ if (!sent) {
1009
+ throw new ValidationError8(
1010
+ "imessage",
1011
+ "sendEffect could not send the message"
1012
+ );
1013
+ }
1014
+ return { id: sent.id, threadId, raw: sent };
1015
+ }
1016
+ async sendMiniApp(threadId, input) {
1017
+ if (this.local) {
1018
+ throw new NotImplementedError(
1019
+ "sendMiniApp is not supported in local mode",
1020
+ "sendMiniApp"
1021
+ );
1022
+ }
1023
+ const space = await this.requireSpace(threadId, "sendMiniApp");
1024
+ const content = isAppUrl(input) ? appContent(input) : customizedMiniApp(await resolveMiniApp(input));
1025
+ const sent = await space.send(content);
1026
+ if (!sent) {
1027
+ throw new ValidationError8(
1028
+ "imessage",
1029
+ "sendMiniApp could not send the card"
1030
+ );
1031
+ }
1032
+ return { id: sent.id, threadId, raw: sent };
1033
+ }
1034
+ /**
1035
+ * Send a native iMessage voice note — a real, playable waveform bubble (the
1036
+ * message renders with `isAudioMessage`), not an audio file dropped in as an
1037
+ * attachment. A natural fit for TTS-capable bots that reply with speech. Not
1038
+ * part of the Chat SDK `Adapter` interface — exposed as an adapter-specific
1039
+ * extra. Remote only.
1040
+ *
1041
+ * The `input` is either in-memory audio bytes (`Uint8Array` / `Buffer` /
1042
+ * `ArrayBuffer`, a `Blob`, or a Chat SDK `FileUpload`) or an `http(s)` URL (a
1043
+ * `URL` or a string) that is fetched at send time. Audio bytes need an
1044
+ * `audio/*` MIME type — supply `options.mimeType` (e.g. `"audio/mp4"`) or an
1045
+ * `options.name` with an audio extension when it can't be inferred.
1046
+ */
1047
+ async sendVoice(threadId, input, options) {
1048
+ if (this.local) {
1049
+ throw new NotImplementedError(
1050
+ "sendVoice is not supported in local mode",
1051
+ "sendVoice"
1052
+ );
1053
+ }
1054
+ const space = await this.requireSpace(threadId, "sendVoice");
1055
+ const content = await resolveVoice(input, options);
1056
+ const sent = await space.send(content);
1057
+ if (!sent) {
1058
+ throw new ValidationError8(
1059
+ "imessage",
1060
+ "sendVoice could not send the voice message"
1061
+ );
1062
+ }
1063
+ return { id: sent.id, threadId, raw: sent };
1064
+ }
1065
+ /**
1066
+ * Set or clear the chat background — the wallpaper behind a conversation, an
1067
+ * iMessage-only touch with no analog on the plain-text competitors. Not part
1068
+ * of the Chat SDK `Adapter` interface — exposed as an adapter-specific extra.
1069
+ * Remote only.
1070
+ *
1071
+ * The `input` is either the literal `"clear"` (to remove the current
1072
+ * background), in-memory image bytes (`Uint8Array` / `Buffer` / `ArrayBuffer`,
1073
+ * a `Blob`, or a Chat SDK `FileUpload`), or an `http(s)` URL (a `URL` or a
1074
+ * string) that is fetched at send time. Image bytes need an `image/*` MIME
1075
+ * type — supply `options.mimeType` (e.g. `"image/jpeg"`) or an `options.name`
1076
+ * with an image extension when it can't be inferred.
1077
+ *
1078
+ * Fire-and-forget: iMessage acknowledges the control signal without returning
1079
+ * a message, so this resolves to `void` rather than a {@link RawMessage}.
1080
+ */
1081
+ async setBackground(threadId, input, options) {
1082
+ if (this.local) {
1083
+ throw new NotImplementedError(
1084
+ "setBackground is not supported in local mode",
1085
+ "setBackground"
1086
+ );
1087
+ }
1088
+ const space = await this.requireSpace(threadId, "setBackground");
1089
+ const content = await resolveBackground(input, options);
1090
+ await space.send(content);
1091
+ }
640
1092
  async editMessage(threadId, messageId, message) {
641
1093
  if (this.local) {
642
1094
  throw new NotImplementedError(
@@ -651,21 +1103,42 @@ var iMessageAdapter = class {
651
1103
  "editMessage"
652
1104
  );
653
1105
  }
654
- await target.edit(
655
- textContent(this.formatConverter.renderPostable(message))
656
- );
1106
+ await target.edit(this.toSpectrumContent(message).content);
657
1107
  return { id: messageId, threadId, raw: target };
658
1108
  }
659
- async deleteMessage(_threadId, _messageId) {
660
- throw new NotImplementedError(
661
- "deleteMessage is not implemented",
662
- "deleteMessage"
663
- );
1109
+ async deleteMessage(threadId, messageId) {
1110
+ if (this.local) {
1111
+ throw new NotImplementedError(
1112
+ "deleteMessage is not supported in local mode",
1113
+ "deleteMessage"
1114
+ );
1115
+ }
1116
+ const target = await this.resolveMessage(threadId, messageId);
1117
+ if (!target) {
1118
+ throw new NotImplementedError(
1119
+ "deleteMessage requires the target message to have been sent or received in this session",
1120
+ "deleteMessage"
1121
+ );
1122
+ }
1123
+ await target.unsend();
664
1124
  }
665
1125
  parseMessage(raw) {
666
1126
  const message = raw;
667
1127
  return buildChatMessage(message, message.space);
668
1128
  }
1129
+ /**
1130
+ * Fetch a single message by id. spectrum-ts can resolve a message by id
1131
+ * (from the inbound cache or the provider's by-id lookup) even though it has
1132
+ * no paginated history API, so single-message reads work where
1133
+ * `fetchMessages` cannot. Returns `null` when the message can't be resolved.
1134
+ */
1135
+ async fetchMessage(threadId, messageId) {
1136
+ const target = await this.resolveMessage(threadId, messageId);
1137
+ if (!target) {
1138
+ return null;
1139
+ }
1140
+ return this.parseMessage(target);
1141
+ }
669
1142
  async fetchMessages(_threadId, _options) {
670
1143
  throw new NotImplementedError(
671
1144
  "fetchMessages (message history) is not supported by spectrum-ts",
@@ -696,13 +1169,27 @@ var iMessageAdapter = class {
696
1169
  "addReaction"
697
1170
  );
698
1171
  }
699
- await target.react(glyph);
1172
+ const reaction = await target.react(glyph);
1173
+ if (reaction) {
1174
+ this.cache.rememberReaction(messageId, glyph, reaction);
1175
+ }
700
1176
  }
701
- async removeReaction(_threadId, _messageId, _emoji) {
702
- throw new NotImplementedError(
703
- "removeReaction is not supported (spectrum-ts has no reaction-removal API)",
704
- "removeReaction"
705
- );
1177
+ async removeReaction(_threadId, messageId, emoji) {
1178
+ if (this.local) {
1179
+ throw new NotImplementedError(
1180
+ "removeReaction is not supported in local mode",
1181
+ "removeReaction"
1182
+ );
1183
+ }
1184
+ const glyph = emojiToGlyph(emoji);
1185
+ const reaction = this.cache.takeReaction(messageId, glyph);
1186
+ if (!reaction) {
1187
+ throw new NotImplementedError(
1188
+ "removeReaction requires the reaction to have been added via addReaction in this session",
1189
+ "removeReaction"
1190
+ );
1191
+ }
1192
+ await reaction.unsend();
706
1193
  }
707
1194
  async startTyping(threadId, _status) {
708
1195
  if (this.local) {
@@ -718,6 +1205,44 @@ var iMessageAdapter = class {
718
1205
  });
719
1206
  }, TYPING_DURATION_MS);
720
1207
  }
1208
+ /**
1209
+ * Cold-start a DM with a phone number / handle. spectrum-ts resolves (or
1210
+ * creates) the 1:1 conversation from the participant via `space.create`, so
1211
+ * the bot can message a user it has never received from. Returns the encoded
1212
+ * thread id, ready to pass to `postMessage`.
1213
+ */
1214
+ async openDM(userId) {
1215
+ if (!this.app) {
1216
+ throw new NotImplementedError(
1217
+ "openDM requires the adapter to be initialized",
1218
+ "openDM"
1219
+ );
1220
+ }
1221
+ const space = await this.platformSpaces().create(userId);
1222
+ this.cache.rememberSpace(space);
1223
+ return encodeThreadId({ chatGuid: space.id });
1224
+ }
1225
+ /**
1226
+ * Mark a received message (and the conversation up to it) as read, surfacing
1227
+ * a read receipt where iMessage supports one. Remote only; not part of the
1228
+ * Chat SDK `Adapter` interface — exposed as an adapter-specific extra.
1229
+ */
1230
+ async markRead(threadId, messageId) {
1231
+ if (this.local) {
1232
+ throw new NotImplementedError(
1233
+ "markRead is not supported in local mode",
1234
+ "markRead"
1235
+ );
1236
+ }
1237
+ const target = await this.resolveMessage(threadId, messageId);
1238
+ if (!target) {
1239
+ throw new NotImplementedError(
1240
+ "markRead requires the target message to have been received in this session",
1241
+ "markRead"
1242
+ );
1243
+ }
1244
+ await target.read();
1245
+ }
721
1246
  async openModal(triggerId, modal, contextId) {
722
1247
  if (this.local) {
723
1248
  throw new NotImplementedError(
@@ -729,14 +1254,14 @@ var iMessageAdapter = class {
729
1254
  (c) => c.type === "select"
730
1255
  );
731
1256
  if (!select) {
732
- throw new ValidationError4(
1257
+ throw new ValidationError8(
733
1258
  "imessage",
734
1259
  "openModal requires at least one Select child \u2014 iMessage modals map to native polls"
735
1260
  );
736
1261
  }
737
1262
  const labels = select.options.map((o) => o.label);
738
1263
  if (labels.length < 2 || labels.length > 10) {
739
- throw new ValidationError4(
1264
+ throw new ValidationError8(
740
1265
  "imessage",
741
1266
  `iMessage polls require between 2 and 10 options, received ${labels.length}`
742
1267
  );
@@ -896,11 +1421,12 @@ var iMessageAdapter = class {
896
1421
  *
897
1422
  * Prefers a live `Space` cached from the inbound stream (correct sending
898
1423
  * line, no extra round-trip). On a miss — e.g. a webhook-only deployment, or
899
- * a cold send — it reconstructs the Space over gRPC via
900
- * `imessage(app).space([address])`. Only DMs can be rebuilt from a chatGuid:
901
- * the resolver derives them from the peer address, whereas an unseen group
902
- * has no by-id resolver (passing participants would create a *new* chat).
903
- * Returns `undefined` when no Space can be obtained.
1424
+ * a cold send — it rebuilds the Space from the chat GUID via
1425
+ * `imessage(app).space.get(chatGuid)`, which works for DMs and groups alike.
1426
+ * The rebuild can still fail when several iMessage lines are configured and
1427
+ * spectrum-ts cannot infer which line the chat belongs to (`space.get`
1428
+ * requires `params.phone` there). Returns `undefined` when no Space can be
1429
+ * obtained.
904
1430
  */
905
1431
  async resolveSpace(threadId) {
906
1432
  const { chatGuid } = decodeThreadId(threadId);
@@ -908,23 +1434,37 @@ var iMessageAdapter = class {
908
1434
  if (cached) {
909
1435
  return cached;
910
1436
  }
911
- if (!this.app || this.local || !isDMChatGuid(chatGuid)) {
1437
+ if (!this.app) {
912
1438
  return;
913
1439
  }
914
- const address = dmAddressFromChatGuid(chatGuid);
915
- if (!address) {
1440
+ try {
1441
+ const space = await this.platformSpaces().get(chatGuid);
1442
+ this.cache.rememberSpace(space);
1443
+ return space;
1444
+ } catch (error) {
1445
+ this.logger.debug("Could not rebuild Space from chat GUID", {
1446
+ chatGuid,
1447
+ error: String(error)
1448
+ });
916
1449
  return;
917
1450
  }
918
- const platform = imessage(this.app);
919
- const space = await platform.space([address]);
920
- this.cache.rememberSpace(space);
921
- return space;
1451
+ }
1452
+ /**
1453
+ * The iMessage provider's Space namespace (`get` / `create`). `HasProvider`
1454
+ * over the default provider tuple won't narrow to `true`, so `imessage(app)`
1455
+ * types as `never` — cast to the slice of the instance we use.
1456
+ */
1457
+ platformSpaces() {
1458
+ if (!this.app) {
1459
+ throw new Error("Adapter not initialized");
1460
+ }
1461
+ return imessage2(this.app).space;
922
1462
  }
923
1463
  async requireSpace(threadId, action) {
924
1464
  const space = await this.resolveSpace(threadId);
925
1465
  if (!space) {
926
1466
  throw new NotImplementedError(
927
- `${action} requires a DM thread (rebuilt from its address) or a group received in this session; spectrum-ts cannot reconstruct an unseen group chat from its id. Respond within a received message's thread instead.`,
1467
+ `${action} could not resolve this thread. With multiple iMessage lines configured, spectrum-ts needs the chat's sending line to rebuild an unseen thread. Respond within a received message's thread instead.`,
928
1468
  action
929
1469
  );
930
1470
  }
@@ -950,7 +1490,7 @@ function toClientArray(clients) {
950
1490
  }
951
1491
 
952
1492
  // src/factory.ts
953
- import { ValidationError as ValidationError5 } from "@chat-adapter/shared";
1493
+ import { ValidationError as ValidationError9 } from "@chat-adapter/shared";
954
1494
  import { ConsoleLogger } from "chat";
955
1495
  function resolveLocalMode(explicit, hasRemoteCreds) {
956
1496
  if (explicit !== void 0) {
@@ -970,13 +1510,13 @@ function assertRemoteCredentials(creds) {
970
1510
  return;
971
1511
  }
972
1512
  if (!creds.hasServerUrl) {
973
- throw new ValidationError5(
1513
+ throw new ValidationError9(
974
1514
  "imessage",
975
1515
  "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
1516
  );
977
1517
  }
978
1518
  if (!creds.hasApiKey) {
979
- throw new ValidationError5(
1519
+ throw new ValidationError9(
980
1520
  "imessage",
981
1521
  "apiKey is required when local is false. Set IMESSAGE_API_KEY or provide it in config."
982
1522
  );
@@ -1016,6 +1556,12 @@ export {
1016
1556
  createiMessageAdapter,
1017
1557
  deriveAddress,
1018
1558
  iMessageAdapter,
1019
- iMessageFormatConverter
1559
+ iMessageEffect,
1560
+ iMessageFormatConverter,
1561
+ isAppUrl,
1562
+ resolveBackground,
1563
+ resolveEffect,
1564
+ resolveMiniApp,
1565
+ resolveVoice
1020
1566
  };
1021
1567
  //# sourceMappingURL=index.js.map