meta-sovereign 0.49.2 → 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.
@@ -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;
@@ -104,6 +104,7 @@ import { createGitHubCheck } from '../sources/github-check.js';
104
104
  import { handleMetrics } from './metrics.js';
105
105
  import { jsonLog } from './log.js';
106
106
  import { listenLocal } from './local-tls.js';
107
+ import { handlePullRoutes, pullContext } from './routes-pull.js';
107
108
  import { createPeer, attachSyncWebSocket } from '../sync/index.js';
108
109
  import { attachSignaling } from '../sync/webrtc-signaling.js';
109
110
  import { registerDefaultHandlers } from './handlers-bootstrap.js';
@@ -148,7 +149,9 @@ export const CSP =
148
149
  "default-src 'self'; " +
149
150
  "script-src 'self' 'wasm-unsafe-eval'; " +
150
151
  "style-src 'self'; " +
151
- "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; ' +
152
155
  'frame-src https:; ' +
153
156
  "connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
154
157
  "worker-src 'self'; " +
@@ -576,6 +579,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
576
579
  () => ctx.clientLog?.(req, res, p, ctx) ?? false,
577
580
  () => handleAutoImportRoutes(store, req, res, p, ctx),
578
581
  () => handleWhatsAppRoutes(store, req, res, p, ctx),
582
+ () => handlePullRoutes(store, req, res, p, ctx),
579
583
  () => handleTelegramRoutes(req, res, p, ctx),
580
584
  () => handleMessageRoutes(store, req, res, p, url, ctx),
581
585
  () => handleEmailGets(store, req, res, p, url, ctx),
