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.
@@ -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 });
@@ -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 { createEmailPoller } from './email-poller.js';
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
- 'https://*.userapi.com https://*.vk-cdn.net https://*.vkuserphoto.ru; ' +
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 = (store, storeDir, poll, log, later) => {
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
- const emailPoller =
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(store, storeDir, emailPoll, log, later),
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(store, log, options ?? {}, github);
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: { code: out.code, at: new Date(now()).toISOString() },
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,
@@ -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
  )
@@ -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
+ }