meta-sovereign 0.49.0 → 0.49.3

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.
Files changed (44) hide show
  1. package/js/src/automation/outbox.js +12 -1
  2. package/js/src/broadcast/index.js +7 -1
  3. package/js/src/crm/reengage.js +3 -2
  4. package/js/src/greetings/index.js +6 -2
  5. package/js/src/server/aggregate.js +16 -2
  6. package/js/src/server/index.js +39 -14
  7. package/js/src/server/routes-pull.js +147 -0
  8. package/js/src/server/routes-whatsapp.js +103 -0
  9. package/js/src/sources/auto-import/files.js +13 -5
  10. package/js/src/sources/auto-import/recipes.js +26 -1
  11. package/js/src/sources/browser-sender.js +43 -5
  12. package/js/src/sources/pull-runner.js +171 -0
  13. package/js/src/sources/vk-html-archive.js +268 -0
  14. package/js/src/sources/vk-limits.js +119 -0
  15. package/js/src/sources/vk-longpoll.js +333 -0
  16. package/js/src/sources/vk-sync.js +138 -0
  17. package/js/src/sources/vk.js +224 -71
  18. package/js/src/sources/web-uis.js +20 -3
  19. package/js/src/sources/whatsapp-archive.js +271 -0
  20. package/js/src/sources/whatsapp-file.js +41 -0
  21. package/js/src/sources/whatsapp-ids.js +94 -0
  22. package/js/src/sources/whatsapp-web-signin.js +156 -0
  23. package/js/src/sources/whatsapp.js +27 -30
  24. package/js/src/storage/migrations/0002-whatsapp-chat-ids.js +148 -0
  25. package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
  26. package/js/src/storage/migrations/index.js +4 -2
  27. package/js/src/web/app.css +43 -0
  28. package/js/src/web/chat-media.js +3 -0
  29. package/js/src/web/chat-remote-media.js +111 -0
  30. package/js/src/web/connection-guides.js +2 -2
  31. package/js/src/web/connections/fetch-now.js +79 -0
  32. package/js/src/web/connections/index.js +34 -0
  33. package/js/src/web/connections/whatsapp-web.js +123 -0
  34. package/js/src/web/dom.js +18 -0
  35. package/js/src/web/locales/en-chat-account.js +17 -0
  36. package/js/src/web/locales/en.js +15 -1
  37. package/js/src/web/locales/hi-chat-account.js +17 -0
  38. package/js/src/web/locales/hi.js +16 -1
  39. package/js/src/web/locales/ru-chat-account.js +17 -0
  40. package/js/src/web/locales/ru.js +15 -1
  41. package/js/src/web/locales/zh-chat-account.js +16 -0
  42. package/js/src/web/locales/zh.js +14 -1
  43. package/js/src/web/message-parts.js +34 -9
  44. package/package.json +6 -2
@@ -208,6 +208,10 @@ export const guardedSend = async ({
208
208
  draft,
209
209
  send,
210
210
  settings = null,
211
+ // The user pressed Send on this very message. Anything else is an
212
+ // automated send and carries `automated: true` to the sender, which
213
+ // refuses WhatsApp Web for it (R-Z9).
214
+ userConfirmed = false,
211
215
  clock = () => new Date(),
212
216
  log = () => {},
213
217
  }) => {
@@ -247,7 +251,14 @@ export const guardedSend = async ({
247
251
  } catch (error) {
248
252
  return refused('outbox-unavailable', `not sent: ${error.message}`);
249
253
  }
250
- return await deliverOnce({ store, record, draft, send, clock, log });
254
+ return await deliverOnce({
255
+ store,
256
+ record,
257
+ draft: { ...draft, automated: !userConfirmed },
258
+ send,
259
+ clock,
260
+ log,
261
+ });
251
262
  } finally {
252
263
  running.delete(id);
253
264
  }
@@ -93,7 +93,13 @@ const postOn = async (name, content, outbox, resolveSource) => {
93
93
  chat: content?.chat ?? null,
94
94
  text: typeof content === 'string' ? content : (content?.text ?? ''),
95
95
  },
96
- send: () => asSend(name, 'post', () => live.post(content)),
96
+ send: ({ automated }) =>
97
+ asSend(name, 'post', () =>
98
+ // WhatsApp refuses an automated personal send (R-Z9).
99
+ name === 'whatsapp'
100
+ ? live.post(content, { automated })
101
+ : live.post(content)
102
+ ),
97
103
  });
