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.
- package/js/src/server/background-jobs.js +4 -3
- package/js/src/server/index.js +9 -1
- package/js/src/server/routes-archive-upload.js +220 -0
- package/js/src/server/routes-automations.js +16 -3
- package/js/src/server/routes-pull.js +2 -4
- package/js/src/sources/browser-sender.js +35 -15
- package/js/src/sources/pull-runner.js +13 -1
- package/js/src/sources/vk-longpoll.js +11 -4
- package/js/src/sources/whatsapp-file.js +79 -42
- package/js/src/web/app.min.js +93 -93
- package/js/src/web/chat-failure.js +3 -0
- package/js/src/web/connection-guides.js +9 -9
- package/js/src/web/connections/index.js +14 -11
- package/js/src/web/dom.js +16 -3
- package/js/src/web/locales/en-chat.js +2 -0
- package/js/src/web/locales/en.js +5 -4
- package/js/src/web/locales/hi-chat.js +2 -0
- package/js/src/web/locales/hi.js +5 -4
- package/js/src/web/locales/ru-chat.js +2 -0
- package/js/src/web/locales/ru.js +5 -4
- package/js/src/web/locales/zh-chat.js +1 -0
- package/js/src/web/locales/zh.js +5 -4
- package/package.json +1 -1
|
@@ -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
|
|
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
|
};
|
package/js/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
};
|