meta-sovereign 0.49.10 → 0.49.12
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/github-service.js +22 -2
- package/js/src/server/index.js +13 -12
- package/js/src/server/routes-pull.js +30 -2
- package/js/src/server/routes-vk.js +93 -0
- package/js/src/sources/pull-runner.js +15 -1
- package/js/src/sources/vk-hosts.js +51 -0
- package/js/src/sources/vk-longpoll.js +12 -1
- package/js/src/sources/vk-older.js +75 -0
- package/js/src/sources/vk-sync.js +6 -0
- package/js/src/sources/vk.js +27 -0
- package/js/src/web/app.css +40 -0
- package/js/src/web/chat-remote-media.js +53 -2
- package/js/src/web/chat-view.js +10 -1
- package/js/src/web/chat-window.js +62 -4
- package/js/src/web/connections/fetch-now.js +9 -2
- package/js/src/web/connections/github-inbox.js +4 -0
- package/js/src/web/connections/github-notifications.js +133 -0
- package/js/src/web/dom.js +7 -0
- package/js/src/web/locales/en-chat-account.js +12 -0
- package/js/src/web/locales/en-github.js +11 -0
- package/js/src/web/locales/hi-chat-account.js +12 -0
- package/js/src/web/locales/hi-github.js +12 -0
- package/js/src/web/locales/ru-chat-account.js +12 -0
- package/js/src/web/locales/ru-github.js +13 -0
- package/js/src/web/locales/zh-chat-account.js +10 -0
- package/js/src/web/locales/zh-github.js +11 -0
- package/js/src/web/vk-captcha.js +95 -0
- package/package.json +3 -1
- package/js/src/server/email-poller.js +0 -76
|
@@ -100,6 +100,16 @@ const errorEntry = (error) => ({
|
|
|
100
100
|
...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// A write that may have been delivered: reported as unknown, never repeated.
|
|
104
|
+
const unknownIfAmbiguous = (error) =>
|
|
105
|
+
error?.ambiguous === true && error.code !== 'send-unknown'
|
|
106
|
+
? Object.assign(error, {
|
|
107
|
+
code: 'send-unknown',
|
|
108
|
+
outcome: 'unknown',
|
|
109
|
+
fallback: false,
|
|
110
|
+
})
|
|
111
|
+
: error;
|
|
112
|
+
|
|
103
113
|
const putAll = async (store, links) => {
|
|
104
114
|
for (const link of links) {
|
|
105
115
|
await store.put(stampSourceLink(link, 'github'));
|
|
@@ -307,16 +317,26 @@ export const createGithubService = ({
|
|
|
307
317
|
const notifications = () => storedNotifications(store);
|
|
308
318
|
|
|
309
319
|
// Marking read changes GitHub: only on an explicit call (never by fetching).
|
|
320
|
+
// A failure that may have reached GitHub is `send-unknown` and is not
|
|
321
|
+
// repeated; the stored copy stays unread until the next fetch shows what
|
|
322
|
+
// GitHub says (R-Z4, R-Z20).
|
|
310
323
|
const markRead = async ({ threadId = null, all = false } = {}) => {
|
|
311
324
|
const { token, live } = await withLive();
|
|
325
|
+
try {
|
|
326
|
+
if (all) {
|
|
327
|
+
await live.markAllRead({ token });
|
|
328
|
+
} else {
|
|
329
|
+
await live.markThreadRead(threadId, { token });
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw unknownIfAmbiguous(error);
|
|
333
|
+
}
|
|
312
334
|
if (all) {
|
|
313
|
-
await live.markAllRead({ token });
|
|
314
335
|
for (const link of await notifications()) {
|
|
315
336
|
await store.put({ ...link, unread: false });
|
|
316
337
|
}
|
|
317
338
|
return { ok: true, all: true };
|
|
318
339
|
}
|
|
319
|
-
await live.markThreadRead(threadId, { token });
|
|
320
340
|
const link = await store.get(`notification:github:${threadId}`);
|
|
321
341
|
if (link) {
|
|
322
342
|
await store.put({ ...link, unread: false });
|
package/js/src/server/index.js
CHANGED
|
@@ -98,7 +98,12 @@ import { handleMessageRoutes } from './routes-messages.js';
|
|
|
98
98
|
import { handleEmailGets } from './routes-email.js';
|
|
99
99
|
import { handleMediaGets } from './routes-media.js';
|
|
100
100
|
import { createAttachmentStore } from './email-attachments.js';
|
|
101
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
VK_CAPTCHA_HOSTS,
|
|
103
|
+
VK_IMAGE_HOSTS,
|
|
104
|
+
VK_MEDIA_HOSTS,
|
|
105
|
+
cspSources,
|
|
106
|
+
} from '../sources/vk-hosts.js';
|
|
102
107
|
import { setAttachmentSink } from '../sources/email.js';
|
|
103
108
|
import { handleEventRoutes } from './routes-events.js';
|
|
104
109
|
import { createPasscodeLock } from './passcode.js';
|
|
@@ -156,7 +161,9 @@ export const CSP =
|
|
|
156
161
|
"style-src 'self'; " +
|
|
157
162
|
"img-src 'self' data: blob: https://vk.com " +
|
|
158
163
|
// VK's picture CDNs, for photos and stickers in VK chats (R-Z8).
|
|
159
|
-
|
|
164
|
+
`${cspSources(VK_IMAGE_HOSTS)} ${cspSources(VK_CAPTCHA_HOSTS)}; ` +
|
|
165
|
+
// Direct VK audio and video files (R-Z20); a VK video page is a link.
|
|
166
|
+
`media-src 'self' ${cspSources(VK_MEDIA_HOSTS)}; ` +
|
|
160
167
|
'frame-src https:; ' +
|
|
161
168
|
"connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
|
|
162
169
|
"worker-src 'self'; " +
|
|
@@ -391,17 +398,12 @@ const mediaDirOf = (storeDir) =>
|
|
|
391
398
|
|
|
392
399
|
// Mail (R-Z5): where attachments are kept next to the store, and the
|
|
393
400
|
// periodic pull of a connected mailbox (`emailPoll: false` turns it off).
|
|
394
|
-
const emailContext = (
|
|
401
|
+
const emailContext = (storeDir) => {
|
|
395
402
|
const emailFiles = storeDir
|
|
396
403
|
? createAttachmentStore(path.join(storeDir, 'media', 'email'))
|
|
397
404
|
: null;
|
|
398
405
|
setAttachmentSink(emailFiles ? (bytes) => emailFiles.save(bytes) : null);
|
|
399
|
-
|
|
400
|
-
poll === false
|
|
401
|
-
? { start() {}, stop() {} }
|
|
402
|
-
: createEmailPoller({ store, log, ...poll });
|
|
403
|
-
later.push(() => emailPoller.start());
|
|
404
|
-
return { emailFiles, emailPoller };
|
|
406
|
+
return { emailFiles };
|
|
405
407
|
};
|
|
406
408
|
|
|
407
409
|
// Listen (with HTTPS beside HTTP when asked, ./local-tls.js) and track
|
|
@@ -839,7 +841,7 @@ export const startServer = async ({
|
|
|
839
841
|
signaling: null,
|
|
840
842
|
secretPassphrase,
|
|
841
843
|
emailLiveFactory,
|
|
842
|
-
...emailContext(
|
|
844
|
+
...emailContext(storeDir),
|
|
843
845
|
...uiContext(allowOrigins, uiUrl, localUi),
|
|
844
846
|
...cvContext(cv),
|
|
845
847
|
replies: replyService(store, storeDir, replies),
|
|
@@ -866,7 +868,7 @@ export const startServer = async ({
|
|
|
866
868
|
later
|
|
867
869
|
),
|
|
868
870
|
chatIndex,
|
|
869
|
-
...pullContext(store, log, pulls, later),
|
|
871
|
+
...pullContext(store, log, pulls, later, emailPoll),
|
|
870
872
|
};
|
|
871
873
|
Object.assign(ctx, backgroundJobs({ ctx, store, log, later, backups }));
|
|
872
874
|
const server = createHttpServer(store, ctx, log);
|
|
@@ -912,7 +914,6 @@ export const startServer = async ({
|
|
|
912
914
|
close: () =>
|
|
913
915
|
new Promise((resolve) => {
|
|
914
916
|
bus?.close();
|
|
915
|
-
ctx.emailPoller.stop();
|
|
916
917
|
ctx.replies.close().catch(() => {});
|
|
917
918
|
ctx.closeAutomations().catch(() => {});
|
|
918
919
|
ctx.stopJobs();
|
|
@@ -23,6 +23,8 @@ import { liveOptionsFrom, pullLiveInto } from '../sources/index.js';
|
|
|
23
23
|
import { createUpworkLive } from '../sources/upwork.js';
|
|
24
24
|
import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
|
|
25
25
|
import { createGithubService } from './github-service.js';
|
|
26
|
+
import { handleVkRoutes } from './routes-vk.js';
|
|
27
|
+
import { emailConfigured } from '../sources/email-store.js';
|
|
26
28
|
import { fail, json, readBody } from './util.js';
|
|
27
29
|
|
|
28
30
|
const MINUTE = 60_000;
|
|
@@ -34,9 +36,24 @@ const connected = (store, source) => async () =>
|
|
|
34
36
|
// still comes from the store.
|
|
35
37
|
const liveOf = (make, given) => (given ? make(given) : undefined);
|
|
36
38
|
|
|
39
|
+
// Mail (R-Z5, on the shared runner since R-Z20): every ten minutes, the first
|
|
40
|
+
// pull 30 s after the server listens, never faster than a minute, backed off
|
|
41
|
+
// after a failure like every job. `emailPoll: false` keeps only the button.
|
|
42
|
+
// `email.pull` (tests) replaces the real pull.
|
|
43
|
+
const MIN_EMAIL_MS = MINUTE;
|
|
44
|
+
const emailJob = (store, poll = {}) => ({
|
|
45
|
+
ready: async () => emailConfigured(await liveOptionsFrom(store, 'email')),
|
|
46
|
+
run: () =>
|
|
47
|
+
(poll.pull ?? ((s) => pullLiveInto(s, 'email', { limit: 20 })))(store),
|
|
48
|
+
intervalMs:
|
|
49
|
+
poll === false ? 0 : Math.max(MIN_EMAIL_MS, poll.intervalMs ?? 10 * MINUTE),
|
|
50
|
+
firstDelayMs: poll === false ? 0 : (poll.firstMs ?? 30_000),
|
|
51
|
+
});
|
|
52
|
+
|
|
37
53
|
/** The jobs: network -> { ready, run, intervalMs }. */
|
|
38
54
|
export const pullJobs = (store, { vkInbox, github, options = {} }) => ({
|
|
39
55
|
...(github ? { github: github.pullJob() } : {}),
|
|
56
|
+
email: emailJob(store, options.email),
|
|
40
57
|
vk: {
|
|
41
58
|
ready: vkInbox.ready,
|
|
42
59
|
run: vkInbox.fetch,
|
|
@@ -103,6 +120,8 @@ const statusOf = async (store, ctx) => {
|
|
|
103
120
|
connected: await ctx.pullRunner.connected(source),
|
|
104
121
|
lastOkAt: state.lastOkAt ?? null,
|
|
105
122
|
lastError: state.lastError ?? null,
|
|
123
|
+
// A captcha, or anything else only the user can clear (R-Z20).
|
|
124
|
+
needsAttention: state.lastError?.needsAttention === true,
|
|
106
125
|
};
|
|
107
126
|
}
|
|
108
127
|
return { ok: true, sources };
|
|
@@ -117,6 +136,9 @@ export const handlePullRoutes = async (store, req, res, p, ctx) => {
|
|
|
117
136
|
if (!ctx.pullRunner) {
|
|
118
137
|
return false;
|
|
119
138
|
}
|
|
139
|
+
if (p.startsWith('/api/vk/')) {
|
|
140
|
+
return handleVkRoutes(req, res, p, ctx);
|
|
141
|
+
}
|
|
120
142
|
if (p === '/api/pull/status' && req.method === 'GET') {
|
|
121
143
|
return json(res, 200, await statusOf(store, ctx)) ?? true;
|
|
122
144
|
}
|
|
@@ -139,7 +161,7 @@ export const handlePullRoutes = async (store, req, res, p, ctx) => {
|
|
|
139
161
|
};
|
|
140
162
|
|
|
141
163
|
/** What a server keeps for the pulls; they start with the other `later` services. */
|
|
142
|
-
export const pullContext = (store, log, options, later) => {
|
|
164
|
+
export const pullContext = (store, log, options, later, emailPoll) => {
|
|
143
165
|
// GitHub's picker routes work even when the pulls are off; its schedule is
|
|
144
166
|
// one more job of the shared runner (R-Z11).
|
|
145
167
|
// Tests give `pulls.githubLiveFactory`, a fake createGithubLive.
|
|
@@ -153,11 +175,17 @@ export const pullContext = (store, log, options, later) => {
|
|
|
153
175
|
if (options === false) {
|
|
154
176
|
return { ...given, pullRunner: null };
|
|
155
177
|
}
|
|
156
|
-
const services = createPullServices(
|
|
178
|
+
const services = createPullServices(
|
|
179
|
+
store,
|
|
180
|
+
log,
|
|
181
|
+
{ ...options, email: emailPoll },
|
|
182
|
+
github
|
|
183
|
+
);
|
|
157
184
|
later.push(services.start);
|
|
158
185
|
return {
|
|
159
186
|
...given,
|
|
160
187
|
pullRunner: services.pullRunner,
|
|
188
|
+
vkInbox: services.vkInbox,
|
|
161
189
|
stopPulls: services.stop,
|
|
162
190
|
};
|
|
163
191
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VK scroll-back (R-Z20), a user action from the page:
|
|
3
|
+
*
|
|
4
|
+
* POST /api/vk/history {chat, before?, captchaSid?, captchaKey?}
|
|
5
|
+
* -> { ok, imported, complete }
|
|
6
|
+
* or { ok: false, error, code } with `code` one of bad-request |
|
|
7
|
+
* not-connected | rate-limited (HTTP 429) | captcha (HTTP 409, plus
|
|
8
|
+
* `captcha: { sid, img }` -- `img` only when it is on VK's own API
|
|
9
|
+
* host) | the network's own code.
|
|
10
|
+
*
|
|
11
|
+
* `chat` is the VK peer id, `before` the VK id of the oldest message the
|
|
12
|
+
* page holds. A captcha is shown to the user by the page, which repeats
|
|
13
|
+
* this request once with `captchaSid` and `captchaKey`; the program never
|
|
14
|
+
* solves one, and pulls in the background never ask (they back off and mark
|
|
15
|
+
* the connection as needing attention: ../sources/pull-runner.js).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { fail, json, readBody } from './util.js';
|
|
19
|
+
import { isTrustedMutation } from './routes-auto-import.js';
|
|
20
|
+
import { safeMessage } from './redact.js';
|
|
21
|
+
import { vkCaptchaImage } from '../sources/vk-hosts.js';
|
|
22
|
+
|
|
23
|
+
const PEER = /^-?\d{1,15}$/;
|
|
24
|
+
const MESSAGE = /^\d{1,12}$/;
|
|
25
|
+
const SID = /^[\w.-]{1,64}$/;
|
|
26
|
+
const KEY = /^\S{1,32}$/;
|
|
27
|
+
const STATUS = { 'rate-limited': 429, captcha: 409, 'not-connected': 409 };
|
|
28
|
+
|
|
29
|
+
const text = (value) => (value === undefined ? undefined : String(value));
|
|
30
|
+
|
|
31
|
+
/** The checked request, or a message saying what is wrong. */
|
|
32
|
+
export const historyRequest = (body) => {
|
|
33
|
+
const peerId = text(body.chat);
|
|
34
|
+
if (!PEER.test(peerId ?? '') || Number(peerId) === 0) {
|
|
35
|
+
return { error: 'a VK chat id is needed' };
|
|
36
|
+
}
|
|
37
|
+
const before = text(body.before);
|
|
38
|
+
if (before !== undefined && !MESSAGE.test(before)) {
|
|
39
|
+
return { error: '"before" must be a VK message id' };
|
|
40
|
+
}
|
|
41
|
+
const sid = text(body.captchaSid);
|
|
42
|
+
const key = text(body.captchaKey);
|
|
43
|
+
if ((sid === undefined) !== (key === undefined)) {
|
|
44
|
+
return { error: 'captchaSid and captchaKey go together' };
|
|
45
|
+
}
|
|
46
|
+
if (sid !== undefined && !(SID.test(sid) && KEY.test(key))) {
|
|
47
|
+
return { error: 'the captcha answer is not valid' };
|
|
48
|
+
}
|
|
49
|
+
return { peerId, before, captchaSid: sid, captchaKey: key };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const failure = (res, error) => {
|
|
53
|
+
const code = error.code ?? 'failed';
|
|
54
|
+
const captcha =
|
|
55
|
+
code === 'captcha'
|
|
56
|
+
? {
|
|
57
|
+
captcha: {
|
|
58
|
+
sid: error.captchaSid ?? null,
|
|
59
|
+
img: error.captchaImg ? vkCaptchaImage(error.captchaImg) : null,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
: {};
|
|
63
|
+
return (
|
|
64
|
+
json(res, STATUS[code] ?? 502, {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: safeMessage(error),
|
|
67
|
+
code,
|
|
68
|
+
...captcha,
|
|
69
|
+
}) ?? true
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const handleVkRoutes = async (req, res, p, ctx) => {
|
|
74
|
+
if (p !== '/api/vk/history' || req.method !== 'POST') {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (!isTrustedMutation(req, ctx.allowOrigins)) {
|
|
78
|
+
return fail(res, 403, 'forbidden', 'use the meta-sovereign app') ?? true;
|
|
79
|
+
}
|
|
80
|
+
const request = historyRequest(await readBody(req));
|
|
81
|
+
if (request.error) {
|
|
82
|
+
return fail(res, 400, 'bad-request', request.error) ?? true;
|
|
83
|
+
}
|
|
84
|
+
if (!ctx.vkInbox) {
|
|
85
|
+
return fail(res, 404, 'unsupported', 'VK is not enabled') ?? true;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const done = await ctx.vkInbox.older(request);
|
|
89
|
+
return json(res, 200, { ok: true, ...done }) ?? true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return failure(res, error);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
* whose user turned the schedule off can still be fetched by hand) and its
|
|
22
22
|
* `intervalMs` is then just how often `due()` is asked.
|
|
23
23
|
*
|
|
24
|
+
* A pull that VK stops with a captcha (code `captcha`) is marked
|
|
25
|
+
* `needsAttention` in `lastError`: neither the schedule nor this runner
|
|
26
|
+
* shows or solves it (R-Z20; the page asks only for the user's own actions).
|
|
27
|
+
*
|
|
24
28
|
* `pull-state:<source>` keeps `lastOkAt` and a job's own cursor.
|
|
25
29
|
*/
|
|
26
30
|
|
|
@@ -97,7 +101,13 @@ export const createPullRunner = ({
|
|
|
97
101
|
const out = failure(error, { source });
|
|
98
102
|
log?.({ level: 'warn', event: 'pull_failed', source, code: out.code });
|
|
99
103
|
await savePullState(store, source, {
|
|
100
|
-
lastError: {
|
|
104
|
+
lastError: {
|
|
105
|
+
code: out.code,
|
|
106
|
+
at: new Date(now()).toISOString(),
|
|
107
|
+
// A captcha only the user can solve, in the app: the schedule
|
|
108
|
+
// backs off and never asks (R-Z20).
|
|
109
|
+
...(out.code === 'captcha' ? { needsAttention: true } : {}),
|
|
110
|
+
},
|
|
101
111
|
}).catch(() => {});
|
|
102
112
|
return out;
|
|
103
113
|
} finally {
|
|
@@ -160,6 +170,10 @@ export const createPullRunner = ({
|
|
|
160
170
|
fetchNow,
|
|
161
171
|
/** Start the schedules (call after the server listens). */
|
|
162
172
|
start() {
|
|
173
|
+
// A second start must not run a second schedule (R-Z20).
|
|
174
|
+
if (!stopped) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
163
177
|
stopped = false;
|
|
164
178
|
for (const [source, job] of Object.entries(jobs)) {
|
|
165
179
|
if (job.intervalMs > 0) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exact VK hosts the page may load from (R-Z20): one list for the CSP
|
|
3
|
+
* (`img-src`, `media-src`, ../server/index.js) and for the adapter, so a URL
|
|
4
|
+
* is only offered to a player when the CSP would let the browser fetch it.
|
|
5
|
+
* Pictures, audio and video come from VK's CDNs; the captcha picture comes
|
|
6
|
+
* from the API host. Nothing is framed: a VK video page is a link, never an
|
|
7
|
+
* iframe.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Hosts VK serves audio and video files from (`media-src`). */
|
|
11
|
+
export const VK_MEDIA_HOSTS = [
|
|
12
|
+
'*.userapi.com',
|
|
13
|
+
'*.vk-cdn.net',
|
|
14
|
+
'*.vkuseraudio.net',
|
|
15
|
+
'*.vkuservideo.net',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Hosts of pictures (`img-src`): the media hosts and VK's photo CDN. */
|
|
19
|
+
export const VK_IMAGE_HOSTS = [...VK_MEDIA_HOSTS, '*.vkuserphoto.ru'];
|
|
20
|
+
|
|
21
|
+
/** Where VK's captcha picture is served from (`img-src`). */
|
|
22
|
+
export const VK_CAPTCHA_HOSTS = ['api.vk.com', 'api.vk.ru'];
|
|
23
|
+
|
|
24
|
+
/** A CSP source list for `hosts`: `https://a https://b`. */
|
|
25
|
+
export const cspSources = (hosts) =>
|
|
26
|
+
hosts.map((host) => `https://${host}`).join(' ');
|
|
27
|
+
|
|
28
|
+
const matches = (host, pattern) =>
|
|
29
|
+
pattern.startsWith('*.')
|
|
30
|
+
? host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1
|
|
31
|
+
: host === pattern;
|
|
32
|
+
|
|
33
|
+
/** `url` when it is an https address on one of `hosts`, else null. */
|
|
34
|
+
export const onHosts = (url, hosts) => {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(String(url));
|
|
37
|
+
return parsed.protocol === 'https:' &&
|
|
38
|
+
!parsed.username &&
|
|
39
|
+
hosts.some((pattern) => matches(parsed.hostname, pattern))
|
|
40
|
+
? parsed.href
|
|
41
|
+
: null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** A direct audio/video file address, only on VK's CDNs. */
|
|
48
|
+
export const vkMediaUrl = (url) => onHosts(url, VK_MEDIA_HOSTS);
|
|
49
|
+
|
|
50
|
+
/** VK's captcha picture address, only on VK's API hosts. */
|
|
51
|
+
export const vkCaptchaImage = (url) => onHosts(url, VK_CAPTCHA_HOSTS);
|
|
@@ -181,7 +181,7 @@ const linksOfBatch = async (live, batch) => {
|
|
|
181
181
|
};
|
|
182
182
|
|
|
183
183
|
const failureDelay = (error, fails) =>
|
|
184
|
-
AUTH_CODES.has(Number(error?.vkCode))
|
|
184
|
+
AUTH_CODES.has(Number(error?.vkCode)) || error?.code === 'captcha'
|
|
185
185
|
? 5 * 60_000
|
|
186
186
|
: Math.min(5 * 60_000, 5000 * 2 ** Math.min(fails - 1, 6));
|
|
187
187
|
|
|
@@ -307,6 +307,17 @@ export const createVkLongPoll = ({
|
|
|
307
307
|
event: 'vk_longpoll_failed',
|
|
308
308
|
code: error.code ?? error.vkCode ?? null,
|
|
309
309
|
});
|
|
310
|
+
if (error.code === 'captcha') {
|
|
311
|
+
// Only the user can solve it, in the app; this loop just waits
|
|
312
|
+
// and says so (R-Z20).
|
|
313
|
+
await savePullState(store, SOURCE, {
|
|
314
|
+
lastError: {
|
|
315
|
+
code: 'captcha',
|
|
316
|
+
at: new Date(now()).toISOString(),
|
|
317
|
+
needsAttention: true,
|
|
318
|
+
},
|
|
319
|
+
}).catch(() => {});
|
|
320
|
+
}
|
|
310
321
|
await sleep(failureDelay(error, fails));
|
|
311
322
|
}
|
|
312
323
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-back for a VK chat (R-Z20): when the reader reaches the top the page
|
|
3
|
+
* asks for the page before its oldest message (`start_message_id` + `offset`
|
|
4
|
+
* 1), through the same paced client as the pulls (./vk-limits.js). Messages
|
|
5
|
+
* already stored are not written again, and an empty page marks the chat
|
|
6
|
+
* finished (`history:vk:<peer>`, written after the page was stored), so
|
|
7
|
+
* nothing asks past the beginning. A captcha (error 14) is thrown to the
|
|
8
|
+
* page, which asks the user; the same request is then repeated once with
|
|
9
|
+
* `captcha_sid` and `captcha_key` -- never solved by the program. The same
|
|
10
|
+
* request made twice at once (two scroll events) is one request to VK.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { putAll } from '../storage/universal.js';
|
|
14
|
+
import { stamp } from './vk-longpoll.js';
|
|
15
|
+
|
|
16
|
+
const SOURCE = 'vk';
|
|
17
|
+
export const OLDER_PAGE = 50;
|
|
18
|
+
|
|
19
|
+
export const vkHistoryId = (peerId) => `history:vk:${peerId}`;
|
|
20
|
+
|
|
21
|
+
const olderNow = async (
|
|
22
|
+
{ store, context },
|
|
23
|
+
{ peerId, before = null, captchaSid, captchaKey }
|
|
24
|
+
) => {
|
|
25
|
+
const mark = vkHistoryId(peerId);
|
|
26
|
+
if ((await store.get(mark))?.complete) {
|
|
27
|
+
return { imported: 0, complete: true, cached: true };
|
|
28
|
+
}
|
|
29
|
+
const { live } = await context();
|
|
30
|
+
const page = await live.pullMessages({
|
|
31
|
+
peerId,
|
|
32
|
+
count: OLDER_PAGE,
|
|
33
|
+
...(before ? { startMessageId: before, offset: 1 } : {}),
|
|
34
|
+
captchaSid,
|
|
35
|
+
captchaKey,
|
|
36
|
+
});
|
|
37
|
+
const fresh = [];
|
|
38
|
+
for (const link of stamp(page.links)) {
|
|
39
|
+
if (!(await store.get(link.id))) {
|
|
40
|
+
fresh.push(link);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await putAll(store, fresh);
|
|
44
|
+
const complete = page.rawCount === 0;
|
|
45
|
+
if (complete) {
|
|
46
|
+
await store.put({
|
|
47
|
+
id: mark,
|
|
48
|
+
source: SOURCE,
|
|
49
|
+
chat: String(peerId),
|
|
50
|
+
complete: true,
|
|
51
|
+
tokens: ['history', SOURCE, String(peerId)],
|
|
52
|
+
children: [],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return { imported: fresh.length, complete };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** `older({ peerId, before?, captchaSid?, captchaKey? })` -> { imported, complete }. */
|
|
59
|
+
export const createVkOlder = (env) => {
|
|
60
|
+
const asking = new Map();
|
|
61
|
+
return (request) => {
|
|
62
|
+
const key = JSON.stringify([
|
|
63
|
+
request.peerId,
|
|
64
|
+
request.before ?? null,
|
|
65
|
+
request.captchaSid ?? null,
|
|
66
|
+
]);
|
|
67
|
+
if (!asking.has(key)) {
|
|
68
|
+
asking.set(
|
|
69
|
+
key,
|
|
70
|
+
olderNow(env, request).finally(() => asking.delete(key))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return asking.get(key);
|
|
74
|
+
};
|
|
75
|
+
};
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* When the Long Poll is not running, one poll with `wait=0` drains pending
|
|
10
10
|
* events too. A user token is held to 3 requests a second, a community token
|
|
11
11
|
* to 20 (./vk-limits.js); error 6 backs off.
|
|
12
|
+
*
|
|
13
|
+
* `older` (./vk-older.js) is scroll-back for a VK chat (R-Z20).
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { putAll } from '../storage/universal.js';
|
|
@@ -18,6 +20,7 @@ import { VK_COMMUNITY_RPS, VK_USER_RPS } from './vk-limits.js';
|
|
|
18
20
|
import { loadPullState, savePullState } from './pull-runner.js';
|
|
19
21
|
import { codedError } from './http.js';
|
|
20
22
|
import { createVkLongPoll, detectMode, stamp } from './vk-longpoll.js';
|
|
23
|
+
import { createVkOlder } from './vk-older.js';
|
|
21
24
|
|
|
22
25
|
const SOURCE = 'vk';
|
|
23
26
|
const BACKFILL_CHATS = 3;
|
|
@@ -114,6 +117,8 @@ export const createVkInbox = ({
|
|
|
114
117
|
return imported;
|
|
115
118
|
};
|
|
116
119
|
|
|
120
|
+
const older = createVkOlder({ store, context });
|
|
121
|
+
|
|
117
122
|
/** "Fetch now" and the periodic pull: chats, more history, pending events. */
|
|
118
123
|
const fetch = async () => {
|
|
119
124
|
const { live } = await context();
|
|
@@ -128,6 +133,7 @@ export const createVkInbox = ({
|
|
|
128
133
|
return {
|
|
129
134
|
ready,
|
|
130
135
|
fetch,
|
|
136
|
+
older,
|
|
131
137
|
step: longpoll.step,
|
|
132
138
|
start: longpoll.start,
|
|
133
139
|
stop: longpoll.stop,
|
package/js/src/sources/vk.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
resolveOption,
|
|
31
31
|
} from './http.js';
|
|
32
32
|
import { contentSticker } from './sticker.js';
|
|
33
|
+
import { vkMediaUrl } from './vk-hosts.js';
|
|
33
34
|
import { isGreetingText } from '../greetings/detect.js';
|
|
34
35
|
import { vkStickerPreview } from '../greetings/catalog.js';
|
|
35
36
|
import {
|
|
@@ -141,6 +142,19 @@ const vkChatType = (peerId) => {
|
|
|
141
142
|
return 'private';
|
|
142
143
|
};
|
|
143
144
|
|
|
145
|
+
// A video: a playable file only when the attachment carries a direct mp4 on a
|
|
146
|
+
// VK CDN; the `player` address of video.get is an iframe page on vk.com, so
|
|
147
|
+
// the page gets the video's own page to link out to, never a frame (R-Z20).
|
|
148
|
+
const vkVideoLinks = (vid) => ({
|
|
149
|
+
src: vkMediaUrl(
|
|
150
|
+
vid.files?.mp4_720 ?? vid.files?.mp4_480 ?? vid.files?.mp4_360 ?? ''
|
|
151
|
+
),
|
|
152
|
+
page:
|
|
153
|
+
vid.owner_id !== undefined && vid.id !== undefined
|
|
154
|
+
? `https://vk.com/video${vid.owner_id}_${vid.id}`
|
|
155
|
+
: null,
|
|
156
|
+
});
|
|
157
|
+
|
|
144
158
|
// One handler per VK attachment type — kept small so vkAttachments stays
|
|
145
159
|
// under complexity 10.
|
|
146
160
|
const vkAttachmentHandlers = {
|
|
@@ -177,10 +191,14 @@ const vkAttachmentHandlers = {
|
|
|
177
191
|
},
|
|
178
192
|
audio(att) {
|
|
179
193
|
const { artist, title } = att.audio ?? {};
|
|
194
|
+
// VK gives most apps no audio file address; when it does and it is a
|
|
195
|
+
// direct file on a VK CDN, the page plays it (R-Z20).
|
|
196
|
+
const src = att.audio?.url ? vkMediaUrl(att.audio.url) : null;
|
|
180
197
|
return {
|
|
181
198
|
kind: 'audio',
|
|
182
199
|
title: [artist, title].filter(Boolean).join(' - ') || null,
|
|
183
200
|
duration: att.audio?.duration ?? null,
|
|
201
|
+
...(src && !/\.m3u8(\?|$)/.test(src) ? { src } : {}),
|
|
184
202
|
};
|
|
185
203
|
},
|
|
186
204
|
wall(att) {
|
|
@@ -195,9 +213,12 @@ const vkAttachmentHandlers = {
|
|
|
195
213
|
},
|
|
196
214
|
audio_message(att) {
|
|
197
215
|
const am = att.audio_message ?? {};
|
|
216
|
+
// mp3 first: Safari cannot play ogg (R-Z20).
|
|
217
|
+
const src = vkMediaUrl(am.link_mp3 ?? am.link_ogg ?? '');
|
|
198
218
|
return {
|
|
199
219
|
kind: 'voice',
|
|
200
220
|
url: am.link_ogg ?? am.link_mp3 ?? null,
|
|
221
|
+
...(src ? { src } : {}),
|
|
201
222
|
duration: am.duration ?? null,
|
|
202
223
|
};
|
|
203
224
|
},
|
|
@@ -207,11 +228,14 @@ const vkAttachmentHandlers = {
|
|
|
207
228
|
vid.image?.find((i) => i.width >= 320)?.url ??
|
|
208
229
|
vid.image?.[0]?.url ??
|
|
209
230
|
null;
|
|
231
|
+
const { src, page } = vkVideoLinks(vid);
|
|
210
232
|
return {
|
|
211
233
|
kind: 'video',
|
|
212
234
|
title: vid.title ?? null,
|
|
213
235
|
duration: vid.duration ?? null,
|
|
214
236
|
thumb,
|
|
237
|
+
...(page ? { url: page } : {}),
|
|
238
|
+
...(src ? { src } : {}),
|
|
215
239
|
};
|
|
216
240
|
},
|
|
217
241
|
sticker(att) {
|
|
@@ -656,6 +680,9 @@ const vkLivePulls = (call) => {
|
|
|
656
680
|
offset: options.offset,
|
|
657
681
|
start_message_id: options.startMessageId,
|
|
658
682
|
extended: 1,
|
|
683
|
+
// The user solved VK's captcha: the same request again (R-Z20).
|
|
684
|
+
captcha_sid: options.captchaSid,
|
|
685
|
+
captcha_key: options.captchaKey,
|
|
659
686
|
},
|
|
660
687
|
options.token
|
|
661
688
|
)
|
package/js/src/web/app.css
CHANGED
|
@@ -12567,3 +12567,43 @@ main[data-view='chat'] > .api-error-toast {
|
|
|
12567
12567
|
color: var(--text-muted);
|
|
12568
12568
|
border-style: dashed;
|
|
12569
12569
|
}
|
|
12570
|
+
|
|
12571
|
+
/* R-Z20: VK players, video link card, captcha picture, notification list */
|
|
12572
|
+
.remote-player {
|
|
12573
|
+
display: block;
|
|
12574
|
+
width: 100%;
|
|
12575
|
+
max-width: 320px;
|
|
12576
|
+
border-radius: 8px;
|
|
12577
|
+
}
|
|
12578
|
+
audio.remote-player {
|
|
12579
|
+
height: 40px;
|
|
12580
|
+
}
|
|
12581
|
+
.remote-card {
|
|
12582
|
+
display: flex;
|
|
12583
|
+
flex-direction: column;
|
|
12584
|
+
gap: 0.25rem;
|
|
12585
|
+
}
|
|
12586
|
+
.vk-captcha-img {
|
|
12587
|
+
display: block;
|
|
12588
|
+
max-width: 100%;
|
|
12589
|
+
background: #fff;
|
|
12590
|
+
border-radius: 8px;
|
|
12591
|
+
}
|
|
12592
|
+
.notification-list {
|
|
12593
|
+
list-style: none;
|
|
12594
|
+
margin: 0;
|
|
12595
|
+
padding: 0;
|
|
12596
|
+
display: flex;
|
|
12597
|
+
flex-direction: column;
|
|
12598
|
+
gap: 0.4rem;
|
|
12599
|
+
}
|
|
12600
|
+
.notification-list li {
|
|
12601
|
+
display: flex;
|
|
12602
|
+
flex-wrap: wrap;
|
|
12603
|
+
align-items: center;
|
|
12604
|
+
gap: 0.5rem;
|
|
12605
|
+
}
|
|
12606
|
+
.note-title {
|
|
12607
|
+
flex: 1 1 12rem;
|
|
12608
|
+
overflow-wrap: anywhere;
|
|
12609
|
+
}
|