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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * VK's own "Request your data" archive (R-Z8): `messages/<peer>/messages<N>.html`
3
+ * pages, 50 messages each. VK ships them as Windows-1251 HTML. The page of
4
+ * one chat looks like
5
+ *
6
+ * <div class="message" data-id="123">
7
+ * <div class="message__header"><a href="https://vk.com/id7">Ann Lee</a>, 5 янв 2021 в 12:02:33 (ред.)</div>
8
+ * <div>text<div class="kludges"><div class="attachment">
9
+ * <div class="attachment__description">Фотография</div>
10
+ * <a class="attachment__link" href="https://...">Ссылка</a></div></div></div>
11
+ * </div>
12
+ *
13
+ * This reads such a page into the JSON shape the VK adapter already imports
14
+ * (`{ conversations: [{ peer, chat_settings, messages }] }`), attachments in
15
+ * the shape of VK's API so one mapper serves both. The archive has no time
16
+ * zone; times are read in this machine's time zone, like WhatsApp exports
17
+ * (R-Z9). A sender known only by name keeps the name.
18
+ * The pages are tolerant of both Russian and English archives.
19
+ */
20
+
21
+ const MONTHS = {
22
+ янв: 0,
23
+ января: 0,
24
+ jan: 0,
25
+ january: 0,
26
+ фев: 1,
27
+ февраля: 1,
28
+ feb: 1,
29
+ february: 1,
30
+ мар: 2,
31
+ марта: 2,
32
+ mar: 2,
33
+ march: 2,
34
+ апр: 3,
35
+ апреля: 3,
36
+ apr: 3,
37
+ april: 3,
38
+ мая: 4,
39
+ май: 4,
40
+ may: 4,
41
+ июн: 5,
42
+ июня: 5,
43
+ jun: 5,
44
+ june: 5,
45
+ июл: 6,
46
+ июля: 6,
47
+ jul: 6,
48
+ july: 6,
49
+ авг: 7,
50
+ августа: 7,
51
+ aug: 7,
52
+ august: 7,
53
+ сен: 8,
54
+ сентября: 8,
55
+ sep: 8,
56
+ sept: 8,
57
+ september: 8,
58
+ окт: 9,
59
+ октября: 9,
60
+ oct: 9,
61
+ october: 9,
62
+ ноя: 10,
63
+ ноября: 10,
64
+ nov: 10,
65
+ november: 10,
66
+ дек: 11,
67
+ декабря: 11,
68
+ dec: 11,
69
+ december: 11,
70
+ };
71
+
72
+ const ENTITIES = {
73
+ amp: '&',
74
+ lt: '<',
75
+ gt: '>',
76
+ quot: '"',
77
+ apos: "'",
78
+ nbsp: ' ',
79
+ };
80
+
81
+ const decodeEntities = (text) =>
82
+ text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name) => {
83
+ if (name[0] === '#') {
84
+ const code =
85
+ name[1].toLowerCase() === 'x'
86
+ ? Number.parseInt(name.slice(2), 16)
87
+ : Number(name.slice(1));
88
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
89
+ }
90
+ return ENTITIES[name.toLowerCase()] ?? whole;
91
+ });
92
+
93
+ const textOf = (html) =>
94
+ decodeEntities(
95
+ html
96
+ .replace(/<br\s*\/?>/gi, '\n')
97
+ .replace(/<[^>]*>/g, '')
98
+ .replace(/[ \t]+\n/g, '\n')
99
+ ).trim();
100
+
101
+ /** HTML bytes to text: Windows-1251 as VK ships it, else UTF-8. */
102
+ export const decodeVkHtml = (bytes) => {
103
+ if (typeof bytes === 'string') {
104
+ return bytes;
105
+ }
106
+ const head = new TextDecoder('latin1').decode(bytes.subarray(0, 2048));
107
+ const label = /charset=["']?([\w-]+)/i.exec(head)?.[1] ?? 'utf-8';
108
+ try {
109
+ return new TextDecoder(label).decode(bytes);
110
+ } catch {
111
+ return new TextDecoder('utf-8').decode(bytes);
112
+ }
113
+ };
114
+
115
+ /** Whether text/bytes look like a VK archive page rather than JSON. */
116
+ export const looksLikeVkHtml = (archive) => {
117
+ if (archive instanceof Uint8Array) {
118
+ return true;
119
+ }
120
+ return typeof archive === 'string' && /^\s*</.test(archive);
121
+ };
122
+
123
+ const DATE_RE =
124
+ /(\d{1,2})\s+([A-Za-zА-Яа-яё]+)\.?\s+(\d{4})\s*(?:в|at|,)?\s*(\d{1,2}):(\d{2})(?::(\d{2}))?/;
125
+
126
+ /** "5 янв 2021 в 12:02:33" to unix seconds in THIS machine's time zone, or null. */
127
+ export const parseVkArchiveDate = (text) => {
128
+ const m = DATE_RE.exec(text);
129
+ const month = m ? MONTHS[m[2].toLowerCase()] : undefined;
130
+ if (month === undefined) {
131
+ return null;
132
+ }
133
+ const [, day, , year, hour, minute, second = '0'] = m;
134
+ return (
135
+ new Date(
136
+ Number(year),
137
+ month,
138
+ Number(day),
139
+ Number(hour),
140
+ Number(minute),
141
+ Number(second)
142
+ ).getTime() / 1000
143
+ );
144
+ };
145
+
146
+ // The VK id behind a profile link: id7 -> 7, club9 / public9 -> -9.
147
+ const idFromHref = (href) => {
148
+ const m = /vk\.(?:com|ru)\/(id|club|public)(\d+)/.exec(href ?? '');
149
+ if (!m) {
150
+ return null;
151
+ }
152
+ return m[1] === 'id' ? Number(m[2]) : -Number(m[2]);
153
+ };
154
+
155
+ const ATTACHMENTS = [
156
+ [
157
+ /^(фотограф|photo)/i,
158
+ (url) => ({
159
+ type: 'photo',
160
+ photo: { sizes: [{ url, width: 0, height: 0 }] },
161
+ }),
162
+ ],
163
+ [
164
+ /^(голосов|voice|audio message)/i,
165
+ (url) => ({ type: 'audio_message', audio_message: { link_ogg: url } }),
166
+ ],
167
+ [
168
+ /^(стикер|sticker)/i,
169
+ (url) => ({ type: 'sticker', sticker: { images: [{ url, width: 128 }] } }),
170
+ ],
171
+ [/^(ссылк|link)/i, (url) => ({ type: 'link', link: { url, title: url } })],
172
+ [
173
+ /^(документ|document|файл|file)/i,
174
+ (url) => ({
175
+ type: 'doc',
176
+ doc: { url, title: url?.split('/').pop() ?? null },
177
+ }),
178
+ ],
179
+ [
180
+ /^(видео|video)/i,
181
+ (url, d) => ({ type: 'video', video: { title: url ?? d } }),
182
+ ],
183
+ ];
184
+
185
+ const attachmentOf = (description, url) => {
186
+ const found = ATTACHMENTS.find(([pattern]) => pattern.test(description));
187
+ return found
188
+ ? found[1](url ?? null, description)
189
+ : { type: description.toLowerCase().slice(0, 40) || 'attachment' };
190
+ };
191
+
192
+ const attachmentsOf = (html) =>
193
+ [
194
+ ...html.matchAll(
195
+ /<div class="attachment__description">([\s\S]*?)<\/div>\s*(?:<a class="attachment__link" href="([^"]*)")?/g
196
+ ),
197
+ ].map((m) => attachmentOf(textOf(m[1]), m[2] ? decodeEntities(m[2]) : null));
198
+
199
+ const messageOf = (html) => {
200
+ const id = /data-id="(-?\d+)"/.exec(html)?.[1];
201
+ const header = /<div class="message__header">([\s\S]*?)<\/div>/.exec(
202
+ html
203
+ )?.[1];
204
+ if (!header) {
205
+ return null;
206
+ }
207
+ const afterHeader = html.slice(html.indexOf(header) + header.length);
208
+ const kludges = afterHeader.indexOf('<div class="kludges">');
209
+ const bodyHtml = (
210
+ kludges < 0 ? afterHeader : afterHeader.slice(0, kludges)
211
+ ).replace(/^\s*<\/div>/, '');
212
+ const plain = textOf(header);
213
+ const stamp = DATE_RE.exec(plain);
214
+ const name = (stamp ? plain.slice(0, stamp.index) : plain).replace(
215
+ /[,\s]+$/,
216
+ ''
217
+ );
218
+ const fromId = idFromHref(/href="([^"]*)"/.exec(header)?.[1]);
219
+ const date = parseVkArchiveDate(plain);
220
+ const own = /^(вы|you)$/i.test(name);
221
+ const attachments = attachmentsOf(afterHeader);
222
+ return {
223
+ ...(id ? { id: Number(id) } : {}),
224
+ from_id: fromId ?? name,
225
+ from_name: name || null,
226
+ date,
227
+ text: textOf(bodyHtml),
228
+ ...(own ? { out: 1 } : {}),
229
+ ...(/\((ред\.|edited)\)/i.test(plain) && date ? { update_time: date } : {}),
230
+ ...(attachments.length > 0 ? { attachments } : {}),
231
+ };
232
+ };
233
+
234
+ // `messages/<peer>/messages0.html`: a number, `c<chat>` or a negative id.
235
+ export const peerFromArchivePath = (name) => {
236
+ const m = /messages\/(-?\d+|c\d+)\//.exec(String(name).replaceAll('\\', '/'));
237
+ if (!m) {
238
+ return null;
239
+ }
240
+ return m[1].startsWith('c')
241
+ ? 2000000000 + Number(m[1].slice(1))
242
+ : Number(m[1]);
243
+ };
244
+
245
+ /**
246
+ * One archive page to `{ conversations: [...] }`. `peer` is the dialog's
247
+ * id (from the folder name); the chat's title is the page header's link text.
248
+ */
249
+ export const parseVkHtmlPage = (html, { peer = null } = {}) => {
250
+ const parts = html.split(/<div class="message"[^>]*?(?=\sdata-id)/).slice(1);
251
+ const chunks = parts.length > 0 ? parts : [];
252
+ const messages = chunks
253
+ .map((chunk) => messageOf(chunk))
254
+ .filter((msg) => msg && (msg.text || msg.attachments));
255
+ const title = textOf(
256
+ /<div class="page_block_header[^"]*">([\s\S]*?)<\/div>/.exec(html)?.[1] ??
257
+ ''
258
+ );
259
+ return {
260
+ conversations: [
261
+ {
262
+ peer: { id: peer ?? messages[0]?.from_id ?? 'unknown' },
263
+ ...(title ? { chat_settings: { title } } : {}),
264
+ messages,
265
+ },
266
+ ],
267
+ };
268
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * VK request pacing and error handling (R-Z8).
3
+ *
4
+ * VK allows 3 requests per second per user token and 20 per second per
5
+ * community token (https://dev.vk.com/en/api/api-requests). A sliding
6
+ * window lets a short burst through and then waits, so a pull of many
7
+ * chats never earns error 6 ("too many requests per second"). If VK
8
+ * answers 6 anyway, the call waits with a growing pause and repeats: error
9
+ * 6 means VK refused before doing anything, so repeating is safe (a send
10
+ * also carries a `random_id`, which VK de-duplicates). After the last try
11
+ * the failure is a plain coded error, never a crash.
12
+ * Captcha (error 14) is never repeated by itself: the user has to solve it.
13
+ */
14
+
15
+ import { codedError } from './http.js';
16
+
17
+ export const VK_USER_RPS = 3;
18
+ export const VK_COMMUNITY_RPS = 20;
19
+
20
+ const pause = (ms) =>
21
+ new Promise((resolve) => globalThis.setTimeout(resolve, ms));
22
+
23
+ /** A sliding-window limiter: at most `perSecond` starts in any second. */
24
+ export const createRateLimiter = ({
25
+ perSecond = VK_USER_RPS,
26
+ now = Date.now,
27
+ sleep = pause,
28
+ } = {}) => {
29
+ const starts = [];
30
+ let chain = Promise.resolve();
31
+ const turn = async () => {
32
+ for (;;) {
33
+ const at = now();
34
+ while (starts.length > 0 && at - starts[0] >= 1000) {
35
+ starts.shift();
36
+ }
37
+ if (starts.length < perSecond) {
38
+ starts.push(at);
39
+ return;
40
+ }
41
+ await sleep(Math.max(1, 1000 - (at - starts[0])));
42
+ }
43
+ };
44
+ return {
45
+ perSecond,
46
+ wait() {
47
+ const next = chain.then(turn);
48
+ chain = next.catch(() => {});
49
+ return next;
50
+ },
51
+ };
52
+ };
53
+
54
+ /** The error VK answered with; `captcha` carries what the user must solve. */
55
+ export const vkApiError = (payload) => {
56
+ const code = payload.error.error_code ?? 'unknown';
57
+ const message = payload.error.error_msg ?? 'VK API error';
58
+ const error = new Error(`vk API ${code}: ${message}`);
59
+ error.vkCode = code;
60
+ if (Number(code) === 14) {
61
+ Object.assign(error, {
62
+ code: 'captcha',
63
+ fallback: false,
64
+ captchaSid: payload.error.captcha_sid ?? null,
65
+ captchaImg: payload.error.captcha_img ?? null,
66
+ });
67
+ }
68
+ return error;
69
+ };
70
+
71
+ export const isTooMany = (error) => Number(error?.vkCode) === 6;
72
+
73
+ /**
74
+ * Run `attempt()`; on VK error 6 wait (`pauseMs`, doubling) and repeat up to
75
+ * `tries` times, then give up with a `rate-limited` error.
76
+ */
77
+ export const withTooManyBackoff = async (
78
+ attempt,
79
+ { tries = 4, pauseMs = 400, sleep = pause } = {}
80
+ ) => {
81
+ for (let n = 0; ; n += 1) {
82
+ try {
83
+ return await attempt();
84
+ } catch (error) {
85
+ if (!isTooMany(error)) {
86
+ throw error;
87
+ }
88
+ if (n + 1 >= tries) {
89
+ throw Object.assign(
90
+ codedError(
91
+ 'VK is rate limiting this token (error 6); try again in a minute',
92
+ 'rate-limited'
93
+ ),
94
+ { vkCode: 6, fallback: false }
95
+ );
96
+ }
97
+ await sleep(pauseMs * 2 ** n);
98
+ }
99
+ }
100
+ };
101
+
102
+ /**
103
+ * `random_id` for messages.send: VK drops a second send with the same
104
+ * (peer, random_id), so a retry of one draft must reuse it. It is derived
105
+ * from the draft's `clientId` / outbox key, as the Telegram sender does.
106
+ * VK takes a 32-bit int; 0 would turn de-duplication off.
107
+ */
108
+ export const vkRandomId = (clientId) => {
109
+ if (!clientId) {
110
+ return 1 + Math.floor(Math.random() * 0x7ffffffe);
111
+ }
112
+ // FNV-1a over the text: stable, dependency-free (this file also runs in
113
+ // the browser bundle), 31 bits, never 0.
114
+ let hash = 0x811c9dc5;
115
+ for (const ch of `vk:${clientId}`) {
116
+ hash = Math.imul(hash ^ ch.codePointAt(0), 0x01000193) >>> 0;
117
+ }
118
+ return hash & 0x7fffffff || 1;
119
+ };