meta-sovereign 0.49.16 → 0.49.17

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.
@@ -78,7 +78,8 @@ export const backgroundJobs = ({ ctx, store, log, later, backups }) => {
78
78
  });
79
79
  const purge = cachePurgeFor({ store, log, later });
80
80
  return {
81
- // Returns a promise settled once a backup in flight has finished.
81
+ // Returns a promise settled once a backup and the pulls in flight
82
+ // have finished.
82
83
  stopJobs: () => {
83
84
  const backupDone = backup.stopBackups();
84
85
  audience.stopAudienceSync();
@@ -86,8 +87,8 @@ export const backgroundJobs = ({ ctx, store, log, later, backups }) => {
86
87
  // The other timers and pollers the server started with the port.
87
88
  ctx.autoImport?.close();
88
89
  ctx.whatsappSignIn?.stop().catch(() => {});
89
- ctx.stopPulls?.();
90
- return backupDone;
90
+ const pullsDone = ctx.stopPulls?.();
91
+ return Promise.all([backupDone, pullsDone]).then(() => {});
91
92
  },
92
93
  };
93
94
  };
@@ -40,6 +40,7 @@
40
40
  * POST /api/email/send -> local-server email send proxy
41
41
  * GET /api/email/attachment -> a mail attachment from the media folder (routes-email.js)
42
42
  * GET /api/media/<network>/<sha256>[.ext] -> imported archive media (routes-media.js, R-Z15)
43
+ * POST /api/archive/upload?source=&name= -> import an export file (routes-archive-upload.js)
43
44
  * GET /api/backups -> list archives (gated on archiveDir)
44
45
  * POST /api/backups -> { passphrase?, keep? } -> { file }
45
46
  * POST /api/backups/restore -> { file, passphrase? } -> { restored }
@@ -97,6 +98,10 @@ import { handleTelegramRoutes } from './routes-telegram.js';
97
98
  import { handleMessageRoutes } from './routes-messages.js';
98
99
  import { handleEmailGets } from './routes-email.js';
99
100
  import { handleMediaGets } from './routes-media.js';
101
+ import {
102
+ handleArchiveUpload,
103
+ UPLOAD_TIMEOUT_MS,
104
+ } from './routes-archive-upload.js';
100
105
  import { createAttachmentStore } from './email-attachments.js';
