@spectrum-ts/imessage 8.1.0 → 8.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.
Files changed (2) hide show
  1. package/dist/index.js +202 -67
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { MessageEffect, NotFoundError, ValidationError, createClient } from "@ph
2
2
  import { IMessageSDK } from "@photon-ai/imessage-kit";
3
3
  import { sanitizePhone, withSpan } from "@photon-ai/otel";
4
4
  import { UnsupportedError, appLayoutSchema, cloud, definePlatform, fromVCard, mergeStreams, read, stream, text, toVCard } from "@spectrum-ts/core";
5
- import { asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asText, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, messageEffectSchema, photoActionSchema, reactionSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
5
+ import { asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asReply, asText, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, messageEffectSchema, photoActionSchema, reactionSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
6
6
  import z from "zod";
7
7
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
8
  import { createReadStream } from "node:fs";
@@ -204,8 +204,9 @@ const spaceSchema = z.object({
204
204
  const spaceParamsSchema = z.object({ phone: z.string().optional() });
205
205
  /**
206
206
  * iMessage-specific per-message metadata surfaced on `IMessageMessage`.
207
- * - `partIndex`: attachment index within a multi-part message (0 for bare
208
- * or single-attachment messages; 0..N-1 for a group's sub-items).
207
+ * - `partIndex`: ordered part index within a multi-part message. Text and
208
+ * attachment parts both consume an index (0 for bare or single-part
209
+ * messages; 0..N-1 for a group's sub-items).
209
210
  * - `parentId`: guid of the parent message for a group sub-item. Undefined
210
211
  * when the message itself is the parent.
211
212
  */
@@ -445,6 +446,43 @@ const getPollCache = (owner) => {
445
446
  }
446
447
  return cache;
447
448
  };
449
+ const addTextPart = (parts, text) => {
450
+ const trimmed = text?.trim();
451
+ if (trimmed) parts.push({
452
+ type: "text",
453
+ text: trimmed
454
+ });
455
+ };
456
+ const hasUsableTextPart = (text) => text?.split("").some((segment) => segment.trim()) ?? false;
457
+ const addAttachmentParts = (parts, attachments) => {
458
+ for (const attachment of attachments) if (attachment) parts.push({
459
+ type: "attachment",
460
+ attachment
461
+ });
462
+ };
463
+ const toOrderedParts = (text, attachments) => {
464
+ const parts = [];
465
+ if (!text) {
466
+ addAttachmentParts(parts, attachments);
467
+ return parts;
468
+ }
469
+ if (!text.includes("")) {
470
+ addAttachmentParts(parts, attachments);
471
+ addTextPart(parts, text);
472
+ return parts;
473
+ }
474
+ const textSegments = text.split("");
475
+ for (let i = 0; i < attachments.length; i++) {
476
+ addTextPart(parts, textSegments[i]);
477
+ const attachment = attachments[i];
478
+ if (attachment) parts.push({
479
+ type: "attachment",
480
+ attachment
481
+ });
482
+ }
483
+ addTextPart(parts, textSegments.slice(attachments.length).join(""));
484
+ return parts;
485
+ };
448
486
  //#endregion
449
487
  //#region src/shared/vcard.ts
450
488
  const VCARD_MIME_TYPES = new Set([
@@ -487,12 +525,31 @@ const toVCardContent$1 = async (att) => {
487
525
  const localAttachmentContent = async (att) => isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent$1(att) : toAttachmentContent$1(att);
488
526
  //#endregion
489
527
  //#region src/local/inbound.ts
490
- const ATTACHMENT_PLACEHOLDER = "";
491
528
  const ATTACHMENT_JOIN_RETRY_DELAY_MS = 250;
492
529
  const ATTACHMENT_JOIN_RETRY_LIMIT = 8;
493
530
  const ATTACHMENT_JOIN_FETCH_LIMIT = 10;
494
- const hasAttachmentPlaceholder = (message) => message.text?.includes(ATTACHMENT_PLACEHOLDER) ?? false;
531
+ const hasAttachmentPlaceholder = (message) => message.text?.includes("") ?? false;
495
532
  const isPendingAttachmentJoin = (message) => message.attachments.length === 0 && (message.hasAttachments || hasAttachmentPlaceholder(message));
533
+ const replyTargetId = (message) => message.threadRootMessageId ?? void 0;
534
+ const stubReplyTarget$1 = (space, targetId) => ({
535
+ id: targetId,
536
+ content: asCustom({
537
+ imessage_type: "reply-target",
538
+ stub: true
539
+ }),
540
+ space
541
+ });
542
+ const asProviderReply$1 = (content, target) => asReply({
543
+ content,
544
+ target
545
+ });
546
+ const wrapReply = (messages, targetId) => {
547
+ if (!targetId) return messages;
548
+ return messages.map((message) => ({
549
+ ...message,
550
+ content: asProviderReply$1(message.content, stubReplyTarget$1(message.space, targetId))
551
+ }));
552
+ };
496
553
  const refetchUntilAttachmentsSettle = async (client, message) => {
497
554
  if (!message.chatId) return message;
498
555
  for (let attempt = 0; attempt < ATTACHMENT_JOIN_RETRY_LIMIT; attempt += 1) {
@@ -529,19 +586,40 @@ const toMessages = async (message) => {
529
586
  },
530
587
  timestamp: message.createdAt
531
588
  };
532
- if (message.attachments.length > 0) return Promise.all(message.attachments.map(async (att) => ({
533
- ...base,
534
- id: `${message.id}:${att.id}`,
535
- content: await localAttachmentContent(att)
536
- })));
537
- return [{
589
+ const targetId = replyTargetId(message);
590
+ if (message.attachments.length > 0) {
591
+ if (!hasUsableTextPart(message.text)) return wrapReply(await Promise.all(message.attachments.map(async (att) => ({
592
+ ...base,
593
+ id: `${message.id}:${att.id}`,
594
+ content: await localAttachmentContent(att)
595
+ }))), targetId);
596
+ const parts = toOrderedParts(message.text, message.attachments);
597
+ const messages = [];
598
+ for (let i = 0; i < parts.length; i++) {
599
+ const part = parts[i];
600
+ if (!part) continue;
601
+ messages.push(part.type === "text" ? {
602
+ ...base,
603
+ id: `${message.id}:text:${i}`,
604
+ content: asText(part.text),
605
+ partIndex: i
606
+ } : {
607
+ ...base,
608
+ id: `${message.id}:${part.attachment.id}`,
609
+ content: await localAttachmentContent(part.attachment),
610
+ partIndex: i
611
+ });
612
+ }
613
+ return wrapReply(messages, targetId);
614
+ }
615
+ return wrapReply([{
538
616
  ...base,
539
617
  id: message.id,
540
618
  content: {
541
619
  type: "text",
542
620
  text: message.text ?? ""
543
621
  }
544
- }];
622
+ }], targetId);
545
623
  };
546
624
  const messages$3 = (client) => stream((emit, end) => {
547
625
  let lastPromise = Promise.resolve();
@@ -824,6 +902,10 @@ const asProviderGroup = (items) => groupSchema.parse({
824
902
  type: "group",
825
903
  items
826
904
  });
905
+ const asProviderReply = (content, target) => asReply({
906
+ content,
907
+ target
908
+ });
827
909
  const buildMessageBase = (message, chatGuidHint, timestamp, phone) => {
828
910
  const chat = resolveChatGuid(message, chatGuidHint);
829
911
  return {
@@ -868,73 +950,114 @@ const buildAttachmentMessage = async (client, base, info, id, partIndex, parentI
868
950
  if (parentId !== void 0) msg.parentId = parentId;
869
951
  return msg;
870
952
  };
871
- const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
872
- const messageGuidStr = message.guid;
873
- const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
953
+ const buildTextMessage = (base, text, id, partIndex, parentId) => {
954
+ const msg = {
955
+ ...base,
956
+ id,
957
+ content: asText(text),
958
+ partIndex
959
+ };
960
+ if (parentId !== void 0) msg.parentId = parentId;
961
+ return msg;
962
+ };
963
+ const buildOrderedPartMessage = async (client, base, part, id, partIndex, parentId) => part.type === "text" ? buildTextMessage(base, part.text, id, partIndex, parentId) : await buildAttachmentMessage(client, base, part.attachment, id, partIndex, parentId);
964
+ const buildUnwrappedContentMessage = async (client, base, message, messageGuidStr) => {
874
965
  const attachments = messageAttachments(message);
875
- if (attachments.length === 1) {
876
- const info = attachments[0];
877
- if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
878
- return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
879
- }
880
- if (attachments.length > 1) {
881
- const items = [];
882
- for (let i = 0; i < attachments.length; i++) {
883
- const info = attachments[i];
884
- if (!info) continue;
885
- items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
886
- }
966
+ if (attachments.length === 0) {
967
+ const text = message.content.text;
887
968
  return {
888
969
  ...base,
889
970
  id: messageGuidStr,
890
- content: asProviderGroup(items)
971
+ content: text ? asText(text) : asCustom(message)
891
972
  };
892
973
  }
893
- const text = message.content.text;
974
+ const parts = toOrderedParts(message.content.text, attachments);
975
+ if (parts.length === 0) return {
976
+ ...base,
977
+ id: messageGuidStr,
978
+ content: asCustom(message)
979
+ };
980
+ if (parts.length === 1) {
981
+ const part = parts[0];
982
+ if (!part) throw new Error("Unreachable: parts.length === 1 but no element");
983
+ return buildOrderedPartMessage(client, base, part, messageGuidStr, 0);
984
+ }
985
+ const items = [];
986
+ for (let i = 0; i < parts.length; i++) {
987
+ const part = parts[i];
988
+ if (!part) continue;
989
+ items.push(await buildOrderedPartMessage(client, base, part, formatChildId(i, messageGuidStr), i, messageGuidStr));
990
+ }
894
991
  return {
895
992
  ...base,
896
993
  id: messageGuidStr,
897
- content: text ? asText(text) : asCustom(message)
994
+ content: asProviderGroup(items)
898
995
  };
899
996
  };
997
+ const replyTargetGuid = (message) => message.replyTargetGuid ?? message.threadOriginatorGuid;
998
+ const stubReplyTarget = (base, targetGuid) => ({
999
+ id: targetGuid,
1000
+ content: asCustom({
1001
+ imessage_type: "reply-target",
1002
+ stub: true
1003
+ }),
1004
+ space: base.space
1005
+ });
1006
+ const resolveReplyTarget = async (client, base, targetGuid, currentGuid, options) => {
1007
+ if (targetGuid === currentGuid || options.visitedReplyGuids?.has(targetGuid)) return stubReplyTarget(base, targetGuid);
1008
+ const cached = options.cache?.get(targetGuid);
1009
+ if (cached) return cached;
1010
+ try {
1011
+ const visitedReplyGuids = new Set(options.visitedReplyGuids);
1012
+ visitedReplyGuids.add(currentGuid);
1013
+ const rebuilt = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), options.phone, base.space.id, options.cache, visitedReplyGuids);
1014
+ if (options.cache) cacheMessage(options.cache, rebuilt);
1015
+ return rebuilt;
1016
+ } catch (err) {
1017
+ if (!(err instanceof NotFoundError)) log$2.warn("failed to resolve iMessage reply target; falling back to stub target", {
1018
+ "spectrum.imessage.message.guid": currentGuid,
1019
+ "spectrum.imessage.reply.target_guid": targetGuid,
1020
+ ...errorAttrs(err)
1021
+ }, err);
1022
+ return stubReplyTarget(base, targetGuid);
1023
+ }
1024
+ };
1025
+ const buildContentMessage = async (client, base, message, messageGuidStr, options) => {
1026
+ const msg = await buildUnwrappedContentMessage(client, base, message, messageGuidStr);
1027
+ const targetGuid = replyTargetGuid(message);
1028
+ if (!targetGuid) return msg;
1029
+ const target = await resolveReplyTarget(client, base, targetGuid, messageGuidStr, options);
1030
+ return {
1031
+ ...msg,
1032
+ content: asProviderReply(msg.content, target)
1033
+ };
1034
+ };
1035
+ const messageGroupContent = (message) => {
1036
+ if (message.content.type === "group") return message.content;
1037
+ if (message.content.type === "reply" && message.content.content.type === "group") return message.content.content;
1038
+ };
1039
+ const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint, cache, visitedReplyGuids) => {
1040
+ const messageGuidStr = message.guid;
1041
+ return buildContentMessage(client, buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone), message, messageGuidStr, {
1042
+ cache,
1043
+ phone,
1044
+ visitedReplyGuids
1045
+ });
1046
+ };
900
1047
  const cacheMessage = (cache, message) => {
901
1048
  cache.set(message.id, message);
902
- if (message.content.type === "group") {
903
- for (const item of message.content.items) if (isIMessageMessage(item)) cache.set(item.id, item);
1049
+ const group = messageGroupContent(message);
1050
+ if (group) {
1051
+ for (const item of group.items) if (isIMessageMessage(item)) cache.set(item.id, item);
904
1052
  }
905
1053
  };
906
1054
  const toInboundMessages = async (client, cache, event, phone) => {
907
1055
  const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
908
1056
  const messageGuidStr = event.message.guid;
909
- const attachments = messageAttachments(event.message);
910
- if (attachments.length === 1) {
911
- const info = attachments[0];
912
- if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
913
- const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
914
- cacheMessage(cache, msg);
915
- return [msg];
916
- }
917
- if (attachments.length > 1) {
918
- const items = [];
919
- for (let i = 0; i < attachments.length; i++) {
920
- const info = attachments[i];
921
- if (!info) continue;
922
- items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
923
- }
924
- const parent = {
925
- ...base,
926
- id: messageGuidStr,
927
- content: asProviderGroup(items)
928
- };
929
- cacheMessage(cache, parent);
930
- return [parent];
931
- }
932
- const text = event.message.content.text;
933
- const msg = {
934
- ...base,
935
- id: messageGuidStr,
936
- content: text ? asText(text) : asCustom(event.message)
937
- };
1057
+ const msg = await buildContentMessage(client, base, event.message, messageGuidStr, {
1058
+ cache,
1059
+ phone
1060
+ });
938
1061
  cacheMessage(cache, msg);
939
1062
  return [msg];
940
1063
  };
@@ -944,17 +1067,18 @@ const getMessage$1 = async (remote, spaceId, msgId, phone) => {
944
1067
  if (cached) return cached;
945
1068
  const childRef = parseChildId(msgId);
946
1069
  if (childRef) try {
947
- const parent = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(childRef.parentGuid)), phone, spaceId);
1070
+ const parent = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(childRef.parentGuid)), phone, spaceId, cache);
948
1071
  cacheMessage(cache, parent);
949
- if (parent.content.type !== "group") return;
950
- const item = parent.content.items[childRef.partIndex];
1072
+ const group = messageGroupContent(parent);
1073
+ if (!group) return;
1074
+ const item = group.items[childRef.partIndex];
951
1075
  return isIMessageMessage(item) ? item : void 0;
952
1076
  } catch (err) {
953
1077
  if (err instanceof NotFoundError) return;
954
1078
  throw err;
955
1079
  }
956
1080
  try {
957
- const rebuilt = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(msgId)), phone, spaceId);
1081
+ const rebuilt = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(msgId)), phone, spaceId, cache);
958
1082
  cacheMessage(cache, rebuilt);
959
1083
  return rebuilt;
960
1084
  } catch (err) {
@@ -2073,8 +2197,15 @@ const imessage = definePlatform("iMessage", {
2073
2197
  schema: spaceSchema,
2074
2198
  params: spaceParamsSchema,
2075
2199
  create: async ({ input, client }) => {
2076
- if (isLocal(client)) throw UnsupportedError.action("space.create", "iMessage (local mode)", "local mode only supports replying to existing messages");
2077
2200
  if (input.users.length === 0) throw new Error("iMessage space creation requires at least one user");
2201
+ if (isLocal(client)) {
2202
+ if (input.users.length > 1) throw UnsupportedError.action("space.create", "iMessage (local mode)", "local mode cannot create group chats — use space.get(chatGuid) for an existing group");
2203
+ return {
2204
+ id: dmChatGuid(input.users[0]?.id ?? ""),
2205
+ type: "dm",
2206
+ phone: ""
2207
+ };
2208
+ }
2078
2209
  if (client.length === 0) throw new Error("No iMessage clients configured");
2079
2210
  const addresses = input.users.map((u) => u.id);
2080
2211
  if (isSharedMode(client)) {
@@ -2094,7 +2225,11 @@ const imessage = definePlatform("iMessage", {
2094
2225
  };
2095
2226
  },
2096
2227
  get: async ({ input, client }) => {
2097
- if (isLocal(client)) throw UnsupportedError.action("space.get", "iMessage (local mode)", "local mode only supports replying to existing messages");
2228
+ if (isLocal(client)) return {
2229
+ id: input.id,
2230
+ type: chatTypeFromGuid(input.id),
2231
+ phone: ""
2232
+ };
2098
2233
  if (client.length === 0) throw new Error("No iMessage clients configured");
2099
2234
  const phone = isSharedMode(client) ? SHARED_PHONE : input.params?.phone ?? (client.length === 1 ? client[0]?.phone : void 0);
2100
2235
  if (!phone) throw new Error(`iMessage space.get requires params.phone when multiple clients are configured. Available: ${availablePhones(client).join(", ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectrum-ts/imessage",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "iMessage provider for spectrum-ts — local and remote (advanced) modes.",
5
5
  "repository": {
6
6
  "type": "git",