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.
- package/js/src/automation/outbox.js +12 -1
- package/js/src/broadcast/index.js +7 -1
- package/js/src/crm/reengage.js +3 -2
- package/js/src/greetings/index.js +6 -2
- package/js/src/server/aggregate.js +16 -2
- package/js/src/server/index.js +39 -14
- package/js/src/server/routes-pull.js +147 -0
- package/js/src/server/routes-whatsapp.js +103 -0
- package/js/src/sources/auto-import/files.js +13 -5
- package/js/src/sources/auto-import/recipes.js +26 -1
- package/js/src/sources/browser-sender.js +43 -5
- package/js/src/sources/pull-runner.js +171 -0
- package/js/src/sources/vk-html-archive.js +268 -0
- package/js/src/sources/vk-limits.js +119 -0
- package/js/src/sources/vk-longpoll.js +333 -0
- package/js/src/sources/vk-sync.js +138 -0
- package/js/src/sources/vk.js +224 -71
- package/js/src/sources/web-uis.js +20 -3
- package/js/src/sources/whatsapp-archive.js +271 -0
- package/js/src/sources/whatsapp-file.js +41 -0
- package/js/src/sources/whatsapp-ids.js +94 -0
- package/js/src/sources/whatsapp-web-signin.js +156 -0
- package/js/src/sources/whatsapp.js +27 -30
- package/js/src/storage/migrations/0002-whatsapp-chat-ids.js +148 -0
- package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
- package/js/src/storage/migrations/index.js +4 -2
- package/js/src/web/app.css +43 -0
- package/js/src/web/chat-media.js +3 -0
- package/js/src/web/chat-remote-media.js +111 -0
- package/js/src/web/connection-guides.js +2 -2
- package/js/src/web/connections/fetch-now.js +79 -0
- package/js/src/web/connections/index.js +34 -0
- package/js/src/web/connections/whatsapp-web.js +123 -0
- package/js/src/web/dom.js +18 -0
- package/js/src/web/locales/en-chat-account.js +17 -0
- package/js/src/web/locales/en.js +15 -1
- package/js/src/web/locales/hi-chat-account.js +17 -0
- package/js/src/web/locales/hi.js +16 -1
- package/js/src/web/locales/ru-chat-account.js +17 -0
- package/js/src/web/locales/ru.js +15 -1
- package/js/src/web/locales/zh-chat-account.js +16 -0
- package/js/src/web/locales/zh.js +14 -1
- package/js/src/web/message-parts.js +34 -9
- 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({
|
|
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: (
|
|
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 }
|
package/js/src/crm/reengage.js
CHANGED
|
@@ -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),
|
|
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
|
|
47
|
+
const at = seenAt(m.timestamp);
|
|
48
|
+
const latest = !entry.lastSeen || (at ?? '') > entry.lastSeen;
|
|
35
49
|
if (latest) {
|
|
36
|
-
entry.lastSeen =
|
|
50
|
+
entry.lastSeen = at;
|
|
37
51
|
entry.name = m.senderName ?? entry.name;
|
|
38
52
|
} else {
|
|
39
53
|
entry.name ??= m.senderName ?? null;
|
package/js/src/server/index.js
CHANGED
|
@@ -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
|
|
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);
|
|
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 =
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]',
|