@@ -787,15 +791,14 @@ export const startServer = async ({
787
791
  telegramExtraBuild,
788
792
  // Tests and demos pass a fake, so GitHub is never asked (R-Y53).
789
793
  githubCheck,
790
- // The hosted app and whether this server also serves its own copy
791
- // (./hosted-ui.js, R-Y58 and R-Y59).
794
+ // The hosted app, and whether this serves its own copy (./hosted-ui.js).
792
795
  uiUrl,
793
796
  localUi,
794
- // HTTPS on the same port for Safari (./local-tls.js, R-Y64); off
795
- // unless asked for, `serve` turns it on.
797
+ // HTTPS on the same port for Safari (./local-tls.js, R-Y64); `serve` sets it.
796
798
  tls,
797
799
  // Tests: a fake `(dir) => bytes|null` for the free disk space (R-Z14).
798
800
  diskFree,
801
+ pulls,
799
802
  } = {}) => {
800
803
  const { store, resolvedArchiveDir, flush, storeHealth } = await initStore({
801
804
  providedStore,
@@ -853,13 +856,13 @@ export const startServer = async ({
853
856
  later
854
857
  ),
855
858
  chatIndex,
859
+ ...pullContext(store, log, pulls, later),
856
860
  };
857
861
  const server = createHttpServer(store, ctx, log);
858
862
  const sockets = trackSockets(server);
859
- let bus = null;
860
- if (enableHandlers) {
861
- bus = registerDefaultHandlers(store, { browser: () => ctx.send });
862
- }
863
+ const bus = enableHandlers
864
+ ? registerDefaultHandlers(store, { browser: () => ctx.send })
865
+ : null;
863
866
  const { wsHandle, signaling } = attachSockets(server, ctx, store, {
864
867
  enableSync,
865
868
  node,
@@ -903,6 +906,7 @@ export const startServer = async ({
903
906
  ctx.closeAutomations().catch(() => {});
904
907
  ctx.autoImport?.close();
905
908
  ctx.whatsappSignIn?.stop().catch(() => {});
909
+ ctx.stopPulls?.();
906
910
  ctx.chatIndex.close();
907
911
  stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
908
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
+ };
@@ -25,7 +25,11 @@ const toArchive = (recipe, name, text, context = {}) => {
25
25
  };
26
26
 
27
27
  const parseEntry = async (provider, recipe, name, data, context) => {
28
- const archive = toArchive(recipe, name, data.toString('utf8'), 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
 
@@ -23,6 +23,11 @@
23
23
 
24
24
  import { parseCsv } from './archive.js';
25
25
  import { chatNameFromFile } from '../whatsapp-ids.js';
26
+ import {
27
+ decodeVkHtml,
28
+ parseVkHtmlPage,
29
+ peerFromArchivePath,
30
+ } from '../vk-html-archive.js';
26
31
 
27
32
  // X archives ship data as `window.YTD.<name>.part0 = [ ... ]`.
28
33
  const ytdPayload = (text) => JSON.parse(text.replace(/^[^=[]*=\s*/, ''));
@@ -64,6 +69,14 @@ const linkedinToArchive = (_name, text) => ({
64
69
  ),
65
70
  });
66
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
+
67
80
  export const autoImportRecipes = {
68
81
  email: {
69
82
  url: 'https://takeout.google.com/settings/takeout/custom/gmail',
@@ -87,7 +100,11 @@ export const autoImportRecipes = {
87
100
  vk: {
88
101
  url: 'https://vk.com/data_protection?section=rules',
89
102
  domains: ['vk.com', 'vk.ru'],
90
- 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,
91
108
  },
92
109
  x: {
93
110
  url: 'https://x.com/settings/download_your_data',
@@ -0,0 +1,171 @@
1
+ /**
2
+ * One small runner for "fetch now" and "fetch on a schedule" of a network's
3
+ * inbox (R-Z8; the page's Fetch button and the background poller share it).
4
+ *
5
+ * A job is `{ ready(), run(), intervalMs }`:
6
+ * - `ready()` is true when the network is connected (a saved token), so a
7
+ * network nobody connected is never polled;
8
+ * - `run()` pulls and stores, returning `{ imported, ... }`. It saves any
9
+ * cursor itself, and only AFTER the store write succeeded, so a failed
10
+ * write is read again next time instead of skipped.
11
+ *
12
+ * `fetchNow(source)` is the button: at most one run per `minGapMs` (15 s) for
13
+ * a network, background runs counted too, and one at a time. `start()` is
14
+ * the schedule: each ready job runs every `intervalMs`, and after a failure
15
+ * the wait doubles up to `maxBackoffMs`, so a dead token or a down network
16
+ * is not hammered. Nothing starts before `start()`, which the server calls
17
+ * only once it listens. Errors come back as `{ ok: false, error, code }`
18
+ * with secrets masked; nothing here throws at a caller.
19
+ *
20
+ * `pull-state:<source>` keeps `lastOkAt` and a job's own cursor.
21
+ */
22
+
23
+ import { safeMessage } from '../server/redact.js';
24
+
25
+ export const PULL_MIN_GAP_MS = 15_000;
26
+ const MAX_BACKOFF_MS = 60 * 60_000;
27
+
28
+ const stateId = (source) => `pull-state:${source}`;
29
+ // Writes of one source's state are queued, so two patches never overwrite
30
+ // each other (a manual fetch and the poller can overlap).
31
+ const queues = new WeakMap();
32
+
33
+ export const loadPullState = async (store, source) =>
34
+ (await store.get(stateId(source))) ?? {};
35
+
36
+ /** Merge `patch` into the source's state link; the write has finished on return. */
37
+ export const savePullState = (store, source, patch) => {
38
+ const byStore = queues.get(store) ?? new Map();
39
+ queues.set(store, byStore);
40
+ const run = async () => {
41
+ const current = await loadPullState(store, source);
42
+ const next = {
43
+ ...current,
44
+ ...patch,
45
+ id: stateId(source),
46
+ tokens: ['pull', 'state', source],
47
+ children: [],
48
+ };
49
+ await store.put(next);
50
+ return next;
51
+ };
52
+ const queued = (byStore.get(source) ?? Promise.resolve()).then(run, run);
53
+ byStore.set(
54
+ source,
55
+ queued.catch(() => {})
56
+ );
57
+ return queued;
58
+ };
59
+
60
+ const failure = (error, extra = {}) => ({
61
+ ok: false,
62
+ error: safeMessage(error),
63
+ code: error?.code && typeof error.code === 'string' ? error.code : 'failed',
64
+ ...extra,
65
+ });
66
+
67
+ export const createPullRunner = ({
68
+ store,
69
+ jobs,
70
+ now = Date.now,
71
+ minGapMs = PULL_MIN_GAP_MS,
72
+ maxBackoffMs = MAX_BACKOFF_MS,
73
+ log = null,
74
+ setTimer = globalThis.setTimeout,
75
+ clearTimer = globalThis.clearTimeout,
76
+ } = {}) => {
77
+ const lastStart = new Map();
78
+ const running = new Set();
79
+ const timers = new Map();
80
+ let stopped = true;
81
+
82
+ const execute = async (source) => {
83
+ const job = jobs[source];
84
+ running.add(source);
85
+ lastStart.set(source, now());
86
+ try {
87
+ const result = (await job.run()) ?? {};
88
+ const lastOkAt = new Date(now()).toISOString();
89
+ await savePullState(store, source, { lastOkAt, lastError: null });
90
+ return { ok: true, source, lastOkAt, imported: result.imported ?? 0 };
91
+ } catch (error) {
92
+ const out = failure(error, { source });
93
+ log?.({ level: 'warn', event: 'pull_failed', source, code: out.code });
94
+ await savePullState(store, source, {
95
+ lastError: { code: out.code, at: new Date(now()).toISOString() },
96
+ }).catch(() => {});
97
+ return out;
98
+ } finally {
99
+ running.delete(source);
100
+ }
101
+ };
102
+
103
+ const fetchNow = async (source) => {
104
+ const job = jobs[source];
105
+ if (!job) {
106
+ return failure(new Error(`no inbox pull for "${source}"`), {
107
+ code: 'unsupported',
108
+ });
109
+ }
110
+ if (!(await job.ready())) {
111
+ return failure(new Error(`${source} is not connected: save its token`), {
112
+ code: 'not-connected',
113
+ });
114
+ }
115
+ const since = now() - (lastStart.get(source) ?? -Infinity);
116
+ if (running.has(source) || since < minGapMs) {
117
+ return failure(new Error('fetched a moment ago; wait a few seconds'), {
118
+ code: 'rate-limited',
119
+ retryAfterMs: running.has(source) ? minGapMs : minGapMs - since,
120
+ });
121
+ }
122
+ return execute(source);
123
+ };
124
+
125
+ // One scheduled run; the number of failures in a row after it.
126
+ const tick = async (source, fails) => {
127
+ try {
128
+ if (!(await jobs[source].ready()) || running.has(source)) {
129
+ return fails;
130
+ }
131
+ return (await execute(source)).ok ? 0 : fails + 1;
132
+ } catch {
133
+ return fails + 1;
134
+ }
135
+ };
136
+
137
+ const schedule = (source, delay, fails) => {
138
+ if (stopped) {
139
+ return;
140
+ }
141
+ const timer = setTimer(async () => {
142
+ const next = await tick(source, fails);
143
+ const wait = jobs[source].intervalMs * 2 ** Math.min(next, 10);
144
+ schedule(source, Math.min(wait, maxBackoffMs), next);
145
+ }, delay);
146
+ timer?.unref?.();
147
+ timers.set(source, timer);
148
+ };
149
+
150
+ return {
151
+ fetchNow,
152
+ /** Start the schedules (call after the server listens). */
153
+ start() {
154
+ stopped = false;
155
+ for (const [source, job] of Object.entries(jobs)) {
156
+ if (job.intervalMs > 0) {
157
+ schedule(source, job.firstDelayMs ?? job.intervalMs, 0);
158
+ }
159
+ }
160
+ },
161
+ stop() {
162
+ stopped = true;
163
+ for (const timer of timers.values()) {
164
+ clearTimer(timer);
165
+ }
166
+ timers.clear();
167
+ },
168
+ sources: () => Object.keys(jobs),
169
+ connected: (source) => jobs[source].ready(),
170
+ };
171
+ };