@spectrum-ts/imessage-local 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License Copyright (c) 2025 Photon AI
2
+
3
+ Permission is hereby granted,
4
+ free of charge, to any person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use, copy, modify, merge,
7
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ (including the next paragraph) shall be included in all copies or substantial
13
+ portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # `@spectrum-ts/imessage-local`
2
+
3
+ Local macOS iMessage provider for spectrum-ts, powered by `@photon-ai/imessage-kit`.
4
+
5
+ ```sh
6
+ bun add spectrum-ts @spectrum-ts/imessage-local
7
+ ```
8
+
9
+ ```ts
10
+ import { imessage } from "@spectrum-ts/imessage-local";
11
+ import { Spectrum } from "spectrum-ts";
12
+
13
+ const spectrum = Spectrum({
14
+ platforms: [imessage.config()],
15
+ });
16
+ ```
17
+
18
+ This package is intentionally not included in the batteries-included
19
+ `spectrum-ts` package. Install it only on the macOS host that will access the
20
+ local Messages database.
@@ -0,0 +1,269 @@
1
+ import { IMessageSDK } from "@photon-ai/imessage-kit";
2
+ import { Attachment, ContentBuilder, ContentInput, SchemaMessage, Space, read } from "@spectrum-ts/core";
3
+ import { PhotoInput } from "@spectrum-ts/core/authoring";
4
+ import z from "zod";
5
+
6
+ //#region src/types.d.ts
7
+ declare const userSchema: z.ZodObject<{
8
+ address: z.ZodOptional<z.ZodString>;
9
+ country: z.ZodOptional<z.ZodString>;
10
+ service: z.ZodOptional<z.ZodEnum<{
11
+ iMessage: "iMessage";
12
+ SMS: "SMS";
13
+ RCS: "RCS";
14
+ unknown: "unknown";
15
+ }>>;
16
+ }, z.core.$strip>;
17
+ declare const spaceSchema: z.ZodObject<{
18
+ id: z.ZodString;
19
+ type: z.ZodEnum<{
20
+ dm: "dm";
21
+ group: "group";
22
+ }>;
23
+ phone: z.ZodString;
24
+ }, z.core.$strip>;
25
+ type IMessageMessage = SchemaMessage<typeof userSchema, typeof spaceSchema> & {
26
+ direction?: "inbound" | "outbound";
27
+ partIndex?: number;
28
+ parentId?: string;
29
+ };
30
+ //#endregion
31
+ //#region ../imessage/src/content/background.d.ts
32
+ type BackgroundInput = PhotoInput;
33
+ /**
34
+ * Set or clear the chat background. iMessage-only, remote-only.
35
+ *
36
+ * - `background("clear")` — remove the current chat background.
37
+ * - `background("./photo.jpg")` — set background from a filesystem path.
38
+ * MIME type is inferred from the extension; override with `options.mimeType`.
39
+ * - `background(new URL("https://…/photo.jpg"))` — fetch the background
40
+ * lazily over the network. Bytes stay in memory (safe in read-only
41
+ * environments). MIME type is inferred from the URL pathname extension;
42
+ * override with `options.mimeType` when the URL has no usable extension.
43
+ * - `background(buffer, { mimeType })` — set background from in-memory bytes.
44
+ * `options.mimeType` is required.
45
+ *
46
+ * `"clear"` is a reserved string-literal sentinel. If you have a file literally
47
+ * named `clear` with no extension, pass `"./clear"` or load it as a Buffer.
48
+ *
49
+ * `space.send(background(...))` is the canonical form; `space.background(...)`
50
+ * is sugar attached via `PlatformDef.space.actions` (only typed on
51
+ * `PlatformSpace<IMessageDef>`).
52
+ *
53
+ * `Background` is intentionally not a member of the universal `Content`
54
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
55
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
56
+ * The framework treats it as a fire-and-forget control signal at runtime.
57
+ */
58
+ declare function background(input: "clear"): ContentBuilder;
59
+ declare function background(input: string | Buffer | URL, options?: {
60
+ mimeType?: string;
61
+ }): ContentBuilder;
62
+ //#endregion
63
+ //#region ../imessage/src/content/contact-card.d.ts
64
+ /**
65
+ * iMessage-only "share contact card" control signal. Pushes the *local
66
+ * account's native contact card* (the name + photo a recipient sees in their
67
+ * Messages app) to a chat via the SDK's `chats.shareContactInfo`.
68
+ *
69
+ * This is Apple's "Share Name and Photo" mechanism — distinct from the
70
+ * universal `contact(...)` content, which uploads an arbitrary person's vCard
71
+ * as a *file* attachment. There is no payload: the card shared is always the
72
+ * bot account's own.
73
+ *
74
+ * Like `background`, it lives entirely under the iMessage provider and never
75
+ * enters the universal `Content` discriminated union. The framework recognizes
76
+ * it via two generic content-level contracts:
77
+ *
78
+ * 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
79
+ * `platform/build.ts` reads this tag and warns-and-skips when a different
80
+ * platform receives it.
81
+ * 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check treats
82
+ * this as a side-effecting send that returns no message id, the same way it
83
+ * treats `read` / `typing`.
84
+ *
85
+ * iMessage's `send` handler narrows back via the `isContactCard` type guard
86
+ * before dispatching to `chats.shareContactInfo`.
87
+ */
88
+ declare const contactCardSchema: z.ZodObject<{
89
+ type: z.ZodLiteral<"contactCard">;
90
+ __platform: z.ZodLiteral<"iMessage">;
91
+ __fireAndForget: z.ZodLiteral<true>;
92
+ }, z.core.$strip>;
93
+ type ContactCard = z.infer<typeof contactCardSchema>;
94
+ /**
95
+ * Share the bot account's native iMessage contact card (name + photo) with the
96
+ * chat. iMessage-only, remote-only.
97
+ *
98
+ * `space.send(nativeContactCard())` is the canonical form; `space.shareContactCard()`
99
+ * is sugar attached via `PlatformDef.space.actions` (only typed on
100
+ * `PlatformSpace<IMessageDef>`).
101
+ *
102
+ * This is an explicit, on-demand share and always fires — unlike the automatic
103
+ * best-effort share gated behind the `imessageSynced` project profile, which
104
+ * dedupes to once per chat per 24h (see `remote/contact-share.ts`). Works in
105
+ * both DMs and group chats; the recipient chooses whether to accept the card.
106
+ *
107
+ * `ContactCard` is intentionally not a member of the universal `Content`
108
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
109
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
110
+ * The framework treats it as a fire-and-forget control signal at runtime.
111
+ */
112
+ declare function nativeContactCard(): ContentBuilder;
113
+ //#endregion
114
+ //#region ../imessage/src/content/customized-mini-app.d.ts
115
+ declare const layoutSchema: z.ZodObject<{
116
+ caption: z.ZodOptional<z.ZodString>;
117
+ subcaption: z.ZodOptional<z.ZodString>;
118
+ trailingCaption: z.ZodOptional<z.ZodString>;
119
+ trailingSubcaption: z.ZodOptional<z.ZodString>;
120
+ image: z.ZodOptional<z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>>;
121
+ imageTitle: z.ZodOptional<z.ZodString>;
122
+ imageSubtitle: z.ZodOptional<z.ZodString>;
123
+ summary: z.ZodOptional<z.ZodString>;
124
+ }, z.core.$strip>;
125
+ /**
126
+ * iMessage-only mini-app card content. Lives entirely under the iMessage
127
+ * provider — never enters the universal `Content` discriminated union. The
128
+ * framework recognizes it via the generic content-level platform contract:
129
+ *
130
+ * - `__platform: "iMessage"` — `findUnsupportedPlatformContent` reads this tag
131
+ * and warns-and-skips when a different platform receives it.
132
+ *
133
+ * Unlike `background` / `read`, this content is **not** `__fireAndForget`: it
134
+ * produces a real outbound message, so the iMessage `send` handler narrows
135
+ * back to `CustomizedMiniApp` via the `isCustomizedMiniApp` guard and returns
136
+ * the resulting `ProviderMessageRecord` (rather than `void`).
137
+ */
138
+ declare const customizedMiniAppSchema: z.ZodObject<{
139
+ type: z.ZodLiteral<"customized-mini-app">;
140
+ __platform: z.ZodLiteral<"iMessage">;
141
+ appName: z.ZodString;
142
+ appStoreId: z.ZodOptional<z.ZodNumber>;
143
+ extensionBundleId: z.ZodString;
144
+ layout: z.ZodObject<{
145
+ caption: z.ZodOptional<z.ZodString>;
146
+ subcaption: z.ZodOptional<z.ZodString>;
147
+ trailingCaption: z.ZodOptional<z.ZodString>;
148
+ trailingSubcaption: z.ZodOptional<z.ZodString>;
149
+ image: z.ZodOptional<z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>>>;
150
+ imageTitle: z.ZodOptional<z.ZodString>;
151
+ imageSubtitle: z.ZodOptional<z.ZodString>;
152
+ summary: z.ZodOptional<z.ZodString>;
153
+ }, z.core.$strip>;
154
+ live: z.ZodOptional<z.ZodBoolean>;
155
+ teamId: z.ZodString;
156
+ url: z.ZodURL;
157
+ }, z.core.$strip>;
158
+ type CustomizedMiniApp = z.infer<typeof customizedMiniAppSchema>;
159
+ type CustomizedMiniAppLayout = z.infer<typeof layoutSchema>;
160
+ type CustomizedMiniAppInput = Omit<CustomizedMiniApp, "type" | "__platform">;
161
+ /**
162
+ * Construct a `customized-mini-app` content value. iMessage-only, remote-only.
163
+ *
164
+ * The layout is what recipients see in the bubble. `teamId` and
165
+ * `extensionBundleId` identify the iMessage extension that receives `url` when
166
+ * the recipient taps the card; the server constructs the matching
167
+ * `MSMessageExtensionBalloonPlugin` plugin id from these values. `appStoreId`
168
+ * is optional and only points recipients without the extension at its App
169
+ * Store entry. `live` is optional; when omitted, the remote server keeps the
170
+ * static layout preview visible.
171
+ *
172
+ * `space.send(customizedMiniApp(...))` is the canonical form.
173
+ *
174
+ * `CustomizedMiniApp` is intentionally not a member of the universal `Content`
175
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
176
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
177
+ */
178
+ declare function customizedMiniApp(input: CustomizedMiniAppInput): ContentBuilder;
179
+ //#endregion
180
+ //#region ../imessage/src/content/effect.d.ts
181
+ declare const messageEffects: {
182
+ readonly balloons: "com.apple.messages.effect.CKBalloonEffect";
183
+ readonly celebration: "com.apple.messages.effect.CKHappyBirthdayEffect";
184
+ readonly confetti: "com.apple.messages.effect.CKConfettiEffect";
185
+ readonly echo: "com.apple.messages.effect.CKEchoEffect";
186
+ readonly fireworks: "com.apple.messages.effect.CKFireworksEffect";
187
+ readonly gentle: "com.apple.MobileSMS.expressivesend.gentle";
188
+ readonly heart: "com.apple.messages.effect.CKHeartEffect";
189
+ readonly invisible: "com.apple.MobileSMS.expressivesend.invisibleink";
190
+ readonly lasers: "com.apple.messages.effect.CKLasersEffect";
191
+ readonly loud: "com.apple.MobileSMS.expressivesend.loud";
192
+ readonly slam: "com.apple.MobileSMS.expressivesend.impact";
193
+ readonly sparkles: "com.apple.messages.effect.CKSparklesEffect";
194
+ readonly spotlight: "com.apple.messages.effect.CKSpotlightEffect";
195
+ };
196
+ type IMessageMessageEffect = (typeof messageEffects)[keyof typeof messageEffects];
197
+ declare function effect(input: ContentInput, messageEffect: IMessageMessageEffect): ContentBuilder;
198
+ //#endregion
199
+ //#region src/index.d.ts
200
+ declare const imessage: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"iMessage", import("zod").ZodObject<{}, import("zod/v4/core").$strip>, import("zod").ZodObject<{
201
+ address: import("zod").ZodOptional<import("zod").ZodString>;
202
+ country: import("zod").ZodOptional<import("zod").ZodString>;
203
+ service: import("zod").ZodOptional<import("zod").ZodEnum<{
204
+ iMessage: "iMessage";
205
+ SMS: "SMS";
206
+ RCS: "RCS";
207
+ unknown: "unknown";
208
+ }>>;
209
+ }, import("zod/v4/core").$strip>, import("zod").ZodObject<{
210
+ id: import("zod").ZodString;
211
+ type: import("zod").ZodEnum<{
212
+ dm: "dm";
213
+ group: "group";
214
+ }>;
215
+ phone: import("zod").ZodString;
216
+ }, import("zod/v4/core").$strip>, import("zod").ZodObject<{}, import("zod/v4/core").$strip>, IMessageSDK, {
217
+ id: string;
218
+ }, {
219
+ id: string;
220
+ type: "dm" | "group";
221
+ phone: string;
222
+ }, import("zod").ZodObject<{
223
+ partIndex: import("zod").ZodOptional<import("zod").ZodNumber>;
224
+ parentId: import("zod").ZodOptional<import("zod").ZodString>;
225
+ }, import("zod/v4/core").$strip>, IMessageMessage, undefined, {
226
+ background: (space: Space, input: BackgroundInput, options?: {
227
+ mimeType?: string;
228
+ }) => Promise<void>;
229
+ shareContactCard: (space: Space) => Promise<void>;
230
+ }, Record<never, never>, {
231
+ getMessage: ({
232
+ client
233
+ }: {
234
+ client: IMessageSDK;
235
+ config: Record<string, never>;
236
+ store: import("@spectrum-ts/core").Store;
237
+ }, _space: {
238
+ id: string;
239
+ type: "dm" | "group";
240
+ phone: string;
241
+ } & {
242
+ id: string;
243
+ __platform: string;
244
+ }, messageId: string) => Promise<IMessageMessage | undefined>;
245
+ getMembers: () => Promise<never>;
246
+ getAvatar: () => Promise<never>;
247
+ getDisplayName: () => Promise<never>;
248
+ getAttachment: () => Promise<Attachment | undefined>;
249
+ }>> & Readonly<{
250
+ effect: {
251
+ message: {
252
+ readonly balloons: "com.apple.messages.effect.CKBalloonEffect";
253
+ readonly celebration: "com.apple.messages.effect.CKHappyBirthdayEffect";
254
+ readonly confetti: "com.apple.messages.effect.CKConfettiEffect";
255
+ readonly echo: "com.apple.messages.effect.CKEchoEffect";
256
+ readonly fireworks: "com.apple.messages.effect.CKFireworksEffect";
257
+ readonly gentle: "com.apple.MobileSMS.expressivesend.gentle";
258
+ readonly heart: "com.apple.messages.effect.CKHeartEffect";
259
+ readonly invisible: "com.apple.MobileSMS.expressivesend.invisibleink";
260
+ readonly lasers: "com.apple.messages.effect.CKLasersEffect";
261
+ readonly loud: "com.apple.MobileSMS.expressivesend.loud";
262
+ readonly slam: "com.apple.MobileSMS.expressivesend.impact";
263
+ readonly sparkles: "com.apple.messages.effect.CKSparklesEffect";
264
+ readonly spotlight: "com.apple.messages.effect.CKSpotlightEffect";
265
+ };
266
+ };
267
+ }>;
268
+ //#endregion
269
+ export { type BackgroundInput, type ContactCard, type CustomizedMiniApp, type CustomizedMiniAppInput, type CustomizedMiniAppLayout, type IMessageMessageEffect, background, customizedMiniApp, effect, imessage, nativeContactCard, read };
package/dist/index.js ADDED
@@ -0,0 +1,536 @@
1
+ import { IMessageSDK } from "@photon-ai/imessage-kit";
2
+ import { UnsupportedError, appLayoutSchema, definePlatform, fromVCard, read, stream, text, toVCard } from "@spectrum-ts/core";
3
+ import { asAttachment, asContact, asCustom, asReply, asText, buildPhotoAction, messageEffectSchema, photoActionSchema } from "@spectrum-ts/core/authoring";
4
+ import z from "zod";
5
+ import { setTimeout } from "node:timers/promises";
6
+ import { createReadStream } from "node:fs";
7
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { Readable } from "node:stream";
9
+ import { tmpdir } from "node:os";
10
+ import { basename, join } from "node:path";
11
+ //#region ../imessage/src/content/background.ts
12
+ /**
13
+ * iMessage-only chat background content. Lives entirely under the iMessage
14
+ * provider — never enters the universal `Content` discriminated union. The
15
+ * framework recognizes it via two generic content-level contracts:
16
+ *
17
+ * 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
18
+ * `platform/build.ts` reads this tag and warns-and-skips when a different
19
+ * platform receives it.
20
+ * 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check
21
+ * treats this as a side-effecting send that returns no message id, the
22
+ * same way it treats `reaction` / `typing` / `edit`.
23
+ *
24
+ * iMessage's `send` handler narrows back to `Background` via the `isBackground`
25
+ * type guard before dispatching to `chats.setBackground` / `removeBackground`.
26
+ */
27
+ const backgroundSchema = z.object({
28
+ type: z.literal("background"),
29
+ __platform: z.literal("iMessage"),
30
+ __fireAndForget: z.literal(true),
31
+ action: photoActionSchema
32
+ });
33
+ const isBackground = (v) => backgroundSchema.safeParse(v).success;
34
+ function background(input, options) {
35
+ const action = buildPhotoAction(input, options, "background");
36
+ return { build: async () => backgroundSchema.parse({
37
+ type: "background",
38
+ __platform: "iMessage",
39
+ __fireAndForget: true,
40
+ action
41
+ }) };
42
+ }
43
+ //#endregion
44
+ //#region ../imessage/src/content/contact-card.ts
45
+ /**
46
+ * iMessage-only "share contact card" control signal. Pushes the *local
47
+ * account's native contact card* (the name + photo a recipient sees in their
48
+ * Messages app) to a chat via the SDK's `chats.shareContactInfo`.
49
+ *
50
+ * This is Apple's "Share Name and Photo" mechanism — distinct from the
51
+ * universal `contact(...)` content, which uploads an arbitrary person's vCard
52
+ * as a *file* attachment. There is no payload: the card shared is always the
53
+ * bot account's own.
54
+ *
55
+ * Like `background`, it lives entirely under the iMessage provider and never
56
+ * enters the universal `Content` discriminated union. The framework recognizes
57
+ * it via two generic content-level contracts:
58
+ *
59
+ * 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
60
+ * `platform/build.ts` reads this tag and warns-and-skips when a different
61
+ * platform receives it.
62
+ * 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check treats
63
+ * this as a side-effecting send that returns no message id, the same way it
64
+ * treats `read` / `typing`.
65
+ *
66
+ * iMessage's `send` handler narrows back via the `isContactCard` type guard
67
+ * before dispatching to `chats.shareContactInfo`.
68
+ */
69
+ const contactCardSchema = z.object({
70
+ type: z.literal("contactCard"),
71
+ __platform: z.literal("iMessage"),
72
+ __fireAndForget: z.literal(true)
73
+ });
74
+ const isContactCard = (v) => contactCardSchema.safeParse(v).success;
75
+ /**
76
+ * Share the bot account's native iMessage contact card (name + photo) with the
77
+ * chat. iMessage-only, remote-only.
78
+ *
79
+ * `space.send(nativeContactCard())` is the canonical form; `space.shareContactCard()`
80
+ * is sugar attached via `PlatformDef.space.actions` (only typed on
81
+ * `PlatformSpace<IMessageDef>`).
82
+ *
83
+ * This is an explicit, on-demand share and always fires — unlike the automatic
84
+ * best-effort share gated behind the `imessageSynced` project profile, which
85
+ * dedupes to once per chat per 24h (see `remote/contact-share.ts`). Works in
86
+ * both DMs and group chats; the recipient chooses whether to accept the card.
87
+ *
88
+ * `ContactCard` is intentionally not a member of the universal `Content`
89
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
90
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
91
+ * The framework treats it as a fire-and-forget control signal at runtime.
92
+ */
93
+ function nativeContactCard() {
94
+ return { build: async () => contactCardSchema.parse({
95
+ type: "contactCard",
96
+ __platform: "iMessage",
97
+ __fireAndForget: true
98
+ }) };
99
+ }
100
+ //#endregion
101
+ //#region ../imessage/src/content/customized-mini-app.ts
102
+ const layoutSchema = appLayoutSchema;
103
+ /**
104
+ * iMessage-only mini-app card content. Lives entirely under the iMessage
105
+ * provider — never enters the universal `Content` discriminated union. The
106
+ * framework recognizes it via the generic content-level platform contract:
107
+ *
108
+ * - `__platform: "iMessage"` — `findUnsupportedPlatformContent` reads this tag
109
+ * and warns-and-skips when a different platform receives it.
110
+ *
111
+ * Unlike `background` / `read`, this content is **not** `__fireAndForget`: it
112
+ * produces a real outbound message, so the iMessage `send` handler narrows
113
+ * back to `CustomizedMiniApp` via the `isCustomizedMiniApp` guard and returns
114
+ * the resulting `ProviderMessageRecord` (rather than `void`).
115
+ */
116
+ const customizedMiniAppSchema = z.object({
117
+ type: z.literal("customized-mini-app"),
118
+ __platform: z.literal("iMessage"),
119
+ appName: z.string().nonempty(),
120
+ appStoreId: z.number().int().positive().optional(),
121
+ extensionBundleId: z.string().nonempty(),
122
+ layout: layoutSchema,
123
+ live: z.boolean().optional(),
124
+ teamId: z.string(),
125
+ url: z.url()
126
+ });
127
+ const isCustomizedMiniApp = (v) => customizedMiniAppSchema.safeParse(v).success;
128
+ const asCustomizedMiniApp = (input) => customizedMiniAppSchema.parse({
129
+ type: "customized-mini-app",
130
+ __platform: "iMessage",
131
+ ...input
132
+ });
133
+ /**
134
+ * Construct a `customized-mini-app` content value. iMessage-only, remote-only.
135
+ *
136
+ * The layout is what recipients see in the bubble. `teamId` and
137
+ * `extensionBundleId` identify the iMessage extension that receives `url` when
138
+ * the recipient taps the card; the server constructs the matching
139
+ * `MSMessageExtensionBalloonPlugin` plugin id from these values. `appStoreId`
140
+ * is optional and only points recipients without the extension at its App
141
+ * Store entry. `live` is optional; when omitted, the remote server keeps the
142
+ * static layout preview visible.
143
+ *
144
+ * `space.send(customizedMiniApp(...))` is the canonical form.
145
+ *
146
+ * `CustomizedMiniApp` is intentionally not a member of the universal `Content`
147
+ * union — the `as unknown as Content` cast keeps the builder shape compatible
148
+ * with the framework's `ContentBuilder.build(): Promise<Content>` signature.
149
+ */
150
+ function customizedMiniApp(input) {
151
+ return { build: async () => asCustomizedMiniApp(input) };
152
+ }
153
+ //#endregion
154
+ //#region ../imessage/src/content/effect.ts
155
+ const messageEffects = {
156
+ balloons: "com.apple.messages.effect.CKBalloonEffect",
157
+ celebration: "com.apple.messages.effect.CKHappyBirthdayEffect",
158
+ confetti: "com.apple.messages.effect.CKConfettiEffect",
159
+ echo: "com.apple.messages.effect.CKEchoEffect",
160
+ fireworks: "com.apple.messages.effect.CKFireworksEffect",
161
+ gentle: "com.apple.MobileSMS.expressivesend.gentle",
162
+ heart: "com.apple.messages.effect.CKHeartEffect",
163
+ invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
164
+ lasers: "com.apple.messages.effect.CKLasersEffect",
165
+ loud: "com.apple.MobileSMS.expressivesend.loud",
166
+ slam: "com.apple.MobileSMS.expressivesend.impact",
167
+ sparkles: "com.apple.messages.effect.CKSparklesEffect",
168
+ spotlight: "com.apple.messages.effect.CKSpotlightEffect"
169
+ };
170
+ const SUPPORTED_EFFECTS = new Set(Object.values(messageEffects));
171
+ const resolveContent = (input) => typeof input === "string" ? text(input).build() : input.build();
172
+ function effect(input, messageEffect) {
173
+ return { build: async () => {
174
+ if (!SUPPORTED_EFFECTS.has(messageEffect)) throw new Error(`Unsupported iMessage message effect "${messageEffect}"`);
175
+ const inner = await resolveContent(input);
176
+ if (inner.type !== "text" && inner.type !== "markdown" && inner.type !== "attachment") throw new Error(`imessage effect() only supports text, markdown, and attachment content, got "${inner.type}"`);
177
+ return messageEffectSchema.parse({
178
+ type: "effect",
179
+ content: inner,
180
+ effect: messageEffect
181
+ });
182
+ } };
183
+ }
184
+ //#endregion
185
+ //#region src/ids.ts
186
+ const dmChatGuid = (address) => `any;-;${address}`;
187
+ const chatTypeFromGuid = (guid) => guid.includes(";+;") ? "group" : "dm";
188
+ const addTextPart = (parts, text) => {
189
+ const trimmed = text?.trim();
190
+ if (trimmed) parts.push({
191
+ type: "text",
192
+ text: trimmed
193
+ });
194
+ };
195
+ const hasUsableTextPart = (text) => text?.split("").some((segment) => segment.trim()) ?? false;
196
+ const addAttachmentParts = (parts, attachments) => {
197
+ for (const attachment of attachments) if (attachment) parts.push({
198
+ type: "attachment",
199
+ attachment
200
+ });
201
+ };
202
+ const toOrderedParts = (text, attachments) => {
203
+ const parts = [];
204
+ if (!text) {
205
+ addAttachmentParts(parts, attachments);
206
+ return parts;
207
+ }
208
+ if (!text.includes("")) {
209
+ addAttachmentParts(parts, attachments);
210
+ addTextPart(parts, text);
211
+ return parts;
212
+ }
213
+ const textSegments = text.split("");
214
+ for (let i = 0; i < attachments.length; i++) {
215
+ addTextPart(parts, textSegments[i]);
216
+ const attachment = attachments[i];
217
+ if (attachment) parts.push({
218
+ type: "attachment",
219
+ attachment
220
+ });
221
+ }
222
+ addTextPart(parts, textSegments.slice(attachments.length).join(""));
223
+ return parts;
224
+ };
225
+ //#endregion
226
+ //#region ../imessage/src/shared/vcard.ts
227
+ const VCARD_MIME_TYPES = new Set([
228
+ "text/vcard",
229
+ "text/x-vcard",
230
+ "text/directory",
231
+ "application/vcard",
232
+ "application/x-vcard"
233
+ ]);
234
+ const normalizeMimeType = (mimeType) => (mimeType.split(";")[0] ?? "").trim().toLowerCase();
235
+ const isVCardAttachment = (mimeType, fileName) => {
236
+ if (mimeType && VCARD_MIME_TYPES.has(normalizeMimeType(mimeType))) return true;
237
+ return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
238
+ };
239
+ const vcardFileName = (contact) => {
240
+ return `${(contact.name?.formatted ?? contact.user?.id ?? "contact").replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
241
+ };
242
+ const readLocalAttachment = async (att) => {
243
+ if (!att.localPath) throw new Error(`iMessage attachment ${att.id} has no local file available on disk`);
244
+ return readFile(att.localPath);
245
+ };
246
+ const toAttachmentContent = (att) => {
247
+ const { localPath } = att;
248
+ return asAttachment({
249
+ id: att.id,
250
+ name: att.fileName ?? "attachment",
251
+ mimeType: att.mimeType,
252
+ size: att.sizeBytes,
253
+ read: () => readLocalAttachment(att),
254
+ stream: localPath ? async () => Readable.toWeb(createReadStream(localPath)) : void 0
255
+ });
256
+ };
257
+ const toVCardContent = async (att) => {
258
+ try {
259
+ return asContact(fromVCard((await readLocalAttachment(att)).toString("utf8")));
260
+ } catch {
261
+ return toAttachmentContent(att);
262
+ }
263
+ };
264
+ const localAttachmentContent = async (att) => isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent(att) : toAttachmentContent(att);
265
+ //#endregion
266
+ //#region src/local/inbound.ts
267
+ const ATTACHMENT_JOIN_RETRY_DELAY_MS = 250;
268
+ const ATTACHMENT_JOIN_RETRY_LIMIT = 8;
269
+ const ATTACHMENT_JOIN_FETCH_LIMIT = 10;
270
+ const hasAttachmentPlaceholder = (message) => message.text?.includes("") ?? false;
271
+ const isPendingAttachmentJoin = (message) => message.attachments.length === 0 && (message.hasAttachments || hasAttachmentPlaceholder(message));
272
+ const replyTargetId = (message) => message.threadRootMessageId ?? void 0;
273
+ const stubReplyTarget = (space, targetId) => ({
274
+ id: targetId,
275
+ content: asCustom({
276
+ imessage_type: "reply-target",
277
+ stub: true
278
+ }),
279
+ space
280
+ });
281
+ const asProviderReply = (content, target) => asReply({
282
+ content,
283
+ target
284
+ });
285
+ const wrapReply = (messages, targetId) => {
286
+ if (!targetId) return messages;
287
+ return messages.map((message) => ({
288
+ ...message,
289
+ content: asProviderReply(message.content, stubReplyTarget(message.space, targetId))
290
+ }));
291
+ };
292
+ const refetchUntilAttachmentsSettle = async (client, message) => {
293
+ if (!message.chatId) return message;
294
+ for (let attempt = 0; attempt < ATTACHMENT_JOIN_RETRY_LIMIT; attempt += 1) {
295
+ await setTimeout(ATTACHMENT_JOIN_RETRY_DELAY_MS);
296
+ let rows;
297
+ try {
298
+ rows = await client.getMessages({
299
+ chatId: message.chatId,
300
+ limit: ATTACHMENT_JOIN_FETCH_LIMIT,
301
+ since: message.createdAt
302
+ });
303
+ } catch {
304
+ continue;
305
+ }
306
+ const refreshed = rows.find((row) => row.id === message.id);
307
+ if (refreshed && !isPendingAttachmentJoin(refreshed)) return refreshed;
308
+ }
309
+ return message;
310
+ };
311
+ const toMessages = async (message) => {
312
+ const { chatId, chatKind } = message;
313
+ if (!chatId || chatKind === "unknown") return [];
314
+ if (message.reaction !== null || message.kind !== "text" || message.retractedAt !== null) return [];
315
+ if (isPendingAttachmentJoin(message)) return [];
316
+ const base = {
317
+ sender: {
318
+ id: message.participant ?? "",
319
+ ...message.participant ? { address: message.participant } : {}
320
+ },
321
+ space: {
322
+ id: chatId,
323
+ type: chatKind === "group" ? "group" : "dm",
324
+ phone: ""
325
+ },
326
+ timestamp: message.createdAt
327
+ };
328
+ const targetId = replyTargetId(message);
329
+ if (message.attachments.length > 0) {
330
+ if (!hasUsableTextPart(message.text)) return wrapReply(await Promise.all(message.attachments.map(async (att) => ({
331
+ ...base,
332
+ id: `${message.id}:${att.id}`,
333
+ content: await localAttachmentContent(att)
334
+ }))), targetId);
335
+ const parts = toOrderedParts(message.text, message.attachments);
336
+ const messages = [];
337
+ for (let i = 0; i < parts.length; i++) {
338
+ const part = parts[i];
339
+ if (!part) continue;
340
+ messages.push(part.type === "text" ? {
341
+ ...base,
342
+ id: `${message.id}:text:${i}`,
343
+ content: asText(part.text),
344
+ partIndex: i
345
+ } : {
346
+ ...base,
347
+ id: `${message.id}:${part.attachment.id}`,
348
+ content: await localAttachmentContent(part.attachment),
349
+ partIndex: i
350
+ });
351
+ }
352
+ return wrapReply(messages, targetId);
353
+ }
354
+ return wrapReply([{
355
+ ...base,
356
+ id: message.id,
357
+ content: {
358
+ type: "text",
359
+ text: message.text ?? ""
360
+ }
361
+ }], targetId);
362
+ };
363
+ const messages$1 = (client) => stream((emit, end) => {
364
+ let lastPromise = Promise.resolve();
365
+ const handleIncoming = async (message) => {
366
+ const ms = await toMessages(isPendingAttachmentJoin(message) ? await refetchUntilAttachmentsSettle(client, message) : message);
367
+ for (const m of ms) await emit(m);
368
+ };
369
+ const startPromise = client.startWatching({
370
+ onIncomingMessage: (message) => {
371
+ lastPromise = lastPromise.then(() => handleIncoming(message)).catch(end);
372
+ },
373
+ onError: end
374
+ }).catch(end);
375
+ return async () => {
376
+ await startPromise.catch(() => {});
377
+ await client.stopWatching();
378
+ await lastPromise.catch(() => {});
379
+ };
380
+ });
381
+ //#endregion
382
+ //#region ../imessage/src/shared/errors.ts
383
+ const LOCAL_IMESSAGE_PLATFORM = "iMessage (local mode)";
384
+ const unsupportedLocalContent = (type, detail) => UnsupportedError.content(type, LOCAL_IMESSAGE_PLATFORM, detail);
385
+ //#endregion
386
+ //#region src/local/send.ts
387
+ const synthRecord = (spaceId, content) => ({
388
+ id: crypto.randomUUID(),
389
+ content,
390
+ space: { id: spaceId },
391
+ timestamp: /* @__PURE__ */ new Date()
392
+ });
393
+ const sendTempFile = async (client, spaceId, name, data) => {
394
+ const safeName = basename(name) || "attachment";
395
+ const dir = await mkdtemp(join(tmpdir(), "spectrum-"));
396
+ const tmp = join(dir, safeName);
397
+ await writeFile(tmp, data);
398
+ try {
399
+ await client.send({
400
+ to: spaceId,
401
+ attachments: [tmp]
402
+ });
403
+ } finally {
404
+ await rm(dir, {
405
+ recursive: true,
406
+ force: true
407
+ }).catch(() => {});
408
+ }
409
+ };
410
+ const send$1 = async (client, spaceId, content) => {
411
+ switch (content.type) {
412
+ case "text":
413
+ await client.send({
414
+ to: spaceId,
415
+ text: content.text
416
+ });
417
+ return synthRecord(spaceId, content);
418
+ case "attachment":
419
+ await sendTempFile(client, spaceId, content.name, await content.read());
420
+ return synthRecord(spaceId, content);
421
+ case "contact": {
422
+ const vcf = await toVCard(content);
423
+ await sendTempFile(client, spaceId, vcardFileName(content), Buffer.from(vcf, "utf8"));
424
+ return synthRecord(spaceId, content);
425
+ }
426
+ case "effect": throw unsupportedLocalContent("effect", "message effects require remote iMessage");
427
+ case "poll": throw unsupportedLocalContent("poll");
428
+ default: throw unsupportedLocalContent(content.type);
429
+ }
430
+ };
431
+ const getMessage$1 = async (_client, _id) => void 0;
432
+ //#endregion
433
+ //#region src/local/api.ts
434
+ const messages = (client) => messages$1(client);
435
+ const send = (client, spaceId, content) => send$1(client, spaceId, content);
436
+ const getMessage = (client, id) => getMessage$1(client, id);
437
+ //#endregion
438
+ //#region src/types.ts
439
+ const configSchema = z.object({});
440
+ const userSchema = z.object({
441
+ address: z.string().optional(),
442
+ country: z.string().optional(),
443
+ service: z.enum([
444
+ "iMessage",
445
+ "SMS",
446
+ "RCS",
447
+ "unknown"
448
+ ]).optional()
449
+ });
450
+ const spaceSchema = z.object({
451
+ id: z.string(),
452
+ type: z.enum(["dm", "group"]),
453
+ phone: z.string()
454
+ });
455
+ const spaceParamsSchema = z.object({});
456
+ const messageSchema = z.object({
457
+ partIndex: z.number().int().nonnegative().optional(),
458
+ parentId: z.string().optional()
459
+ });
460
+ //#endregion
461
+ //#region src/index.ts
462
+ const LOCAL_PLATFORM = "iMessage (local mode)";
463
+ const unsupportedAction = (action, detail) => {
464
+ throw UnsupportedError.action(action, LOCAL_PLATFORM, detail);
465
+ };
466
+ const handleLocalOnlySend = async (client, spaceId, content) => {
467
+ if (content.type === "typing") return;
468
+ if (content.type === "app") return await send(client, spaceId, await text(await content.url()).build());
469
+ if (content.type === "edit") return unsupportedAction("edit");
470
+ if (content.type === "unsend") return unsupportedAction("unsend");
471
+ if (content.type === "streamText") return unsupportedAction("streamText", "streaming text responses require remote iMessage");
472
+ if (content.type === "rename") return unsupportedAction("rename", "renaming chats requires remote iMessage");
473
+ if (content.type === "avatar") return unsupportedAction("avatar", "setting group avatars requires remote iMessage");
474
+ if (content.type === "addMember") return unsupportedAction("addMember", "adding members requires remote iMessage");
475
+ if (content.type === "removeMember") return unsupportedAction("removeMember", "removing members requires remote iMessage");
476
+ if (content.type === "leaveSpace") return unsupportedAction("leaveSpace", "leaving chats requires remote iMessage");
477
+ if (content.type === "read") return unsupportedAction("read", "marking chats as read requires remote iMessage");
478
+ if (content.type === "reply") return unsupportedAction("reply");
479
+ if (content.type === "reaction") return unsupportedAction("react");
480
+ if (isBackground(content)) return unsupportedAction("background", "chat backgrounds require remote iMessage");
481
+ if (isContactCard(content)) return unsupportedAction("shareContactCard", "sharing the contact card requires remote iMessage");
482
+ if (isCustomizedMiniApp(content)) return unsupportedAction("customized-mini-app", "mini app cards require remote iMessage");
483
+ return await send(client, spaceId, content);
484
+ };
485
+ const imessage = definePlatform("iMessage", {
486
+ config: configSchema,
487
+ static: { effect: { message: messageEffects } },
488
+ lifecycle: {
489
+ createClient: async () => new IMessageSDK(),
490
+ destroyClient: async ({ client }) => {
491
+ await client.close();
492
+ }
493
+ },
494
+ user: {
495
+ schema: userSchema,
496
+ resolve: async ({ input }) => ({ id: input.userID })
497
+ },
498
+ space: {
499
+ schema: spaceSchema,
500
+ params: spaceParamsSchema,
501
+ create: async ({ input }) => {
502
+ if (input.users.length === 0) throw new Error("iMessage space creation requires at least one user");
503
+ if (input.users.length > 1) return unsupportedAction("space.create", "local mode cannot create group chats — use space.get(chatGuid) for an existing group");
504
+ return {
505
+ id: dmChatGuid(input.users[0]?.id ?? ""),
506
+ type: "dm",
507
+ phone: ""
508
+ };
509
+ },
510
+ get: async ({ input }) => ({
511
+ id: input.id,
512
+ type: chatTypeFromGuid(input.id),
513
+ phone: ""
514
+ }),
515
+ actions: {
516
+ background: async (space, input, options) => {
517
+ await space.send(background(input, options));
518
+ },
519
+ shareContactCard: async (space) => {
520
+ await space.send(nativeContactCard());
521
+ }
522
+ }
523
+ },
524
+ message: { schema: messageSchema },
525
+ messages: ({ client }) => messages(client),
526
+ send: async ({ space, content, client }) => handleLocalOnlySend(client, space.id, content),
527
+ actions: {
528
+ getMessage: async ({ client }, _space, messageId) => getMessage(client, messageId),
529
+ getMembers: async () => unsupportedAction("getMembers", "listing members requires remote iMessage"),
530
+ getAvatar: async () => unsupportedAction("getAvatar", "fetching group avatars requires remote iMessage"),
531
+ getDisplayName: async () => unsupportedAction("getDisplayName", "reading chat display names requires remote iMessage"),
532
+ getAttachment: async () => unsupportedAction("getAttachment", "fetching attachments by GUID requires remote iMessage")
533
+ }
534
+ });
535
+ //#endregion
536
+ export { background, customizedMiniApp, effect, imessage, nativeContactCard, read };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@spectrum-ts/imessage-local",
3
+ "version": "10.0.0",
4
+ "description": "Local macOS iMessage provider for spectrum-ts.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/photon-hq/spectrum-ts.git",
8
+ "directory": "packages/imessage-local"
9
+ },
10
+ "homepage": "https://photon.codes/spectrum",
11
+ "bugs": {
12
+ "url": "https://github.com/photon-hq/spectrum-ts/issues"
13
+ },
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@photon-ai/imessage-kit": "^3.0.0",
30
+ "zod": "^4.2.1"
31
+ },
32
+ "peerDependencies": {
33
+ "@spectrum-ts/core": "^10.0.0",
34
+ "typescript": "^5 || ^6.0.0"
35
+ },
36
+ "license": "MIT"
37
+ }