spectrum-ts 0.3.0 → 0.5.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.
@@ -2,10 +2,14 @@ import {
2
2
  cloud
3
3
  } from "../../chunk-HXM64ENV.js";
4
4
  import {
5
+ asAttachment,
6
+ asContact,
5
7
  asCustom,
8
+ fromVCard,
6
9
  mergeStreams,
7
- stream
8
- } from "../../chunk-V2PK557T.js";
10
+ stream,
11
+ toVCard
12
+ } from "../../chunk-UZWRB3FZ.js";
9
13
  import {
10
14
  asText,
11
15
  definePlatform
@@ -102,40 +106,121 @@ async function disposeCloudAuth(clients) {
102
106
  }
103
107
 
104
108
  // src/providers/imessage/local.ts
105
- import { unlink, writeFile } from "fs/promises";
109
+ import { createReadStream } from "fs";
110
+ import { mkdtemp, rm, writeFile } from "fs/promises";
106
111
  import { tmpdir } from "os";
107
- import { join } from "path";
112
+ import { basename, join } from "path";
113
+ import { Readable } from "stream";
114
+ import {
115
+ readAttachmentBytes
116
+ } from "@photon-ai/imessage-kit";
117
+ var DEFAULT_ATTACHMENT_NAME = "attachment";
118
+ var VCARD_MIME_TYPES = /* @__PURE__ */ new Set([
119
+ "text/vcard",
120
+ "text/x-vcard",
121
+ "text/directory",
122
+ "application/vcard",
123
+ "application/x-vcard"
124
+ ]);
125
+ var normalizeMimeType = (mimeType) => (mimeType.split(";")[0] ?? "").trim().toLowerCase();
126
+ var isVCardAttachment = (mimeType, fileName) => {
127
+ if (mimeType && VCARD_MIME_TYPES.has(normalizeMimeType(mimeType))) {
128
+ return true;
129
+ }
130
+ return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
131
+ };
108
132
  var toSpace = (message) => ({
109
133
  id: message.chatId,
110
134
  type: message.chatKind === "group" ? "group" : "dm"
111
135
  });
112
- var toMessage = (message) => ({
113
- id: message.id,
114
- content: { type: "text", text: message.text ?? "" },
115
- sender: { id: message.participant ?? "" },
116
- space: toSpace(message),
117
- timestamp: message.createdAt
118
- });
119
- var messages = (client) => stream((emit) => {
136
+ var toAttachmentContent = (att) => {
137
+ const { localPath } = att;
138
+ return asAttachment({
139
+ name: att.fileName ?? DEFAULT_ATTACHMENT_NAME,
140
+ mimeType: att.mimeType,
141
+ size: att.sizeBytes,
142
+ read: () => readAttachmentBytes(att),
143
+ stream: localPath ? async () => Readable.toWeb(
144
+ createReadStream(localPath)
145
+ ) : void 0
146
+ });
147
+ };
148
+ var toVCardContent = async (att) => {
149
+ try {
150
+ const buf = await readAttachmentBytes(att);
151
+ return asContact(fromVCard(buf.toString("utf8")));
152
+ } catch {
153
+ return toAttachmentContent(att);
154
+ }
155
+ };
156
+ var toMessages = async (message) => {
157
+ const base = {
158
+ sender: { id: message.participant ?? "" },
159
+ space: toSpace(message),
160
+ timestamp: message.createdAt
161
+ };
162
+ if (message.attachments.length > 0) {
163
+ return Promise.all(
164
+ message.attachments.map(async (att) => ({
165
+ ...base,
166
+ id: `${message.id}:${att.id}`,
167
+ content: isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent(att) : toAttachmentContent(att)
168
+ }))
169
+ );
170
+ }
171
+ return [
172
+ {
173
+ ...base,
174
+ id: message.id,
175
+ content: { type: "text", text: message.text ?? "" }
176
+ }
177
+ ];
178
+ };
179
+ var messages = (client) => stream((emit, end) => {
180
+ let lastPromise = Promise.resolve();
120
181
  client.startWatching({
121
- onMessage: (message) => emit(toMessage(message))
182
+ onMessage: (message) => {
183
+ lastPromise = lastPromise.then(() => toMessages(message)).then((ms) => {
184
+ for (const m of ms) {
185
+ emit(m);
186
+ }
187
+ }).catch((error) => end(error));
188
+ }
122
189
  });
123
190
  return () => client.stopWatching();
124
191
  });
192
+ var vcardFileName = (content) => {
193
+ const base = content.name?.formatted ?? content.user?.id ?? "contact";
194
+ return `${base.replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
195
+ };
196
+ var sendTempFile = async (client, spaceId, name, data) => {
197
+ const safeName = basename(name) || DEFAULT_ATTACHMENT_NAME;
198
+ const dir = await mkdtemp(join(tmpdir(), "spectrum-"));
199
+ const tmp = join(dir, safeName);
200
+ await writeFile(tmp, data);
201
+ try {
202
+ await client.send(spaceId, { attachments: [tmp] });
203
+ } finally {
204
+ await rm(dir, { recursive: true, force: true }).catch(() => {
205
+ });
206
+ }
207
+ };
125
208
  var send = async (client, spaceId, content) => {
126
209
  switch (content.type) {
127
210
  case "text":
128
211
  await client.send(spaceId, content.text);
129
212
  break;
130
- case "attachment": {
131
- const tmp = join(tmpdir(), `spectrum-${Date.now()}-${content.name}`);
132
- await writeFile(tmp, content.data);
133
- try {
134
- await client.send(spaceId, { attachments: [tmp] });
135
- } finally {
136
- await unlink(tmp).catch(() => {
137
- });
138
- }
213
+ case "attachment":
214
+ await sendTempFile(client, spaceId, content.name, await content.read());
215
+ break;
216
+ case "contact": {
217
+ const vcf = await toVCard(content);
218
+ await sendTempFile(
219
+ client,
220
+ spaceId,
221
+ vcardFileName(content),
222
+ Buffer.from(vcf, "utf8")
223
+ );
139
224
  break;
140
225
  }
141
226
  default:
@@ -149,21 +234,190 @@ import {
149
234
  messageGuid,
150
235
  Reaction
151
236
  } from "@photon-ai/advanced-imessage";
237
+
238
+ // src/utils/audio.ts
239
+ import { spawn } from "child_process";
240
+ import { mkdtemp as mkdtemp2, readFile, rm as rm2, writeFile as writeFile2 } from "fs/promises";
241
+ import { tmpdir as tmpdir2 } from "os";
242
+ import { join as join2 } from "path";
243
+ var M4A_BRANDS = /* @__PURE__ */ new Set([
244
+ "M4A ",
245
+ "M4B ",
246
+ "M4P ",
247
+ "mp42",
248
+ "mp41",
249
+ "isom",
250
+ "iso2"
251
+ ]);
252
+ var M4A_MIME_TYPES = /* @__PURE__ */ new Set([
253
+ "audio/mp4",
254
+ "audio/mp4a-latm",
255
+ "audio/x-m4a",
256
+ "audio/aac",
257
+ "audio/aacp"
258
+ ]);
259
+ var FFMPEG_MISSING_MESSAGE = "voice content: input is not m4a/aac and ffmpeg is unavailable. Install `ffmpeg-static` or ensure `ffmpeg` is on PATH.";
260
+ var isM4a = (buffer) => {
261
+ if (buffer.length < 12) {
262
+ return false;
263
+ }
264
+ if (buffer.toString("ascii", 4, 8) !== "ftyp") {
265
+ return false;
266
+ }
267
+ return M4A_BRANDS.has(buffer.toString("ascii", 8, 12));
268
+ };
269
+ var isM4aMimeType = (mimeType) => M4A_MIME_TYPES.has(mimeType.toLowerCase());
270
+ var cachedFfmpegPath;
271
+ var tryStaticBinary = async () => {
272
+ try {
273
+ const mod = await import("ffmpeg-static");
274
+ return mod.default ?? void 0;
275
+ } catch {
276
+ return void 0;
277
+ }
278
+ };
279
+ var resolveFfmpegPath = async () => {
280
+ if (cachedFfmpegPath) {
281
+ return cachedFfmpegPath;
282
+ }
283
+ cachedFfmpegPath = await tryStaticBinary() ?? "ffmpeg";
284
+ return cachedFfmpegPath;
285
+ };
286
+ var collectStream = (stream2) => {
287
+ if (!stream2) {
288
+ return Promise.resolve("");
289
+ }
290
+ return new Promise((resolve, reject) => {
291
+ const chunks = [];
292
+ stream2.on("data", (chunk) => {
293
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
294
+ });
295
+ stream2.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
296
+ stream2.on("error", reject);
297
+ });
298
+ };
299
+ var isMissingBinaryError = (err) => err?.code === "ENOENT";
300
+ var runFfmpeg = (ffmpegPath, args) => {
301
+ const proc = spawn(ffmpegPath, args, { stdio: ["ignore", "ignore", "pipe"] });
302
+ const stderr = collectStream(proc.stderr);
303
+ const exit = new Promise((resolve, reject) => {
304
+ proc.on(
305
+ "error",
306
+ (err) => reject(
307
+ isMissingBinaryError(err) ? new Error(FFMPEG_MISSING_MESSAGE) : err
308
+ )
309
+ );
310
+ proc.on("exit", (code) => resolve(code ?? -1));
311
+ });
312
+ return Promise.all([exit, stderr]).then(([code, text]) => ({
313
+ code,
314
+ stderr: text
315
+ }));
316
+ };
317
+ var DURATION_PATTERN = /Duration:\s*(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?/;
318
+ var parseDuration = (stderr) => {
319
+ const match = stderr.match(DURATION_PATTERN);
320
+ if (!match) {
321
+ return void 0;
322
+ }
323
+ const [, hh, mm, ss, frac] = match;
324
+ const seconds = Number(hh) * 3600 + Number(mm) * 60 + Number(ss) + Number(`0.${frac ?? 0}`);
325
+ return Number.isFinite(seconds) ? seconds : void 0;
326
+ };
327
+ var transcodeToM4a = async (buffer) => {
328
+ const ffmpeg = await resolveFfmpegPath();
329
+ const dir = await mkdtemp2(join2(tmpdir2(), "spectrum-voice-"));
330
+ const inPath = join2(dir, "in");
331
+ const outPath = join2(dir, "out.m4a");
332
+ try {
333
+ await writeFile2(inPath, buffer);
334
+ const { code, stderr } = await runFfmpeg(ffmpeg, [
335
+ "-y",
336
+ "-i",
337
+ inPath,
338
+ "-f",
339
+ "ipod",
340
+ "-c:a",
341
+ "aac",
342
+ outPath
343
+ ]);
344
+ if (code !== 0) {
345
+ throw new Error(`ffmpeg conversion failed (exit ${code}): ${stderr}`);
346
+ }
347
+ const out = await readFile(outPath);
348
+ return { buffer: out, duration: parseDuration(stderr) };
349
+ } finally {
350
+ await rm2(dir, { recursive: true, force: true }).catch(() => {
351
+ });
352
+ }
353
+ };
354
+ var ensureM4a = async (buffer, mimeType) => {
355
+ if (isM4aMimeType(mimeType) || isM4a(buffer)) {
356
+ return { buffer };
357
+ }
358
+ return transcodeToM4a(buffer);
359
+ };
360
+
361
+ // src/providers/imessage/remote.ts
362
+ var VCARD_MIME_TYPES2 = /* @__PURE__ */ new Set([
363
+ "text/vcard",
364
+ "text/x-vcard",
365
+ "text/directory",
366
+ "application/vcard",
367
+ "application/x-vcard"
368
+ ]);
369
+ var isVCardAttachment2 = (mimeType, fileName) => {
370
+ if (mimeType && VCARD_MIME_TYPES2.has(mimeType.toLowerCase())) {
371
+ return true;
372
+ }
373
+ return Boolean(fileName?.toLowerCase().endsWith(".vcf"));
374
+ };
152
375
  var TAPBACK_NAMES = new Set(
153
376
  Object.values(Reaction).filter((r) => r !== "emoji" && r !== "sticker")
154
377
  );
155
- var toMessage2 = (event) => {
378
+ var baseMessage = (event) => ({
379
+ sender: { id: event.message.sender?.address ?? "" },
380
+ space: {
381
+ id: event.chatGuid,
382
+ type: event.chatGuid.includes(";+;") ? "group" : "dm"
383
+ },
384
+ timestamp: event.timestamp
385
+ });
386
+ var toAttachmentContent2 = (client, info) => asAttachment({
387
+ name: info.fileName,
388
+ mimeType: info.mimeType,
389
+ size: info.totalBytes,
390
+ read: async () => Buffer.from(await client.attachments.downloadBuffer(info.guid)),
391
+ stream: async () => client.attachments.download(info.guid).stream
392
+ });
393
+ var toVCardContent2 = async (client, info) => {
394
+ try {
395
+ const buf = Buffer.from(await client.attachments.downloadBuffer(info.guid));
396
+ return asContact(fromVCard(buf.toString("utf8")));
397
+ } catch {
398
+ return toAttachmentContent2(client, info);
399
+ }
400
+ };
401
+ var toMessages2 = async (client, event) => {
402
+ const base = baseMessage(event);
403
+ const messageGuidStr = event.message.guid;
404
+ if (event.message.attachments.length > 0) {
405
+ return Promise.all(
406
+ event.message.attachments.map(async (info) => ({
407
+ ...base,
408
+ id: `${messageGuidStr}:${info.guid}`,
409
+ content: isVCardAttachment2(info.mimeType, info.fileName) ? await toVCardContent2(client, info) : toAttachmentContent2(client, info)
410
+ }))
411
+ );
412
+ }
156
413
  const text = event.message.text;
157
- return {
158
- id: event.message.guid,
159
- content: text ? asText(text) : asCustom(event.message),
160
- sender: { id: event.message.sender?.address ?? "" },
161
- space: {
162
- id: event.chatGuid,
163
- type: event.chatGuid.includes(";+;") ? "group" : "dm"
164
- },
165
- timestamp: event.timestamp
166
- };
414
+ return [
415
+ {
416
+ ...base,
417
+ id: messageGuidStr,
418
+ content: text ? asText(text) : asCustom(event.message)
419
+ }
420
+ ];
167
421
  };
168
422
  var clientStream = (client) => {
169
423
  const sub = client.messages.subscribe("message.received");
@@ -171,7 +425,9 @@ var clientStream = (client) => {
171
425
  (async () => {
172
426
  try {
173
427
  for await (const event of sub) {
174
- emit(toMessage2(event));
428
+ for (const message of await toMessages2(client, event)) {
429
+ emit(message);
430
+ }
175
431
  }
176
432
  end();
177
433
  } catch (e) {
@@ -181,6 +437,20 @@ var clientStream = (client) => {
181
437
  return () => sub.close();
182
438
  });
183
439
  };
440
+ var sendVCardAttachment = (remote, name, vcf) => remote.attachments.upload({
441
+ data: Buffer.from(vcf, "utf8"),
442
+ fileName: name,
443
+ mimeType: "text/vcard"
444
+ });
445
+ var vcardFileName2 = (contact) => {
446
+ const base = contact.name?.formatted ?? contact.user?.id ?? "contact";
447
+ return `${base.replace(/[^a-zA-Z0-9_\-.]/g, "_")}.vcf`;
448
+ };
449
+ var sendContactAttachment = async (remote, content) => {
450
+ const vcf = await toVCard(content);
451
+ const upload = await sendVCardAttachment(remote, vcardFileName2(content), vcf);
452
+ return upload.guid;
453
+ };
184
454
  var messages2 = (clients) => mergeStreams(clients.map(clientStream));
185
455
  var startTyping = async (clients, spaceId) => {
186
456
  const remote = clients[0];
@@ -207,7 +477,7 @@ var send2 = async (clients, spaceId, content) => {
207
477
  break;
208
478
  case "attachment": {
209
479
  const attachment = await remote.attachments.upload({
210
- data: content.data,
480
+ data: await content.read(),
211
481
  fileName: content.name,
212
482
  mimeType: content.mimeType
213
483
  });
@@ -216,6 +486,27 @@ var send2 = async (clients, spaceId, content) => {
216
486
  });
217
487
  break;
218
488
  }
489
+ case "contact": {
490
+ const attachment = await sendContactAttachment(remote, content);
491
+ await remote.messages.send(chatGuid(spaceId), "", { attachment });
492
+ break;
493
+ }
494
+ case "voice": {
495
+ const { buffer } = await ensureM4a(
496
+ await content.read(),
497
+ content.mimeType
498
+ );
499
+ const attachment = await remote.attachments.upload({
500
+ data: buffer,
501
+ fileName: content.name ?? "voice.m4a",
502
+ mimeType: "audio/x-m4a"
503
+ });
504
+ await remote.messages.send(chatGuid(spaceId), "", {
505
+ attachment: attachment.guid,
506
+ audioMessage: true
507
+ });
508
+ break;
509
+ }
219
510
  default:
220
511
  throw new Error(`Unsupported iMessage content type: ${content.type}`);
221
512
  }
@@ -233,7 +524,7 @@ var replyToMessage = async (clients, spaceId, msgId, content) => {
233
524
  break;
234
525
  case "attachment": {
235
526
  const attachment = await remote.attachments.upload({
236
- data: content.data,
527
+ data: await content.read(),
237
528
  fileName: content.name,
238
529
  mimeType: content.mimeType
239
530
  });
@@ -243,6 +534,28 @@ var replyToMessage = async (clients, spaceId, msgId, content) => {
243
534
  });
244
535
  break;
245
536
  }
537
+ case "contact": {
538
+ const attachment = await sendContactAttachment(remote, content);
539
+ await remote.messages.send(chat, "", { attachment, replyTo });
540
+ break;
541
+ }
542
+ case "voice": {
543
+ const { buffer } = await ensureM4a(
544
+ await content.read(),
545
+ content.mimeType
546
+ );
547
+ const attachment = await remote.attachments.upload({
548
+ data: buffer,
549
+ fileName: content.name ?? "voice.m4a",
550
+ mimeType: "audio/x-m4a"
551
+ });
552
+ await remote.messages.send(chat, "", {
553
+ attachment: attachment.guid,
554
+ audioMessage: true,
555
+ replyTo
556
+ });
557
+ break;
558
+ }
246
559
  default:
247
560
  throw new Error(`Unsupported iMessage content type: ${content.type}`);
248
561
  }
@@ -1,4 +1,4 @@
1
- import { d as Platform, c as PlatformDef, P as ProviderMessage } from '../../types-DuE2hXuJ.js';
1
+ import { d as Platform, c as PlatformDef, P as ProviderMessage } from '../../types-CfiD_00g.js';
2
2
  import * as node_readline from 'node:readline';
3
3
  import z__default from 'zod';
4
4
  import 'hotscript';
@@ -1,7 +1,7 @@
1
1
  import { M as ManagedStream } from '../../stream-DGy4geUK.js';
2
2
  import * as z from 'zod';
3
3
  import z__default from 'zod';
4
- import { l as SchemaMessage, d as Platform, c as PlatformDef, P as ProviderMessage } from '../../types-DuE2hXuJ.js';
4
+ import { l as SchemaMessage, d as Platform, c as PlatformDef, P as ProviderMessage } from '../../types-CfiD_00g.js';
5
5
  import * as zod_v4_core from 'zod/v4/core';
6
6
  import { WhatsAppClient } from '@photon-ai/whatsapp-business';
7
7
  import 'hotscript';