101
106
  import {
102
107
  VK_CAPTCHA_HOSTS,
@@ -586,6 +591,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
586
591
  () => ctx.clientLog?.(req, res, p, ctx) ?? false,
587
592
  () => handleAutoImportRoutes(store, req, res, p, ctx),
588
593
  () => handleWhatsAppRoutes(store, req, res, p, ctx),
594
+ () => handleArchiveUpload(store, req, res, p, ctx),
589
595
  () => handlePullRoutes(store, req, res, p, ctx),
590
596
  () => handleTelegramRoutes(req, res, p, ctx),
591
597
  () => handleMessageRoutes(store, req, res, p, url, ctx),
@@ -659,7 +665,9 @@ const initStore = async ({
659
665
  // The HTTP server: each request is logged, and routed (./account-scope.js
660
666
  // picks the Telegram account it is for).
661
667
  const createHttpServer = (store, ctx, log) =>
662
- http.createServer((req, res) => {
668
+ // A big export uploaded over Wi-Fi takes longer than Node's default 5
669
+ // minutes to arrive (R-Z23); headers still time out at Node's 60 s.
670
+ http.createServer({ requestTimeout: UPLOAD_TIMEOUT_MS }, (req, res) => {
663
671
  const start = Date.now();
664
672
  res.on('finish', () => {
665
673
  log?.({
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Archive uploads (R-Z23):
3
+ * POST /api/archive/upload?source=<network>&name=<file name>
4
+ * (application/octet-stream body: the file's bytes)
5
+ * -> { ok, imported, files: [{ name, imported, error? }], media?, chat? }
6
+ *
7
+ * The page's import box sends an export it was given (a Facebook, X,
8
+ * LinkedIn or WhatsApp .zip, a Telegram result.json, ...) here. The bytes
9
+ * are streamed to a private temp folder in the store (never held in
10
+ * memory, never base64, no fixed size cap) and read by the same importer
11
+ * as auto-import (../sources/auto-import/files.js: a zip is opened in
12
+ * place, media copied to `<store>/media/<network>/`); a WhatsApp export
13
+ * keeps its own importer (../sources/whatsapp-file.js), which copies the
14
+ * attachments its chat names. The temp file is removed afterwards, and
15
+ * leftovers of a crash are swept on the next upload.
16
+ *
17
+ * Not JSON, so not isTrustedMutation's check: its own, as for the Telegram
18
+ * upload — the bytes' type is not one a form on another site can send
19
+ * without asking first (CORS), and another site's page is refused. A disk
20
+ * without room for the file twice over (the file and what it becomes)
21
+ * is refused before anything is written (507).
22
+ */
23
+
24
+ import { createWriteStream, promises as fs } from 'node:fs';
25
+ import { randomBytes } from 'node:crypto';
26
+ import path from 'node:path';
27
+ import { pipeline } from 'node:stream/promises';
28
+ import { json } from './util.js';
29
+ import { classifyOrigin } from './cors.js';
30
+ import { safeMessage } from './redact.js';
31
+ import { enoughSpace } from '../storage/disk-space.js';
32
+ import { ensurePrivateDir } from '../storage/durable.js';
33
+ import { autoImportRecipes } from '../sources/auto-import/recipes.js';
34
+ import { importDownloadedFile } from '../sources/auto-import/files.js';
35
+ import { importWhatsAppPath } from '../sources/whatsapp-file.js';
36
+ import { isFatalWrite } from '../sources/archive/context.js';
37
+
38
+ export const ARCHIVE_UPLOAD_ROUTE = '/api/archive/upload';
39
+ const UPLOADS = 'uploads';
40
+ // A temp folder this old is a crashed upload's: no request lives that long.
41
+ const STALE_MS = 6 * 60 * 60_000;
42
+ /** How long a request may take to arrive (the server's `requestTimeout`). */
43
+ export const UPLOAD_TIMEOUT_MS = 3 * 60 * 60_000;
44
+
45
+ const reply = (res, status, body) =>
46
+ json(res, status, { ok: status < 400, ...body }) ?? true;
47
+
48
+ // Refused before the body was read: drain it so the socket stays usable.
49
+ const refuse = (req, res, status, code, error) => {
50
+ req.resume();
51
+ return reply(res, status, { error, code });
52
+ };
53
+
54
+ /** A plain file name: no folders or control characters, at most 200 long. */
55
+ export const uploadName = (raw) => {
56
+ const base = path.posix.basename(String(raw ?? '').replace(/\\/g, '/'));
57
+ const clean = [...base]
58
+ .filter((ch) => ch.charCodeAt(0) >= 0x20 && ch.charCodeAt(0) !== 0x7f)
59
+ .join('')
60
+ .slice(-200);
61
+ return clean && clean !== '.' && clean !== '..' ? clean : 'archive';
62
+ };
63
+
64
+ const sweepStale = async (root, now = Date.now()) => {
65
+ for (const name of await fs.readdir(root).catch(() => [])) {
66
+ const dir = path.join(root, name);
67
+ const stat = await fs.stat(dir).catch(() => null);
68
+ if (stat?.isDirectory() && now - stat.mtimeMs > STALE_MS) {
69
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
70
+ }
71
+ }
72
+ };
73
+
74
+ const importUploaded = async (store, { source, name, file, mediaDir }) => {
75
+ if (source === 'whatsapp') {
76
+ const out = await importWhatsAppPath(store, name, file, { mediaDir });
77
+ return { ...out, files: [{ name, imported: out.imported }] };
78
+ }
79
+ const files = await importDownloadedFile({
80
+ store,
81
+ provider: source,
82
+ filePath: file,
83
+ mediaDir,
84
+ });
85
+ return {
86
+ imported: files.reduce((sum, row) => sum + (row.imported ?? 0), 0),
87
+ files,
88
+ };
89
+ };
90
+
91
+ // The answer's status: a full disk, a file with nothing to import, or done.
92
+ const statusOf = (out) => {
93
+ if (out.files.some((row) => row.aborted)) {
94
+ return 507;
95
+ }
96
+ return out.imported === 0 && out.files.every((row) => row.error) ? 400 : 200;
97
+ };
98
+
99
+ const failed = (out) => {
100
+ const status = statusOf(out);
101
+ if (status === 200) {
102
+ return {};
103
+ }
104
+ const row = out.files.find((r) => r.error);
105
+ return {
106
+ error: row?.error ?? 'nothing to import',
107
+ code: status === 507 ? 'store-write-failed' : 'bad-archive',
108
+ };
109
+ };
110
+
111
+ const NOT_WHOLE = Object.freeze([
112
+ 400,
113
+ { error: 'the file did not arrive whole; try again', code: 'upload-failed' },
114
+ ]);
115
+
116
+ // The bytes to `file`, then the import: `[status, body]`.
117
+ const importInto = async (req, ctx, file, { source, name, size }) => {
118
+ try {
119
+ await pipeline(req, createWriteStream(file, { flags: 'wx', mode: 0o600 }));
120
+ } catch {
121
+ return NOT_WHOLE;
122
+ }
123
+ if ((await fs.stat(file)).size !== size) {
124
+ return NOT_WHOLE;
125
+ }
126
+ try {
127
+ const out = await importUploaded(ctx.store, {
128
+ source,
129
+ name,
130
+ file,
131
+ mediaDir: path.join(ctx.storeDir, 'media'),
132
+ });
133
+ return [statusOf(out), { ...out, ...failed(out) }];
134
+ } catch (error) {
135
+ // A full or unwritable disk is not a bad file (R-Z14).
136
+ return isFatalWrite(error)
137
+ ? [507, { error: safeMessage(error), code: 'store-write-failed' }]
138
+ : [400, { error: safeMessage(error), code: 'bad-archive' }];
139
+ }
140
+ };
141
+
142
+ // The temp folder is gone before the answer is sent.
143
+ const receive = async (req, res, ctx, wanted) => {
144
+ const root = path.join(ctx.storeDir, UPLOADS);
145
+ await ensurePrivateDir(root);
146
+ await sweepStale(root);
147
+ const dir = path.join(root, randomBytes(8).toString('hex'));
148
+ await ensurePrivateDir(dir);
149
+ let answer;
150
+ try {
151
+ answer = await importInto(req, ctx, path.join(dir, wanted.name), wanted);
152
+ } finally {
153
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
154
+ }
155
+ return reply(res, ...answer);
156
+ };
157
+
158
+ /** POST /api/archive/upload (see the header). */
159
+ export const handleArchiveUpload = async (store, req, res, p, ctx) => {
160
+ if (p !== ARCHIVE_UPLOAD_ROUTE || req.method !== 'POST') {
161
+ return false;
162
+ }
163
+ const type = String(req.headers['content-type'] ?? '').toLowerCase();
164
+ if (
165
+ !type.startsWith('application/octet-stream') ||
166
+ classifyOrigin(req, ctx?.allowOrigins ?? []) === 'denied'
167
+ ) {
168
+ return refuse(
169
+ req,
170
+ res,
171
+ 403,
172
+ 'forbidden',
173
+ 'archives are sent from the app only'
174
+ );
175
+ }
176
+ const query = new URL(req.url, 'http://local').searchParams;
177
+ const source = query.get('source') ?? '';
178
+ if (!Object.hasOwn(autoImportRecipes, source)) {
179
+ return refuse(
180
+ req,
181
+ res,
182
+ 400,
183
+ 'bad-source',
184
+ `no archive import for "${source}"`
185
+ );
186
+ }
187
+ if (!ctx?.storeDir) {
188
+ return refuse(
189
+ req,
190
+ res,
191
+ 501,
192
+ 'no-store',
193
+ 'archive uploads need a store folder'
194
+ );
195
+ }
196
+ const size = Number(req.headers['content-length']);
197
+ if (!Number.isSafeInteger(size) || size <= 0) {
198
+ return refuse(req, res, 411, 'length-required', 'the file size is needed');
199
+ }
200
+ const space = await enoughSpace(ctx.storeDir, size * 2, ctx.diskFree);
201
+ if (!space.ok) {
202
+ return refuse(
203
+ req,
204
+ res,
205
+ 507,
206
+ 'low-disk',
207
+ `not enough free disk space for this file: about ${Math.ceil(space.needed / 1048576)} MB needed`
208
+ );
209
+ }
210
+ return receive(
211
+ req,
212
+ res,
213
+ { ...ctx, store },
214
+ {
215
+ source,
216
+ name: uploadName(query.get('name')),
217
+ size,
218
+ }
219
+ );
220
+ };
@@ -199,6 +199,20 @@ const sendOnce = (ctx, draft, send) => {
199
199
  return promise;
200
200
  };
201
201
 
202
+ // The network has it: a store that cannot take the copy (a full disk,
203
+ // R-Z14) does not make it unsent — answering "failed" would invite a
204
+ // second send. The sync brings the message once the store takes writes.
205
+ const keepSent = async (store, links) => {
206
+ try {
207
+ for (const link of links) {
208
+ await store.put(link);
209
+ }
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ };
215
+
202
216
  const handleSend = async (store, res, body, ctx) => {
203
217
  if (!validChat(body) || !String(body.text ?? '').trim()) {
204
218
  return fail(res, 400, 'bad-message', 'source, chat and text are needed');
@@ -232,14 +246,13 @@ const handleSend = async (store, res, body, ctx) => {
232
246
  const sent = await sendOnce(ctx, draft, async () => {
233
247
  const result = await ctx.send(draft);
234
248
  const links = sentLinks(draft, result);
235
- for (const link of links) {
236
- await store.put(link);
237
- }
249
+ const stored = await keepSent(store, links);
238
250
  return {
239
251
  status: 'sent',
240
252
  via: result?.via,
241
253
  link: links.at(-1) ?? null,
242
254
  links,
255
+ ...(stored ? {} : { stored: false }),
243
256
  ...(result?.scheduled ? { scheduled: true } : {}),
244
257
  };
245
258
  });
@@ -105,10 +105,8 @@ export const createPullServices = (store, log, options = {}, github = null) => {
105
105
  pullRunner.start();
106
106
  vkInbox.start();
107
107
  },
108
- stop: () => {
109
- pullRunner.stop();
110
- vkInbox.stop();
111
- },
108
+ // Resolves once the pulls in flight have stored what they fetched.
109
+ stop: () => Promise.all([vkInbox.stop(), pullRunner.stop()]).then(() => {}),
112
110
  };
113
111
  };
114
112
 
@@ -39,6 +39,7 @@ import { createAdapterSender } from '../crm/reengage.js';
39
39
  import { WEB_UIS } from './web-uis.js';
40
40
  import { createBrowserActions } from './browser-actions.js';
41
41
  import { createMessageActions } from './message-actions.js';
42
+ import { markSendOutcome } from './http.js';
42
43
 
43
44
  const isNumericId = (value) => /^-?\d+$/.test(String(value));
44
45
 
@@ -227,16 +228,29 @@ const typeLines = async (commander, text, delayMs) => {
227
228
  }
228
229
  };
229
230
 
231
+ // A failure while pressing Send may come after the press took effect: the
232
+ // message may be out, so it is never retried or sent another way (R-Z4).
233
+ const sentInDoubt = (error) =>
234
+ Object.assign(markSendOutcome(error, { ambiguous: true }), {
235
+ code: 'send-unknown',
236
+ outcome: 'unknown',
237
+ fallback: false,
238
+ });
239
+
230
240
  /** Click the send button when it is there, otherwise press the key. */
231
241
  const pressSend = async (ui, commander) => {
232
242
  const button = ui.send?.selector
233
243
  ? await firstMatch(commander, ui.send.selector)
234
244
  : null;
235
- if (button) {
236
- await commander.clickButton({ selector: button, scrollIntoView: true });
237
- return;
245
+ try {
246
+ if (button) {
247
+ await commander.clickButton({ selector: button, scrollIntoView: true });
248
+ return;
249
+ }
250
+ await commander.pressKey({ key: ui.send?.key ?? 'Enter' });
251
+ } catch (error) {
252
+ throw sentInDoubt(error);
238
253
  }
239
- await commander.pressKey({ key: ui.send?.key ?? 'Enter' });
240
254
  };
241
255
 
242
256
  /**
@@ -534,6 +548,22 @@ const markUnknown = (error) => {
534
548
  return error;
535
549
  };
536
550
 
551
+ // What a failed web-UI send raises after the API failed with `apiError`.
552
+ const browserFailure = (error, apiError, draft, action) => {
553
+ // "Not allowed to automate this" is the answer, not a pair of failures.
554
+ if (error?.code === 'unsupported') {
555
+ return error;
556
+ }
557
+ // Maybe delivered by the web UI: told as such, never "failed" (R-Z4).
558
+ if (!apiError || error?.ambiguous === true) {
559
+ return markUnknown(error);
560
+ }
561
+ return new SendFallbackError(
562
+ [apiError, error],
563
+ `${draft?.source}: ${action === 'send' ? 'sending' : action} failed through the API (${apiError.message}) and the web UI (${error.message})`
564
+ );
565
+ };
566
+
537
567
  /**
538
568
  * API first, browser second.
539
569
  *
@@ -584,17 +614,7 @@ export const createFallbackSender =
584
614
  apiError ? { apiError: apiError.message } : {}
585
615
  );
586
616
  } catch (error) {
587
- // "Not allowed to automate this" is the answer, not a pair of failures.
588
- if (error?.code === 'unsupported') {
589
- throw error;
590
- }
591
- if (!apiError) {
592
- throw error;
593
- }
594
- throw new SendFallbackError(
595
- [apiError, error],
596
- `${draft?.source}: ${action === 'send' ? 'sending' : action} failed through the API (${apiError.message}) and the web UI (${error.message})`
597
- );
617
+ throw browserFailure(error, apiError, draft, action);
598
618
  }
599
619
  };
600
620
 
@@ -86,9 +86,12 @@ export const createPullRunner = ({
86
86
  const lastStart = new Map();
87
87
  const running = new Set();
88
88
  const timers = new Map();
89
+ // Runs in flight, so that `stop()` can be waited for: a server that
90
+ // closed mid-run would otherwise store the pull after its last flush.
91
+ const flights = new Set();
89
92
  let stopped = true;
90
93
 
91
- const execute = async (source) => {
94
+ const runOnce = async (source) => {
92
95
  const job = jobs[source];
93
96
  running.add(source);
94
97
  lastStart.set(source, now());
@@ -115,6 +118,13 @@ export const createPullRunner = ({
115
118
  }
116
119
  };
117
120
 
121
+ const execute = (source) => {
122
+ const flight = runOnce(source);
123
+ flights.add(flight);
124
+ flight.finally(() => flights.delete(flight)).catch(() => {});
125
+ return flight;
126
+ };
127
+
118
128
  const fetchNow = async (source) => {
119
129
  const job = jobs[source];
120
130
  if (!job) {
@@ -181,12 +191,14 @@ export const createPullRunner = ({
181
191
  }
182
192
  }
183
193
  },
194
+ /** Stop the schedules; resolves once the runs in flight are done. */
184
195
  stop() {
185
196
  stopped = true;
186
197
  for (const timer of timers.values()) {
187
198
  clearTimer(timer);
188
199
  }
189
200
  timers.clear();
201
+ return Promise.allSettled([...flights]).then(() => {});
190
202
  },
191
203
  sources: () => Object.keys(jobs),
192
204
  connected: (source) => jobs[source].ready(),
@@ -206,6 +206,9 @@ export const createVkLongPoll = ({
206
206
  }) => {
207
207
  let session = null;
208
208
  let looping = false;
209
+ // Each start's loop; a stopped one ends even if another has started.
210
+ let generation = 0;
211
+ let loopDone = Promise.resolve();
209
212
  let abort = new AbortController();
210
213
 
211
214
  const request = (state, seconds) =>
@@ -280,10 +283,11 @@ export const createVkLongPoll = ({
280
283
  return { ok: true, updates: updates.length, imported: links.length };
281
284
  };
282
285
 
283
- const loop = async () => {
286
+ const loop = async (gen) => {
284
287
  let fails = 0;
288
+ const live = () => looping && gen === generation;
285
289
  await sleep(startDelayMs);
286
- while (looping) {
290
+ while (live()) {
287
291
  try {
288
292
  if (await ready()) {
289
293
  const started = now();
@@ -297,7 +301,7 @@ export const createVkLongPoll = ({
297
301
  await sleep(idleMs);
298
302
  }
299
303
  } catch (error) {
300
- if (!looping) {
304
+ if (!live()) {
301
305
  return;
302
306
  }
303
307
  fails += 1;
@@ -332,13 +336,16 @@ export const createVkLongPoll = ({
332
336
  start() {
333
337
  if (!looping) {
334
338
  looping = true;
335
- loop();
339
+ generation += 1;
340
+ loopDone = loop(generation).catch(() => {});
336
341
  }
337
342
  },
343
+ /** Resolves once a batch in flight is stored (no write after close). */
338
344
  stop() {
339
345
  looping = false;
340
346
  abort.abort();
341
347
  cancelPauses();
348
+ return loopDone;
342
349
  },
343
350
  };
344
351
  };
@@ -51,58 +51,57 @@ const withMedia = async (links, ctx, counts) => {
51
51
  return out;
52
52
  };
53
53
 
54
+ // The chat, and its media copied out, of an export zip already on disk.
55
+ const readZipFile = async (name, file, mediaDir) => {
56
+ const zip = await openZipFile(file);
57
+ try {
58
+ const chat = textEntry(zip.entries);
59
+ if (!chat) {
60
+ throw new Error('no chat .txt found in this zip');
61
+ }
62
+ const archive = {
63
+ text: (await chat.read()).toString('utf8'),
64
+ fileName: /whatsapp/i.test(chat.name) ? chat.name : name,
65
+ files: zip.entries.map((e) => e.name),
66
+ };
67
+ const ctx = createArchiveContext({
68
+ network: 'whatsapp',
69
+ entries: zip.entries,
70
+ save: mediaDir ? mediaSaver(mediaDir) : null,
71
+ });
72
+ const counts = { saved: 0, missing: 0 };
73
+ const links = await withMedia(
74
+ await getSource('whatsapp').parseArchive(archive),
75
+ ctx,
76
+ counts
77
+ );
78
+ return { links, media: { ...counts, warnings: ctx.warnings } };
79
+ } finally {
80
+ await zip.close();
81
+ }
82
+ };
83
+
54
84
  const readZip = async (name, bytes, mediaDir) => {
55
85
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-whatsapp-'));
56
86
  const file = path.join(dir, 'export.zip');
57
87
  try {
58
88
  await fs.writeFile(file, bytes, { mode: 0o600 });
59
- const zip = await openZipFile(file);
60
- try {
61
- const chat = textEntry(zip.entries);
62
- if (!chat) {
63
- throw new Error('no chat .txt found in this zip');
64
- }
65
- const archive = {
66
- text: (await chat.read()).toString('utf8'),
67
- fileName: /whatsapp/i.test(chat.name) ? chat.name : name,
68
- files: zip.entries.map((e) => e.name),
69
- };
70
- const ctx = createArchiveContext({
71
- network: 'whatsapp',
72
- entries: zip.entries,
73
- save: mediaDir ? mediaSaver(mediaDir) : null,
74
- });
75
- const counts = { saved: 0, missing: 0 };
76
- const links = await withMedia(
77
- await getSource('whatsapp').parseArchive(archive),
78
- ctx,
79
- counts
80
- );
81
- return { links, media: { ...counts, warnings: ctx.warnings } };
82
- } finally {
83
- await zip.close();
84
- }
89
+ return await readZipFile(name, file, mediaDir);
85
90
  } finally {
86
91
  await fs.rm(dir, { recursive: true, force: true });
87
92
  }
88
93
  };
89
94
 
90
- export const importWhatsAppFile = async (
91
- store,
92
- name,
93
- bytes,
94
- { mediaDir = null } = {}
95
- ) => {
96
- const { links, media } = isZip(name, bytes)
97
- ? await readZip(name, bytes, mediaDir)
98
- : {
99
- links: await getSource('whatsapp').parseArchive({
100
- text: bytes.toString('utf8'),
101
- fileName: name,
102
- files: [],
103
- }),
104
- media: null,
105
- };
95
+ const readText = async (name, bytes) => ({
96
+ links: await getSource('whatsapp').parseArchive({
97
+ text: bytes.toString('utf8'),
98
+ fileName: name,
99
+ files: [],
100
+ }),
101
+ media: null,
102
+ });
103
+
104
+ const storeLinks = async (store, { links, media }) => {
106
105
  for (const link of links) {
107
106
  await store.put(stampSourceLink(link, 'whatsapp'));
108
107
  }
@@ -112,3 +111,41 @@ export const importWhatsAppFile = async (
112
111
  ...(media ? { media } : {}),
113
112
  };
114
113
  };
114
+
115
+ export const importWhatsAppFile = async (
116
+ store,
117
+ name,
118
+ bytes,
119
+ { mediaDir = null } = {}
120
+ ) =>
121
+ storeLinks(
122
+ store,
123
+ isZip(name, bytes)
124
+ ? await readZip(name, bytes, mediaDir)
125
+ : await readText(name, bytes)
126
+ );
127
+
128
+ /**
129
+ * The same for a file already on disk (an upload streamed to a temp file,
130
+ * R-Z23): a zip is read in place, never loaded whole.
131
+ */
132
+ export const importWhatsAppPath = async (
133
+ store,
134
+ name,
135
+ file,
136
+ { mediaDir = null } = {}
137
+ ) => {
138
+ const handle = await fs.open(file, 'r');
139
+ const head = Buffer.alloc(4);
140
+ try {
141
+ await handle.read(head, 0, 4, 0);
142
+ } finally {
143
+ await handle.close();
144
+ }
145
+ return storeLinks(
146
+ store,
147
+ isZip(name, head)
148
+ ? await readZipFile(name, file, mediaDir)
149
+ : await readText(name, await fs.readFile(file))
150
+ );
151
+ };