spectrum-ts 0.4.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.
@@ -3,10 +3,13 @@ import {
3
3
  } from "../../chunk-HXM64ENV.js";
4
4
  import {
5
5
  asAttachment,
6
+ asContact,
6
7
  asCustom,
8
+ fromVCard,
7
9
  mergeStreams,
8
- stream
9
- } from "../../chunk-5XW4CAWS.js";
10
+ stream,
11
+ toVCard
12
+ } from "../../chunk-UZWRB3FZ.js";
10
13
  import {
11
14
  asText,
12
15
  definePlatform
@@ -104,41 +107,66 @@ async function disposeCloudAuth(clients) {
104
107
 
105
108
  // src/providers/imessage/local.ts
106
109
  import { createReadStream } from "fs";
107
- import { unlink, writeFile } from "fs/promises";
110
+ import { mkdtemp, rm, writeFile } from "fs/promises";
108
111
  import { tmpdir } from "os";
109
- import { join } from "path";
112
+ import { basename, join } from "path";
110
113
  import { Readable } from "stream";
111
114
  import {
112
115
  readAttachmentBytes
113
116
  } from "@photon-ai/imessage-kit";
114
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
+ };
115
132
  var toSpace = (message) => ({
116
133
  id: message.chatId,
117
134
  type: message.chatKind === "group" ? "group" : "dm"
118
135
  });
119
- var toMessages = (message) => {
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) => {
120
157
  const base = {
121
158
  sender: { id: message.participant ?? "" },
122
159
  space: toSpace(message),
123
160
  timestamp: message.createdAt
124
161
  };
125
162
  if (message.attachments.length > 0) {
126
- return message.attachments.map((att) => {
127
- const { localPath } = att;
128
- return {
163
+ return Promise.all(
164
+ message.attachments.map(async (att) => ({
129
165
  ...base,
130
166
  id: `${message.id}:${att.id}`,
131
- content: asAttachment({
132
- name: att.fileName ?? DEFAULT_ATTACHMENT_NAME,
133
- mimeType: att.mimeType,
134
- size: att.sizeBytes,
135
- read: () => readAttachmentBytes(att),
136
- stream: localPath ? async () => Readable.toWeb(
137
- createReadStream(localPath)
138
- ) : void 0
139
- })
140
- };
141
- });
167
+ content: isVCardAttachment(att.mimeType, att.fileName) ? await toVCardContent(att) : toAttachmentContent(att)
168
+ }))
169
+ );
142
170
  }
143
171
  return [
144
172
  {
@@ -149,33 +177,50 @@ var toMessages = (message) => {
149
177
  ];
150
178
  };
151
179
  var messages = (client) => stream((emit, end) => {
180
+ let lastPromise = Promise.resolve();
152
181
  client.startWatching({
153
182
  onMessage: (message) => {
154
- try {
155
- for (const m of toMessages(message)) {
183
+ lastPromise = lastPromise.then(() => toMessages(message)).then((ms) => {
184
+ for (const m of ms) {
156
185
  emit(m);
157
186
  }
158
- } catch (error) {
159
- end(error);
160
- }
187
+ }).catch((error) => end(error));
161
188
  }
162
189
  });
163
190
  return () => client.stopWatching();
164
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
+ };
165
208
  var send = async (client, spaceId, content) => {
166
209
  switch (content.type) {
167
210
  case "text":
168
211
  await client.send(spaceId, content.text);
169
212
  break;
170
- case "attachment": {
171
- const tmp = join(tmpdir(), `spectrum-${Date.now()}-${content.name}`);
172
- await writeFile(tmp, await content.read());
173
- try {
174
- await client.send(spaceId, { attachments: [tmp] });
175
- } finally {
176
- await unlink(tmp).catch(() => {
177
- });
178
- }
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
+ );
179
224
  break;
180
225
  }
181
226
  default:
@@ -189,6 +234,144 @@ import {
189
234
  messageGuid,
190
235
  Reaction
191
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
+ };
192
375
  var TAPBACK_NAMES = new Set(
193
376
  Object.values(Reaction).filter((r) => r !== "emoji" && r !== "sticker")
194
377
  );
@@ -200,21 +383,32 @@ var baseMessage = (event) => ({
200
383
  },
201
384
  timestamp: event.timestamp
202
385
  });
203
- var toMessages2 = (client, event) => {
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) => {
204
402
  const base = baseMessage(event);
205
403
  const messageGuidStr = event.message.guid;
206
404
  if (event.message.attachments.length > 0) {
207
- return event.message.attachments.map((info) => ({
208
- ...base,
209
- id: `${messageGuidStr}:${info.guid}`,
210
- content: asAttachment({
211
- name: info.fileName,
212
- mimeType: info.mimeType,
213
- size: info.totalBytes,
214
- read: async () => Buffer.from(await client.attachments.downloadBuffer(info.guid)),
215
- stream: async () => client.attachments.download(info.guid).stream
216
- })
217
- }));
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
+ );
218
412
  }
219
413
  const text = event.message.text;
220
414
  return [
@@ -231,7 +425,7 @@ var clientStream = (client) => {
231
425
  (async () => {
232
426
  try {
233
427
  for await (const event of sub) {
234
- for (const message of toMessages2(client, event)) {
428
+ for (const message of await toMessages2(client, event)) {
235
429
  emit(message);
236
430
  }
237
431
  }
@@ -243,6 +437,20 @@ var clientStream = (client) => {
243
437
  return () => sub.close();
244
438
  });
245
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
+ };
246
454
  var messages2 = (clients) => mergeStreams(clients.map(clientStream));
247
455
  var startTyping = async (clients, spaceId) => {
248
456
  const remote = clients[0];
@@ -278,6 +486,27 @@ var send2 = async (clients, spaceId, content) => {
278
486
  });
279
487
  break;
280
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
+ }
281
510
  default:
282
511
  throw new Error(`Unsupported iMessage content type: ${content.type}`);
283
512
  }
@@ -305,6 +534,28 @@ var replyToMessage = async (clients, spaceId, msgId, content) => {
305
534
  });
306
535
  break;
307
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
+ }
308
559
  default:
309
560
  throw new Error(`Unsupported iMessage content type: ${content.type}`);
310
561
  }
@@ -1,4 +1,4 @@
1
- import { d as Platform, c as PlatformDef, P as ProviderMessage } from '../../types-BdWMydUJ.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-BdWMydUJ.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';