@photon-ai/chat-adapter-imessage 2.1.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
  );
@@ -317,8 +460,8 @@ function titleKey(chatGuid, title) {
317
460
  }
318
461
 
319
462
  // src/internal/outbound.ts
320
- import { ValidationError as ValidationError3 } from "@chat-adapter/shared";
321
- import { lookup as lookupMimeType } from "mime-types";
463
+ import { ValidationError as ValidationError5 } from "@chat-adapter/shared";
464
+ import { lookup as lookupMimeType2 } from "mime-types";
322
465
  import { attachment } from "spectrum-ts";
323
466
  var EMOJI_GLYPHS = {
324
467
  heart: "\u2764\uFE0F",
@@ -336,7 +479,7 @@ function emojiToGlyph(emoji) {
336
479
  const name = typeof emoji === "string" ? emoji : emoji.name;
337
480
  const glyph = EMOJI_GLYPHS[name];
338
481
  if (!glyph) {
339
- throw new ValidationError3(
482
+ throw new ValidationError5(
340
483
  "imessage",
341
484
  `Unsupported iMessage tapback: "${name}". Supported: heart, thumbs_up, thumbs_down, laugh, emphasize, question`
342
485
  );
@@ -354,7 +497,7 @@ async function fileToAttachment(file) {
354
497
  buffer = Buffer.from(data);
355
498
  }
356
499
  const name = file.filename || "attachment";
357
- const mimeType = file.mimeType || lookupMimeType(name) || "application/octet-stream";
500
+ const mimeType = file.mimeType || lookupMimeType2(name) || "application/octet-stream";
358
501
  return attachment(buffer, { name, mimeType });
359
502
  }
360
503
 
@@ -411,7 +554,8 @@ import {
411
554
  isParagraphNode,
412
555
  isStrongNode,
413
556
  isTextNode,
414
- parseMarkdown as parseMarkdown2
557
+ parseMarkdown as parseMarkdown2,
558
+ stringifyMarkdown
415
559
  } from "chat";
416
560
  var iMessageFormatConverter = class extends BaseFormatConverter {
417
561
  /**
@@ -431,6 +575,30 @@ var iMessageFormatConverter = class extends BaseFormatConverter {
431
575
  toAst(text) {
432
576
  return parseMarkdown2(text);
433
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
+ }
434
602
  nodeToPlainText(node) {
435
603
  if (isParagraphNode(node)) {
436
604
  return getNodeChildren(node).map((child) => this.nodeToPlainText(child)).join("");
@@ -478,6 +646,169 @@ var iMessageFormatConverter = class extends BaseFormatConverter {
478
646
  }
479
647
  };
480
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
+
481
812
  // src/adapter.ts
482
813
  var TYPING_DURATION_MS = 3e3;
483
814
  var iMessageAdapter = class {
@@ -502,7 +833,7 @@ var iMessageAdapter = class {
502
833
  pump = null;
503
834
  constructor(config) {
504
835
  if (config.local && process.platform !== "darwin") {
505
- throw new ValidationError4(
836
+ throw new ValidationError8(
506
837
  "imessage",
507
838
  "iMessage adapter local mode requires macOS. Current platform: " + process.platform
508
839
  );
@@ -532,7 +863,7 @@ var iMessageAdapter = class {
532
863
  serverUrl: this.serverUrl
533
864
  }
534
865
  );
535
- const providers = [imessage.config(providerConfig)];
866
+ const providers = [imessage2.config(providerConfig)];
536
867
  this.app = projectId && projectSecret ? await Spectrum({ providers, projectId, projectSecret }) : await Spectrum({ providers });
537
868
  this.pump = new MessagePump(
538
869
  () => {
@@ -612,26 +943,152 @@ var iMessageAdapter = class {
612
943
  this.routeWebhookMessage(payload, options);
613
944
  return new Response(null, { status: 200 });
614
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
+ }
615
959
  async postMessage(threadId, message) {
616
960
  const space = await this.requireSpace(threadId, "postMessage");
617
- const body = this.formatConverter.renderPostable(message);
961
+ const { body, content } = this.toSpectrumContent(message);
618
962
  const files = extractFiles(message);
619
963
  let first;
620
964
  if (body && body.trim().length > 0) {
621
- first = await space.send(textContent(body)) ?? first;
965
+ first = await space.send(content) ?? first;
622
966
  }
623
967
  for (const file of files) {
624
968
  const sent = await space.send(await fileToAttachment(file)) ?? void 0;
625
969
  first ??= sent;
626
970
  }
627
971
  if (!first) {
628
- throw new ValidationError4(
972
+ throw new ValidationError8(
629
973
  "imessage",
630
974
  "postMessage requires non-empty text or at least one attachment"
631
975
  );
632
976
  }
633
977
  return { id: first.id, threadId, raw: first };
634
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
+ }
635
1092
  async editMessage(threadId, messageId, message) {
636
1093
  if (this.local) {
637
1094
  throw new NotImplementedError(
@@ -646,21 +1103,42 @@ var iMessageAdapter = class {
646
1103
  "editMessage"
647
1104
  );
648
1105
  }
649
- await target.edit(
650
- textContent(this.formatConverter.renderPostable(message))
651
- );
1106
+ await target.edit(this.toSpectrumContent(message).content);
652
1107
  return { id: messageId, threadId, raw: target };
653
1108
  }
654
- async deleteMessage(_threadId, _messageId) {
655
- throw new NotImplementedError(
656
- "deleteMessage is not implemented",
657
- "deleteMessage"
658
- );
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();
659
1124
  }
660
1125
  parseMessage(raw) {
661
1126
  const message = raw;
662
1127
  return buildChatMessage(message, message.space);
663
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
+ }
664
1142
  async fetchMessages(_threadId, _options) {
665
1143
  throw new NotImplementedError(
666
1144
  "fetchMessages (message history) is not supported by spectrum-ts",
@@ -691,13 +1169,27 @@ var iMessageAdapter = class {
691
1169
  "addReaction"
692
1170
  );
693
1171
  }
694
- await target.react(glyph);
1172
+ const reaction = await target.react(glyph);
1173
+ if (reaction) {
1174
+ this.cache.rememberReaction(messageId, glyph, reaction);
1175
+ }
695
1176
  }
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
- );
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();
701
1193
  }
702
1194
  async startTyping(threadId, _status) {
703
1195
  if (this.local) {
@@ -713,6 +1205,44 @@ var iMessageAdapter = class {
713
1205
  });
714
1206
  }, TYPING_DURATION_MS);
715
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
+ }
716
1246
  async openModal(triggerId, modal, contextId) {
717
1247
  if (this.local) {
718
1248
  throw new NotImplementedError(
@@ -724,14 +1254,14 @@ var iMessageAdapter = class {
724
1254
  (c) => c.type === "select"
725
1255
  );
726
1256
  if (!select) {
727
- throw new ValidationError4(
1257
+ throw new ValidationError8(
728
1258
  "imessage",
729
1259
  "openModal requires at least one Select child \u2014 iMessage modals map to native polls"
730
1260
  );
731
1261
  }
732
1262
  const labels = select.options.map((o) => o.label);
733
1263
  if (labels.length < 2 || labels.length > 10) {
734
- throw new ValidationError4(
1264
+ throw new ValidationError8(
735
1265
  "imessage",
736
1266
  `iMessage polls require between 2 and 10 options, received ${labels.length}`
737
1267
  );
@@ -907,9 +1437,8 @@ var iMessageAdapter = class {
907
1437
  if (!this.app) {
908
1438
  return;
909
1439
  }
910
- const platform = imessage(this.app);
911
1440
  try {
912
- const space = await platform.space.get(chatGuid);
1441
+ const space = await this.platformSpaces().get(chatGuid);
913
1442
  this.cache.rememberSpace(space);
914
1443
  return space;
915
1444
  } catch (error) {
@@ -920,6 +1449,17 @@ var iMessageAdapter = class {
920
1449
  return;
921
1450
  }
922
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;
1462
+ }
923
1463
  async requireSpace(threadId, action) {
924
1464
  const space = await this.resolveSpace(threadId);
925
1465
  if (!space) {
@@ -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