98
104
  return outcome.status === 'sent'
99
105
  ? { network: name, ok: true, result: outcome.result }
@@ -525,7 +525,7 @@ export const createReengageSession = ({
525
525
  index += 1;
526
526
  return current();
527
527
  };
528
- const sendCurrent = async () => {
528
+ const sendCurrent = async (userConfirmed = false) => {
529
529
  const draft = requireCurrent();
530
530
  if (typeof send !== 'function') {
531
531
  throw new Error('re-engage session has no sender; use markSent()');
@@ -536,6 +536,7 @@ export const createReengageSession = ({
536
536
  owner: 'reengage',
537
537
  draft,
538
538
  send,
539
+ userConfirmed,
539
540
  clock,
540
541
  });
541
542
  if (outcome.status === 'sent') {
@@ -579,7 +580,7 @@ export const createReengageSession = ({
579
580
  return items[index];
580
581
  },
581
582
  /** Send the current draft — the one action safe mode allows. */
582
- confirm: sendCurrent,
583
+ confirm: () => sendCurrent(true),
583
584
  /** The user sent it in their own app; record it and move on. */
584
585
  async markSent() {
585
586
  const draft = requireCurrent();
@@ -151,8 +151,12 @@ const sendEnvelope = async (
151
151
  contact: envelope.contact,
152
152
  text: envelope.text ?? `sticker:${envelope.sticker?.id ?? ''}`,
153
153
  },
154
- send: () =>
155
- live.post(outgoingContent(envelope), credentials[envelope.network] ?? {}),
154
+ send: ({ automated }) =>
155
+ live.post(outgoingContent(envelope), {
156
+ ...(credentials[envelope.network] ?? {}),
157
+ // WhatsApp refuses an automated personal send (R-Z9).
158
+ ...(envelope.network === 'whatsapp' ? { automated } : {}),
159
+ }),
156
160
  clock,
157
161
  });
158
162
  if (outcome.status !== 'sent') {
@@ -27,13 +27,27 @@ const blank = (id) => ({
27
27
  open: null,
28
28
  });
29
29
 
30
+ // A message time as an ISO string. VK and WhatsApp keep unix seconds (a
31
+ // number), which used to break the contact sort and `/api/status` (R-Z8).
32
+ const seenAt = (value) => {
33
+ if (value === null || value === undefined || value === '') {
34
+ return null;
35
+ }
36
+ const n = Number(value);
37
+ if (Number.isFinite(n) && !/[-:T]/.test(String(value))) {
38
+ return new Date(n < 1e12 ? n * 1000 : n).toISOString();
39
+ }
40
+ return String(value);
41
+ };
42
+
30
43
  const addMessage = (entry, m) => {
31
44
  entry.networks.add(m.source);
32
45
  entry.chats.add(m.chat);
33
46
  entry.messageCount += 1;
34
- const latest = !entry.lastSeen || (m.timestamp ?? '') > entry.lastSeen;
47
+ const at = seenAt(m.timestamp);
48
+ const latest = !entry.lastSeen || (at ?? '') > entry.lastSeen;
35
49
  if (latest) {
36
- entry.lastSeen = m.timestamp ?? null;
50
+ entry.lastSeen = at;
37
51
  entry.name = m.senderName ?? entry.name;
38
52
  } else {
39
53
  entry.name ??= m.senderName ?? null;
@@ -87,6 +87,8 @@ import { createChatIndex } from './chat-index.js';
87
87
  import { handleChatRoutes } from './routes-chat.js';
88
88
  import { createClinkReplyStore } from '../replies/reply-store-clink.js';
89
89
  import { handleAutoImportRoutes } from './routes-auto-import.js';
90
+ import { handleWhatsAppRoutes } from './routes-whatsapp.js';
91
+ import { profileDirIn } from '../sources/whatsapp-web-signin.js';
90
92
  import { handleTelegramRoutes } from './routes-telegram.js';
91
93
  import { handleMessageRoutes } from './routes-messages.js';
92
94
  import { handleEmailGets } from './routes-email.js';
@@ -102,6 +104,7 @@ import { createGitHubCheck } from '../sources/github-check.js';
102
104
  import { handleMetrics } from './metrics.js';
103
105
  import { jsonLog } from './log.js';
104
106
  import { listenLocal } from './local-tls.js';
107
+ import { handlePullRoutes, pullContext } from './routes-pull.js';
105
108
  import { createPeer, attachSyncWebSocket } from '../sync/index.js';
106
109
  import { attachSignaling } from '../sync/webrtc-signaling.js';
107
110
  import { registerDefaultHandlers } from './handlers-bootstrap.js';
@@ -146,7 +149,9 @@ export const CSP =
146
149
  "default-src 'self'; " +
147
150
  "script-src 'self' 'wasm-unsafe-eval'; " +
148
151
  "style-src 'self'; " +
149
- "img-src 'self' data: blob: https://vk.com; " +
152
+ "img-src 'self' data: blob: https://vk.com " +
153
+ // VK's picture CDNs, for photos and stickers in VK chats (R-Z8).
154
+ 'https://*.userapi.com https://*.vk-cdn.net https://*.vkuserphoto.ru; ' +
150
155
  'frame-src https:; ' +
151
156
  "connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
152
157
  "worker-src 'self'; " +
@@ -573,6 +578,8 @@ const routeOn = async (store, req, res, ctx, url, p) => {
573
578
  () => handleEventRoutes(store, req, res, p, ctx),
574
579
  () => ctx.clientLog?.(req, res, p, ctx) ?? false,
575
580
  () => handleAutoImportRoutes(store, req, res, p, ctx),
581
+ () => handleWhatsAppRoutes(store, req, res, p, ctx),
582
+ () => handlePullRoutes(store, req, res, p, ctx),
576
583
  () => handleTelegramRoutes(req, res, p, ctx),
577
584
  () => handleMessageRoutes(store, req, res, p, url, ctx),
578
585
  () => handleEmailGets(store, req, res, p, url, ctx),
@@ -721,6 +728,26 @@ const attachSockets = (server, ctx, store, { enableSync, node }) => {
721
728
  return { wsHandle, signaling };
722
729
  };
723
730
 
731
+ // The browser sender signs in to the web apps in profiles inside the store
732
+ // (owner only), the same ones "Sign in to WhatsApp Web" opens (R-Z9).
733
+ const withStoreProfiles = (automations = {}, storeDir) => ({
734
+ ...automations,
735
+ browser: {
736
+ profileDirFor: (source) => profileDirIn(storeDir, source),
737
+ ...automations.browser,
738
+ },
739
+ });
740
+
741
+ // The open connections of `server`, so a shutdown can end them.
742
+ const trackSockets = (server) => {
743
+ const sockets = new Set();
744
+ server.on('connection', (socket) => {
745
+ sockets.add(socket);
746
+ socket.on('close', () => sockets.delete(socket));
747
+ });
748
+ return sockets;
749
+ };
750
+
724
751
  // The automations' part of the context, fed by the Telegram account.
725
752
  const automationFor = (store, automations, log, telegramAccount, later) =>
726
753
  automationContext(
@@ -764,15 +791,14 @@ export const startServer = async ({
764
791
  telegramExtraBuild,
765
792
  // Tests and demos pass a fake, so GitHub is never asked (R-Y53).
766
793
  githubCheck,
767
- // The hosted app and whether this server also serves its own copy
768
- // (./hosted-ui.js, R-Y58 and R-Y59).
794
+ // The hosted app, and whether this serves its own copy (./hosted-ui.js).
769
795
  uiUrl,
770
796
  localUi,
771
- // HTTPS on the same port for Safari (./local-tls.js, R-Y64); off
772
- // unless asked for, `serve` turns it on.
797
+ // HTTPS on the same port for Safari (./local-tls.js, R-Y64); `serve` sets it.
773
798
  tls,
774
799
  // Tests: a fake `(dir) => bytes|null` for the free disk space (R-Z14).
775
800
  diskFree,
801
+ pulls,
776
802
  } = {}) => {
777
803
  const { store, resolvedArchiveDir, flush, storeHealth } = await initStore({
778
804
  providedStore,
@@ -783,6 +809,7 @@ export const startServer = async ({
783
809
  });
784
810
  // Services that run in the background start only after the port is ours.
785
811
  const later = [];
812
+ automations = withStoreProfiles(automations, storeDir);
786
813
  const chatIndex = createChatIndex(store);
787
814
  const telegramAccount = telegramAccountFor(
788
815
  store,
@@ -829,17 +856,13 @@ export const startServer = async ({
829
856
  later
830
857
  ),
831
858
  chatIndex,
859
+ ...pullContext(store, log, pulls, later),
832
860
  };
833
861
  const server = createHttpServer(store, ctx, log);
834
- const sockets = new Set();
835
- server.on('connection', (socket) => {
836
- sockets.add(socket);
837
- socket.on('close', () => sockets.delete(socket));
838
- });
839
- let bus = null;
840
- if (enableHandlers) {
841
- bus = registerDefaultHandlers(store, { browser: () => ctx.send });
842
- }
862
+ const sockets = trackSockets(server);
863
+ const bus = enableHandlers
864
+ ? registerDefaultHandlers(store, { browser: () => ctx.send })
865
+ : null;
843
866
  const { wsHandle, signaling } = attachSockets(server, ctx, store, {
844
867
  enableSync,
845
868
  node,
@@ -882,6 +905,8 @@ export const startServer = async ({
882
905
  ctx.replies.close().catch(() => {});
883
906
  ctx.closeAutomations().catch(() => {});
884
907
  ctx.autoImport?.close();
908
+ ctx.whatsappSignIn?.stop().catch(() => {});
909
+ ctx.stopPulls?.();
885
910
  ctx.chatIndex.close();
886
911
  stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
887
912
  wsHandle?.close();
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Inbox pulls for the networks that have no live connection of their own
3
+ * (R-Z8): the Connections card's "Fetch now" button and the background
4
+ * schedule behind it.
5
+ *
6
+ * Its routes:
7
+ * POST /api/pull {source} -> { ok, source, imported, lastOkAt }
8
+ * or { ok: false, error, code } with `code` one of
9
+ * unsupported | not-connected | rate-limited
10
+ * (HTTP 429, at most one fetch per 15 s per
11
+ * network, `retryAfterMs`) | the network's own code
12
+ * GET /api/pull/status -> { ok, sources: { <source>: { connected,
13
+ * lastOkAt, lastError } } }
14
+ *
15
+ * The same `createPullRunner` (../sources/pull-runner.js) serves every
16
+ * network: add a job to `pullJobs` to give another network a button and a
17
+ * schedule. VK also keeps a Long Poll running (../sources/vk-sync.js).
18
+ */
19
+
20
+ import { createPullRunner, loadPullState } from '../sources/pull-runner.js';
21
+ import { createVkInbox } from '../sources/vk-sync.js';
22
+ import { liveOptionsFrom, pullLiveInto } from '../sources/index.js';
23
+ import { createUpworkLive } from '../sources/upwork.js';
24
+ import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
25
+ import { fail, json, readBody } from './util.js';
26
+
27
+ const MINUTE = 60_000;
28
+
29
+ const connected = (store, source) => async () =>
30
+ Boolean((await liveOptionsFrom(store, source)).token);
31
+
32
+ // `live` for a network whose test double or host is given; the saved token
33
+ // still comes from the store.
34
+ const liveOf = (make, given) => (given ? make(given) : undefined);
35
+
36
+ /** The jobs: network -> { ready, run, intervalMs }. */
37
+ export const pullJobs = (store, { vkInbox, options = {} }) => ({
38
+ vk: {
39
+ ready: vkInbox.ready,
40
+ run: vkInbox.fetch,
41
+ intervalMs: 10 * MINUTE,
42
+ firstDelayMs: 30_000,
43
+ },
44
+ upwork: {
45
+ ready: connected(store, 'upwork'),
46
+ run: () =>
47
+ pullLiveInto(store, 'upwork', {
48
+ live: liveOf(createUpworkLive, options.upwork),
49
+ }),
50
+ intervalMs: 5 * MINUTE,
51
+ firstDelayMs: 30_000,
52
+ },
53
+ peopleperhour: {
54
+ ready: connected(store, 'peopleperhour'),
55
+ run: () =>
56
+ pullLiveInto(store, 'peopleperhour', {
57
+ live: liveOf(createPeoplePerHourLive, options.peopleperhour),
58
+ }),
59
+ intervalMs: 5 * MINUTE,
60
+ firstDelayMs: 30_000,
61
+ },
62
+ });
63
+
64
+ /**
65
+ * The pull services for a server: `{ pullRunner, vkInbox, startAll, stopAll }`.
66
+ * `options` (tests): `{ vk: {baseUrl, fetchImpl, wait}, upwork: {baseUrl},
67
+ * peopleperhour: {baseUrl}, minGapMs, intervals }`; `false` turns it all off.
68
+ */
69
+ export const createPullServices = (store, log, options = {}) => {
70
+ const vkInbox = createVkInbox({ store, log, ...options.vk });
71
+ const jobs = pullJobs(store, { vkInbox, options });
72
+ for (const [source, intervalMs] of Object.entries(options.intervals ?? {})) {
73
+ jobs[source].intervalMs = intervalMs;
74
+ jobs[source].firstDelayMs = intervalMs;
75
+ }
76
+ const pullRunner = createPullRunner({
77
+ store,
78
+ jobs,
79
+ log,
80
+ minGapMs: options.minGapMs,
81
+ });
82
+ return {
83
+ pullRunner,
84
+ vkInbox,
85
+ start: () => {
86
+ pullRunner.start();
87
+ vkInbox.start();
88
+ },
89
+ stop: () => {
90
+ pullRunner.stop();
91
+ vkInbox.stop();
92
+ },
93
+ };
94
+ };
95
+
96
+ const statusOf = async (store, ctx) => {
97
+ const sources = {};
98
+ for (const source of ctx.pullRunner.sources()) {
99
+ const state = await loadPullState(store, source);
100
+ sources[source] = {
101
+ connected: await ctx.pullRunner.connected(source),
102
+ lastOkAt: state.lastOkAt ?? null,
103
+ lastError: state.lastError ?? null,
104
+ };
105
+ }
106
+ return { ok: true, sources };
107
+ };
108
+
109
+ const sourceOf = (body) =>
110
+ typeof body.source === 'string' && /^[a-z]{2,30}$/.test(body.source)
111
+ ? body.source
112
+ : null;
113
+
114
+ export const handlePullRoutes = async (store, req, res, p, ctx) => {
115
+ if (!ctx.pullRunner) {
116
+ return false;
117
+ }
118
+ if (p === '/api/pull/status' && req.method === 'GET') {
119
+ return json(res, 200, await statusOf(store, ctx)) ?? true;
120
+ }
121
+ if (p !== '/api/pull' || req.method !== 'POST') {
122
+ return false;
123
+ }
124
+ const source = sourceOf(await readBody(req));
125
+ if (!source) {
126
+ return fail(res, 400, 'bad-request', 'a "source" is needed') ?? true;
127
+ }
128
+ const outcome = await ctx.pullRunner.fetchNow(source);
129
+ if (outcome.ok) {
130
+ return json(res, 200, outcome) ?? true;
131
+ }
132
+ const status =
133
+ { 'rate-limited': 429, unsupported: 404, 'not-connected': 409 }[
134
+ outcome.code
135
+ ] ?? 502;
136
+ return json(res, status, outcome) ?? true;
137
+ };
138
+
139
+ /** What a server keeps for the pulls; they start with the other `later` services. */
140
+ export const pullContext = (store, log, options, later) => {
141
+ if (options === false) {
142
+ return { pullRunner: null };
143
+ }
144
+ const services = createPullServices(store, log, options ?? {});
145
+ later.push(services.start);
146
+ return { pullRunner: services.pullRunner, stopPulls: services.stop };
147
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * WhatsApp routes (R-Z9).
3
+ *
4
+ * GET /api/whatsapp/web -> { ok, state, open, signedIn, signedInAt, terms }
5
+ * POST /api/whatsapp/web/sign-in -> opens the visible WhatsApp Web window
6
+ * POST /api/whatsapp/web/close -> closes it
7
+ * POST /api/whatsapp/archive {name, data(base64)}
8
+ * -> imports an "Export chat" .txt or .zip
9
+ *
10
+ * The sign-in window is for the person to scan the QR code themselves
11
+ * (see ../sources/whatsapp-web-signin.js); these routes never touch it.
12
+ */
13
+
14
+ import { json, readBody } from './util.js';
15
+ import { isTrustedMutation } from './routes-auto-import.js';
16
+ import { safeMessage } from './redact.js';
17
+ import { createWhatsAppWebSignIn } from '../sources/whatsapp-web-signin.js';
18
+ import { importWhatsAppFile } from '../sources/whatsapp-file.js';
19
+
20
+ const PREFIX = '/api/whatsapp/';
21
+
22
+ // What the page must tell the person (also in the docs, four languages).
23
+ export const WHATSAPP_TERMS = Object.freeze({
24
+ automation: false,
25
+ note: "WhatsApp's terms do not allow automated or bulk use of WhatsApp Web. Meta Sovereign only types and sends a message you confirm yourself.",
26
+ });
27
+
28
+ const signInFor = (ctx) => {
29
+ ctx.whatsappSignIn ??= createWhatsAppWebSignIn({
30
+ storeDir: ctx.storeDir,
31
+ ...(ctx.whatsappSignInOptions ?? {}),
32
+ log: ctx.log,
33
+ });
34
+ return ctx.whatsappSignIn;
35
+ };
36
+
37
+ const reply = (res, status, body) =>
38
+ json(res, status, { ok: status < 400, ...body }) ?? true;
39
+
40
+ const failure = (res, error) =>
41
+ reply(res, error.code === 'browser-unavailable' ? 503 : 500, {
42
+ error: safeMessage(error),
43
+ code: error.code ?? 'internal',
44
+ });
45
+
46
+ const archiveRoute = async (store, req, res) => {
47
+ const body = await readBody(req, { limit: 64 * 1024 * 1024 });
48
+ const bytes = Buffer.from(String(body.data ?? ''), 'base64');
49
+ if (bytes.length === 0 || typeof body.name !== 'string') {
50
+ return reply(res, 400, {
51
+ error: 'name and data (base64) are required',
52
+ code: 'bad-request',
53
+ });
54
+ }
55
+ try {
56
+ return reply(res, 200, await importWhatsAppFile(store, body.name, bytes));
57
+ } catch (error) {
58
+ return reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
59
+ }
60
+ };
61
+
62
+ const webRoute = async (req, res, ctx, action) => {
63
+ const signIn = signInFor(ctx);
64
+ if (req.method === 'GET') {
65
+ return reply(res, 200, {
66
+ ...(await signIn.status()),
67
+ terms: WHATSAPP_TERMS,
68
+ });
69
+ }
70
+ try {
71
+ const status =
72
+ action === 'sign-in' ? await signIn.start() : await signIn.stop();
73
+ return reply(res, 200, { ...status, terms: WHATSAPP_TERMS });
74
+ } catch (error) {
75
+ return failure(res, error);
76
+ }
77
+ };
78
+
79
+ export const handleWhatsAppRoutes = async (store, req, res, p, ctx) => {
80
+ if (!p.startsWith(PREFIX)) {
81
+ return false;
82
+ }
83
+ const route = p.slice(PREFIX.length);
84
+ const isWeb =
85
+ route === 'web' || route === 'web/sign-in' || route === 'web/close';
86
+ const isArchive = route === 'archive';
87
+ if (!isWeb && !isArchive) {
88
+ return false;
89
+ }
90
+ if (req.method === 'POST' && !isTrustedMutation(req, ctx.allowOrigins)) {
91
+ return reply(res, 403, {
92
+ error: 'this must be started from the meta-sovereign app',
93
+ code: 'forbidden',
94
+ });
95
+ }
96
+ if (isArchive) {
97
+ return req.method === 'POST' ? archiveRoute(store, req, res) : false;
98
+ }
99
+ const expected = route === 'web' ? 'GET' : 'POST';
100
+ return req.method === expected
101
+ ? webRoute(req, res, ctx, route.split('/')[1])
102
+ : false;
103
+ };
@@ -9,9 +9,9 @@ import { getSource, stampSourceLink, buildMessageLink } from '../index.js';
9
9
  import { archiveReaderFor } from './archive.js';
10
10
  import { autoImportRecipes } from './recipes.js';
11
11
 
12
- const toArchive = (recipe, name, text) => {
12
+ const toArchive = (recipe, name, text, context = {}) => {
13
13
  if (recipe.toArchive) {
14
- return recipe.toArchive(name, text);
14
+ return recipe.toArchive(name, text, context);
15
15
  }
16
16
  const trimmed = text.trim();
17
17
  if (/\.json$/i.test(name) || /^[[{]/.test(trimmed)) {
@@ -24,8 +24,12 @@ const toArchive = (recipe, name, text) => {
24
24
  return text;
25
25
  };
26
26
 
27
- const parseEntry = async (provider, recipe, name, data) => {
28
- const archive = toArchive(recipe, name, data.toString('utf8'));
27
+ const parseEntry = async (provider, recipe, name, data, context) => {
28
+ // A recipe may decode its own bytes (VK's Windows-1251 pages, R-Z8).
29
+ const text = recipe.decode
30
+ ? recipe.decode(name, data)
31
+ : data.toString('utf8');
32
+ const archive = toArchive(recipe, name, text, context);
29
33
  return (await getSource(provider).parseArchive(archive)) ?? [];
30
34
  };
31
35
 
@@ -47,6 +51,7 @@ export const importDownloadedFile = async ({ store, provider, filePath }) => {
47
51
  : recipe.files.test(name)
48
52
  ? [{ name, read: () => buf }]
49
53
  : [];
54
+ const names = reader ? reader(buf).map((entry) => entry.name) : [];
50
55
  const reports = [];
51
56
  for (const entry of candidates) {
52
57
  const label = reader ? `${name}:${entry.name}` : name;
@@ -55,7 +60,10 @@ export const importDownloadedFile = async ({ store, provider, filePath }) => {
55
60
  provider,
56
61
  recipe,
57
62
  entry.name,
58
- entry.read()
63
+ entry.read(),
64
+ // The container's own name and file list (WhatsApp names the chat
65
+ // after the zip and finds the media in it).
66
+ { container: reader ? name : null, entries: names }
59
67
  );
60
68
  for (const link of links) {
61
69
  await store.put(stampSourceLink(link, provider));
@@ -22,6 +22,12 @@
22
22
  */
23
23
 
24
24
  import { parseCsv } from './archive.js';
25
+ import { chatNameFromFile } from '../whatsapp-ids.js';
26
+ import {
27
+ decodeVkHtml,
28
+ parseVkHtmlPage,
29
+ peerFromArchivePath,
30
+ } from '../vk-html-archive.js';
25
31
 
26
32
  // X archives ship data as `window.YTD.<name>.part0 = [ ... ]`.
27
33
  const ytdPayload = (text) => JSON.parse(text.replace(/^[^=[]*=\s*/, ''));
@@ -63,6 +69,14 @@ const linkedinToArchive = (_name, text) => ({
63
69
  ),
64
70
  });
65
71
 
72
+ const vkDecode = (name, bytes) =>
73
+ /\.html$/i.test(name) ? decodeVkHtml(bytes) : bytes.toString('utf8');
74
+
75
+ const vkToArchive = (name, text) =>
76
+ /\.html$/i.test(name)
77
+ ? parseVkHtmlPage(text, { peer: peerFromArchivePath(name) })
78
+ : JSON.parse(text);
79
+
66
80
  export const autoImportRecipes = {
67
81
  email: {
68
82
  url: 'https://takeout.google.com/settings/takeout/custom/gmail',
@@ -86,7 +100,11 @@ export const autoImportRecipes = {
86
100
  vk: {
87
101
  url: 'https://vk.com/data_protection?section=rules',
88
102
  domains: ['vk.com', 'vk.ru'],
89
- files: /messages.*\.json$/i,
103
+ // The JSON of konard/vk-export, or the pages of VK's own archive:
104
+ // messages/<peer>/messages<N>.html in Windows-1251 (R-Z8).
105
+ files: /(messages.*\.json|messages\/[^/]+\/messages\d*\.html)$/i,
106
+ decode: vkDecode,
107
+ toArchive: vkToArchive,
90
108
  },
91
109
  x: {
92
110
  url: 'https://x.com/settings/download_your_data',
@@ -97,7 +115,14 @@ export const autoImportRecipes = {
97
115
  whatsapp: {
98
116
  url: 'https://web.whatsapp.com/',
99
117
  domains: ['whatsapp.com'],
118
+ // An "Export chat" .txt, or the .zip of one with media (R-Z9); the
119
+ // chat is named after the file (or the zip holding `_chat.txt`).
100
120
  files: /\.txt$/i,
121
+ toArchive: (name, text, context = {}) => ({
122
+ text,
123
+ fileName: chatNameFromFile(name) ? name : (context.container ?? name),
124
+ files: context.entries ?? [],
125
+ }),
101
126
  scrape: {
102
127
  chat: '#main header span[dir="auto"]',
103
128
  message: '#main div[data-id]',