@spectrum-ts/imessage 5.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 +21 -0
- package/README.md +34 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +2153 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2153 @@
|
|
|
1
|
+
import { MessageEffect, NotFoundError, ValidationError, createClient } from "@photon-ai/advanced-imessage";
|
|
2
|
+
import { IMessageSDK } from "@photon-ai/imessage-kit";
|
|
3
|
+
import { sanitizePhone, withSpan } from "@photon-ai/otel";
|
|
4
|
+
import { UnsupportedError, appLayoutSchema, cloud, definePlatform, fromVCard, mergeStreams, read, stream, text, toVCard } from "@spectrum-ts/core";
|
|
5
|
+
import { asAttachment, asContact, asCustom, asGroup, asPoll, asPollOption, asRichlink, asText, buildPhotoAction, createLogger, ensureM4a, errorAttrs, groupSchema, messageEffectSchema, photoActionSchema, reactionSchema, resumableOrderedStream, sanitizeErrorMessage } from "@spectrum-ts/core/authoring";
|
|
6
|
+
import z from "zod";
|
|
7
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
8
|
+
import { createReadStream } from "node:fs";
|
|
9
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
import { Marked } from "marked";
|
|
14
|
+
import { LRUCache } from "lru-cache";
|
|
15
|
+
//#region src/content/background.ts
|
|
16
|
+
/**
|
|
17
|
+
* iMessage-only chat background content. Lives entirely under the iMessage
|
|
18
|
+
* provider — never enters the universal `Content` discriminated union. The
|
|
19
|
+
* framework recognizes it via two generic content-level contracts:
|
|
20
|
+
*
|
|
21
|
+
* 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
|
|
22
|
+
* `platform/build.ts` reads this tag and warns-and-skips when a different
|
|
23
|
+
* platform receives it.
|
|
24
|
+
* 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check
|
|
25
|
+
* treats this as a side-effecting send that returns no message id, the
|
|
26
|
+
* same way it treats `reaction` / `typing` / `edit`.
|
|
27
|
+
*
|
|
28
|
+
* iMessage's `send` handler narrows back to `Background` via the `isBackground`
|
|
29
|
+
* type guard before dispatching to `chats.setBackground` / `removeBackground`.
|
|
30
|
+
*/
|
|
31
|
+
const backgroundSchema = z.object({
|
|
32
|
+
type: z.literal("background"),
|
|
33
|
+
__platform: z.literal("iMessage"),
|
|
34
|
+
__fireAndForget: z.literal(true),
|
|
35
|
+
action: photoActionSchema
|
|
36
|
+
});
|
|
37
|
+
const isBackground = (v) => backgroundSchema.safeParse(v).success;
|
|
38
|
+
function background(input, options) {
|
|
39
|
+
const action = buildPhotoAction(input, options, "background");
|
|
40
|
+
return { build: async () => backgroundSchema.parse({
|
|
41
|
+
type: "background",
|
|
42
|
+
__platform: "iMessage",
|
|
43
|
+
__fireAndForget: true,
|
|
44
|
+
action
|
|
45
|
+
}) };
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/content/contact-card.ts
|
|
49
|
+
/**
|
|
50
|
+
* iMessage-only "share contact card" control signal. Pushes the *local
|
|
51
|
+
* account's native contact card* (the name + photo a recipient sees in their
|
|
52
|
+
* Messages app) to a chat via the SDK's `chats.shareContactInfo`.
|
|
53
|
+
*
|
|
54
|
+
* This is Apple's "Share Name and Photo" mechanism — distinct from the
|
|
55
|
+
* universal `contact(...)` content, which uploads an arbitrary person's vCard
|
|
56
|
+
* as a *file* attachment. There is no payload: the card shared is always the
|
|
57
|
+
* bot account's own.
|
|
58
|
+
*
|
|
59
|
+
* Like `background`, it lives entirely under the iMessage provider and never
|
|
60
|
+
* enters the universal `Content` discriminated union. The framework recognizes
|
|
61
|
+
* it via two generic content-level contracts:
|
|
62
|
+
*
|
|
63
|
+
* 1. `__platform: "iMessage"` — `findUnsupportedPlatformContent` in
|
|
64
|
+
* `platform/build.ts` reads this tag and warns-and-skips when a different
|
|
65
|
+
* platform receives it.
|
|
66
|
+
* 2. `__fireAndForget: true` — `dispatchSend`'s fire-and-forget check treats
|
|
67
|
+
* this as a side-effecting send that returns no message id, the same way it
|
|
68
|
+
* treats `read` / `typing`.
|
|
69
|
+
*
|
|
70
|
+
* iMessage's `send` handler narrows back via the `isContactCard` type guard
|
|
71
|
+
* before dispatching to `chats.shareContactInfo`.
|
|
72
|
+
*/
|
|
73
|
+
const contactCardSchema = z.object({
|
|
74
|
+
type: z.literal("contactCard"),
|
|
75
|
+
__platform: z.literal("iMessage"),
|
|
76
|
+
__fireAndForget: z.literal(true)
|
|
77
|
+
});
|
|
78
|
+
const isContactCard = (v) => contactCardSchema.safeParse(v).success;
|
|
79
|
+
/**
|
|
80
|
+
* Share the bot account's native iMessage contact card (name + photo) with the
|
|
81
|
+
* chat. iMessage-only, remote-only.
|
|
82
|
+
*
|
|
83
|
+
* `space.send(nativeContactCard())` is the canonical form; `space.shareContactCard()`
|
|
84
|
+
* is sugar attached via `PlatformDef.space.actions` (only typed on
|
|
85
|
+
* `PlatformSpace<IMessageDef>`).
|
|
86
|
+
*
|
|
87
|
+
* This is an explicit, on-demand share and always fires — unlike the automatic
|
|
88
|
+
* best-effort share gated behind the `imessageSynced` project profile, which
|
|
89
|
+
* dedupes to once per chat per 24h (see `remote/contact-share.ts`). Works in
|
|
90
|
+
* both DMs and group chats; the recipient chooses whether to accept the card.
|
|
91
|
+
*
|
|
92
|
+
* `ContactCard` is intentionally not a member of the universal `Content`
|
|
93
|
+
* union — the `as unknown as Content` cast keeps the builder shape compatible
|
|
94
|
+
* with the framework's `ContentBuilder.build(): Promise<Content>` signature.
|
|
95
|
+
* The framework treats it as a fire-and-forget control signal at runtime.
|
|
96
|
+
*/
|
|
97
|
+
function nativeContactCard() {
|
|
98
|
+
return { build: async () => contactCardSchema.parse({
|
|
99
|
+
type: "contactCard",
|
|
100
|
+
__platform: "iMessage",
|
|
101
|
+
__fireAndForget: true
|
|
102
|
+
}) };
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/content/customized-mini-app.ts
|
|
106
|
+
const layoutSchema = appLayoutSchema;
|
|
107
|
+
/**
|
|
108
|
+
* iMessage-only mini-app card content. Lives entirely under the iMessage
|
|
109
|
+
* provider — never enters the universal `Content` discriminated union. The
|
|
110
|
+
* framework recognizes it via the generic content-level platform contract:
|
|
111
|
+
*
|
|
112
|
+
* - `__platform: "iMessage"` — `findUnsupportedPlatformContent` reads this tag
|
|
113
|
+
* and warns-and-skips when a different platform receives it.
|
|
114
|
+
*
|
|
115
|
+
* Unlike `background` / `read`, this content is **not** `__fireAndForget`: it
|
|
116
|
+
* produces a real outbound message, so the iMessage `send` handler narrows
|
|
117
|
+
* back to `CustomizedMiniApp` via the `isCustomizedMiniApp` guard and returns
|
|
118
|
+
* the resulting `ProviderMessageRecord` (rather than `void`).
|
|
119
|
+
*/
|
|
120
|
+
const customizedMiniAppSchema = z.object({
|
|
121
|
+
type: z.literal("customized-mini-app"),
|
|
122
|
+
__platform: z.literal("iMessage"),
|
|
123
|
+
appName: z.string().nonempty(),
|
|
124
|
+
appStoreId: z.number().int().positive().optional(),
|
|
125
|
+
extensionBundleId: z.string().nonempty(),
|
|
126
|
+
layout: layoutSchema,
|
|
127
|
+
teamId: z.string(),
|
|
128
|
+
url: z.url()
|
|
129
|
+
});
|
|
130
|
+
const isCustomizedMiniApp = (v) => customizedMiniAppSchema.safeParse(v).success;
|
|
131
|
+
const asCustomizedMiniApp = (input) => customizedMiniAppSchema.parse({
|
|
132
|
+
type: "customized-mini-app",
|
|
133
|
+
__platform: "iMessage",
|
|
134
|
+
...input
|
|
135
|
+
});
|
|
136
|
+
/**
|
|
137
|
+
* Construct a `customized-mini-app` content value. iMessage-only, remote-only.
|
|
138
|
+
*
|
|
139
|
+
* The layout is what recipients see in the bubble. `teamId` and
|
|
140
|
+
* `extensionBundleId` identify the iMessage extension that receives `url` when
|
|
141
|
+
* the recipient taps the card; the server constructs the matching
|
|
142
|
+
* `MSMessageExtensionBalloonPlugin` plugin id from these values. `appStoreId`
|
|
143
|
+
* is optional and only points recipients without the extension at its App
|
|
144
|
+
* Store entry.
|
|
145
|
+
*
|
|
146
|
+
* `space.send(customizedMiniApp(...))` is the canonical form.
|
|
147
|
+
*
|
|
148
|
+
* `CustomizedMiniApp` is intentionally not a member of the universal `Content`
|
|
149
|
+
* union — the `as unknown as Content` cast keeps the builder shape compatible
|
|
150
|
+
* with the framework's `ContentBuilder.build(): Promise<Content>` signature.
|
|
151
|
+
*/
|
|
152
|
+
function customizedMiniApp(input) {
|
|
153
|
+
return { build: async () => asCustomizedMiniApp(input) };
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/content/effect.ts
|
|
157
|
+
const SUPPORTED_EFFECTS = new Set(Object.values(MessageEffect));
|
|
158
|
+
const resolveContent = (input) => typeof input === "string" ? text(input).build() : input.build();
|
|
159
|
+
function effect(input, messageEffect) {
|
|
160
|
+
return { build: async () => {
|
|
161
|
+
if (!SUPPORTED_EFFECTS.has(messageEffect)) throw new Error(`Unsupported iMessage message effect "${messageEffect}"`);
|
|
162
|
+
const inner = await resolveContent(input);
|
|
163
|
+
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}"`);
|
|
164
|
+
return messageEffectSchema.parse({
|
|
165
|
+
type: "effect",
|
|
166
|
+
content: inner,
|
|
167
|
+
effect: messageEffect
|
|
168
|
+
});
|
|
169
|
+
} };
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/types.ts
|
|
173
|
+
/**
|
|
174
|
+
* Sentinel phone for shared-token mode. The single shared client serves an
|
|
175
|
+
* unknown set of numbers (the SDK exposes no recipient field on inbound and
|
|
176
|
+
* no `from` parameter on send), so all routing through it tags this sentinel.
|
|
177
|
+
*/
|
|
178
|
+
const SHARED_PHONE = "shared";
|
|
179
|
+
const isLocal = (client) => client instanceof IMessageSDK;
|
|
180
|
+
const clientEntry = z.object({
|
|
181
|
+
address: z.string(),
|
|
182
|
+
token: z.string(),
|
|
183
|
+
phone: z.string()
|
|
184
|
+
});
|
|
185
|
+
const configSchema = z.union([z.object({ local: z.literal(true) }), z.object({
|
|
186
|
+
local: z.literal(false).optional().default(false),
|
|
187
|
+
clients: clientEntry.or(z.array(clientEntry)).optional()
|
|
188
|
+
})]);
|
|
189
|
+
z.object({});
|
|
190
|
+
const spaceSchema = z.object({
|
|
191
|
+
id: z.string(),
|
|
192
|
+
type: z.enum(["dm", "group"]),
|
|
193
|
+
phone: z.string()
|
|
194
|
+
});
|
|
195
|
+
const spaceParamsSchema = z.object({ phone: z.string().optional() });
|
|
196
|
+
/**
|
|
197
|
+
* iMessage-specific per-message metadata surfaced on `IMessageMessage`.
|
|
198
|
+
* - `partIndex`: attachment index within a multi-part message (0 for bare
|
|
199
|
+
* or single-attachment messages; 0..N-1 for a group's sub-items).
|
|
200
|
+
* - `parentId`: guid of the parent message for a group sub-item. Undefined
|
|
201
|
+
* when the message itself is the parent.
|
|
202
|
+
*/
|
|
203
|
+
const messageSchema = z.object({
|
|
204
|
+
partIndex: z.number().int().nonnegative().optional(),
|
|
205
|
+
parentId: z.string().optional()
|
|
206
|
+
});
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/auth.ts
|
|
209
|
+
const log$3 = createLogger("spectrum.imessage.auth");
|
|
210
|
+
const RENEWAL_RATIO = .8;
|
|
211
|
+
const EXPIRY_BUFFER_MS = 3e4;
|
|
212
|
+
const RETRY_DELAY_MS = 3e4;
|
|
213
|
+
const cloudAuthState = /* @__PURE__ */ new WeakMap();
|
|
214
|
+
const requirePhone = (data, instanceId) => {
|
|
215
|
+
const phone = data.numbers?.[instanceId];
|
|
216
|
+
if (!phone) throw new Error(`iMessage instance ${instanceId} has no phone assigned`);
|
|
217
|
+
return phone;
|
|
218
|
+
};
|
|
219
|
+
async function createCloudClients(projectId, projectSecret) {
|
|
220
|
+
let tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
221
|
+
let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
222
|
+
let disposed = false;
|
|
223
|
+
let renewalTimer;
|
|
224
|
+
let refreshFailures = 0;
|
|
225
|
+
const records = [];
|
|
226
|
+
const syncPhones = (data) => {
|
|
227
|
+
for (const { entry, instanceId } of records) entry.phone = requirePhone(data, instanceId);
|
|
228
|
+
};
|
|
229
|
+
const onRefreshSuccess = () => {
|
|
230
|
+
if (refreshFailures > 0) {
|
|
231
|
+
log$3.info("imessage token refresh recovered", { "spectrum.imessage.auth.attempt": refreshFailures });
|
|
232
|
+
refreshFailures = 0;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
const onRefreshFailure = (error) => {
|
|
236
|
+
refreshFailures += 1;
|
|
237
|
+
log$3.warn("imessage token refresh failed; retrying", {
|
|
238
|
+
"spectrum.imessage.auth.attempt": refreshFailures,
|
|
239
|
+
"spectrum.imessage.auth.retry_in_ms": RETRY_DELAY_MS,
|
|
240
|
+
...errorAttrs(error)
|
|
241
|
+
}, error);
|
|
242
|
+
};
|
|
243
|
+
const scheduleRenewal = () => {
|
|
244
|
+
if (disposed) return;
|
|
245
|
+
const ttlMs = tokenData.expiresIn * 1e3;
|
|
246
|
+
const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
|
|
247
|
+
renewalTimer = setTimeout(async () => {
|
|
248
|
+
try {
|
|
249
|
+
tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
250
|
+
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
251
|
+
if (tokenData.type === "dedicated") syncPhones(tokenData);
|
|
252
|
+
onRefreshSuccess();
|
|
253
|
+
scheduleRenewal();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
onRefreshFailure(error);
|
|
256
|
+
renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
|
|
257
|
+
renewalTimer?.unref?.();
|
|
258
|
+
}
|
|
259
|
+
}, renewInMs);
|
|
260
|
+
renewalTimer?.unref?.();
|
|
261
|
+
};
|
|
262
|
+
scheduleRenewal();
|
|
263
|
+
const refreshIfNeeded = async () => {
|
|
264
|
+
if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
|
|
265
|
+
tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
266
|
+
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
267
|
+
if (tokenData.type === "dedicated") syncPhones(tokenData);
|
|
268
|
+
onRefreshSuccess();
|
|
269
|
+
scheduleRenewal();
|
|
270
|
+
};
|
|
271
|
+
if (tokenData.type === "shared") {
|
|
272
|
+
const entries = [{
|
|
273
|
+
phone: SHARED_PHONE,
|
|
274
|
+
client: createClient({
|
|
275
|
+
address: process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "imessage.spectrum.photon.codes:443",
|
|
276
|
+
tls: true,
|
|
277
|
+
token: async () => {
|
|
278
|
+
await refreshIfNeeded();
|
|
279
|
+
return tokenData.token;
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}];
|
|
283
|
+
cloudAuthState.set(entries, { dispose: () => {
|
|
284
|
+
disposed = true;
|
|
285
|
+
if (renewalTimer !== void 0) {
|
|
286
|
+
clearTimeout(renewalTimer);
|
|
287
|
+
renewalTimer = void 0;
|
|
288
|
+
}
|
|
289
|
+
} });
|
|
290
|
+
return entries;
|
|
291
|
+
}
|
|
292
|
+
const dedicated = tokenData;
|
|
293
|
+
for (const [instanceId, token] of Object.entries(dedicated.auth)) {
|
|
294
|
+
const entry = {
|
|
295
|
+
phone: requirePhone(dedicated, instanceId),
|
|
296
|
+
client: createClient({
|
|
297
|
+
address: `${instanceId}.imsg.photon.codes:443`,
|
|
298
|
+
tls: true,
|
|
299
|
+
token: async () => {
|
|
300
|
+
await refreshIfNeeded();
|
|
301
|
+
return tokenData.auth[instanceId] ?? token;
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
records.push({
|
|
306
|
+
entry,
|
|
307
|
+
instanceId
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
const entries = records.map((r) => r.entry);
|
|
311
|
+
cloudAuthState.set(entries, { dispose: () => {
|
|
312
|
+
disposed = true;
|
|
313
|
+
if (renewalTimer !== void 0) {
|
|
314
|
+
clearTimeout(renewalTimer);
|
|
315
|
+
renewalTimer = void 0;
|
|
316
|
+
}
|
|
317
|
+
} });
|
|
318
|
+
return entries;
|
|
319
|
+
}
|
|
320
|
+
async function disposeCloudAuth(clients) {
|
|
321
|
+
const auth = cloudAuthState.get(clients);
|
|
322
|
+
if (auth) {
|
|
323
|
+
auth.dispose();
|
|
324
|
+
cloudAuthState.delete(clients);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/cache.ts
|
|
329
|
+
const DEFAULT_MAX = 1e3;
|
|
330
|
+
/**
|
|
331
|
+
* Bounded insertion-order cache of recently-seen iMessage messages, keyed by
|
|
332
|
+
* guid. Provides O(1) lookup for reaction target resolution. When capacity is
|
|
333
|
+
* exceeded, the oldest entry is evicted. Access does not promote recency —
|
|
334
|
+
* this is a bounded FIFO, not an LRU. The workload (reactions arriving shortly
|
|
335
|
+
* after the message they target) doesn't benefit from LRU semantics, and FIFO
|
|
336
|
+
* avoids a dependency.
|
|
337
|
+
*/
|
|
338
|
+
var MessageCache = class {
|
|
339
|
+
map = /* @__PURE__ */ new Map();
|
|
340
|
+
max;
|
|
341
|
+
constructor(max = DEFAULT_MAX) {
|
|
342
|
+
this.max = max;
|
|
343
|
+
}
|
|
344
|
+
get(id) {
|
|
345
|
+
return this.map.get(id);
|
|
346
|
+
}
|
|
347
|
+
set(id, message) {
|
|
348
|
+
if (this.map.has(id)) this.map.delete(id);
|
|
349
|
+
this.map.set(id, message);
|
|
350
|
+
if (this.map.size > this.max) {
|
|
351
|
+
const first = this.map.keys().next().value;
|
|
352
|
+
if (first !== void 0) this.map.delete(first);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
clear() {
|
|
356
|
+
this.map.clear();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
/**
|
|
360
|
+
* Bounded insertion-order cache of recently-seen iMessage polls, keyed by
|
|
361
|
+
* poll message guid. The public poll shape deliberately hides provider ids;
|
|
362
|
+
* `optionsByIdentifier` keeps the private lookup table needed to correlate
|
|
363
|
+
* vote events back to public `PollChoice` objects.
|
|
364
|
+
*/
|
|
365
|
+
var PollCache = class {
|
|
366
|
+
map = /* @__PURE__ */ new Map();
|
|
367
|
+
max;
|
|
368
|
+
constructor(max = DEFAULT_MAX) {
|
|
369
|
+
this.max = max;
|
|
370
|
+
}
|
|
371
|
+
get(id) {
|
|
372
|
+
return this.map.get(id);
|
|
373
|
+
}
|
|
374
|
+
set(id, poll) {
|
|
375
|
+
if (this.map.has(id)) this.map.delete(id);
|
|
376
|
+
this.map.set(id, poll);
|
|
377
|
+
if (this.map.size > this.max) {
|
|
378
|
+
const first = this.map.keys().next().value;
|
|
379
|
+
if (first !== void 0) this.map.delete(first);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
clear() {
|
|
383
|
+
this.map.clear();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const messageCaches = /* @__PURE__ */ new WeakMap();
|
|
387
|
+
const pollCaches = /* @__PURE__ */ new WeakMap();
|
|
388
|
+
/**
|
|
389
|
+
* Returns a per-client message cache. Keyed by an object (the client array
|
|
390
|
+
* for remote, or the IMessageSDK instance for local), so each iMessage
|
|
391
|
+
* provider instance has its own cache and multiple providers don't share
|
|
392
|
+
* state accidentally.
|
|
393
|
+
*/
|
|
394
|
+
const getMessageCache = (owner) => {
|
|
395
|
+
let cache = messageCaches.get(owner);
|
|
396
|
+
if (!cache) {
|
|
397
|
+
cache = new MessageCache();
|
|
398
|
+
messageCaches.set(owner, cache);
|
|
399
|
+
}
|
|
400
|
+
return cache;
|
|
401
|
+
};
|
|
402
|
+
const getPollCache = (owner) => {
|
|
403
|
+
let cache = pollCaches.get(owner);
|
|
404
|
+
if (!cache) {
|
|
405
|
+
cache = new PollCache();
|
|
406
|
+
pollCaches.set(owner, cache);
|
|
407
|
+
}
|
|
408
|
+
return cache;
|
|
409
|
+
};
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/shared/vcard.ts
|
|
412
|
+
const VCARD_MIME_TYPES = new Set([
|
|
413
|
+
"text/vcard",
|
|
414
|
+
"text/x-vcard",
|
|
415
|
+
"text/directory",
|
|
416
|
+
"application/vcard",
|
|
417
|
+
"application/x-vcard"
|
|
418
|
+
]);
|
|
419
|
+
const normalizeMimeType = (mimeType) => (mimeType.split(";")[0] ?? "").trim().toLowerCase();
|
|
420
|
+
const isVCardAttachment = (mimeType, fileName) => {
|
|
421
|
+
if (mimeType && VCARD_MIME_TYPES.has(normalizeMimeType(mimeType))) return true;
|
|
422
|
+
return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
|
|
423
|
+
};
|
|
424
|
+
const vcardFileName = (contact) => {
|
|
425
|
+
return `${(contact.name?.formatted ?? contact.user?.id ?? "contact").replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
|
|
426
|
+
};
|
|
427
|
+
const readLocalAttachment = async (att) => {
|
|
428
|
+
if (!att.localPath) throw new Error(`iMessage attachment ${att.id} has no local file available on disk`);
|
|
429
|
+
return readFile(att.localPath);
|
|
430
|
+
};
|
|
431
|
+
const toAttachmentContent$1 = (att) => {
|
|
432
|
+
const { localPath } = att;
|
|
433
|
+
return asAttachment({
|
|
434
|
+
id: att.id,
|
|
435
|
+
name: att.fileName ?? "attachment",
|
|
436
|
+
mimeType: att.mimeType,
|
|
437
|
+
size: att.sizeBytes,
|
|
438
|
+
read: () => readLocalAttachment(att),
|
|
439
|
+
stream: localPath ? async () => Readable.toWeb(createReadStream(localPath)) : void 0
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
const toVCardContent$1 = async (att) => {
|
|
443
|
+
try {
|
|
444
|
+
return asContact(fromVCard((await readLocalAttachment(att)).toString("utf8")));
|
|
445
|
+
} catch {
|
|
446
|
+
return toAttachmentContent$1(att);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const localAttachmentContent = async (att) => isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent$1(att) : toAttachmentContent$1(att);
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/local/inbound.ts
|
|
452
|
+
const ATTACHMENT_PLACEHOLDER = "";
|
|
453
|
+
const ATTACHMENT_JOIN_RETRY_DELAY_MS = 250;
|
|
454
|
+
const ATTACHMENT_JOIN_RETRY_LIMIT = 8;
|
|
455
|
+
const ATTACHMENT_JOIN_FETCH_LIMIT = 10;
|
|
456
|
+
const hasAttachmentPlaceholder = (message) => message.text?.includes(ATTACHMENT_PLACEHOLDER) ?? false;
|
|
457
|
+
const isPendingAttachmentJoin = (message) => message.attachments.length === 0 && (message.hasAttachments || hasAttachmentPlaceholder(message));
|
|
458
|
+
const refetchUntilAttachmentsSettle = async (client, message) => {
|
|
459
|
+
if (!message.chatId) return message;
|
|
460
|
+
for (let attempt = 0; attempt < ATTACHMENT_JOIN_RETRY_LIMIT; attempt += 1) {
|
|
461
|
+
await setTimeout$1(ATTACHMENT_JOIN_RETRY_DELAY_MS);
|
|
462
|
+
let rows;
|
|
463
|
+
try {
|
|
464
|
+
rows = await client.getMessages({
|
|
465
|
+
chatId: message.chatId,
|
|
466
|
+
limit: ATTACHMENT_JOIN_FETCH_LIMIT,
|
|
467
|
+
since: message.createdAt
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const refreshed = rows.find((row) => row.id === message.id);
|
|
473
|
+
if (refreshed && !isPendingAttachmentJoin(refreshed)) return refreshed;
|
|
474
|
+
}
|
|
475
|
+
return message;
|
|
476
|
+
};
|
|
477
|
+
const toMessages = async (message) => {
|
|
478
|
+
const { chatId, chatKind } = message;
|
|
479
|
+
if (!chatId || chatKind === "unknown") return [];
|
|
480
|
+
if (message.reaction !== null || message.kind !== "text" || message.retractedAt !== null) return [];
|
|
481
|
+
if (isPendingAttachmentJoin(message)) return [];
|
|
482
|
+
const base = {
|
|
483
|
+
sender: { id: message.participant ?? "" },
|
|
484
|
+
space: {
|
|
485
|
+
id: chatId,
|
|
486
|
+
type: chatKind === "group" ? "group" : "dm",
|
|
487
|
+
phone: ""
|
|
488
|
+
},
|
|
489
|
+
timestamp: message.createdAt
|
|
490
|
+
};
|
|
491
|
+
if (message.attachments.length > 0) return Promise.all(message.attachments.map(async (att) => ({
|
|
492
|
+
...base,
|
|
493
|
+
id: `${message.id}:${att.id}`,
|
|
494
|
+
content: await localAttachmentContent(att)
|
|
495
|
+
})));
|
|
496
|
+
return [{
|
|
497
|
+
...base,
|
|
498
|
+
id: message.id,
|
|
499
|
+
content: {
|
|
500
|
+
type: "text",
|
|
501
|
+
text: message.text ?? ""
|
|
502
|
+
}
|
|
503
|
+
}];
|
|
504
|
+
};
|
|
505
|
+
const messages$3 = (client) => stream((emit, end) => {
|
|
506
|
+
let lastPromise = Promise.resolve();
|
|
507
|
+
const handleIncoming = async (message) => {
|
|
508
|
+
const ms = await toMessages(isPendingAttachmentJoin(message) ? await refetchUntilAttachmentsSettle(client, message) : message);
|
|
509
|
+
for (const m of ms) await emit(m);
|
|
510
|
+
};
|
|
511
|
+
const startPromise = client.startWatching({
|
|
512
|
+
onIncomingMessage: (message) => {
|
|
513
|
+
lastPromise = lastPromise.then(() => handleIncoming(message)).catch(end);
|
|
514
|
+
},
|
|
515
|
+
onError: end
|
|
516
|
+
}).catch(end);
|
|
517
|
+
return async () => {
|
|
518
|
+
await startPromise.catch(() => {});
|
|
519
|
+
await client.stopWatching();
|
|
520
|
+
await lastPromise.catch(() => {});
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/shared/errors.ts
|
|
525
|
+
const IMESSAGE_PLATFORM = "iMessage";
|
|
526
|
+
const LOCAL_IMESSAGE_PLATFORM = "iMessage (local mode)";
|
|
527
|
+
const unsupportedRemoteContent = (type, detail) => UnsupportedError.content(type, IMESSAGE_PLATFORM, detail);
|
|
528
|
+
const unsupportedLocalContent = (type, detail) => UnsupportedError.content(type, LOCAL_IMESSAGE_PLATFORM, detail);
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/local/send.ts
|
|
531
|
+
const synthRecord = (spaceId, content) => ({
|
|
532
|
+
id: crypto.randomUUID(),
|
|
533
|
+
content,
|
|
534
|
+
space: { id: spaceId },
|
|
535
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
536
|
+
});
|
|
537
|
+
const sendTempFile = async (client, spaceId, name, data) => {
|
|
538
|
+
const safeName = basename(name) || "attachment";
|
|
539
|
+
const dir = await mkdtemp(join(tmpdir(), "spectrum-"));
|
|
540
|
+
const tmp = join(dir, safeName);
|
|
541
|
+
await writeFile(tmp, data);
|
|
542
|
+
try {
|
|
543
|
+
await client.send({
|
|
544
|
+
to: spaceId,
|
|
545
|
+
attachments: [tmp]
|
|
546
|
+
});
|
|
547
|
+
} finally {
|
|
548
|
+
await rm(dir, {
|
|
549
|
+
recursive: true,
|
|
550
|
+
force: true
|
|
551
|
+
}).catch(() => {});
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
const send$3 = async (client, spaceId, content) => {
|
|
555
|
+
switch (content.type) {
|
|
556
|
+
case "text":
|
|
557
|
+
await client.send({
|
|
558
|
+
to: spaceId,
|
|
559
|
+
text: content.text
|
|
560
|
+
});
|
|
561
|
+
return synthRecord(spaceId, content);
|
|
562
|
+
case "attachment":
|
|
563
|
+
await sendTempFile(client, spaceId, content.name, await content.read());
|
|
564
|
+
return synthRecord(spaceId, content);
|
|
565
|
+
case "contact": {
|
|
566
|
+
const vcf = await toVCard(content);
|
|
567
|
+
await sendTempFile(client, spaceId, vcardFileName(content), Buffer.from(vcf, "utf8"));
|
|
568
|
+
return synthRecord(spaceId, content);
|
|
569
|
+
}
|
|
570
|
+
case "effect": throw unsupportedLocalContent("effect", "message effects require remote iMessage");
|
|
571
|
+
case "poll": throw unsupportedLocalContent("poll");
|
|
572
|
+
default: throw unsupportedLocalContent(content.type);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const getMessage$3 = async (_client, _id) => void 0;
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/local/api.ts
|
|
578
|
+
const messages$2 = (client) => messages$3(client);
|
|
579
|
+
const send$2 = async (client, spaceId, content) => send$3(client, spaceId, content);
|
|
580
|
+
const getMessage$2 = async (client, id) => getMessage$3(client, id);
|
|
581
|
+
//#endregion
|
|
582
|
+
//#region src/remote/ids.ts
|
|
583
|
+
const PART_PREFIX = /^p:(\d+)\//;
|
|
584
|
+
const dmChatGuid = (address) => `any;-;${address}`;
|
|
585
|
+
const chatTypeFromGuid = (guid) => guid.includes(";+;") ? "group" : "dm";
|
|
586
|
+
const toChatGuid = (value) => value;
|
|
587
|
+
const toMessageGuid = (value) => value;
|
|
588
|
+
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
|
|
589
|
+
const parseChildId = (id) => {
|
|
590
|
+
const match = id.match(PART_PREFIX);
|
|
591
|
+
if (!match) return null;
|
|
592
|
+
return {
|
|
593
|
+
parentGuid: id.replace(PART_PREFIX, ""),
|
|
594
|
+
partIndex: Number(match[1])
|
|
595
|
+
};
|
|
596
|
+
};
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/remote/avatar.ts
|
|
599
|
+
/**
|
|
600
|
+
* Apply an `Avatar` content value to a remote iMessage group chat.
|
|
601
|
+
*
|
|
602
|
+
* `set` uploads the icon bytes via `groups.setIcon`; `clear` removes the
|
|
603
|
+
* current icon via `groups.removeIcon`. Both surfaces are fire-and-forget —
|
|
604
|
+
* no message id is produced. The caller (`handleAvatar` in the iMessage
|
|
605
|
+
* provider) is responsible for the group-only / remote-only guards.
|
|
606
|
+
*/
|
|
607
|
+
const setIcon$1 = async (remote, spaceId, content) => {
|
|
608
|
+
const chat = toChatGuid(spaceId);
|
|
609
|
+
if (content.action.kind === "clear") {
|
|
610
|
+
await remote.groups.removeIcon(chat);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const buffer = await content.action.read();
|
|
614
|
+
await remote.groups.setIcon(chat, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
|
|
615
|
+
};
|
|
616
|
+
//#endregion
|
|
617
|
+
//#region src/remote/background.ts
|
|
618
|
+
/**
|
|
619
|
+
* Apply a `Background` content value to a remote iMessage chat.
|
|
620
|
+
*
|
|
621
|
+
* `set` uploads the photo bytes via `chats.setBackground`; `clear` removes
|
|
622
|
+
* any current background via `chats.removeBackground`. Both surfaces are
|
|
623
|
+
* fire-and-forget — no message id is produced.
|
|
624
|
+
*/
|
|
625
|
+
const setBackground$1 = async (remote, spaceId, content) => {
|
|
626
|
+
const chat = toChatGuid(spaceId);
|
|
627
|
+
if (content.action.kind === "clear") {
|
|
628
|
+
await remote.chats.removeBackground(chat);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const buffer = await content.action.read();
|
|
632
|
+
await remote.chats.setBackground(chat, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
|
|
633
|
+
};
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/remote/contact-card.ts
|
|
636
|
+
/**
|
|
637
|
+
* Share the local account's native contact card (name + photo) with the chat.
|
|
638
|
+
*
|
|
639
|
+
* The SDK exposes a single chat-level `chats.shareContactInfo(chatGuid)` — the
|
|
640
|
+
* card shared is always the bot account's own, so there is no payload beyond
|
|
641
|
+
* the chat. `send` has already resolved the space into `spaceId` by the time
|
|
642
|
+
* the dispatcher reaches here.
|
|
643
|
+
*
|
|
644
|
+
* On-demand and unconditional: unlike the proactive `ContactShareTracker` in
|
|
645
|
+
* `contact-share.ts` (24h dedupe, gated behind the `imessageSynced` profile),
|
|
646
|
+
* this fires every time the caller asks.
|
|
647
|
+
*/
|
|
648
|
+
const shareContactCard$1 = async (remote, spaceId) => {
|
|
649
|
+
await remote.chats.shareContactInfo(toChatGuid(spaceId));
|
|
650
|
+
};
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/remote/customized-mini-app.ts
|
|
653
|
+
/**
|
|
654
|
+
* Send a `CustomizedMiniApp` card to a remote iMessage chat.
|
|
655
|
+
*
|
|
656
|
+
* Unlike `setBackground`, this produces a real outbound message, so it returns
|
|
657
|
+
* a `ProviderMessageRecord`. The `content` carries extra `type` / `__platform`
|
|
658
|
+
* tags the SDK ignores; it is passed as a variable (not an object literal) so
|
|
659
|
+
* no excess-property check applies, and the wire serializer reads only the
|
|
660
|
+
* fields it knows.
|
|
661
|
+
*/
|
|
662
|
+
const sendCustomizedMiniApp$1 = async (remote, spaceId, content) => {
|
|
663
|
+
const chat = toChatGuid(spaceId);
|
|
664
|
+
const message = await remote.messages.sendCustomizedMiniApp(chat, content);
|
|
665
|
+
return {
|
|
666
|
+
id: message.guid,
|
|
667
|
+
content,
|
|
668
|
+
direction: "outbound",
|
|
669
|
+
space: { id: spaceId },
|
|
670
|
+
timestamp: message.dateCreated
|
|
671
|
+
};
|
|
672
|
+
};
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/remote/attachments.ts
|
|
675
|
+
/**
|
|
676
|
+
* Stream the primary file bytes of an attachment as a `ReadableStream`.
|
|
677
|
+
* Skips header and Live Photo companion frames; emits only `primaryChunk`
|
|
678
|
+
* payloads. Cleans up the underlying gRPC iterator on cancel and on error.
|
|
679
|
+
*/
|
|
680
|
+
const downloadPrimaryAttachmentStream = (client, attachmentGuid) => {
|
|
681
|
+
const frames = client.attachments.downloadStream(attachmentGuid);
|
|
682
|
+
const iterator = frames[Symbol.asyncIterator]();
|
|
683
|
+
let closed = false;
|
|
684
|
+
const closeFrames = async () => {
|
|
685
|
+
if (closed) return;
|
|
686
|
+
closed = true;
|
|
687
|
+
try {
|
|
688
|
+
await iterator.return?.();
|
|
689
|
+
} finally {
|
|
690
|
+
await frames.close();
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
return new ReadableStream({
|
|
694
|
+
async cancel() {
|
|
695
|
+
await closeFrames();
|
|
696
|
+
},
|
|
697
|
+
async pull(controller) {
|
|
698
|
+
try {
|
|
699
|
+
while (true) {
|
|
700
|
+
const result = await iterator.next();
|
|
701
|
+
if (result.done) {
|
|
702
|
+
controller.close();
|
|
703
|
+
await closeFrames();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (result.value.type === "primaryChunk") {
|
|
707
|
+
controller.enqueue(result.value.data);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} catch (error) {
|
|
712
|
+
await closeFrames();
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
/**
|
|
719
|
+
* Collect the primary file bytes of an attachment into a single `Buffer`.
|
|
720
|
+
* Skips header and Live Photo companion frames.
|
|
721
|
+
*/
|
|
722
|
+
const downloadPrimaryAttachment = async (client, attachmentGuid) => {
|
|
723
|
+
const chunks = [];
|
|
724
|
+
const frames = client.attachments.downloadStream(attachmentGuid);
|
|
725
|
+
try {
|
|
726
|
+
for await (const frame of frames) if (frame.type === "primaryChunk") chunks.push(Buffer.from(frame.data));
|
|
727
|
+
} finally {
|
|
728
|
+
await frames.close();
|
|
729
|
+
}
|
|
730
|
+
return Buffer.concat(chunks);
|
|
731
|
+
};
|
|
732
|
+
/**
|
|
733
|
+
* Fetch an attachment by GUID and wrap it as a spectrum `Attachment`. The
|
|
734
|
+
* returned object is lazy: `.read()` triggers a Buffer download, `.stream()`
|
|
735
|
+
* opens a fresh byte stream. Calling both issues two independent gRPC
|
|
736
|
+
* downloads — cache `.read()` if you need the bytes more than once.
|
|
737
|
+
*
|
|
738
|
+
* Returns `undefined` when the GUID is unknown to the server.
|
|
739
|
+
*/
|
|
740
|
+
const getRemoteAttachment = async (client, guid) => {
|
|
741
|
+
let info;
|
|
742
|
+
try {
|
|
743
|
+
info = await client.attachments.get(guid);
|
|
744
|
+
} catch (err) {
|
|
745
|
+
if (err instanceof NotFoundError) return;
|
|
746
|
+
throw err;
|
|
747
|
+
}
|
|
748
|
+
return asAttachment({
|
|
749
|
+
id: info.guid,
|
|
750
|
+
name: info.fileName,
|
|
751
|
+
mimeType: info.mimeType,
|
|
752
|
+
size: info.totalBytes,
|
|
753
|
+
read: () => downloadPrimaryAttachment(client, info.guid),
|
|
754
|
+
stream: async () => downloadPrimaryAttachmentStream(client, info.guid)
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/remote/inbound.ts
|
|
759
|
+
const log$2 = createLogger("spectrum.imessage.inbound");
|
|
760
|
+
const URL_BALLOON_BUNDLE_ID = "com.apple.messages.URLBalloonProvider";
|
|
761
|
+
const getBalloonBundleId = (message) => message.content.balloonBundleId;
|
|
762
|
+
const messageAttachments = (message) => message.content.attachments;
|
|
763
|
+
const resolveChatGuid = (message, hint) => {
|
|
764
|
+
if (hint) return hint;
|
|
765
|
+
return message.chatGuids?.[0] ?? "";
|
|
766
|
+
};
|
|
767
|
+
const resolveSenderId = (message) => message.sender?.address ?? "";
|
|
768
|
+
const isIMessageMessage = (value) => {
|
|
769
|
+
if (typeof value !== "object" || value === null) return false;
|
|
770
|
+
const record = value;
|
|
771
|
+
return typeof record.id === "string" && record.id.length > 0 && typeof record.content === "object" && record.content !== null && typeof record.space === "object" && record.space !== null;
|
|
772
|
+
};
|
|
773
|
+
const asProviderGroup = (items) => groupSchema.parse({
|
|
774
|
+
type: "group",
|
|
775
|
+
items
|
|
776
|
+
});
|
|
777
|
+
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => {
|
|
778
|
+
const chat = resolveChatGuid(message, chatGuidHint);
|
|
779
|
+
return {
|
|
780
|
+
direction: message.isFromMe ? "outbound" : "inbound",
|
|
781
|
+
sender: { id: resolveSenderId(message) },
|
|
782
|
+
space: {
|
|
783
|
+
id: chat,
|
|
784
|
+
type: chatTypeFromGuid(chat),
|
|
785
|
+
phone
|
|
786
|
+
},
|
|
787
|
+
timestamp
|
|
788
|
+
};
|
|
789
|
+
};
|
|
790
|
+
const toAttachmentContent = (client, info) => asAttachment({
|
|
791
|
+
id: info.guid,
|
|
792
|
+
name: info.fileName,
|
|
793
|
+
mimeType: info.mimeType,
|
|
794
|
+
size: info.totalBytes,
|
|
795
|
+
read: async () => await downloadPrimaryAttachment(client, info.guid),
|
|
796
|
+
stream: async () => downloadPrimaryAttachmentStream(client, info.guid)
|
|
797
|
+
});
|
|
798
|
+
const toVCardContent = async (client, info) => {
|
|
799
|
+
try {
|
|
800
|
+
return asContact(fromVCard((await downloadPrimaryAttachment(client, info.guid)).toString("utf8")));
|
|
801
|
+
} catch (err) {
|
|
802
|
+
log$2.warn("failed to parse vCard attachment; falling back to attachment content", {
|
|
803
|
+
"spectrum.imessage.attachment.guid": info.guid,
|
|
804
|
+
...errorAttrs(err)
|
|
805
|
+
}, err);
|
|
806
|
+
return toAttachmentContent(client, info);
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
const attachmentContent = async (client, info) => isVCardAttachment(info.mimeType, info.fileName) ? await toVCardContent(client, info) : toAttachmentContent(client, info);
|
|
810
|
+
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
|
|
811
|
+
const content = await attachmentContent(client, info);
|
|
812
|
+
const msg = {
|
|
813
|
+
...base,
|
|
814
|
+
id,
|
|
815
|
+
content,
|
|
816
|
+
partIndex
|
|
817
|
+
};
|
|
818
|
+
if (parentId !== void 0) msg.parentId = parentId;
|
|
819
|
+
return msg;
|
|
820
|
+
};
|
|
821
|
+
const toRichlinkMessage = (message, base, id) => {
|
|
822
|
+
const url = message.content.text ?? "";
|
|
823
|
+
try {
|
|
824
|
+
return {
|
|
825
|
+
...base,
|
|
826
|
+
id,
|
|
827
|
+
content: asRichlink({ url })
|
|
828
|
+
};
|
|
829
|
+
} catch (err) {
|
|
830
|
+
log$2.warn("failed to convert message to rich link; falling back to text/custom content", {
|
|
831
|
+
"spectrum.imessage.message.id": id,
|
|
832
|
+
...errorAttrs(err)
|
|
833
|
+
}, err);
|
|
834
|
+
return {
|
|
835
|
+
...base,
|
|
836
|
+
id,
|
|
837
|
+
content: url ? asText(url) : asCustom(message)
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
|
|
842
|
+
const messageGuidStr = message.guid;
|
|
843
|
+
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
|
|
844
|
+
const attachments = messageAttachments(message);
|
|
845
|
+
if (attachments.length === 1) {
|
|
846
|
+
const info = attachments[0];
|
|
847
|
+
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
|
848
|
+
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
|
849
|
+
}
|
|
850
|
+
if (attachments.length > 1) {
|
|
851
|
+
const items = [];
|
|
852
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
853
|
+
const info = attachments[i];
|
|
854
|
+
if (!info) continue;
|
|
855
|
+
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
...base,
|
|
859
|
+
id: messageGuidStr,
|
|
860
|
+
content: asProviderGroup(items)
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (getBalloonBundleId(message) === URL_BALLOON_BUNDLE_ID) return toRichlinkMessage(message, base, messageGuidStr);
|
|
864
|
+
const text = message.content.text;
|
|
865
|
+
return {
|
|
866
|
+
...base,
|
|
867
|
+
id: messageGuidStr,
|
|
868
|
+
content: text ? asText(text) : asCustom(message)
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
const cacheMessage = (cache, message) => {
|
|
872
|
+
cache.set(message.id, message);
|
|
873
|
+
if (message.content.type === "group") {
|
|
874
|
+
for (const item of message.content.items) if (isIMessageMessage(item)) cache.set(item.id, item);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
const toInboundMessages = async (client, cache, event, phone) => {
|
|
878
|
+
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
|
|
879
|
+
const messageGuidStr = event.message.guid;
|
|
880
|
+
if (getBalloonBundleId(event.message) === URL_BALLOON_BUNDLE_ID) {
|
|
881
|
+
const msg = toRichlinkMessage(event.message, base, messageGuidStr);
|
|
882
|
+
cacheMessage(cache, msg);
|
|
883
|
+
return [msg];
|
|
884
|
+
}
|
|
885
|
+
const attachments = messageAttachments(event.message);
|
|
886
|
+
if (attachments.length === 1) {
|
|
887
|
+
const info = attachments[0];
|
|
888
|
+
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
|
889
|
+
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
|
890
|
+
cacheMessage(cache, msg);
|
|
891
|
+
return [msg];
|
|
892
|
+
}
|
|
893
|
+
if (attachments.length > 1) {
|
|
894
|
+
const items = [];
|
|
895
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
896
|
+
const info = attachments[i];
|
|
897
|
+
if (!info) continue;
|
|
898
|
+
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
|
899
|
+
}
|
|
900
|
+
const parent = {
|
|
901
|
+
...base,
|
|
902
|
+
id: messageGuidStr,
|
|
903
|
+
content: asProviderGroup(items)
|
|
904
|
+
};
|
|
905
|
+
cacheMessage(cache, parent);
|
|
906
|
+
return [parent];
|
|
907
|
+
}
|
|
908
|
+
const text = event.message.content.text;
|
|
909
|
+
const msg = {
|
|
910
|
+
...base,
|
|
911
|
+
id: messageGuidStr,
|
|
912
|
+
content: text ? asText(text) : asCustom(event.message)
|
|
913
|
+
};
|
|
914
|
+
cacheMessage(cache, msg);
|
|
915
|
+
return [msg];
|
|
916
|
+
};
|
|
917
|
+
const getMessage$1 = async (remote, spaceId, msgId, phone) => {
|
|
918
|
+
const cache = getMessageCache(remote);
|
|
919
|
+
const cached = cache.get(msgId);
|
|
920
|
+
if (cached) return cached;
|
|
921
|
+
const childRef = parseChildId(msgId);
|
|
922
|
+
if (childRef) try {
|
|
923
|
+
const parent = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(childRef.parentGuid)), phone, spaceId);
|
|
924
|
+
cacheMessage(cache, parent);
|
|
925
|
+
if (parent.content.type !== "group") return;
|
|
926
|
+
const item = parent.content.items[childRef.partIndex];
|
|
927
|
+
return isIMessageMessage(item) ? item : void 0;
|
|
928
|
+
} catch (err) {
|
|
929
|
+
if (err instanceof NotFoundError) return;
|
|
930
|
+
throw err;
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
const rebuilt = await rebuildFromAppleMessage(remote, await remote.messages.get(toMessageGuid(msgId)), phone, spaceId);
|
|
934
|
+
cacheMessage(cache, rebuilt);
|
|
935
|
+
return rebuilt;
|
|
936
|
+
} catch (err) {
|
|
937
|
+
if (err instanceof NotFoundError) return;
|
|
938
|
+
throw err;
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region src/remote/reactions.ts
|
|
943
|
+
const EMOJI_TO_TAPBACK = {
|
|
944
|
+
"❤️": "love",
|
|
945
|
+
"👍": "like",
|
|
946
|
+
"👎": "dislike",
|
|
947
|
+
"😂": "laugh",
|
|
948
|
+
"‼️": "emphasize",
|
|
949
|
+
"❓": "question"
|
|
950
|
+
};
|
|
951
|
+
const TAPBACK_TO_EMOJI = Object.fromEntries(Object.entries(EMOJI_TO_TAPBACK).map(([emoji, kind]) => [kind, emoji]));
|
|
952
|
+
const reactionEmoji = (reaction) => reaction.kind === "emoji" ? reaction.emoji : TAPBACK_TO_EMOJI[reaction.kind];
|
|
953
|
+
const asProviderReaction = (emoji, target) => reactionSchema.parse({
|
|
954
|
+
emoji,
|
|
955
|
+
target,
|
|
956
|
+
type: "reaction"
|
|
957
|
+
});
|
|
958
|
+
const resolveReactionTarget = async (client, cache, chat, targetGuid, partIndex, phone) => {
|
|
959
|
+
let candidate = cache.get(targetGuid);
|
|
960
|
+
if (!candidate) try {
|
|
961
|
+
candidate = await rebuildFromAppleMessage(client, await client.messages.get(toMessageGuid(targetGuid)), phone, chat);
|
|
962
|
+
cacheMessage(cache, candidate);
|
|
963
|
+
} catch {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (candidate.content.type === "group") {
|
|
967
|
+
const items = candidate.content.items;
|
|
968
|
+
if (!Array.isArray(items)) return candidate;
|
|
969
|
+
const item = items[partIndex ?? 0];
|
|
970
|
+
return isIMessageMessage(item) ? item : candidate;
|
|
971
|
+
}
|
|
972
|
+
return candidate;
|
|
973
|
+
};
|
|
974
|
+
const toReactionMessages = async (client, cache, event, phone) => {
|
|
975
|
+
const emoji = reactionEmoji(event.reaction);
|
|
976
|
+
if (!emoji) return [];
|
|
977
|
+
const senderAddress = event.actor?.address;
|
|
978
|
+
if (!senderAddress) return [];
|
|
979
|
+
const resolved = await resolveReactionTarget(client, cache, event.chatGuid, event.messageGuid, event.targetPartIndex, phone);
|
|
980
|
+
if (!resolved) return [];
|
|
981
|
+
const partSuffix = typeof event.targetPartIndex === "number" ? `:${event.targetPartIndex}` : "";
|
|
982
|
+
return [{
|
|
983
|
+
sender: { id: senderAddress },
|
|
984
|
+
space: {
|
|
985
|
+
id: event.chatGuid,
|
|
986
|
+
type: chatTypeFromGuid(event.chatGuid),
|
|
987
|
+
phone
|
|
988
|
+
},
|
|
989
|
+
timestamp: event.occurredAt,
|
|
990
|
+
id: `${event.messageGuid}:reaction:${event.sequence}${partSuffix}`,
|
|
991
|
+
content: asProviderReaction(emoji, resolved)
|
|
992
|
+
}];
|
|
993
|
+
};
|
|
994
|
+
const toSettableReaction = (emoji) => {
|
|
995
|
+
const native = EMOJI_TO_TAPBACK[emoji];
|
|
996
|
+
return native ? { kind: native } : {
|
|
997
|
+
kind: "emoji",
|
|
998
|
+
emoji
|
|
999
|
+
};
|
|
1000
|
+
};
|
|
1001
|
+
const tapbackTarget = (target) => ({
|
|
1002
|
+
guid: toMessageGuid(target.parentId ?? target.id),
|
|
1003
|
+
opts: typeof target.partIndex === "number" ? { partIndex: target.partIndex } : void 0
|
|
1004
|
+
});
|
|
1005
|
+
const reactToMessage$1 = async (remote, spaceId, target, reaction) => {
|
|
1006
|
+
const { guid, opts } = tapbackTarget(target);
|
|
1007
|
+
const sent = await remote.messages.setReaction(toChatGuid(spaceId), guid, toSettableReaction(reaction), true, opts);
|
|
1008
|
+
return {
|
|
1009
|
+
id: sent.guid,
|
|
1010
|
+
content: asProviderReaction(reaction, target),
|
|
1011
|
+
direction: "outbound",
|
|
1012
|
+
space: { id: spaceId },
|
|
1013
|
+
timestamp: sent.dateCreated
|
|
1014
|
+
};
|
|
1015
|
+
};
|
|
1016
|
+
const unsendReaction$1 = async (remote, spaceId, target, reaction) => {
|
|
1017
|
+
const { guid, opts } = tapbackTarget(target);
|
|
1018
|
+
await remote.messages.setReaction(toChatGuid(spaceId), guid, toSettableReaction(reaction), false, opts);
|
|
1019
|
+
};
|
|
1020
|
+
//#endregion
|
|
1021
|
+
//#region src/remote/read.ts
|
|
1022
|
+
/**
|
|
1023
|
+
* Mark every unread message in the chat as read.
|
|
1024
|
+
*
|
|
1025
|
+
* The SDK exposes only a chat-level `chats.markRead(chatGuid)` — there is no
|
|
1026
|
+
* per-message API. The `Read` content's `target` is used by the caller to
|
|
1027
|
+
* derive the chat, which `send` has already resolved into `spaceId` by the
|
|
1028
|
+
* time the dispatcher reaches here.
|
|
1029
|
+
*/
|
|
1030
|
+
const markRead$1 = async (remote, spaceId) => {
|
|
1031
|
+
await remote.chats.markRead(toChatGuid(spaceId));
|
|
1032
|
+
};
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region src/remote/rename.ts
|
|
1035
|
+
/**
|
|
1036
|
+
* Apply a `Rename` content value to a remote iMessage group chat.
|
|
1037
|
+
* Fire-and-forget — the `Chat` returned by `setDisplayName` is discarded.
|
|
1038
|
+
*/
|
|
1039
|
+
const setDisplayName$1 = async (remote, spaceId, content) => {
|
|
1040
|
+
await remote.groups.setDisplayName(toChatGuid(spaceId), content.displayName);
|
|
1041
|
+
};
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/remote/markdown.ts
|
|
1044
|
+
const markdownLexer = new Marked();
|
|
1045
|
+
const BULLET = "• ";
|
|
1046
|
+
const HR_LINE = "———";
|
|
1047
|
+
const NESTED_LIST_INDENT = " ";
|
|
1048
|
+
const BLOCK_SEPARATOR = "\n\n";
|
|
1049
|
+
const TABLE_CELL_SEPARATOR = " | ";
|
|
1050
|
+
const DEFAULT_LIST_START = 1;
|
|
1051
|
+
const LEADING_WHITESPACE = /^\s+/;
|
|
1052
|
+
const TRAILING_WHITESPACE = /\s+$/;
|
|
1053
|
+
const MONOSPACE_UPPER_A = 120432;
|
|
1054
|
+
const MONOSPACE_LOWER_A = 120458;
|
|
1055
|
+
const MONOSPACE_DIGIT_ZERO = 120822;
|
|
1056
|
+
const UPPER_A = 65;
|
|
1057
|
+
const UPPER_Z = 90;
|
|
1058
|
+
const LOWER_A = 97;
|
|
1059
|
+
const LOWER_Z = 122;
|
|
1060
|
+
const DIGIT_ZERO = 48;
|
|
1061
|
+
const DIGIT_NINE = 57;
|
|
1062
|
+
const monospaceCodePoint = (codePoint) => {
|
|
1063
|
+
if (codePoint >= UPPER_A && codePoint <= UPPER_Z) return MONOSPACE_UPPER_A + (codePoint - UPPER_A);
|
|
1064
|
+
if (codePoint >= LOWER_A && codePoint <= LOWER_Z) return MONOSPACE_LOWER_A + (codePoint - LOWER_A);
|
|
1065
|
+
if (codePoint >= DIGIT_ZERO && codePoint <= DIGIT_NINE) return MONOSPACE_DIGIT_ZERO + (codePoint - DIGIT_ZERO);
|
|
1066
|
+
return codePoint;
|
|
1067
|
+
};
|
|
1068
|
+
const toMonospace = (text) => {
|
|
1069
|
+
let out = "";
|
|
1070
|
+
for (const char of text) {
|
|
1071
|
+
const codePoint = char.codePointAt(0);
|
|
1072
|
+
out += codePoint === void 0 ? char : String.fromCodePoint(monospaceCodePoint(codePoint));
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
};
|
|
1076
|
+
const STYLE_ORDER = [
|
|
1077
|
+
"bold",
|
|
1078
|
+
"italic",
|
|
1079
|
+
"strikethrough"
|
|
1080
|
+
];
|
|
1081
|
+
const plain = (text) => ({
|
|
1082
|
+
text,
|
|
1083
|
+
styles: []
|
|
1084
|
+
});
|
|
1085
|
+
const withStyle = (spans, style) => spans.map((span) => span.styles.includes(style) ? span : {
|
|
1086
|
+
...span,
|
|
1087
|
+
styles: [...span.styles, style]
|
|
1088
|
+
});
|
|
1089
|
+
const asLink = (spans) => spans.map((span) => ({
|
|
1090
|
+
...span,
|
|
1091
|
+
link: true
|
|
1092
|
+
}));
|
|
1093
|
+
const spanText = (spans) => {
|
|
1094
|
+
let out = "";
|
|
1095
|
+
for (const span of spans) out += span.text;
|
|
1096
|
+
return out;
|
|
1097
|
+
};
|
|
1098
|
+
const joinSpans = (blocks, separator) => {
|
|
1099
|
+
const out = [];
|
|
1100
|
+
for (const [index, block] of blocks.entries()) {
|
|
1101
|
+
if (index > 0) out.push(plain(separator));
|
|
1102
|
+
out.push(...block);
|
|
1103
|
+
}
|
|
1104
|
+
return out;
|
|
1105
|
+
};
|
|
1106
|
+
const splitSpanLines = (spans) => {
|
|
1107
|
+
let current = [];
|
|
1108
|
+
const lines = [current];
|
|
1109
|
+
for (const span of spans) {
|
|
1110
|
+
const parts = span.text.split("\n");
|
|
1111
|
+
for (const [index, part] of parts.entries()) {
|
|
1112
|
+
if (index > 0) {
|
|
1113
|
+
current = [];
|
|
1114
|
+
lines.push(current);
|
|
1115
|
+
}
|
|
1116
|
+
if (part) current.push({
|
|
1117
|
+
...span,
|
|
1118
|
+
text: part
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return lines;
|
|
1123
|
+
};
|
|
1124
|
+
const asMarkedToken = (token) => token;
|
|
1125
|
+
const checkboxPrefix = (item) => {
|
|
1126
|
+
if (!item.task) return "";
|
|
1127
|
+
return item.checked ? "[x] " : "[ ] ";
|
|
1128
|
+
};
|
|
1129
|
+
const listMarker = (list, index) => {
|
|
1130
|
+
if (!list.ordered) return BULLET;
|
|
1131
|
+
return `${(list.start === "" ? DEFAULT_LIST_START : list.start) + index}. `;
|
|
1132
|
+
};
|
|
1133
|
+
const renderLink = (token) => {
|
|
1134
|
+
if (token.text === token.href) return [{
|
|
1135
|
+
text: token.href,
|
|
1136
|
+
styles: [],
|
|
1137
|
+
link: true
|
|
1138
|
+
}];
|
|
1139
|
+
return [...asLink(renderInlineTokens(token.tokens)), {
|
|
1140
|
+
text: ` (${token.href})`,
|
|
1141
|
+
styles: [],
|
|
1142
|
+
link: true
|
|
1143
|
+
}];
|
|
1144
|
+
};
|
|
1145
|
+
const renderImage = (token) => [{
|
|
1146
|
+
text: token.text ? `${token.text} (${token.href})` : token.href,
|
|
1147
|
+
styles: [],
|
|
1148
|
+
link: true
|
|
1149
|
+
}];
|
|
1150
|
+
const renderInlineToken = (token) => {
|
|
1151
|
+
switch (token.type) {
|
|
1152
|
+
case "strong": return withStyle(renderInlineTokens(token.tokens), "bold");
|
|
1153
|
+
case "em": return withStyle(renderInlineTokens(token.tokens), "italic");
|
|
1154
|
+
case "del": return withStyle(renderInlineTokens(token.tokens), "strikethrough");
|
|
1155
|
+
case "codespan": return [plain(toMonospace(token.text))];
|
|
1156
|
+
case "br": return [plain("\n")];
|
|
1157
|
+
case "link": return renderLink(token);
|
|
1158
|
+
case "image": return renderImage(token);
|
|
1159
|
+
case "escape": return [plain(token.text)];
|
|
1160
|
+
case "text": return token.tokens ? renderInlineTokens(token.tokens) : [plain(token.text)];
|
|
1161
|
+
case "html": return [plain(token.text)];
|
|
1162
|
+
case "checkbox": return [];
|
|
1163
|
+
default: return "raw" in token ? [plain(String(token.raw))] : [];
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
const renderInlineTokens = (tokens) => {
|
|
1167
|
+
const out = [];
|
|
1168
|
+
for (const token of tokens) out.push(...renderInlineToken(asMarkedToken(token)));
|
|
1169
|
+
return out;
|
|
1170
|
+
};
|
|
1171
|
+
const renderBlockquote = (quote) => {
|
|
1172
|
+
const lines = splitSpanLines(renderBlockTokens(quote.tokens));
|
|
1173
|
+
const out = [];
|
|
1174
|
+
for (const [index, line] of lines.entries()) {
|
|
1175
|
+
if (index > 0) out.push(plain("\n"));
|
|
1176
|
+
out.push(plain(line.length > 0 ? "> " : ">"), ...line);
|
|
1177
|
+
}
|
|
1178
|
+
return out;
|
|
1179
|
+
};
|
|
1180
|
+
const renderList = (list) => {
|
|
1181
|
+
const out = [];
|
|
1182
|
+
for (const [index, item] of list.items.entries()) {
|
|
1183
|
+
const prefix = `${listMarker(list, index)}${checkboxPrefix(item)}`;
|
|
1184
|
+
const blocks = [];
|
|
1185
|
+
for (const token of item.tokens) {
|
|
1186
|
+
const rendered = renderBlockToken(asMarkedToken(token));
|
|
1187
|
+
if (spanText(rendered)) blocks.push(rendered);
|
|
1188
|
+
}
|
|
1189
|
+
const [first = [], ...rest] = splitSpanLines(joinSpans(blocks, "\n"));
|
|
1190
|
+
if (out.length > 0) out.push(plain("\n"));
|
|
1191
|
+
out.push(plain(prefix), ...first);
|
|
1192
|
+
for (const line of rest) out.push(plain(`\n${NESTED_LIST_INDENT}`), ...line);
|
|
1193
|
+
}
|
|
1194
|
+
return out;
|
|
1195
|
+
};
|
|
1196
|
+
const renderTable = (table) => {
|
|
1197
|
+
const out = [];
|
|
1198
|
+
const pushRow = (cells, rowIndex) => {
|
|
1199
|
+
if (rowIndex > 0) out.push(plain("\n"));
|
|
1200
|
+
for (const [cellIndex, cell] of cells.entries()) {
|
|
1201
|
+
if (cellIndex > 0) out.push(plain(TABLE_CELL_SEPARATOR));
|
|
1202
|
+
out.push(...renderInlineTokens(cell.tokens));
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
pushRow(table.header, 0);
|
|
1206
|
+
for (const [index, row] of table.rows.entries()) pushRow(row, index + 1);
|
|
1207
|
+
return out;
|
|
1208
|
+
};
|
|
1209
|
+
const renderBlockToken = (token) => {
|
|
1210
|
+
switch (token.type) {
|
|
1211
|
+
case "heading": return withStyle(renderInlineTokens(token.tokens), "bold");
|
|
1212
|
+
case "paragraph": return renderInlineTokens(token.tokens);
|
|
1213
|
+
case "code": return [plain(toMonospace(token.text))];
|
|
1214
|
+
case "blockquote": return renderBlockquote(token);
|
|
1215
|
+
case "list": return renderList(token);
|
|
1216
|
+
case "table": return renderTable(token);
|
|
1217
|
+
case "hr": return [plain(HR_LINE)];
|
|
1218
|
+
case "space":
|
|
1219
|
+
case "def": return [];
|
|
1220
|
+
default: return renderInlineToken(token);
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
const renderBlockTokens = (tokens) => {
|
|
1224
|
+
const blocks = [];
|
|
1225
|
+
for (const token of tokens) {
|
|
1226
|
+
const rendered = renderBlockToken(asMarkedToken(token));
|
|
1227
|
+
if (spanText(rendered)) blocks.push(rendered);
|
|
1228
|
+
}
|
|
1229
|
+
return joinSpans(blocks, BLOCK_SEPARATOR);
|
|
1230
|
+
};
|
|
1231
|
+
const trimSpans = (spans) => {
|
|
1232
|
+
const trimmed = [...spans];
|
|
1233
|
+
while (trimmed.length > 0) {
|
|
1234
|
+
const first = trimmed.at(0);
|
|
1235
|
+
const text = first?.text.replace(LEADING_WHITESPACE, "");
|
|
1236
|
+
if (first && text) {
|
|
1237
|
+
trimmed[0] = {
|
|
1238
|
+
...first,
|
|
1239
|
+
text
|
|
1240
|
+
};
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
trimmed.shift();
|
|
1244
|
+
}
|
|
1245
|
+
while (trimmed.length > 0) {
|
|
1246
|
+
const last = trimmed.at(-1);
|
|
1247
|
+
const text = last?.text.replace(TRAILING_WHITESPACE, "");
|
|
1248
|
+
if (last && text) {
|
|
1249
|
+
trimmed[trimmed.length - 1] = {
|
|
1250
|
+
...last,
|
|
1251
|
+
text
|
|
1252
|
+
};
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
trimmed.pop();
|
|
1256
|
+
}
|
|
1257
|
+
return trimmed;
|
|
1258
|
+
};
|
|
1259
|
+
const finalize = (spans) => {
|
|
1260
|
+
let text = "";
|
|
1261
|
+
let hasLinks = false;
|
|
1262
|
+
const open = /* @__PURE__ */ new Map();
|
|
1263
|
+
const ranges = [];
|
|
1264
|
+
const close = (style, end) => {
|
|
1265
|
+
const start = open.get(style);
|
|
1266
|
+
open.delete(style);
|
|
1267
|
+
if (start !== void 0 && end > start) ranges.push({
|
|
1268
|
+
type: style,
|
|
1269
|
+
start,
|
|
1270
|
+
length: end - start
|
|
1271
|
+
});
|
|
1272
|
+
};
|
|
1273
|
+
for (const span of spans) {
|
|
1274
|
+
if (!span.text) continue;
|
|
1275
|
+
hasLinks ||= span.link === true;
|
|
1276
|
+
const offset = text.length;
|
|
1277
|
+
for (const style of STYLE_ORDER) if (span.styles.includes(style)) {
|
|
1278
|
+
if (!open.has(style)) open.set(style, offset);
|
|
1279
|
+
} else close(style, offset);
|
|
1280
|
+
text += span.text;
|
|
1281
|
+
}
|
|
1282
|
+
for (const style of STYLE_ORDER) close(style, text.length);
|
|
1283
|
+
ranges.sort((a, b) => a.start - b.start || STYLE_ORDER.indexOf(a.type) - STYLE_ORDER.indexOf(b.type));
|
|
1284
|
+
return {
|
|
1285
|
+
text,
|
|
1286
|
+
formatting: ranges,
|
|
1287
|
+
hasLinks
|
|
1288
|
+
};
|
|
1289
|
+
};
|
|
1290
|
+
/**
|
|
1291
|
+
* Render standard markdown (CommonMark + GFM) to iMessage styled text: a
|
|
1292
|
+
* plain string plus UTF-16 formatting ranges for `messages.sendText`'s
|
|
1293
|
+
* `formatting` option. Block layout matches the plain-text renderer (list
|
|
1294
|
+
* bullets, `label (url)` links); inline emphasis becomes native
|
|
1295
|
+
* bold/italic/strikethrough ranges instead of being stripped, headings
|
|
1296
|
+
* render as bold, and code maps to Unicode mathematical monospace
|
|
1297
|
+
* characters. `hasLinks` reports whether any link or image put a URL into
|
|
1298
|
+
* the text, so the sender can enable Apple's data-detector pass.
|
|
1299
|
+
*/
|
|
1300
|
+
const markdownToIMessageText = (markdown) => finalize(trimSpans(renderBlockTokens(markdownLexer.lexer(markdown))));
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/remote/send.ts
|
|
1303
|
+
const GROUP_ITEM_ALLOWED = new Set([
|
|
1304
|
+
"text",
|
|
1305
|
+
"markdown",
|
|
1306
|
+
"attachment",
|
|
1307
|
+
"contact",
|
|
1308
|
+
"voice"
|
|
1309
|
+
]);
|
|
1310
|
+
const GROUP_TEXT_TYPES = new Set(["text", "markdown"]);
|
|
1311
|
+
const MAX_GROUP_TEXT_ITEMS = 1;
|
|
1312
|
+
const outboundRecord = (spaceId, id, content, timestamp, extras) => ({
|
|
1313
|
+
id,
|
|
1314
|
+
content,
|
|
1315
|
+
direction: "outbound",
|
|
1316
|
+
space: { id: spaceId },
|
|
1317
|
+
timestamp,
|
|
1318
|
+
...extras
|
|
1319
|
+
});
|
|
1320
|
+
const outboundGroupItem = (spaceId, id, content, timestamp, partIndex, parentId) => outboundRecord(spaceId, id, content, timestamp, {
|
|
1321
|
+
partIndex,
|
|
1322
|
+
parentId
|
|
1323
|
+
});
|
|
1324
|
+
const providerGroup = (items) => asGroup({ items });
|
|
1325
|
+
const withReply = (options, replyTo) => replyTo ? {
|
|
1326
|
+
...options,
|
|
1327
|
+
replyTo
|
|
1328
|
+
} : options;
|
|
1329
|
+
const replyOptions = (replyTo) => replyTo ? { replyTo } : void 0;
|
|
1330
|
+
const effectOption = (effect) => effect ? { effect } : {};
|
|
1331
|
+
const formattingOption = (formatting) => formatting.length > 0 ? { formatting } : {};
|
|
1332
|
+
const dataDetectionOption = (hasLinks) => hasLinks ? { enableDataDetection: true } : {};
|
|
1333
|
+
const renderMarkdown = (markdown) => {
|
|
1334
|
+
const rendered = markdownToIMessageText(markdown);
|
|
1335
|
+
if (!rendered.text) throw unsupportedRemoteContent("markdown", "renders to empty text — nothing to send");
|
|
1336
|
+
return rendered;
|
|
1337
|
+
};
|
|
1338
|
+
const replyTargetFromId = (messageId) => {
|
|
1339
|
+
const childRef = parseChildId(messageId);
|
|
1340
|
+
if (childRef) return {
|
|
1341
|
+
guid: toMessageGuid(childRef.parentGuid),
|
|
1342
|
+
partIndex: childRef.partIndex
|
|
1343
|
+
};
|
|
1344
|
+
return toMessageGuid(messageId);
|
|
1345
|
+
};
|
|
1346
|
+
const outboundMessage = (spaceId, message, content) => outboundRecord(spaceId, message.guid, content, message.dateCreated);
|
|
1347
|
+
const outboundPoll = (spaceId, poll, content) => outboundRecord(spaceId, poll.pollMessageGuid, content, /* @__PURE__ */ new Date());
|
|
1348
|
+
const sendVCardAttachment = (remote, name, vcf) => remote.attachments.upload({
|
|
1349
|
+
data: Buffer.from(vcf, "utf8"),
|
|
1350
|
+
fileName: name
|
|
1351
|
+
});
|
|
1352
|
+
const sendContactAttachment = async (remote, content) => {
|
|
1353
|
+
const vcf = await toVCard(content);
|
|
1354
|
+
const name = vcardFileName(content);
|
|
1355
|
+
return {
|
|
1356
|
+
guid: (await sendVCardAttachment(remote, name, vcf)).attachment.guid,
|
|
1357
|
+
name
|
|
1358
|
+
};
|
|
1359
|
+
};
|
|
1360
|
+
const uploadAttachment = async (remote, content) => {
|
|
1361
|
+
return {
|
|
1362
|
+
guid: (await remote.attachments.upload({
|
|
1363
|
+
data: await content.read(),
|
|
1364
|
+
fileName: content.name
|
|
1365
|
+
})).attachment.guid,
|
|
1366
|
+
name: content.name
|
|
1367
|
+
};
|
|
1368
|
+
};
|
|
1369
|
+
const uploadVoice = async (remote, content) => {
|
|
1370
|
+
const { buffer } = await ensureM4a(await content.read(), content.mimeType);
|
|
1371
|
+
const name = content.name ?? "voice.m4a";
|
|
1372
|
+
return {
|
|
1373
|
+
guid: (await remote.attachments.upload({
|
|
1374
|
+
data: buffer,
|
|
1375
|
+
fileName: name
|
|
1376
|
+
})).attachment.guid,
|
|
1377
|
+
name
|
|
1378
|
+
};
|
|
1379
|
+
};
|
|
1380
|
+
const sendContent = async (remote, spaceId, chat, content, replyTo, effect) => {
|
|
1381
|
+
switch (content.type) {
|
|
1382
|
+
case "effect": return sendContent(remote, spaceId, chat, content.content, replyTo, content.effect);
|
|
1383
|
+
case "text": return outboundMessage(spaceId, await remote.messages.sendText(chat, content.text, withReply(effectOption(effect), replyTo)), content);
|
|
1384
|
+
case "markdown": {
|
|
1385
|
+
const rendered = renderMarkdown(content.markdown);
|
|
1386
|
+
return outboundMessage(spaceId, await remote.messages.sendText(chat, rendered.text, withReply({
|
|
1387
|
+
...effectOption(effect),
|
|
1388
|
+
...formattingOption(rendered.formatting),
|
|
1389
|
+
...dataDetectionOption(rendered.hasLinks)
|
|
1390
|
+
}, replyTo)), content);
|
|
1391
|
+
}
|
|
1392
|
+
case "richlink": return outboundMessage(spaceId, await remote.messages.sendText(chat, content.url, withReply({ enableLinkPreview: true }, replyTo)), content);
|
|
1393
|
+
case "attachment": {
|
|
1394
|
+
const { guid } = await uploadAttachment(remote, content);
|
|
1395
|
+
return outboundMessage(spaceId, await remote.messages.sendAttachment(chat, guid, withReply(effectOption(effect), replyTo)), content);
|
|
1396
|
+
}
|
|
1397
|
+
case "contact": {
|
|
1398
|
+
const { guid } = await sendContactAttachment(remote, content);
|
|
1399
|
+
return outboundMessage(spaceId, await remote.messages.sendAttachment(chat, guid, replyOptions(replyTo)), content);
|
|
1400
|
+
}
|
|
1401
|
+
case "voice": {
|
|
1402
|
+
const { guid } = await uploadVoice(remote, content);
|
|
1403
|
+
return outboundMessage(spaceId, await remote.messages.sendAttachment(chat, guid, {
|
|
1404
|
+
isAudioMessage: true,
|
|
1405
|
+
...replyOptions(replyTo)
|
|
1406
|
+
}), content);
|
|
1407
|
+
}
|
|
1408
|
+
case "poll":
|
|
1409
|
+
if (replyTo) throw unsupportedRemoteContent("poll", "polls cannot be sent as replies");
|
|
1410
|
+
return outboundPoll(spaceId, await remote.polls.create(chat, content.title, content.options.map((option) => option.title)), content);
|
|
1411
|
+
default: throw unsupportedRemoteContent(content.type);
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
const validateGroupContent = (content) => {
|
|
1415
|
+
let textCount = 0;
|
|
1416
|
+
for (const sub of content.items) {
|
|
1417
|
+
const itemType = sub.content.type;
|
|
1418
|
+
if (!GROUP_ITEM_ALLOWED.has(itemType)) throw unsupportedRemoteContent("group", `"${itemType}" items are not supported inside a group`);
|
|
1419
|
+
if (GROUP_TEXT_TYPES.has(itemType) && ++textCount > MAX_GROUP_TEXT_ITEMS) throw unsupportedRemoteContent("group", `groups can contain at most ${MAX_GROUP_TEXT_ITEMS} text item`);
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
const resolvePart = async (remote, content) => {
|
|
1423
|
+
switch (content.type) {
|
|
1424
|
+
case "text": return { text: content.text };
|
|
1425
|
+
case "markdown": {
|
|
1426
|
+
const rendered = renderMarkdown(content.markdown);
|
|
1427
|
+
return {
|
|
1428
|
+
text: rendered.text,
|
|
1429
|
+
...formattingOption(rendered.formatting)
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
case "attachment": {
|
|
1433
|
+
const { guid, name } = await uploadAttachment(remote, content);
|
|
1434
|
+
return {
|
|
1435
|
+
attachmentGuid: guid,
|
|
1436
|
+
attachmentName: name
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
case "contact": {
|
|
1440
|
+
const { guid, name } = await sendContactAttachment(remote, content);
|
|
1441
|
+
return {
|
|
1442
|
+
attachmentGuid: guid,
|
|
1443
|
+
attachmentName: name
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
case "voice": {
|
|
1447
|
+
const { guid, name } = await uploadVoice(remote, content);
|
|
1448
|
+
return {
|
|
1449
|
+
attachmentGuid: guid,
|
|
1450
|
+
attachmentName: name
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
default: throw unsupportedRemoteContent(content.type);
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
const send$1 = async (remote, spaceId, content) => {
|
|
1457
|
+
const chat = toChatGuid(spaceId);
|
|
1458
|
+
if (content.type === "group") {
|
|
1459
|
+
validateGroupContent(content);
|
|
1460
|
+
const resolved = await Promise.all(content.items.map((sub) => resolvePart(remote, sub.content)));
|
|
1461
|
+
const message = await remote.messages.sendMultipart(chat, resolved.map((part, idx) => ({
|
|
1462
|
+
...part,
|
|
1463
|
+
bubbleIndex: idx
|
|
1464
|
+
})));
|
|
1465
|
+
const parentGuid = message.guid;
|
|
1466
|
+
const timestamp = message.dateCreated;
|
|
1467
|
+
return outboundRecord(spaceId, parentGuid, providerGroup(content.items.map((sub, idx) => outboundGroupItem(spaceId, formatChildId(idx, parentGuid), sub.content, timestamp, idx, parentGuid))), timestamp);
|
|
1468
|
+
}
|
|
1469
|
+
return sendContent(remote, spaceId, chat, content);
|
|
1470
|
+
};
|
|
1471
|
+
const replyToMessage$1 = async (remote, spaceId, msgId, content) => {
|
|
1472
|
+
return sendContent(remote, spaceId, toChatGuid(spaceId), content, replyTargetFromId(msgId));
|
|
1473
|
+
};
|
|
1474
|
+
const editMessage$1 = async (remote, spaceId, msgId, content) => {
|
|
1475
|
+
if (content.type !== "text") throw unsupportedRemoteContent(content.type, "only text content can be edited");
|
|
1476
|
+
const childRef = parseChildId(msgId);
|
|
1477
|
+
await remote.messages.edit(toChatGuid(spaceId), toMessageGuid(childRef?.parentGuid ?? msgId), content.text, childRef ? { partIndex: childRef.partIndex } : void 0);
|
|
1478
|
+
};
|
|
1479
|
+
const unsendMessage$1 = async (remote, spaceId, msgId) => {
|
|
1480
|
+
const childRef = parseChildId(msgId);
|
|
1481
|
+
await remote.messages.unsend(toChatGuid(spaceId), toMessageGuid(childRef?.parentGuid ?? msgId), childRef ? { partIndex: childRef.partIndex } : void 0);
|
|
1482
|
+
};
|
|
1483
|
+
//#endregion
|
|
1484
|
+
//#region src/remote/contact-share.ts
|
|
1485
|
+
const log$1 = createLogger("spectrum.imessage.contact");
|
|
1486
|
+
const SHARE_TTL_MS = 1440 * 60 * 1e3;
|
|
1487
|
+
const MAX_TRACKED_CHATS = 1e4;
|
|
1488
|
+
/**
|
|
1489
|
+
* Tracks which chats this bot has already proactively pushed its contact card
|
|
1490
|
+
* to, so `im.chats.shareContactInfo` is fired at most once per chat per 24h
|
|
1491
|
+
* per iMessage provider instance.
|
|
1492
|
+
*
|
|
1493
|
+
* Backed by `lru-cache` for TTL + bounded memory. `ttlAutopurge: false`
|
|
1494
|
+
* keeps eviction lazy (on access) — there is no background timer to leak
|
|
1495
|
+
* across Spectrum lifecycles.
|
|
1496
|
+
*/
|
|
1497
|
+
var ContactShareTracker = class {
|
|
1498
|
+
cache = new LRUCache({
|
|
1499
|
+
max: MAX_TRACKED_CHATS,
|
|
1500
|
+
ttl: SHARE_TTL_MS,
|
|
1501
|
+
ttlAutopurge: false
|
|
1502
|
+
});
|
|
1503
|
+
/**
|
|
1504
|
+
* Best-effort share. The cache is set eagerly so that a burst of inbound
|
|
1505
|
+
* messages for the same chat coalesces to a single API call. On failure the
|
|
1506
|
+
* entry is evicted so the next inbound retries — transient errors don't
|
|
1507
|
+
* permanently mute the feature for a chat. Never awaits and never throws:
|
|
1508
|
+
* the receive stream must not crash on share failures.
|
|
1509
|
+
*/
|
|
1510
|
+
maybeShare(client, chatGuid) {
|
|
1511
|
+
if (this.cache.has(chatGuid)) return;
|
|
1512
|
+
this.cache.set(chatGuid, true);
|
|
1513
|
+
const safeChatGuid = sanitizeErrorMessage(chatGuid);
|
|
1514
|
+
client.chats.shareContactInfo(chatGuid).then(() => {
|
|
1515
|
+
log$1.info("shared contact card", { "spectrum.imessage.contact.chat": safeChatGuid });
|
|
1516
|
+
}).catch((error) => {
|
|
1517
|
+
this.cache.delete(chatGuid);
|
|
1518
|
+
log$1.warn("failed to share contact card", {
|
|
1519
|
+
"spectrum.imessage.contact.chat": safeChatGuid,
|
|
1520
|
+
...errorAttrs(error)
|
|
1521
|
+
}, error);
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
const trackers = /* @__PURE__ */ new WeakMap();
|
|
1526
|
+
/**
|
|
1527
|
+
* Returns a per-owner tracker. Mirrors `getMessageCache`/`getPollCache` in
|
|
1528
|
+
* ../cache.ts — keyed by an object (the `RemoteClient[]` array for remote
|
|
1529
|
+
* mode), so each iMessage provider instance has its own tracker and multiple
|
|
1530
|
+
* providers don't share state accidentally.
|
|
1531
|
+
*/
|
|
1532
|
+
const getContactShareTracker = (owner) => {
|
|
1533
|
+
let tracker = trackers.get(owner);
|
|
1534
|
+
if (!tracker) {
|
|
1535
|
+
tracker = new ContactShareTracker();
|
|
1536
|
+
trackers.set(owner, tracker);
|
|
1537
|
+
}
|
|
1538
|
+
return tracker;
|
|
1539
|
+
};
|
|
1540
|
+
//#endregion
|
|
1541
|
+
//#region src/remote/polls.ts
|
|
1542
|
+
const log = createLogger("spectrum.imessage.poll");
|
|
1543
|
+
const isVotedPollEvent = (event) => event.delta.type === "voted";
|
|
1544
|
+
const isUnvotedPollEvent = (event) => event.delta.type === "unvoted";
|
|
1545
|
+
const toCachedPoll = (input) => {
|
|
1546
|
+
const poll = asPoll({
|
|
1547
|
+
title: input.title,
|
|
1548
|
+
options: input.options.map((optionInfo) => ({ title: optionInfo.text }))
|
|
1549
|
+
});
|
|
1550
|
+
const optionsByIdentifier = /* @__PURE__ */ new Map();
|
|
1551
|
+
for (const [index, optionInfo] of input.options.entries()) {
|
|
1552
|
+
const option = poll.options[index];
|
|
1553
|
+
if (option && optionInfo.optionIdentifier) optionsByIdentifier.set(optionInfo.optionIdentifier, option);
|
|
1554
|
+
}
|
|
1555
|
+
return {
|
|
1556
|
+
poll,
|
|
1557
|
+
optionsByIdentifier
|
|
1558
|
+
};
|
|
1559
|
+
};
|
|
1560
|
+
const cachePollInfo = (cache, info) => {
|
|
1561
|
+
const cached = toCachedPoll(info);
|
|
1562
|
+
cache.set(info.pollMessageGuid, cached);
|
|
1563
|
+
return cached;
|
|
1564
|
+
};
|
|
1565
|
+
const cachePollEvent = (cache, event) => {
|
|
1566
|
+
if (event.delta.type === "created" || event.delta.type === "optionAdded") try {
|
|
1567
|
+
const cached = toCachedPoll({
|
|
1568
|
+
title: event.delta.title,
|
|
1569
|
+
options: event.delta.options
|
|
1570
|
+
});
|
|
1571
|
+
cache.set(event.pollMessageGuid, cached);
|
|
1572
|
+
return cached;
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
log.error("failed to cache poll", {
|
|
1575
|
+
"spectrum.imessage.poll.guid": event.pollMessageGuid,
|
|
1576
|
+
...errorAttrs(e)
|
|
1577
|
+
}, e);
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
1580
|
+
const fetchPollInfo = async (client, cache, event) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const info = await client.polls.get(event.pollMessageGuid);
|
|
1583
|
+
cachePollInfo(cache, info);
|
|
1584
|
+
return info;
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
log.error("failed to fetch poll", {
|
|
1587
|
+
"spectrum.imessage.poll.guid": event.pollMessageGuid,
|
|
1588
|
+
...errorAttrs(e)
|
|
1589
|
+
}, e);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
const resolvePoll = async (client, cache, event) => {
|
|
1594
|
+
const cached = cache.get(event.pollMessageGuid);
|
|
1595
|
+
if (cached) return cached;
|
|
1596
|
+
try {
|
|
1597
|
+
return cachePollInfo(cache, await client.polls.get(event.pollMessageGuid));
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
log.error("failed to resolve poll", {
|
|
1600
|
+
"spectrum.imessage.poll.guid": event.pollMessageGuid,
|
|
1601
|
+
...errorAttrs(e)
|
|
1602
|
+
}, e);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
const buildPollOptionMessage = (input) => {
|
|
1607
|
+
const option = input.cached.optionsByIdentifier.get(input.optionId);
|
|
1608
|
+
if (!option) return;
|
|
1609
|
+
const action = input.selected ? "selected" : "deselected";
|
|
1610
|
+
const eventTime = input.event.occurredAt.getTime();
|
|
1611
|
+
return {
|
|
1612
|
+
id: `${input.event.pollMessageGuid}:${input.senderAddress}:${input.optionId}:${action}:${eventTime}`,
|
|
1613
|
+
sender: { id: input.senderAddress },
|
|
1614
|
+
space: {
|
|
1615
|
+
id: input.chatGuid,
|
|
1616
|
+
type: chatTypeFromGuid(input.chatGuid),
|
|
1617
|
+
phone: input.phone
|
|
1618
|
+
},
|
|
1619
|
+
timestamp: input.event.occurredAt,
|
|
1620
|
+
content: asPollOption({
|
|
1621
|
+
option,
|
|
1622
|
+
poll: input.cached.poll,
|
|
1623
|
+
selected: input.selected
|
|
1624
|
+
})
|
|
1625
|
+
};
|
|
1626
|
+
};
|
|
1627
|
+
const refreshPollMetadata = async (client, pollCache, event) => {
|
|
1628
|
+
const info = await fetchPollInfo(client, pollCache, event);
|
|
1629
|
+
if (!info) return;
|
|
1630
|
+
return pollCache.get(info.pollMessageGuid);
|
|
1631
|
+
};
|
|
1632
|
+
const toPollOptionMessage = async (client, pollCache, event, phone) => {
|
|
1633
|
+
const senderAddress = event.actor?.address;
|
|
1634
|
+
const optionId = event.delta.optionIdentifier;
|
|
1635
|
+
if (!(senderAddress && optionId)) return [];
|
|
1636
|
+
let cached = await resolvePoll(client, pollCache, event);
|
|
1637
|
+
if (!cached) return [];
|
|
1638
|
+
if (!cached.optionsByIdentifier.has(optionId)) {
|
|
1639
|
+
const refreshed = await refreshPollMetadata(client, pollCache, event);
|
|
1640
|
+
if (refreshed) cached = refreshed;
|
|
1641
|
+
}
|
|
1642
|
+
const message = buildPollOptionMessage({
|
|
1643
|
+
cached,
|
|
1644
|
+
chatGuid: event.chatGuid,
|
|
1645
|
+
event,
|
|
1646
|
+
optionId,
|
|
1647
|
+
phone,
|
|
1648
|
+
selected: event.delta.type === "voted",
|
|
1649
|
+
senderAddress
|
|
1650
|
+
});
|
|
1651
|
+
return message ? [message] : [];
|
|
1652
|
+
};
|
|
1653
|
+
const toPollDeltaMessages = async (client, pollCache, event, phone) => {
|
|
1654
|
+
if (isVotedPollEvent(event)) return toPollOptionMessage(client, pollCache, event, phone);
|
|
1655
|
+
if (isUnvotedPollEvent(event)) return toPollOptionMessage(client, pollCache, event, phone);
|
|
1656
|
+
return [];
|
|
1657
|
+
};
|
|
1658
|
+
//#endregion
|
|
1659
|
+
//#region src/remote/stream.ts
|
|
1660
|
+
const isCursorRejectedIMessageError = (error) => error instanceof ValidationError;
|
|
1661
|
+
const streamLabel = (kind, phone) => `imessage.${kind}:${phone === "shared" ? phone : sanitizePhone(phone)}`;
|
|
1662
|
+
const isEventFromCurrentAccount = (event, phone) => event.isFromMe || phone !== "shared" && event.actor?.address !== void 0 && event.actor.address === phone;
|
|
1663
|
+
const toMessageItem = async (client, event, phone, cursor, onInbound) => {
|
|
1664
|
+
if (event.type === "message.received") {
|
|
1665
|
+
if (event.message.isFromMe) return {
|
|
1666
|
+
cursor,
|
|
1667
|
+
id: event.message.guid,
|
|
1668
|
+
values: []
|
|
1669
|
+
};
|
|
1670
|
+
const inboundChatGuid = event.message.chatGuids?.[0];
|
|
1671
|
+
if (inboundChatGuid) onInbound?.(inboundChatGuid);
|
|
1672
|
+
const cache = getMessageCache(client);
|
|
1673
|
+
return {
|
|
1674
|
+
cursor,
|
|
1675
|
+
id: event.message.guid,
|
|
1676
|
+
values: await toInboundMessages(client, cache, event, phone)
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
if (event.type === "message.reactionAdded") {
|
|
1680
|
+
if (isEventFromCurrentAccount(event, phone)) return {
|
|
1681
|
+
cursor,
|
|
1682
|
+
id: `${event.messageGuid}:reaction:${event.sequence}`,
|
|
1683
|
+
values: []
|
|
1684
|
+
};
|
|
1685
|
+
const cache = getMessageCache(client);
|
|
1686
|
+
return {
|
|
1687
|
+
cursor,
|
|
1688
|
+
id: `${event.messageGuid}:reaction:${event.sequence}`,
|
|
1689
|
+
values: await toReactionMessages(client, cache, event, phone)
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
return {
|
|
1693
|
+
cursor,
|
|
1694
|
+
id: `${event.type}:${"messageGuid" in event ? event.messageGuid : "unknown"}:${event.sequence}`,
|
|
1695
|
+
values: []
|
|
1696
|
+
};
|
|
1697
|
+
};
|
|
1698
|
+
const toPollItem = async (client, pollCache, event, phone, cursor) => {
|
|
1699
|
+
cachePollEvent(pollCache, event);
|
|
1700
|
+
if (isEventFromCurrentAccount(event, phone)) return {
|
|
1701
|
+
cursor,
|
|
1702
|
+
id: `${event.pollMessageGuid}:poll:${event.sequence}`,
|
|
1703
|
+
values: []
|
|
1704
|
+
};
|
|
1705
|
+
return {
|
|
1706
|
+
cursor,
|
|
1707
|
+
id: `${event.pollMessageGuid}:poll:${event.sequence}`,
|
|
1708
|
+
values: await toPollDeltaMessages(client, pollCache, event, phone)
|
|
1709
|
+
};
|
|
1710
|
+
};
|
|
1711
|
+
const toCatchUpCompleteItem = (event) => ({
|
|
1712
|
+
cursor: String(event.headSequence),
|
|
1713
|
+
id: `${event.type}:${event.headSequence}`,
|
|
1714
|
+
values: []
|
|
1715
|
+
});
|
|
1716
|
+
const isMessageEvent = (event) => event.type.startsWith("message.");
|
|
1717
|
+
const isPollEvent = (event) => event.type === "poll.changed";
|
|
1718
|
+
async function* catchUpEvents(client, cursor, isWanted) {
|
|
1719
|
+
const since = toResumeAfter(cursor);
|
|
1720
|
+
if (since === void 0) return;
|
|
1721
|
+
for await (const event of client.events.catchUp(since)) {
|
|
1722
|
+
if (event.type === "catchup.complete") {
|
|
1723
|
+
yield event;
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
if (isWanted(event)) yield event;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
const toResumeAfter = (cursor) => {
|
|
1730
|
+
if (!cursor) return;
|
|
1731
|
+
const sequence = Number(cursor);
|
|
1732
|
+
return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : void 0;
|
|
1733
|
+
};
|
|
1734
|
+
async function* afterCursor(stream, cursor) {
|
|
1735
|
+
const resumeAfter = toResumeAfter(cursor);
|
|
1736
|
+
try {
|
|
1737
|
+
for await (const event of stream) {
|
|
1738
|
+
if (resumeAfter !== void 0 && event.sequence <= resumeAfter) continue;
|
|
1739
|
+
yield event;
|
|
1740
|
+
}
|
|
1741
|
+
} finally {
|
|
1742
|
+
await stream.close?.();
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const withClose = (source, cursor) => Object.assign(afterCursor(source, cursor), { close: async () => {
|
|
1746
|
+
await source.close?.();
|
|
1747
|
+
} });
|
|
1748
|
+
const messageStream = (client, phone, onInbound) => resumableOrderedStream({
|
|
1749
|
+
fetchMissed: (cursor) => catchUpEvents(client, cursor, isMessageEvent),
|
|
1750
|
+
isCursorRejectedError: isCursorRejectedIMessageError,
|
|
1751
|
+
label: streamLabel("messages", phone),
|
|
1752
|
+
processLive: (event) => toMessageItem(client, event, phone, String(event.sequence), onInbound),
|
|
1753
|
+
processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toMessageItem(client, event, phone, String(event.sequence), onInbound),
|
|
1754
|
+
subscribeLive: (cursor) => withClose(client.messages.subscribeEvents(), cursor)
|
|
1755
|
+
});
|
|
1756
|
+
const pollStream = (client, pollCache, phone) => resumableOrderedStream({
|
|
1757
|
+
fetchMissed: (cursor) => catchUpEvents(client, cursor, isPollEvent),
|
|
1758
|
+
isCursorRejectedError: isCursorRejectedIMessageError,
|
|
1759
|
+
label: streamLabel("polls", phone),
|
|
1760
|
+
processLive: (event) => toPollItem(client, pollCache, event, phone, String(event.sequence)),
|
|
1761
|
+
processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toPollItem(client, pollCache, event, phone, String(event.sequence)),
|
|
1762
|
+
subscribeLive: (cursor) => withClose(client.polls.subscribeEvents(), cursor)
|
|
1763
|
+
});
|
|
1764
|
+
const clientStream = (client, pollCache, phone, onInbound) => mergeStreams([messageStream(client, phone, onInbound), pollStream(client, pollCache, phone)]);
|
|
1765
|
+
const messages$1 = (clients, projectConfig) => {
|
|
1766
|
+
const pollCache = getPollCache(clients);
|
|
1767
|
+
const tracker = projectConfig?.profile?.imessageSynced === true ? getContactShareTracker(clients) : void 0;
|
|
1768
|
+
return mergeStreams(clients.map((entry) => clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(entry.client, chatGuid) : void 0)));
|
|
1769
|
+
};
|
|
1770
|
+
//#endregion
|
|
1771
|
+
//#region src/remote/stream-text.ts
|
|
1772
|
+
const INITIAL_THROTTLE_MS = 1e3;
|
|
1773
|
+
const BACKOFF_FACTOR = 2;
|
|
1774
|
+
const MAX_EDITS = 5;
|
|
1775
|
+
/**
|
|
1776
|
+
* Deliver a `streamText` content by sending the first chunk as a real message
|
|
1777
|
+
* and editing it in place as more text arrives. The stream materializes into a
|
|
1778
|
+
* normal text message: the returned record carries `asText(fullText)` with the
|
|
1779
|
+
* first send's id and timestamp.
|
|
1780
|
+
*/
|
|
1781
|
+
const sendStreamText$1 = async (remote, spaceId, content) => {
|
|
1782
|
+
if (content.format === "markdown") throw unsupportedRemoteContent("streamText", "markdown-formatted streams have no native iMessage delivery");
|
|
1783
|
+
const chat = toChatGuid(spaceId);
|
|
1784
|
+
let sent;
|
|
1785
|
+
let full = "";
|
|
1786
|
+
let lastSentText = "";
|
|
1787
|
+
let lastEditAt = 0;
|
|
1788
|
+
let editCount = 0;
|
|
1789
|
+
const flushEdit = async (text) => {
|
|
1790
|
+
if (!sent || text === lastSentText) return;
|
|
1791
|
+
await remote.messages.edit(chat, toMessageGuid(sent.guid), text);
|
|
1792
|
+
lastSentText = text;
|
|
1793
|
+
lastEditAt = Date.now();
|
|
1794
|
+
editCount += 1;
|
|
1795
|
+
};
|
|
1796
|
+
for await (const delta of content.stream()) {
|
|
1797
|
+
full += delta;
|
|
1798
|
+
if (!sent) {
|
|
1799
|
+
sent = await remote.messages.sendText(chat, full);
|
|
1800
|
+
lastSentText = full;
|
|
1801
|
+
lastEditAt = Date.now();
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
const hasBudgetForInterimEdit = editCount < MAX_EDITS - 1;
|
|
1805
|
+
const requiredGap = INITIAL_THROTTLE_MS * BACKOFF_FACTOR ** editCount;
|
|
1806
|
+
if (hasBudgetForInterimEdit && Date.now() - lastEditAt >= requiredGap) await flushEdit(full);
|
|
1807
|
+
}
|
|
1808
|
+
if (!sent) throw unsupportedRemoteContent("streamText", "stream produced no text — nothing to send");
|
|
1809
|
+
await flushEdit(full);
|
|
1810
|
+
return {
|
|
1811
|
+
id: sent.guid,
|
|
1812
|
+
content: asText(full),
|
|
1813
|
+
direction: "outbound",
|
|
1814
|
+
space: { id: spaceId },
|
|
1815
|
+
timestamp: sent.dateCreated
|
|
1816
|
+
};
|
|
1817
|
+
};
|
|
1818
|
+
//#endregion
|
|
1819
|
+
//#region src/remote/typing.ts
|
|
1820
|
+
const startTyping$1 = async (remote, spaceId) => {
|
|
1821
|
+
await remote.chats.setTyping(toChatGuid(spaceId), true);
|
|
1822
|
+
};
|
|
1823
|
+
const stopTyping$1 = async (remote, spaceId) => {
|
|
1824
|
+
await remote.chats.setTyping(toChatGuid(spaceId), false);
|
|
1825
|
+
};
|
|
1826
|
+
//#endregion
|
|
1827
|
+
//#region src/remote/api.ts
|
|
1828
|
+
const messages = (clients, projectConfig) => messages$1(clients, projectConfig);
|
|
1829
|
+
const setBackground = async (remote, spaceId, content) => setBackground$1(remote, spaceId, content);
|
|
1830
|
+
const sendCustomizedMiniApp = async (remote, spaceId, content) => sendCustomizedMiniApp$1(remote, spaceId, content);
|
|
1831
|
+
const setDisplayName = async (remote, spaceId, content) => setDisplayName$1(remote, spaceId, content);
|
|
1832
|
+
const setIcon = async (remote, spaceId, content) => setIcon$1(remote, spaceId, content);
|
|
1833
|
+
const markRead = async (remote, spaceId) => {
|
|
1834
|
+
await markRead$1(remote, spaceId);
|
|
1835
|
+
};
|
|
1836
|
+
const shareContactCard = async (remote, spaceId) => {
|
|
1837
|
+
await shareContactCard$1(remote, spaceId);
|
|
1838
|
+
};
|
|
1839
|
+
const startTyping = async (remote, spaceId) => {
|
|
1840
|
+
await startTyping$1(remote, spaceId);
|
|
1841
|
+
};
|
|
1842
|
+
const stopTyping = async (remote, spaceId) => {
|
|
1843
|
+
await stopTyping$1(remote, spaceId);
|
|
1844
|
+
};
|
|
1845
|
+
const send = async (remote, spaceId, content) => send$1(remote, spaceId, content);
|
|
1846
|
+
const sendStreamText = async (remote, spaceId, content) => sendStreamText$1(remote, spaceId, content);
|
|
1847
|
+
const replyToMessage = async (remote, spaceId, msgId, content) => replyToMessage$1(remote, spaceId, msgId, content);
|
|
1848
|
+
const editMessage = async (remote, spaceId, msgId, content) => editMessage$1(remote, spaceId, msgId, content);
|
|
1849
|
+
const reactToMessage = async (remote, spaceId, target, reaction) => reactToMessage$1(remote, spaceId, target, reaction);
|
|
1850
|
+
const unsendMessage = async (remote, spaceId, msgId) => unsendMessage$1(remote, spaceId, msgId);
|
|
1851
|
+
const unsendReaction = async (remote, spaceId, target, reaction) => unsendReaction$1(remote, spaceId, target, reaction);
|
|
1852
|
+
const getMessage = async (remote, spaceId, msgId, phone) => getMessage$1(remote, spaceId, msgId, phone);
|
|
1853
|
+
//#endregion
|
|
1854
|
+
//#region src/remote/app.ts
|
|
1855
|
+
/**
|
|
1856
|
+
* Fixed identity of Spectrum's own iMessage extension. The universal `app`
|
|
1857
|
+
* content renders through this extension, so callers never supply (or even see)
|
|
1858
|
+
* these constants — they pass only a URL and the card opens it inside the
|
|
1859
|
+
* Spectrum mini app on tap. Callers shipping their *own* extension use the
|
|
1860
|
+
* low-level `customizedMiniApp()` instead.
|
|
1861
|
+
*/
|
|
1862
|
+
const SPECTRUM_MINI_APP = {
|
|
1863
|
+
appName: "Spectrum",
|
|
1864
|
+
extensionBundleId: "codes.photon.Spectrum.MessagesExtension",
|
|
1865
|
+
teamId: "P8XT6232SL",
|
|
1866
|
+
appStoreId: 6777616651
|
|
1867
|
+
};
|
|
1868
|
+
/**
|
|
1869
|
+
* Build the iMessage mini-app card for an `app` content: Spectrum's fixed
|
|
1870
|
+
* identity plus the per-message `url` and the `layout` already derived from the
|
|
1871
|
+
* URL's link metadata.
|
|
1872
|
+
*/
|
|
1873
|
+
const toSpectrumMiniApp = (url, layout) => asCustomizedMiniApp({
|
|
1874
|
+
...SPECTRUM_MINI_APP,
|
|
1875
|
+
url,
|
|
1876
|
+
layout
|
|
1877
|
+
});
|
|
1878
|
+
//#endregion
|
|
1879
|
+
//#region src/remote/client.ts
|
|
1880
|
+
const isSharedMode = (clients) => clients.length === 1 && clients[0]?.phone === "shared";
|
|
1881
|
+
const availablePhones = (clients) => clients.map((c) => c.phone);
|
|
1882
|
+
const clientForPhone = (clients, phone) => {
|
|
1883
|
+
if (isSharedMode(clients)) {
|
|
1884
|
+
const entry = clients[0];
|
|
1885
|
+
if (!entry) throw new Error("No iMessage clients configured");
|
|
1886
|
+
return entry.client;
|
|
1887
|
+
}
|
|
1888
|
+
const entry = clients.find((c) => c.phone === phone);
|
|
1889
|
+
if (!entry) {
|
|
1890
|
+
const list = availablePhones(clients).join(", ") || "<none>";
|
|
1891
|
+
throw new Error(`No iMessage client serves phone ${phone}. Available: ${list}`);
|
|
1892
|
+
}
|
|
1893
|
+
return entry.client;
|
|
1894
|
+
};
|
|
1895
|
+
const randomPhone = (clients) => {
|
|
1896
|
+
if (clients.length === 0) throw new Error("No iMessage phones configured for this account");
|
|
1897
|
+
if (isSharedMode(clients)) return SHARED_PHONE;
|
|
1898
|
+
const entry = clients[Math.floor(Math.random() * clients.length)];
|
|
1899
|
+
if (!entry) throw new Error("No iMessage phones configured for this account");
|
|
1900
|
+
return entry.phone;
|
|
1901
|
+
};
|
|
1902
|
+
//#endregion
|
|
1903
|
+
//#region src/index.ts
|
|
1904
|
+
const isPollContent = (content) => content.type === "poll" || content.type === "poll_option";
|
|
1905
|
+
const cacheRemoteOutbound = (remote, space, record) => {
|
|
1906
|
+
if (!record) return record;
|
|
1907
|
+
cacheMessage(getMessageCache(remote), {
|
|
1908
|
+
...record,
|
|
1909
|
+
direction: record.direction ?? "outbound",
|
|
1910
|
+
space: {
|
|
1911
|
+
...record.space,
|
|
1912
|
+
id: record.space.id,
|
|
1913
|
+
phone: space.phone,
|
|
1914
|
+
type: space.type
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
return record;
|
|
1918
|
+
};
|
|
1919
|
+
const handleEdit = async (client, space, content) => {
|
|
1920
|
+
if (isLocal(client)) throw UnsupportedError.action("edit", "iMessage (local mode)");
|
|
1921
|
+
if (content.content.type !== "text") throw UnsupportedError.content("edit", "iMessage", `only text content can be edited (got "${content.content.type}")`);
|
|
1922
|
+
await editMessage(clientForPhone(client, space.phone), space.id, content.target.id, content.content);
|
|
1923
|
+
};
|
|
1924
|
+
const handleUnsend = async (client, space, content) => {
|
|
1925
|
+
if (isLocal(client)) throw UnsupportedError.action("unsend", "iMessage (local mode)");
|
|
1926
|
+
if (isPollContent(content.target.content)) throw UnsupportedError.action("unsend", "iMessage", "iMessage polls cannot be unsent");
|
|
1927
|
+
const remote = clientForPhone(client, space.phone);
|
|
1928
|
+
const targetContent = content.target.content;
|
|
1929
|
+
if (targetContent.type === "reaction") {
|
|
1930
|
+
await unsendReaction(remote, space.id, targetContent.target, targetContent.emoji);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
await unsendMessage(remote, space.id, content.target.id);
|
|
1934
|
+
};
|
|
1935
|
+
const handleStreamText = async (client, space, content) => {
|
|
1936
|
+
if (isLocal(client)) throw UnsupportedError.action("streamText", "iMessage (local mode)", "streaming text responses require remote iMessage");
|
|
1937
|
+
const remote = clientForPhone(client, space.phone);
|
|
1938
|
+
return cacheRemoteOutbound(remote, space, await sendStreamText(remote, space.id, content));
|
|
1939
|
+
};
|
|
1940
|
+
const handleBackground = async (client, space, content) => {
|
|
1941
|
+
if (isLocal(client)) throw UnsupportedError.action("background", "iMessage (local mode)", "chat backgrounds require remote iMessage");
|
|
1942
|
+
await setBackground(clientForPhone(client, space.phone), space.id, content);
|
|
1943
|
+
};
|
|
1944
|
+
const handleCustomizedMiniApp = async (client, space, content) => {
|
|
1945
|
+
if (isLocal(client)) throw UnsupportedError.action("customized-mini-app", "iMessage (local mode)", "mini app cards require remote iMessage");
|
|
1946
|
+
const remote = clientForPhone(client, space.phone);
|
|
1947
|
+
return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, content));
|
|
1948
|
+
};
|
|
1949
|
+
/**
|
|
1950
|
+
* Render the universal `app` content. On remote it becomes a native Spectrum
|
|
1951
|
+
* mini-app card (fixed `SPECTRUM_MINI_APP` identity + the URL + the layout
|
|
1952
|
+
* already parsed from the URL's link metadata). Local mode cannot send cards,
|
|
1953
|
+
* so it degrades to the bare URL as a text message.
|
|
1954
|
+
*/
|
|
1955
|
+
const handleApp = async (client, space, content) => {
|
|
1956
|
+
const url = await content.url();
|
|
1957
|
+
if (isLocal(client)) return await send$2(client, space.id, await text(url).build());
|
|
1958
|
+
const layout = await content.layout();
|
|
1959
|
+
const remote = clientForPhone(client, space.phone);
|
|
1960
|
+
return cacheRemoteOutbound(remote, space, await sendCustomizedMiniApp(remote, space.id, toSpectrumMiniApp(url, layout)));
|
|
1961
|
+
};
|
|
1962
|
+
const handleRead = async (client, space) => {
|
|
1963
|
+
if (isLocal(client)) throw UnsupportedError.action("read", "iMessage (local mode)", "marking chats as read requires remote iMessage");
|
|
1964
|
+
await markRead(clientForPhone(client, space.phone), space.id);
|
|
1965
|
+
};
|
|
1966
|
+
const handleShareContactCard = async (client, space) => {
|
|
1967
|
+
if (isLocal(client)) throw UnsupportedError.action("shareContactCard", "iMessage (local mode)", "sharing the contact card requires remote iMessage");
|
|
1968
|
+
await shareContactCard(clientForPhone(client, space.phone), space.id);
|
|
1969
|
+
};
|
|
1970
|
+
const handleTyping = async (client, space, state) => {
|
|
1971
|
+
if (isLocal(client)) return;
|
|
1972
|
+
const remote = clientForPhone(client, space.phone);
|
|
1973
|
+
if (state === "start") await startTyping(remote, space.id);
|
|
1974
|
+
else await stopTyping(remote, space.id);
|
|
1975
|
+
};
|
|
1976
|
+
const handleRename = async (client, space, content) => {
|
|
1977
|
+
if (isLocal(client)) throw UnsupportedError.action("rename", "iMessage (local mode)", "renaming chats requires remote iMessage");
|
|
1978
|
+
if (space.type !== "group") throw UnsupportedError.action("rename", "iMessage", "only group chats can be renamed (this space is a DM)");
|
|
1979
|
+
await setDisplayName(clientForPhone(client, space.phone), space.id, content);
|
|
1980
|
+
};
|
|
1981
|
+
const handleAvatar = async (client, space, content) => {
|
|
1982
|
+
if (isLocal(client)) throw UnsupportedError.action("avatar", "iMessage (local mode)", "setting group avatars requires remote iMessage");
|
|
1983
|
+
if (space.type !== "group") throw UnsupportedError.action("avatar", "iMessage", "only group chats have avatars (this space is a DM)");
|
|
1984
|
+
await setIcon(clientForPhone(client, space.phone), space.id, content);
|
|
1985
|
+
};
|
|
1986
|
+
/**
|
|
1987
|
+
* Dispatch the iMessage-only fire-and-forget control signals that live outside
|
|
1988
|
+
* the universal `Content` union (`background`, `contactCard`). Each is narrowed
|
|
1989
|
+
* via a runtime guard rather than a `content.type ===` check — the literals
|
|
1990
|
+
* aren't members of `Content["type"]`. Returns `true` when it consumed the
|
|
1991
|
+
* content so `send` can return early, keeping its dispatch chain flat.
|
|
1992
|
+
*/
|
|
1993
|
+
const handleProviderControlSignal = async (client, space, content) => {
|
|
1994
|
+
if (isBackground(content)) {
|
|
1995
|
+
await handleBackground(client, space, content);
|
|
1996
|
+
return true;
|
|
1997
|
+
}
|
|
1998
|
+
if (isContactCard(content)) {
|
|
1999
|
+
await handleShareContactCard(client, space);
|
|
2000
|
+
return true;
|
|
2001
|
+
}
|
|
2002
|
+
return false;
|
|
2003
|
+
};
|
|
2004
|
+
/**
|
|
2005
|
+
* Resolve the remote client for a `reply` / `reaction` whose target is another
|
|
2006
|
+
* message. Both reject local mode and poll targets identically, so the guard
|
|
2007
|
+
* lives here to keep the `send` dispatch flat. `action` labels the error and
|
|
2008
|
+
* `pollNoun` is the plural used in the poll-unsupported message.
|
|
2009
|
+
*/
|
|
2010
|
+
const remoteForMessageTarget = (client, space, target, action, pollNoun) => {
|
|
2011
|
+
if (isLocal(client)) throw UnsupportedError.action(action, "iMessage (local mode)");
|
|
2012
|
+
if (isPollContent(target.content)) throw UnsupportedError.action(action, "iMessage", `iMessage polls do not support ${pollNoun}`);
|
|
2013
|
+
return clientForPhone(client, space.phone);
|
|
2014
|
+
};
|
|
2015
|
+
const imessage = definePlatform("iMessage", {
|
|
2016
|
+
config: configSchema,
|
|
2017
|
+
static: { effect: { message: MessageEffect } },
|
|
2018
|
+
lifecycle: {
|
|
2019
|
+
createClient: async ({ config, projectId, projectSecret }) => {
|
|
2020
|
+
if (config.local) return new IMessageSDK();
|
|
2021
|
+
if (config.clients) return (Array.isArray(config.clients) ? config.clients : [config.clients]).map((e) => ({
|
|
2022
|
+
phone: e.phone,
|
|
2023
|
+
client: createClient({
|
|
2024
|
+
address: e.address,
|
|
2025
|
+
tls: true,
|
|
2026
|
+
token: e.token
|
|
2027
|
+
})
|
|
2028
|
+
}));
|
|
2029
|
+
if (!(projectId && projectSecret)) throw new Error("iMessage requires projectId and projectSecret. Either pass credentials to Spectrum(), use local mode: imessage.config({ local: true }), or provide explicit client config: imessage.config({ clients: [...] })");
|
|
2030
|
+
return await createCloudClients(projectId, projectSecret);
|
|
2031
|
+
},
|
|
2032
|
+
destroyClient: async ({ client }) => {
|
|
2033
|
+
if (isLocal(client)) {
|
|
2034
|
+
await client.close();
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
await disposeCloudAuth(client);
|
|
2038
|
+
await Promise.all(client.map((entry) => entry.client.close()));
|
|
2039
|
+
}
|
|
2040
|
+
},
|
|
2041
|
+
user: { resolve: async ({ input }) => ({ id: input.userID }) },
|
|
2042
|
+
space: {
|
|
2043
|
+
schema: spaceSchema,
|
|
2044
|
+
params: spaceParamsSchema,
|
|
2045
|
+
create: async ({ input, client }) => {
|
|
2046
|
+
if (isLocal(client)) throw UnsupportedError.action("space.create", "iMessage (local mode)", "local mode only supports replying to existing messages");
|
|
2047
|
+
if (input.users.length === 0) throw new Error("iMessage space creation requires at least one user");
|
|
2048
|
+
if (client.length === 0) throw new Error("No iMessage clients configured");
|
|
2049
|
+
const addresses = input.users.map((u) => u.id);
|
|
2050
|
+
if (isSharedMode(client)) {
|
|
2051
|
+
if (addresses.length > 1) throw UnsupportedError.action("space.create", "iMessage (shared mode)", "shared mode cannot create group chats — use a dedicated number, or space.get(chatGuid) for an existing group");
|
|
2052
|
+
return {
|
|
2053
|
+
id: dmChatGuid(addresses[0] ?? ""),
|
|
2054
|
+
type: "dm",
|
|
2055
|
+
phone: SHARED_PHONE
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
const phone = input.params?.phone ?? randomPhone(client);
|
|
2059
|
+
const { chat } = await clientForPhone(client, phone).chats.create(addresses);
|
|
2060
|
+
return {
|
|
2061
|
+
id: chat.guid,
|
|
2062
|
+
type: chat.isGroup ? "group" : "dm",
|
|
2063
|
+
phone
|
|
2064
|
+
};
|
|
2065
|
+
},
|
|
2066
|
+
get: async ({ input, client }) => {
|
|
2067
|
+
if (isLocal(client)) throw UnsupportedError.action("space.get", "iMessage (local mode)", "local mode only supports replying to existing messages");
|
|
2068
|
+
if (client.length === 0) throw new Error("No iMessage clients configured");
|
|
2069
|
+
const phone = isSharedMode(client) ? SHARED_PHONE : input.params?.phone ?? (client.length === 1 ? client[0]?.phone : void 0);
|
|
2070
|
+
if (!phone) throw new Error(`iMessage space.get requires params.phone when multiple clients are configured. Available: ${availablePhones(client).join(", ")}`);
|
|
2071
|
+
return {
|
|
2072
|
+
id: input.id,
|
|
2073
|
+
type: chatTypeFromGuid(input.id),
|
|
2074
|
+
phone
|
|
2075
|
+
};
|
|
2076
|
+
},
|
|
2077
|
+
actions: {
|
|
2078
|
+
background: async (space, input, opts) => {
|
|
2079
|
+
await space.send(background(input, opts));
|
|
2080
|
+
},
|
|
2081
|
+
shareContactCard: async (space) => {
|
|
2082
|
+
await space.send(nativeContactCard());
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
},
|
|
2086
|
+
message: { schema: messageSchema },
|
|
2087
|
+
messages: ({ client, projectConfig }) => isLocal(client) ? messages$2(client) : messages(client, projectConfig),
|
|
2088
|
+
send: async ({ space, content, client }) => {
|
|
2089
|
+
if (content.type === "reply") {
|
|
2090
|
+
const remote = remoteForMessageTarget(client, space, content.target, "reply", "replies");
|
|
2091
|
+
return cacheRemoteOutbound(remote, space, await replyToMessage(remote, space.id, content.target.id, content.content));
|
|
2092
|
+
}
|
|
2093
|
+
if (content.type === "reaction") {
|
|
2094
|
+
const remote = remoteForMessageTarget(client, space, content.target, "react", "reactions");
|
|
2095
|
+
return cacheRemoteOutbound(remote, space, await reactToMessage(remote, space.id, content.target, content.emoji));
|
|
2096
|
+
}
|
|
2097
|
+
if (content.type === "typing") {
|
|
2098
|
+
await handleTyping(client, space, content.state);
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
if (content.type === "edit") {
|
|
2102
|
+
await handleEdit(client, space, content);
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (content.type === "unsend") {
|
|
2106
|
+
await handleUnsend(client, space, content);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (content.type === "streamText") return await handleStreamText(client, space, content);
|
|
2110
|
+
if (content.type === "rename") {
|
|
2111
|
+
await handleRename(client, space, content);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (content.type === "avatar") {
|
|
2115
|
+
await handleAvatar(client, space, content);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
if (content.type === "read") {
|
|
2119
|
+
await handleRead(client, space);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (content.type === "app") return await handleApp(client, space, content);
|
|
2123
|
+
if (await handleProviderControlSignal(client, space, content)) return;
|
|
2124
|
+
if (isCustomizedMiniApp(content)) return await handleCustomizedMiniApp(client, space, content);
|
|
2125
|
+
if (isLocal(client)) return await send$2(client, space.id, content);
|
|
2126
|
+
const remote = clientForPhone(client, space.phone);
|
|
2127
|
+
return cacheRemoteOutbound(remote, space, await send(remote, space.id, content));
|
|
2128
|
+
},
|
|
2129
|
+
actions: {
|
|
2130
|
+
getMessage: async ({ client }, space, messageId) => {
|
|
2131
|
+
if (isLocal(client)) return getMessage$2(client, messageId);
|
|
2132
|
+
return getMessage(clientForPhone(client, space.phone), space.id, messageId, space.phone);
|
|
2133
|
+
},
|
|
2134
|
+
getAttachment: async ({ client }, guid, phone) => {
|
|
2135
|
+
if (isLocal(client)) throw UnsupportedError.action("getAttachment", "iMessage (local mode)", "fetching attachments by GUID requires remote iMessage");
|
|
2136
|
+
if (client.length === 0) throw new Error("No iMessage clients configured");
|
|
2137
|
+
const routedPhone = (() => {
|
|
2138
|
+
if (isSharedMode(client)) return SHARED_PHONE;
|
|
2139
|
+
if (phone) return phone;
|
|
2140
|
+
if (client.length === 1) return client[0].phone;
|
|
2141
|
+
throw new Error(`imessage.getAttachment requires a phone in multi-phone mode. Available: ${availablePhones(client).join(", ")}`);
|
|
2142
|
+
})();
|
|
2143
|
+
const remote = clientForPhone(client, routedPhone);
|
|
2144
|
+
return withSpan("spectrum.imessage.getAttachment", {
|
|
2145
|
+
"spectrum.provider": "iMessage",
|
|
2146
|
+
"spectrum.imessage.attachment.guid": guid,
|
|
2147
|
+
"spectrum.imessage.phone": routedPhone
|
|
2148
|
+
}, () => getRemoteAttachment(remote, guid));
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
//#endregion
|
|
2153
|
+
export { background, customizedMiniApp, effect, imessage, nativeContactCard, read };
|