itd-api 0.7.0 → 0.7.1

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.
Files changed (89) hide show
  1. package/dist/index.cjs +465 -9804
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +231 -4950
  4. package/dist/index.d.ts +231 -4950
  5. package/dist/index.js +331 -9670
  6. package/dist/index.js.map +1 -1
  7. package/dist/{node.cjs → node/index.cjs} +16 -14
  8. package/dist/node/index.cjs.map +1 -0
  9. package/dist/{node.d.cts → node/index.d.cts} +4 -3
  10. package/dist/{node.d.ts → node/index.d.ts} +4 -3
  11. package/dist/{node.js → node/index.js} +5 -3
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/realtime/index.cjs +165 -0
  14. package/dist/realtime/index.cjs.map +1 -0
  15. package/dist/realtime/index.d.cts +51 -0
  16. package/dist/realtime/index.d.ts +51 -0
  17. package/dist/realtime/index.js +120 -0
  18. package/dist/realtime/index.js.map +1 -0
  19. package/dist/rest/index.cjs +334 -0
  20. package/dist/rest/index.cjs.map +1 -0
  21. package/dist/rest/index.d.cts +138 -0
  22. package/dist/rest/index.d.ts +138 -0
  23. package/dist/rest/index.js +238 -0
  24. package/dist/rest/index.js.map +1 -0
  25. package/dist/shared/auth-provider-BfogACAb.js +91 -0
  26. package/dist/shared/auth-provider-BfogACAb.js.map +1 -0
  27. package/dist/shared/auth-provider-CTJkKfgy.cjs +108 -0
  28. package/dist/shared/auth-provider-CTJkKfgy.cjs.map +1 -0
  29. package/dist/shared/contracts-BoT7msmq.d.cts +84 -0
  30. package/dist/shared/contracts-BoT7msmq.d.ts +84 -0
  31. package/dist/{multi-storage-Bf84xiO8.cjs → shared/cookies-DZwFq6kr.cjs} +98 -429
  32. package/dist/shared/cookies-DZwFq6kr.cjs.map +1 -0
  33. package/dist/{multi-storage-BUZaLAPO.js → shared/cookies-tX2sNwxb.js} +99 -352
  34. package/dist/shared/cookies-tX2sNwxb.js.map +1 -0
  35. package/dist/{storage-IHdXw52v.js → shared/errors-Bhrd2fJd.js} +2 -229
  36. package/dist/shared/errors-Bhrd2fJd.js.map +1 -0
  37. package/dist/{storage-DnzZPS_9.cjs → shared/errors-DfU8M5eS.cjs} +1 -288
  38. package/dist/shared/errors-DfU8M5eS.cjs.map +1 -0
  39. package/dist/shared/multi-storage--yTEqiod.cjs +150 -0
  40. package/dist/shared/multi-storage--yTEqiod.cjs.map +1 -0
  41. package/dist/shared/multi-storage-CjAPB5Kq.d.cts +72 -0
  42. package/dist/shared/multi-storage-CkvTUC5m.js +121 -0
  43. package/dist/shared/multi-storage-CkvTUC5m.js.map +1 -0
  44. package/dist/shared/multi-storage-DccjD7Ww.d.ts +72 -0
  45. package/dist/shared/options-Dg5N3r1V.cjs +189 -0
  46. package/dist/shared/options-Dg5N3r1V.cjs.map +1 -0
  47. package/dist/shared/options-DtATYdLr.js +142 -0
  48. package/dist/shared/options-DtATYdLr.js.map +1 -0
  49. package/dist/shared/render-DMp_3Nzk.d.cts +2250 -0
  50. package/dist/shared/render-DZrxhC5_.d.ts +2250 -0
  51. package/dist/shared/render-DyeHJNBw.cjs +4147 -0
  52. package/dist/shared/render-DyeHJNBw.cjs.map +1 -0
  53. package/dist/shared/render-vtLixIiU.js +3992 -0
  54. package/dist/shared/render-vtLixIiU.js.map +1 -0
  55. package/dist/shared/storage-BPJR_k4-.cjs +290 -0
  56. package/dist/shared/storage-BPJR_k4-.cjs.map +1 -0
  57. package/dist/{storage-BqMxs76Y.d.ts → shared/storage-C_eICCep.d.cts} +2 -2
  58. package/dist/{storage-BqMxs76Y.d.cts → shared/storage-C_eICCep.d.ts} +2 -2
  59. package/dist/shared/storage-D86edNCB.js +231 -0
  60. package/dist/shared/storage-D86edNCB.js.map +1 -0
  61. package/dist/shared/url-B6-bXHKt.d.cts +2083 -0
  62. package/dist/shared/url-B6-bXHKt.d.ts +2083 -0
  63. package/dist/shared/url-CYXgxqGx.js +3673 -0
  64. package/dist/shared/url-CYXgxqGx.js.map +1 -0
  65. package/dist/shared/url-yjl2c8Ie.cjs +4032 -0
  66. package/dist/shared/url-yjl2c8Ie.cjs.map +1 -0
  67. package/dist/shared/websocket-BMtihD56.d.ts +562 -0
  68. package/dist/shared/websocket-CbzB1Leq.js +1874 -0
  69. package/dist/shared/websocket-CbzB1Leq.js.map +1 -0
  70. package/dist/shared/websocket-D1p32SB2.d.cts +562 -0
  71. package/dist/shared/websocket-sYlynrr0.cjs +1945 -0
  72. package/dist/shared/websocket-sYlynrr0.cjs.map +1 -0
  73. package/dist/{web.cjs → web/index.cjs} +4 -3
  74. package/dist/web/index.cjs.map +1 -0
  75. package/dist/{web.d.cts → web/index.d.cts} +2 -2
  76. package/dist/{web.d.ts → web/index.d.ts} +2 -2
  77. package/dist/{web.js → web/index.js} +3 -2
  78. package/dist/web/index.js.map +1 -0
  79. package/package.json +40 -12
  80. package/dist/multi-storage-BUZaLAPO.js.map +0 -1
  81. package/dist/multi-storage-BUoEoW8f.d.cts +0 -154
  82. package/dist/multi-storage-Bf84xiO8.cjs.map +0 -1
  83. package/dist/multi-storage-CQSlI_kn.d.ts +0 -154
  84. package/dist/node.cjs.map +0 -1
  85. package/dist/node.js.map +0 -1
  86. package/dist/storage-DnzZPS_9.cjs.map +0 -1
  87. package/dist/storage-IHdXw52v.js.map +0 -1
  88. package/dist/web.cjs.map +0 -1
  89. package/dist/web.js.map +0 -1
@@ -0,0 +1,3992 @@
1
+ import { F as pickArray, I as pickBoolean, L as pickNumber, P as isRecord, R as pickObject, et as createDeviceId, nt as isFile, p as InteractionType, q as STATUS_SERVICE, r as normalizeNotification, tt as isBlob, v as ReportReason, x as SpanType, y as ReportTargetType, z as pickString } from "./url-CYXgxqGx.js";
2
+ import { a as ItdConfigError, l as ItdFileError, s as ItdError, u as ItdFileErrorReason } from "./errors-Bhrd2fJd.js";
3
+ import { o as encodePathSegment } from "./cookies-tX2sNwxb.js";
4
+ import { a as fileTooLarge, c as FileTransferMode, i as isReadableByteStream, n as boundedFileStream, o as optionalBytes, r as isBoundedFileStream, s as DEFAULT_URL_FILE_MAX_BYTES, t as resolveFileStreamOptions } from "./options-DtATYdLr.js";
5
+ //#region src/resources/pagination.ts
6
+ /** Схема пагинации эндпоинта. */
7
+ const PaginationMode = Object.freeze({
8
+ /** Следующая страница запрашивается непрозрачным курсором. */
9
+ Cursor: "cursor",
10
+ /** Следующая страница запрашивается номером. */
11
+ Page: "page",
12
+ /** Следующая страница запрашивается смещением от начала списка. */
13
+ Offset: "offset"
14
+ });
15
+ /** Применяет преобразование к элементам страницы, сохраняя сведения о пагинации. */
16
+ function mapPage(page, map) {
17
+ return {
18
+ ...page,
19
+ items: page.items.map(map)
20
+ };
21
+ }
22
+ /**
23
+ * Достаёт список из ответа, перебирая все формы, которые встречаются у сервера.
24
+ *
25
+ * Список может прийти под именем сущности, под её альтернативным именем
26
+ * (`followers` вместо `users`) либо голым массивом.
27
+ *
28
+ * @param fields имена полей в порядке предпочтения
29
+ */
30
+ function readItems(body, fields) {
31
+ if (Array.isArray(body)) return body;
32
+ for (const field of fields) {
33
+ const items = pickArray(body, field);
34
+ if (items.length > 0) return items;
35
+ }
36
+ const primary = fields[0];
37
+ return primary === void 0 ? [] : pickArray(body, primary);
38
+ }
39
+ /**
40
+ * Достаёт курсор следующей страницы.
41
+ *
42
+ * Проверяются все известные места: внутри `pagination`, рядом со списком и во вложенном
43
+ * объекте `meta.cursor.next`.
44
+ */
45
+ function readCursor(body) {
46
+ const pagination = pickObject(body, "pagination");
47
+ const meta = pickObject(body, "meta");
48
+ const metaCursor = pickObject(meta, "cursor");
49
+ return pickString(pagination, "nextCursor") ?? pickString(body, "nextCursor") ?? pickString(body, "cursor") ?? pickString(metaCursor, "next") ?? null;
50
+ }
51
+ /**
52
+ * Читает страницу курсорной схемы.
53
+ *
54
+ * Используется лентой, постами пользователя и постами по хэштегу.
55
+ *
56
+ * @param fields имена возможных полей со списком; первое — основное
57
+ */
58
+ function readCursorPage(body, ...fields) {
59
+ const pagination = pickObject(body, "pagination");
60
+ const items = readItems(body, fields);
61
+ const nextCursor = readCursor(body);
62
+ return {
63
+ items,
64
+ hasMore: pickBoolean(pagination, "hasMore", pickBoolean(body, "hasMore", nextCursor !== null)),
65
+ nextCursor,
66
+ limit: pickNumber(pagination, "limit", 0) || void 0,
67
+ raw: body
68
+ };
69
+ }
70
+ /**
71
+ * Читает страницу курсорной схемы с курсором рядом со списком.
72
+ *
73
+ * Так устроены комментарии к посту и ответы на комментарий: `nextCursor` и `hasMore`
74
+ * лежат на одном уровне со списком, а не внутри объекта `pagination`.
75
+ */
76
+ function readFlatCursorPage(body, ...fields) {
77
+ const items = readItems(body, fields);
78
+ const nextCursor = readCursor(body);
79
+ const total = pickNumber(body, "total", -1);
80
+ return {
81
+ items,
82
+ hasMore: pickBoolean(body, "hasMore", nextCursor !== null),
83
+ nextCursor,
84
+ ...total >= 0 ? { total } : {},
85
+ raw: body
86
+ };
87
+ }
88
+ /**
89
+ * Читает страницу постраничной схемы: `data.pagination.{page,limit,total,hasMore}`.
90
+ *
91
+ * Если сервер вместо этого прислал курсор, он тоже подхватывается — некоторые списки
92
+ * отвечают в курсорной форме, хотя документация описывает постраничную.
93
+ */
94
+ function readPagedPage(body, ...fields) {
95
+ const pagination = pickObject(body, "pagination");
96
+ const items = readItems(body, fields);
97
+ const nextCursor = readCursor(body);
98
+ return {
99
+ items,
100
+ hasMore: pickBoolean(pagination, "hasMore", pickBoolean(body, "hasMore", nextCursor !== null)),
101
+ page: pickNumber(pagination, "page", 1),
102
+ limit: pickNumber(pagination, "limit", 0) || void 0,
103
+ total: pickNumber(pagination, "total", 0),
104
+ ...nextCursor !== null ? { nextCursor } : {},
105
+ raw: body
106
+ };
107
+ }
108
+ /**
109
+ * Читает страницу схемы со смещением.
110
+ *
111
+ * Используется для списков со смещением, включая уведомления.
112
+ */
113
+ function readOffsetPage(body, field, offset) {
114
+ const items = pickArray(body, field);
115
+ return {
116
+ items,
117
+ hasMore: pickBoolean(body, "hasMore"),
118
+ nextOffset: offset + items.length,
119
+ raw: body
120
+ };
121
+ }
122
+ /**
123
+ * Перебор страниц списка.
124
+ *
125
+ * Скрывает различия трёх схем пагинации: перебор элементов, страниц и сбор в массив
126
+ * выглядят одинаково независимо от эндпоинта.
127
+ *
128
+ * **Одноразовый.** Позиция хранится внутри, поэтому повторный перебор того же объекта
129
+ * ничего не вернёт: он продолжится с места, где закончился прошлый. Нужен второй проход —
130
+ * возьмите новый перебор у того же метода ресурса.
131
+ *
132
+ * @example Перебор элементов
133
+ * ```ts
134
+ * for await (const post of itd.posts.iterate({ tab: 'following' })) {
135
+ * console.log(post.content);
136
+ * }
137
+ * ```
138
+ *
139
+ * @example Первые сто элементов
140
+ * ```ts
141
+ * const posts = await itd.posts.iterate({ tab: 'popular' }).collect(100);
142
+ * ```
143
+ *
144
+ * @example Постранично
145
+ * ```ts
146
+ * for await (const page of itd.users.followers('nowkie').pages()) {
147
+ * console.log(page.items.length, 'из', page.total);
148
+ * }
149
+ * ```
150
+ */
151
+ var Paginator = class {
152
+ #options;
153
+ #maxPages;
154
+ #state;
155
+ #finished = false;
156
+ #pagesLoaded = 0;
157
+ constructor(options) {
158
+ if (options.maxPages !== void 0 && (!Number.isInteger(options.maxPages) || options.maxPages < 1)) throw new ItdConfigError(`maxPages должен быть целым числом не меньше 1, получено: ${options.maxPages}`);
159
+ this.#options = options;
160
+ this.#maxPages = options.maxPages ?? 1e3;
161
+ this.#state = options.start ?? {};
162
+ }
163
+ /**
164
+ * Загружает следующую страницу.
165
+ *
166
+ * @returns страница либо `null`, если перебор закончен
167
+ */
168
+ async next() {
169
+ if (this.#finished) return null;
170
+ if (this.#options.signal?.aborted) return null;
171
+ if (this.#pagesLoaded >= this.#maxPages) {
172
+ this.#finished = true;
173
+ return null;
174
+ }
175
+ const previous = this.#state;
176
+ const page = await this.#options.load(previous);
177
+ this.#pagesLoaded += 1;
178
+ this.#state = this.#advance(previous, page);
179
+ return page;
180
+ }
181
+ /**
182
+ * Перебирает страницы целиком.
183
+ *
184
+ * Полезно, когда нужны сведения о самой странице — например `total`.
185
+ */
186
+ async *pages() {
187
+ for (;;) {
188
+ const page = await this.next();
189
+ if (!page) return;
190
+ yield page;
191
+ }
192
+ }
193
+ /** Перебирает элементы всех страниц подряд. */
194
+ async *[Symbol.asyncIterator]() {
195
+ for await (const page of this.pages()) for (const item of page.items) {
196
+ if (this.#options.signal?.aborted) return;
197
+ yield item;
198
+ }
199
+ }
200
+ /**
201
+ * Собирает элементы в массив.
202
+ *
203
+ * @param max сколько элементов достаточно; без него перебираются все страницы
204
+ */
205
+ async collect(max) {
206
+ if (max !== void 0 && max <= 0) return [];
207
+ const result = [];
208
+ for await (const item of this) {
209
+ result.push(item);
210
+ if (max !== void 0 && result.length >= max) break;
211
+ }
212
+ return result;
213
+ }
214
+ /**
215
+ * Вычисляет позицию следующей страницы и решает, продолжать ли.
216
+ *
217
+ * Здесь же стоят предохранители: пустая страница при `hasMore`, неизменившийся курсор
218
+ * и отсутствие курсора останавливают перебор. Без них ошибка на сервере превратилась бы
219
+ * в бесконечный цикл запросов.
220
+ */
221
+ #advance(previous, page) {
222
+ if (!page.hasMore || page.items.length === 0) {
223
+ this.#finished = true;
224
+ return previous;
225
+ }
226
+ if (this.#options.mode === PaginationMode.Cursor) {
227
+ const cursor = page.nextCursor ?? void 0;
228
+ if (!cursor || cursor === previous.cursor) {
229
+ this.#finished = true;
230
+ return previous;
231
+ }
232
+ return { cursor };
233
+ }
234
+ if (this.#options.mode === PaginationMode.Page) return { page: (previous.page ?? 1) + 1 };
235
+ return { offset: page.nextOffset ?? (previous.offset ?? 0) + page.items.length };
236
+ }
237
+ };
238
+ //#endregion
239
+ //#region src/resources/base.ts
240
+ /** Общая основа всех групп методов клиента. */
241
+ var BaseResource = class {
242
+ /** @internal */
243
+ http;
244
+ constructor(http) {
245
+ this.http = http;
246
+ }
247
+ /**
248
+ * Собирает перебор страниц.
249
+ *
250
+ * @param mode схема пагинации эндпоинта
251
+ * @param load загружает одну страницу для указанной позиции
252
+ * @param options только управление самим перебором: предел, отмена и начальная позиция
253
+ */
254
+ paginate(mode, load, options) {
255
+ return new Paginator({
256
+ mode,
257
+ load,
258
+ ...options?.maxPages !== void 0 ? { maxPages: options.maxPages } : {},
259
+ ...options?.signal !== void 0 ? { signal: options.signal } : {},
260
+ ...options?.start !== void 0 ? { start: options.start } : {}
261
+ });
262
+ }
263
+ /**
264
+ * Собирает пару «загрузка страницы + перебор» из одного описания.
265
+ *
266
+ * Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
267
+ * строятся из них.
268
+ *
269
+ * @example
270
+ * ```ts
271
+ * #feed = this.paginated<Post, FeedParams>({
272
+ * operationId: 'posts.list',
273
+ * path: () => '/api/posts',
274
+ * query: (p) => ({ tab: p.tab, limit: p.limit }),
275
+ * start: (p) => (p.cursor ? { cursor: p.cursor } : {}),
276
+ * read: (body) => readCursorPage<Post>(body, 'posts'),
277
+ * mode: PaginationMode.Cursor,
278
+ * });
279
+ * ```
280
+ */
281
+ paginated(spec) {
282
+ const load = async (params, state, options = {}) => {
283
+ const operationId = typeof spec.operationId === "function" ? spec.operationId(params) : spec.operationId;
284
+ const body = await this.http.operation(operationId, {
285
+ path: spec.path(params),
286
+ query: withPageState(spec.query(params), state),
287
+ ...options
288
+ });
289
+ return spec.read(body, state);
290
+ };
291
+ return {
292
+ list: (params, options) => load(params, spec.start(params), options),
293
+ iterate: (params, options = {}) => {
294
+ const { maxPages, ...requestOptions } = options;
295
+ return this.paginate(spec.mode, (state) => load(params, state, requestOptions), {
296
+ ...maxPages !== void 0 ? { maxPages } : {},
297
+ ...requestOptions.signal !== void 0 ? { signal: requestOptions.signal } : {},
298
+ start: spec.start(params)
299
+ });
300
+ }
301
+ };
302
+ }
303
+ };
304
+ /** Добавляет позицию страницы в параметры запроса. */
305
+ function withPageState(query, state) {
306
+ return {
307
+ ...query,
308
+ ...state.cursor !== void 0 ? { cursor: state.cursor } : {},
309
+ ...state.page !== void 0 ? { page: state.page } : {},
310
+ ...state.offset !== void 0 ? { offset: state.offset } : {}
311
+ };
312
+ }
313
+ //#endregion
314
+ //#region src/builders/base.ts
315
+ /** Метка билдера. Через `Symbol.for` — чтобы распознавание переживало смешивание ESM и CJS. */
316
+ const BUILDER = Symbol.for("itd.builder");
317
+ /** Является ли значение билдером. */
318
+ function isBuilder(value) {
319
+ return typeof value === "object" && value !== null && BUILDER in value;
320
+ }
321
+ /**
322
+ * Приводит любую из трёх форм входа к готовому объекту.
323
+ *
324
+ * Проверка выполняется всегда, независимо от формы: обычный объект проходит ровно те же
325
+ * правила, что и результат билдера.
326
+ *
327
+ * @param input то, что передал пользователь
328
+ * @param factory создаёт пустой билдер для формы с функцией
329
+ * @param validate проверяет собранный объект
330
+ */
331
+ function resolveInput(input, factory, validate) {
332
+ if (typeof input === "function") {
333
+ const result = input(factory());
334
+ return isBuilder(result) ? result.build() : validate(result);
335
+ }
336
+ if (isBuilder(input)) return input.build();
337
+ return validate(input);
338
+ }
339
+ //#endregion
340
+ //#region src/builders/comment.ts
341
+ /**
342
+ * Проверяет данные комментария.
343
+ *
344
+ * @throws {ItdConfigError} если комментарий пуст или голосовой собран неверно
345
+ */
346
+ function validateComment(input) {
347
+ const content = typeof input?.content === "string" ? input.content : "";
348
+ const attachmentIds = input?.attachmentIds ?? [];
349
+ const files = input?.files ?? [];
350
+ const hasContent = content.trim() !== "";
351
+ const hasAttachments = attachmentIds.length > 0 || files.length > 0;
352
+ if (!hasContent && !hasAttachments) throw new ItdConfigError("Комментарий пуст: нужен текст или вложение");
353
+ return input;
354
+ }
355
+ /** Дополнительная проверка голосового комментария. */
356
+ function validateVoice(state) {
357
+ if (state.content.trim() !== "") throw new ItdConfigError("У голосового комментария не может быть текста: API принимает либо текст, либо аудио");
358
+ const total = state.files.length + state.attachmentIds.length;
359
+ if (total !== 1) throw new ItdConfigError(`Голосовой комментарий требует ровно одно аудиовложение, передано: ${total}`);
360
+ }
361
+ /**
362
+ * Билдер комментария и ответа на комментарий.
363
+ *
364
+ * Неизменяемый: каждый вызов возвращает новый экземпляр. Создаётся функцией {@link comment}.
365
+ */
366
+ var CommentBuilder = class CommentBuilder {
367
+ /** @internal */
368
+ [BUILDER] = true;
369
+ #state;
370
+ /** @internal Создавайте билдер функцией {@link comment}. */
371
+ constructor(state) {
372
+ this.#state = state;
373
+ }
374
+ /** Задаёт текст комментария. */
375
+ content(text) {
376
+ return new CommentBuilder({
377
+ ...this.#state,
378
+ content: text
379
+ });
380
+ }
381
+ /** Прикладывает файл — он будет загружен перед отправкой. */
382
+ attach(file) {
383
+ return new CommentBuilder({
384
+ ...this.#state,
385
+ files: [...this.#state.files, file]
386
+ });
387
+ }
388
+ /** Прикладывает уже загруженное вложение. */
389
+ attachId(attachmentId) {
390
+ return new CommentBuilder({
391
+ ...this.#state,
392
+ attachmentIds: [...this.#state.attachmentIds, attachmentId]
393
+ });
394
+ }
395
+ /**
396
+ * Делает комментарий голосовым.
397
+ *
398
+ * Текста у такого комментария быть не должно, а вложение ровно одно — аудио в формате
399
+ * `audio/ogg`. Так его принимает API.
400
+ *
401
+ * @example
402
+ * ```ts
403
+ * import { fromPath } from 'itd-api/node';
404
+ *
405
+ * await itd.posts.comment(postId, (c) => c.voice(fromPath('./answer.ogg')));
406
+ * ```
407
+ */
408
+ voice(audio) {
409
+ return new CommentBuilder({
410
+ ...this.#state,
411
+ files: [audio],
412
+ voice: true
413
+ });
414
+ }
415
+ /**
416
+ * Кому адресован ответ.
417
+ *
418
+ * Имеет смысл только в `itd.comments.reply()`; при отправке комментария к посту
419
+ * это поле вызовет ошибку.
420
+ */
421
+ replyTo(userId) {
422
+ return new CommentBuilder({
423
+ ...this.#state,
424
+ replyToUserId: userId
425
+ });
426
+ }
427
+ build() {
428
+ const { content, attachmentIds, files, voice, ...rest } = this.#state;
429
+ if (voice) validateVoice(this.#state);
430
+ return validateComment({
431
+ ...rest,
432
+ content,
433
+ ...attachmentIds.length > 0 ? { attachmentIds } : {},
434
+ ...files.length > 0 ? { files } : {}
435
+ });
436
+ }
437
+ toJSON() {
438
+ return this.build();
439
+ }
440
+ };
441
+ /**
442
+ * Начинает сборку комментария.
443
+ *
444
+ * @param content текст; можно задать позже методом {@link CommentBuilder.content}
445
+ *
446
+ * @example
447
+ * ```ts
448
+ * import { comment } from 'itd-api';
449
+ *
450
+ * await itd.posts.comment(postId, comment('согласен').attach({ url: memeUrl }));
451
+ * ```
452
+ */
453
+ function comment(content = "") {
454
+ return new CommentBuilder({
455
+ content,
456
+ attachmentIds: [],
457
+ files: [],
458
+ voice: false
459
+ });
460
+ }
461
+ /**
462
+ * Приводит любую форму входа к готовым данным комментария.
463
+ *
464
+ * @param allowReplyTo разрешено ли поле `replyToUserId`; в комментарии к посту — нет
465
+ */
466
+ function resolveComment(input, allowReplyTo = false) {
467
+ const resolved = resolveInput(input, () => comment(), validateComment);
468
+ if (!allowReplyTo && resolved.replyToUserId !== void 0) throw new ItdConfigError("replyTo применим только к ответу на комментарий (itd.comments.reply). В комментарии к посту адресат не указывается.");
469
+ return resolved;
470
+ }
471
+ //#endregion
472
+ //#region src/resources/comments.ts
473
+ /**
474
+ * Комментарии и ответы на них.
475
+ *
476
+ * Доступна как `itd.comments`. Комментарии **к посту** живут в `itd.posts`:
477
+ * `itd.posts.comments()` и `itd.posts.comment()`.
478
+ */
479
+ var CommentsResource = class extends BaseResource {
480
+ #uploadFiles;
481
+ /** Ответы на комментарий: `/api/comments/{id}/replies`, постраничная пагинация. */
482
+ #replies = this.paginated({
483
+ operationId: "comments.replies",
484
+ path: (p) => `/api/comments/${encodePathSegment(p.commentId, "commentId")}/replies`,
485
+ query: (p) => ({ limit: p.limit }),
486
+ start: (p) => p.page !== void 0 ? { page: p.page } : {},
487
+ read: (body) => readPagedPage(body, "replies"),
488
+ mode: PaginationMode.Page
489
+ });
490
+ constructor(http, deps) {
491
+ super(http);
492
+ this.#uploadFiles = deps.uploadFiles;
493
+ }
494
+ /**
495
+ * Загружает страницу ответов на комментарий.
496
+ *
497
+ * Здесь пагинация **постраничная**, в отличие от комментариев к посту, где курсорная.
498
+ */
499
+ replies(commentId, params = {}, options = {}) {
500
+ return this.#replies.list({
501
+ ...params,
502
+ commentId
503
+ }, options);
504
+ }
505
+ /** Перебирает ответы на комментарий. */
506
+ iterateReplies(commentId, params = {}, options = {}) {
507
+ return this.#replies.iterate({
508
+ ...params,
509
+ commentId
510
+ }, options);
511
+ }
512
+ /**
513
+ * Отвечает на комментарий.
514
+ *
515
+ * @example
516
+ * ```ts
517
+ * await itd.comments.reply(commentId, 'согласен');
518
+ * await itd.comments.reply(commentId, (c) => c.content('и вот почему').replyTo(userId));
519
+ * ```
520
+ */
521
+ async reply(commentId, input, options = {}) {
522
+ const data = resolveComment(typeof input === "string" ? { content: input } : input, true);
523
+ const existing = data.attachmentIds ?? [];
524
+ const files = data.files ?? [];
525
+ const attachmentIds = files.length > 0 ? [...existing, ...await this.#uploadFiles(files, options)] : existing;
526
+ return this.http.operation("comments.reply", {
527
+ path: `/api/comments/${encodePathSegment(commentId, "commentId")}/replies`,
528
+ body: {
529
+ content: data.content ?? "",
530
+ attachmentIds,
531
+ ...data.replyToUserId ? { replyToUserId: data.replyToUserId } : {}
532
+ },
533
+ ...options
534
+ });
535
+ }
536
+ /** Редактирует текст комментария. */
537
+ update(commentId, content, options = {}) {
538
+ return this.http.operation("comments.update", {
539
+ path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
540
+ body: { content },
541
+ ...options
542
+ });
543
+ }
544
+ /** Удаляет комментарий. Восстановить его можно через {@link restore}. */
545
+ remove(commentId, options = {}) {
546
+ return this.http.operation("comments.remove", {
547
+ path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
548
+ ...options
549
+ });
550
+ }
551
+ /** Восстанавливает удалённый комментарий. */
552
+ restore(commentId, options = {}) {
553
+ return this.http.operation("comments.restore", {
554
+ path: `/api/comments/${encodePathSegment(commentId, "commentId")}/restore`,
555
+ ...options
556
+ });
557
+ }
558
+ /** Ставит реакцию на комментарий. */
559
+ like(commentId, options = {}) {
560
+ return this.http.operation("comments.like", {
561
+ path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
562
+ ...options
563
+ });
564
+ }
565
+ /** Убирает реакцию с комментария. */
566
+ unlike(commentId, options = {}) {
567
+ return this.http.operation("comments.unlike", {
568
+ path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
569
+ ...options
570
+ });
571
+ }
572
+ };
573
+ //#endregion
574
+ //#region src/core/attachments/url-source.ts
575
+ /** Убирает параметры MIME и приводит его к форме для сравнения. */
576
+ function normalizeMimeType(contentType) {
577
+ return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
578
+ }
579
+ /** Достаёт имя файла из пути URL. */
580
+ function filenameFromUrl(url) {
581
+ const last = url.pathname.split("/").pop();
582
+ if (!last) return void 0;
583
+ try {
584
+ return decodeURIComponent(last) || void 0;
585
+ } catch {
586
+ return last;
587
+ }
588
+ }
589
+ /** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
590
+ function declaredSize(response, maxBytes, url) {
591
+ const header = response.headers.get("content-length");
592
+ if (header === null) return void 0;
593
+ const size = Number(header);
594
+ if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
595
+ if (maxBytes !== void 0 && size > maxBytes) throw fileTooLarge(url, maxBytes, size);
596
+ return size;
597
+ }
598
+ /** Получает HTTP-ответ источника и проверяет его статус. */
599
+ async function fetchFile(target, options, context) {
600
+ let requested;
601
+ try {
602
+ requested = new URL(target);
603
+ } catch {
604
+ throw new ItdConfigError(`«${target}» не разбирается как адрес`);
605
+ }
606
+ if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
607
+ let response;
608
+ try {
609
+ response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
610
+ } catch (error) {
611
+ if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
612
+ throw new ItdFileError(`не удалось получить файл по адресу ${requested.href}`, {
613
+ reason: ItdFileErrorReason.Network,
614
+ url: requested.href,
615
+ retryable: true,
616
+ cause: error
617
+ });
618
+ }
619
+ const finalUrl = response.url ? new URL(response.url) : requested;
620
+ if (!response.ok) {
621
+ await response.body?.cancel().catch(() => {});
622
+ throw new ItdFileError(`источник ${finalUrl.href} ответил статусом ${response.status}`, {
623
+ reason: ItdFileErrorReason.Http,
624
+ url: finalUrl.href,
625
+ status: response.status,
626
+ retryable: response.status === 408 || response.status === 429 || response.status >= 500
627
+ });
628
+ }
629
+ const { maxBytes } = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
630
+ let size;
631
+ try {
632
+ size = declaredSize(response, maxBytes, finalUrl.href);
633
+ } catch (error) {
634
+ await response.body?.cancel().catch(() => {});
635
+ throw error;
636
+ }
637
+ return {
638
+ response,
639
+ url: finalUrl,
640
+ size
641
+ };
642
+ }
643
+ /** Читает ответ с контролем размера до создания итогового `Blob`. */
644
+ async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
645
+ if (!response.body) {
646
+ const blob = await response.blob();
647
+ if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(url, maxBytes, blob.size);
648
+ return blob;
649
+ }
650
+ const chunks = [];
651
+ const reader = boundedFileStream(response.body, {
652
+ ...maxBytes !== void 0 ? { maxBytes } : {},
653
+ streamBufferBytes,
654
+ ...signal ? { signal } : {},
655
+ url,
656
+ retryableRead: true
657
+ }).getReader();
658
+ try {
659
+ for (;;) {
660
+ const next = await reader.read();
661
+ if (next.done) break;
662
+ chunks.push(next.value);
663
+ }
664
+ } finally {
665
+ reader.releaseLock();
666
+ }
667
+ return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
668
+ }
669
+ /** Скачивает файл целиком с ограничением размера. @internal */
670
+ async function downloadFile(target, options, context) {
671
+ const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
672
+ const { response, url } = await fetchFile(target, options, context);
673
+ const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
674
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
675
+ const filename = options.filename ?? filenameFromUrl(url);
676
+ return {
677
+ file: new Blob([blob], { type: contentType ?? "" }),
678
+ ...filename ? { filename } : {},
679
+ ...contentType ? { contentType } : {}
680
+ };
681
+ }
682
+ /** Открывает HTTP-ответ как ограниченный поток. @internal */
683
+ async function openUrlFile(target, options, context) {
684
+ const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
685
+ const { response, url, size } = await fetchFile(target, options, context);
686
+ if (!response.body) throw new ItdFileError(`источник ${url.href} не предоставил потоковое тело`, {
687
+ reason: ItdFileErrorReason.StreamUnavailable,
688
+ url: url.href
689
+ });
690
+ const stream = boundedFileStream(response.body, {
691
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
692
+ streamBufferBytes: resolved.streamBufferBytes,
693
+ ...context.signal ? { signal: context.signal } : {},
694
+ url: url.href,
695
+ retryableRead: true
696
+ });
697
+ const filename = options.filename ?? filenameFromUrl(url);
698
+ const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
699
+ return {
700
+ stream,
701
+ ...filename ? { filename } : {},
702
+ ...contentType ? { contentType } : {},
703
+ ...size !== void 0 ? { size } : {},
704
+ close: () => stream.cancel().catch(() => {})
705
+ };
706
+ }
707
+ //#endregion
708
+ //#region src/core/multipart.ts
709
+ const encoder = new TextEncoder();
710
+ /** Убирает возможность внедрить дополнительный multipart-заголовок через имя файла. */
711
+ function quotedFilename(filename) {
712
+ return filename.replace(/[\r\n]/g, "").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
713
+ }
714
+ /** Удаляет разделители строк, чтобы значение не создало новый multipart-заголовок. */
715
+ function headerValue(value) {
716
+ return value.replace(/[\r\n]/g, "");
717
+ }
718
+ /** Создаёт уникальную границу без зависимости от Web Crypto. */
719
+ function boundary() {
720
+ return `----itd-api-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
721
+ }
722
+ /**
723
+ * Кодирует один файл в multipart без полной буферизации.
724
+ *
725
+ * Читает следующий чанк только когда потребитель запросил данные.
726
+ */
727
+ function createMultipartFileBody(file) {
728
+ const marker = boundary();
729
+ const prefix = encoder.encode(`--${marker}\r\nContent-Disposition: form-data; name="file"; filename="${quotedFilename(file.filename)}"\r\nContent-Type: ${headerValue(file.contentType)}\r\n\r\n`);
730
+ const suffix = encoder.encode(`\r\n--${marker}--\r\n`);
731
+ const reader = file.stream.getReader();
732
+ let stage = "prefix";
733
+ let cancelled = false;
734
+ const cancel = async (reason) => {
735
+ if (cancelled) return;
736
+ cancelled = true;
737
+ await reader.cancel(reason).catch(() => {});
738
+ };
739
+ return {
740
+ body: new ReadableStream({
741
+ async pull(controller) {
742
+ if (stage === "prefix") {
743
+ stage = "file";
744
+ controller.enqueue(prefix);
745
+ return;
746
+ }
747
+ if (stage === "file") try {
748
+ const next = await reader.read();
749
+ if (!next.done) {
750
+ controller.enqueue(next.value);
751
+ return;
752
+ }
753
+ stage = "suffix";
754
+ } catch (error) {
755
+ stage = "done";
756
+ controller.error(error);
757
+ return;
758
+ }
759
+ if (stage === "suffix") {
760
+ stage = "done";
761
+ controller.enqueue(suffix);
762
+ controller.close();
763
+ }
764
+ },
765
+ cancel
766
+ }),
767
+ contentType: `multipart/form-data; boundary=${marker}`,
768
+ cancel
769
+ };
770
+ }
771
+ //#endregion
772
+ //#region src/domain/mime.ts
773
+ /** Изображения, которые принимает `POST /api/files/upload`. */
774
+ const IMAGE_MIME_TYPES = Object.freeze([
775
+ "image/jpeg",
776
+ "image/png",
777
+ "image/gif",
778
+ "image/webp",
779
+ "image/avif",
780
+ "image/heic",
781
+ "image/heif"
782
+ ]);
783
+ /** Видео, которые принимает `POST /api/files/upload`. */
784
+ const VIDEO_MIME_TYPES = Object.freeze([
785
+ "video/mp4",
786
+ "video/webm",
787
+ "video/quicktime"
788
+ ]);
789
+ /** Аудио для голосовых комментариев. */
790
+ const AUDIO_MIME_TYPES = Object.freeze(["audio/ogg"]);
791
+ /** Все типы, которые принимает загрузка. */
792
+ const ALLOWED_MIME_TYPES = Object.freeze([
793
+ ...IMAGE_MIME_TYPES,
794
+ ...VIDEO_MIME_TYPES,
795
+ ...AUDIO_MIME_TYPES
796
+ ]);
797
+ /**
798
+ * Соответствие расширения и MIME-типа.
799
+ *
800
+ * Своя маленькая таблица вместо зависимости: библиотеке нужны ровно те типы,
801
+ * которые принимает API.
802
+ */
803
+ const EXTENSION_TO_MIME = Object.freeze({
804
+ jpg: "image/jpeg",
805
+ jpeg: "image/jpeg",
806
+ jfif: "image/jpeg",
807
+ png: "image/png",
808
+ gif: "image/gif",
809
+ webp: "image/webp",
810
+ avif: "image/avif",
811
+ heic: "image/heic",
812
+ heif: "image/heif",
813
+ mp4: "video/mp4",
814
+ m4v: "video/mp4",
815
+ webm: "video/webm",
816
+ mov: "video/quicktime",
817
+ qt: "video/quicktime",
818
+ ogg: "audio/ogg",
819
+ oga: "audio/ogg",
820
+ opus: "audio/ogg"
821
+ });
822
+ /** Определяет MIME-тип по расширению имени файла. */
823
+ function mimeFromFilename(filename) {
824
+ const dot = filename.lastIndexOf(".");
825
+ if (dot < 0) return void 0;
826
+ return EXTENSION_TO_MIME[filename.slice(dot + 1).toLowerCase()];
827
+ }
828
+ /** Разрешён ли тип к загрузке. */
829
+ function isAllowedMime(mimeType) {
830
+ return ALLOWED_MIME_TYPES.includes(mimeType.toLowerCase());
831
+ }
832
+ /**
833
+ * Проверяет тип файла до отправки.
834
+ *
835
+ * Отказ на стороне клиента экономит и время, и трафик: тот же `UNSUPPORTED_FILE_TYPE`
836
+ * пришёл бы с сервера, но уже после загрузки всего файла.
837
+ *
838
+ * @throws {ItdConfigError} если тип не поддерживается
839
+ */
840
+ function assertAllowedMime(mimeType, filename) {
841
+ if (!mimeType) throw new ItdConfigError(`Не удалось определить тип файла${filename ? ` «${filename}»` : ""}. Укажите его явно: { file, filename, contentType }.`);
842
+ if (!isAllowedMime(mimeType)) throw new ItdConfigError(`Тип «${mimeType}» не поддерживается загрузкой. Допустимые: ${ALLOWED_MIME_TYPES.join(", ")}.`);
843
+ }
844
+ /** Файлы и медиа. */
845
+ var FilesResource = class extends BaseResource {
846
+ #fetch;
847
+ constructor(http, deps) {
848
+ super(http);
849
+ this.#fetch = deps.fetch;
850
+ }
851
+ /**
852
+ * Загружает файл и возвращает его идентификатор.
853
+ *
854
+ * Потоковый источник открывается заново при каждой повторной попытке. Буферный источник
855
+ * после успешного чтения переиспользуется.
856
+ */
857
+ upload(input, uploadOptions = {}, requestOptions = {}) {
858
+ const bodyFactory = this.#createBodyFactory(input, uploadOptions);
859
+ return this.http.operation("files.upload", {
860
+ path: "/api/files/upload",
861
+ bodyFactory,
862
+ ...requestOptions,
863
+ timeout: requestOptions.timeout ?? 3e5
864
+ });
865
+ }
866
+ /** Загружает несколько файлов последовательно, сохраняя порядок. */
867
+ async uploadMany(files, uploadOptions = {}, requestOptions = {}) {
868
+ const ids = [];
869
+ for (const file of files) ids.push((await this.upload(file, uploadOptions, requestOptions)).id);
870
+ return ids;
871
+ }
872
+ /**
873
+ * Загружает сведения о файле.
874
+ *
875
+ * Для ещё не прикреплённого файла сервер может ответить `404`.
876
+ */
877
+ get(fileId, options = {}) {
878
+ return this.http.operation("files.get", {
879
+ path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
880
+ ...options
881
+ });
882
+ }
883
+ /** Удаляет загруженный файл. */
884
+ remove(fileId, options = {}) {
885
+ return this.http.operation("files.remove", {
886
+ path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
887
+ ...options
888
+ });
889
+ }
890
+ /**
891
+ * Создаёт фабрику тела. Успешно подготовленный буфер кешируется, а поток открывается
892
+ * заново, поэтому каждая транспортная попытка получает непрочитанное тело.
893
+ */
894
+ #createBodyFactory(input, options) {
895
+ const streamMode = this.#isStreamInput(input);
896
+ let buffered;
897
+ return async ({ signal, attempt }) => {
898
+ const context = {
899
+ fetch: this.#fetch,
900
+ signal,
901
+ attempt
902
+ };
903
+ let prepared;
904
+ try {
905
+ if (streamMode) prepared = await this.#prepareStream(input, options, context);
906
+ else {
907
+ buffered ??= this.#prepareBuffer(input, options, context).catch((error) => {
908
+ buffered = void 0;
909
+ throw error;
910
+ });
911
+ prepared = await buffered;
912
+ }
913
+ } catch (error) {
914
+ if (error instanceof ItdError || signal.aborted) throw error;
915
+ throw new ItdFileError("не удалось получить содержимое вложения", {
916
+ reason: ItdFileErrorReason.Read,
917
+ retryable: true,
918
+ cause: error
919
+ });
920
+ }
921
+ if (prepared.mode === "buffer") {
922
+ const form = new FormData();
923
+ form.set("file", prepared.blob, prepared.filename);
924
+ return { body: form };
925
+ }
926
+ const multipart = createMultipartFileBody(prepared);
927
+ return {
928
+ body: multipart.body,
929
+ headers: { "Content-Type": multipart.contentType },
930
+ cleanup: async () => {
931
+ await multipart.cancel();
932
+ await prepared.close?.();
933
+ }
934
+ };
935
+ };
936
+ }
937
+ /** Определяет режим без чтения источника. */
938
+ #isStreamInput(input) {
939
+ if (typeof input !== "object" || input === null) return false;
940
+ if ("open" in input) return true;
941
+ if ("url" in input) return resolveFileStreamOptions(input).mode === FileTransferMode.Stream;
942
+ return false;
943
+ }
944
+ /** Получает и проверяет буферный источник. */
945
+ async #prepareBuffer(input, options, context) {
946
+ const content = await this.#resolveBuffer(input, context);
947
+ const filename = options.filename ?? content.filename ?? this.#nameFromMime(options.contentType ?? content.contentType);
948
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
949
+ if (options.validateMime !== false) assertAllowedMime(contentType, filename);
950
+ const blob = isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
951
+ const limits = resolveFileStreamOptions(options);
952
+ if (limits.maxBytes !== void 0 && blob.size > limits.maxBytes) throw new ItdFileError(`файл больше предела в ${limits.maxBytes} байт: ${blob.size}`, {
953
+ reason: ItdFileErrorReason.TooLarge,
954
+ limit: limits.maxBytes,
955
+ actual: blob.size
956
+ });
957
+ return {
958
+ mode: "buffer",
959
+ blob,
960
+ filename
961
+ };
962
+ }
963
+ /** Открывает и проверяет потоковый источник. */
964
+ async #prepareStream(input, options, context) {
965
+ let opened;
966
+ if (typeof input === "object" && input !== null && "open" in input) opened = await input.open(context);
967
+ else if (typeof input === "object" && input !== null && "url" in input) {
968
+ const { url, ...urlOptions } = input;
969
+ opened = await openUrlFile(url, urlOptions, context);
970
+ } else throw new ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
971
+ if (!opened || typeof ReadableStream === "undefined" || !(opened.stream instanceof ReadableStream)) {
972
+ await opened?.close?.();
973
+ throw new ItdConfigError("потоковый источник должен вернуть { stream: ReadableStream }");
974
+ }
975
+ try {
976
+ const filename = options.filename ?? opened.filename ?? this.#nameFromMime(options.contentType ?? opened.contentType);
977
+ const contentType = normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
978
+ if (options.validateMime !== false) assertAllowedMime(contentType, filename);
979
+ const limits = resolveFileStreamOptions({
980
+ mode: FileTransferMode.Stream,
981
+ ...options.maxBytes !== void 0 ? { maxBytes: options.maxBytes } : {},
982
+ ...options.streamBufferBytes !== void 0 ? { streamBufferBytes: options.streamBufferBytes } : {}
983
+ });
984
+ if (limits.maxBytes !== void 0 && opened.size !== void 0 && opened.size > limits.maxBytes) throw new ItdFileError(`файл больше предела в ${limits.maxBytes} байт: ${opened.size}`, {
985
+ reason: ItdFileErrorReason.TooLarge,
986
+ limit: limits.maxBytes,
987
+ actual: opened.size
988
+ });
989
+ return {
990
+ mode: "stream",
991
+ stream: isBoundedFileStream(opened.stream) && options.maxBytes === void 0 && options.streamBufferBytes === void 0 ? opened.stream : boundedFileStream(opened.stream, {
992
+ ...limits.maxBytes !== void 0 ? { maxBytes: limits.maxBytes } : {},
993
+ streamBufferBytes: limits.streamBufferBytes,
994
+ signal: context.signal
995
+ }),
996
+ filename,
997
+ contentType: contentType ?? "",
998
+ ...opened.close ? { close: opened.close } : {}
999
+ };
1000
+ } catch (error) {
1001
+ await opened.close?.();
1002
+ throw error;
1003
+ }
1004
+ }
1005
+ /** Разрешает только буферные формы. */
1006
+ #resolveBuffer(input, context) {
1007
+ if (input instanceof ArrayBuffer || ArrayBuffer.isView(input) || isBlob(input)) return {
1008
+ file: input,
1009
+ ...isFile(input) ? { filename: input.name } : {}
1010
+ };
1011
+ if (typeof input !== "object" || input === null) throw new ItdConfigError(`вложение задано значением типа ${typeof input}; ожидается бинарное значение или объект { file }, { url }, { load }, { open }. Для файла на диске используйте fromPath('./photo.jpg') из itd-api/node`);
1012
+ if ("load" in input) return input.load(context);
1013
+ if ("url" in input) {
1014
+ const { url, ...urlOptions } = input;
1015
+ return downloadFile(url, urlOptions, context);
1016
+ }
1017
+ if ("file" in input) return input;
1018
+ if ("open" in input) throw new ItdConfigError("потоковый источник нельзя использовать как буферный");
1019
+ throw new ItdConfigError("вложение не распознано: ожидается { file }, { url }, { load } или { open }");
1020
+ }
1021
+ /** Подбирает непустое имя, обязательное для multipart. */
1022
+ #nameFromMime(contentType) {
1023
+ const extension = normalizeMimeType(contentType)?.split("/")[1];
1024
+ return extension ? `file.${extension}` : "file";
1025
+ }
1026
+ };
1027
+ //#endregion
1028
+ //#region src/resources/hashtags.ts
1029
+ /**
1030
+ * Хэштеги.
1031
+ *
1032
+ * Доступна как `itd.hashtags`.
1033
+ */
1034
+ var HashtagsResource = class extends BaseResource {
1035
+ /** Посты по хэштегу: `/api/hashtags/{tag}/posts`, курсорная пагинация. */
1036
+ #posts = this.paginated({
1037
+ operationId: "hashtags.posts",
1038
+ path: (p) => `/api/hashtags/${encodePathSegment(p.tag, "tag")}/posts`,
1039
+ query: (p) => ({ limit: p.limit }),
1040
+ start: (p) => p.cursor ? { cursor: p.cursor } : {},
1041
+ read: (body) => readCursorPage(body, "posts"),
1042
+ mode: PaginationMode.Cursor
1043
+ });
1044
+ /**
1045
+ * Ищет хэштеги.
1046
+ *
1047
+ * Без строки запроса возвращает общий список.
1048
+ */
1049
+ async search(query, params = {}, options = {}) {
1050
+ const body = await this.http.operation("hashtags.search", {
1051
+ path: "/api/hashtags",
1052
+ query: {
1053
+ q: query,
1054
+ limit: params.limit
1055
+ },
1056
+ ...options
1057
+ });
1058
+ return pickArray(body, "hashtags");
1059
+ }
1060
+ /** Загружает трендовые хэштеги. */
1061
+ async trending(params = {}, options = {}) {
1062
+ const body = await this.http.operation("hashtags.trending", {
1063
+ path: "/api/hashtags/trending",
1064
+ query: { limit: params.limit },
1065
+ ...options
1066
+ });
1067
+ return pickArray(body, "hashtags");
1068
+ }
1069
+ /**
1070
+ * Загружает страницу постов по хэштегу.
1071
+ *
1072
+ * @param tag название без решётки; кодируется автоматически, поэтому кириллица
1073
+ * и пробелы допустимы
1074
+ */
1075
+ posts(tag, params = {}, options = {}) {
1076
+ return this.#posts.list({
1077
+ ...params,
1078
+ tag
1079
+ }, options);
1080
+ }
1081
+ /** Перебирает посты по хэштегу. */
1082
+ iteratePosts(tag, params = {}, options = {}) {
1083
+ return this.#posts.iterate({
1084
+ ...params,
1085
+ tag
1086
+ }, options);
1087
+ }
1088
+ };
1089
+ //#endregion
1090
+ //#region src/resources/notifications.ts
1091
+ const NOTIFICATION_SETTING_KEYS = [
1092
+ "enabled",
1093
+ "sound",
1094
+ "follows",
1095
+ "wallPosts",
1096
+ "likes",
1097
+ "comments",
1098
+ "mentions"
1099
+ ];
1100
+ /**
1101
+ * Сколько идентификаторов уходит в одном запросе на отметку прочтения.
1102
+ *
1103
+ * Столько же отправляет сайт итд.com — значит на сервере, скорее всего, есть ограничение.
1104
+ */
1105
+ const READ_BATCH_SIZE = 20;
1106
+ /**
1107
+ * Читает настройки уведомлений.
1108
+ *
1109
+ * Сервер отдаёт плоский объект: `enabled`, `sound`, `follows`, `wallPosts`, `likes`,
1110
+ * `comments`, `mentions`. Отсутствующая настройка считается включённой — так же
1111
+ * ведёт себя сайт итд.com.
1112
+ */
1113
+ function readSettings(body) {
1114
+ const settings = {};
1115
+ for (const key of NOTIFICATION_SETTING_KEYS) settings[key] = pickBoolean(body, key, true);
1116
+ return settings;
1117
+ }
1118
+ /**
1119
+ * Уведомления: список, счётчик, отметки о прочтении, настройки.
1120
+ *
1121
+ * Доступна как `itd.notifications`. Все уведомления приведены к единой форме, поэтому
1122
+ * объекты отсюда и из потока событий можно складывать в один список.
1123
+ */
1124
+ var NotificationsResource = class extends BaseResource {
1125
+ /** Уведомления: `/api/notifications/`, пагинация по смещению. */
1126
+ #list = this.paginated({
1127
+ operationId: "notifications.list",
1128
+ path: () => "/api/notifications/",
1129
+ query: (p) => ({ limit: p.limit }),
1130
+ start: (p) => ({ offset: p.offset ?? 0 }),
1131
+ read: (body, state) => {
1132
+ const page = readOffsetPage(body, "notifications", state.offset ?? 0);
1133
+ return {
1134
+ ...page,
1135
+ items: page.items.map(normalizeNotification)
1136
+ };
1137
+ },
1138
+ mode: PaginationMode.Offset
1139
+ });
1140
+ /**
1141
+ * Загружает страницу уведомлений.
1142
+ *
1143
+ * Пагинация здесь основана на смещении.
1144
+ *
1145
+ * @example
1146
+ * ```ts
1147
+ * const page = await itd.notifications.list({ limit: 20 });
1148
+ * const next = await itd.notifications.list({ limit: 20, offset: page.nextOffset });
1149
+ * ```
1150
+ */
1151
+ list(params = {}, options = {}) {
1152
+ return this.#list.list(params, options);
1153
+ }
1154
+ /**
1155
+ * Перебирает уведомления.
1156
+ *
1157
+ * @example
1158
+ * ```ts
1159
+ * for await (const notification of itd.notifications.iterate()) {
1160
+ * console.log(formatNotificationText(notification));
1161
+ * }
1162
+ * ```
1163
+ */
1164
+ iterate(params = {}, options = {}) {
1165
+ return this.#list.iterate(params, options);
1166
+ }
1167
+ /** Загружает число непрочитанных уведомлений. */
1168
+ async count(options = {}) {
1169
+ const body = await this.http.operation("notifications.count", {
1170
+ path: "/api/notifications/count",
1171
+ ...options
1172
+ });
1173
+ return pickNumber(body, "count", 0);
1174
+ }
1175
+ /**
1176
+ * Отмечает уведомление прочитанным.
1177
+ *
1178
+ * @returns сколько записей отметил сервер
1179
+ */
1180
+ async markRead(notificationId, options = {}) {
1181
+ const body = await this.http.operation("notifications.markRead", {
1182
+ path: `/api/notifications/${encodePathSegment(notificationId, "notificationId")}/read`,
1183
+ ...options
1184
+ });
1185
+ return pickNumber(body, "markedCount", 0);
1186
+ }
1187
+ /**
1188
+ * Отмечает прочитанными сразу несколько уведомлений.
1189
+ *
1190
+ * Список автоматически режется на части по 20 идентификаторов — столько же отправляет
1191
+ * сайт итд.com, поэтому на сервере вероятен предел. Части уходят последовательно,
1192
+ * результат суммируется.
1193
+ *
1194
+ * @returns сколько записей отметил сервер суммарно
1195
+ */
1196
+ async markReadBatch(ids, options = {}) {
1197
+ let marked = 0;
1198
+ for (let index = 0; index < ids.length; index += READ_BATCH_SIZE) {
1199
+ const chunk = ids.slice(index, index + READ_BATCH_SIZE);
1200
+ const body = await this.http.operation("notifications.markReadBatch", {
1201
+ path: "/api/notifications/read-batch",
1202
+ body: { ids: chunk },
1203
+ ...options
1204
+ });
1205
+ marked += pickNumber(body, "markedCount", 0);
1206
+ }
1207
+ return marked;
1208
+ }
1209
+ /** Отмечает прочитанными все уведомления. */
1210
+ async markAllRead(options = {}) {
1211
+ const body = await this.http.operation("notifications.markAllRead", {
1212
+ path: "/api/notifications/read-all",
1213
+ ...options
1214
+ });
1215
+ return pickNumber(body, "markedCount", 0);
1216
+ }
1217
+ /** Загружает настройки уведомлений. */
1218
+ async getSettings(options = {}) {
1219
+ return readSettings(await this.http.operation("notifications.getSettings", {
1220
+ path: "/api/notifications/settings",
1221
+ ...options
1222
+ }));
1223
+ }
1224
+ /**
1225
+ * Обновляет настройки уведомлений.
1226
+ *
1227
+ * Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
1228
+ */
1229
+ async updateSettings(input, options = {}) {
1230
+ const payload = {};
1231
+ for (const key of NOTIFICATION_SETTING_KEYS) {
1232
+ const value = input[key];
1233
+ if (value !== void 0) payload[key] = value;
1234
+ }
1235
+ return readSettings(await this.http.operation("notifications.updateSettings", {
1236
+ path: "/api/notifications/settings",
1237
+ body: payload,
1238
+ ...options
1239
+ }));
1240
+ }
1241
+ };
1242
+ //#endregion
1243
+ //#region src/domain/time.ts
1244
+ const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
1245
+ /**
1246
+ * Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
1247
+ *
1248
+ * Строку другого вида возвращает нетронутой.
1249
+ *
1250
+ * @example
1251
+ * ```ts
1252
+ * utcStampToIso('2026-07-23 23:14:25'); // '2026-07-23T23:14:25Z'
1253
+ * utcStampToIso('2026-07-23T23:14:25Z'); // без изменений
1254
+ * ```
1255
+ */
1256
+ function utcStampToIso(value) {
1257
+ const match = typeof value === "string" ? NAIVE_STAMP.exec(value) : null;
1258
+ if (!match) return value;
1259
+ const iso = `${match[1]}T${match[2]}Z`;
1260
+ return Number.isFinite(Date.parse(iso)) ? iso : value;
1261
+ }
1262
+ /**
1263
+ * Разбирает дату API в объект `Date`.
1264
+ *
1265
+ * @returns `null`, если строки нет или она не разбирается
1266
+ *
1267
+ * @example
1268
+ * ```ts
1269
+ * const created = toDate(post.createdAt);
1270
+ * ```
1271
+ */
1272
+ function toDate(value) {
1273
+ if (!value) return null;
1274
+ const date = new Date(value);
1275
+ return Number.isFinite(date.getTime()) ? date : null;
1276
+ }
1277
+ //#endregion
1278
+ //#region src/resources/platform.ts
1279
+ /** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
1280
+ function normalizeStatus(body) {
1281
+ if (!isRecord(body) || !Array.isArray(body.services)) return body;
1282
+ return {
1283
+ ...body,
1284
+ services: body.services.map((service) => typeof service?.last_checked === "string" ? {
1285
+ ...service,
1286
+ last_checked: utcStampToIso(service.last_checked)
1287
+ } : service)
1288
+ };
1289
+ }
1290
+ /**
1291
+ * Сведения о платформе: версии приложений, изменения, анонсы, баннер события.
1292
+ *
1293
+ * Доступна как `itd.platform`.
1294
+ */
1295
+ var PlatformResource = class extends BaseResource {
1296
+ /**
1297
+ * Загружает минимальные и актуальные версии клиентских приложений.
1298
+ *
1299
+ * Endpoint публичный: автоматическая авторизация в запрос не добавляется.
1300
+ *
1301
+ * @example
1302
+ * ```ts
1303
+ * const versions = await itd.platform.version();
1304
+ * console.log(versions.android.latestVersion);
1305
+ * ```
1306
+ */
1307
+ version(options = {}) {
1308
+ return this.http.operation("platform.version", {
1309
+ path: "/api/platform/version",
1310
+ skipAuth: true,
1311
+ ...options
1312
+ });
1313
+ }
1314
+ /** Загружает журнал изменений. */
1315
+ async changelog(options = {}) {
1316
+ const body = await this.http.operation("platform.changelog", {
1317
+ path: "/api/platform/changelog",
1318
+ ...options
1319
+ });
1320
+ return Array.isArray(body) ? body : [];
1321
+ }
1322
+ /** Загружает анонсы платформы. */
1323
+ async announcements(options = {}) {
1324
+ const body = await this.http.operation("platform.announcements", {
1325
+ path: "/api/platform/announcements",
1326
+ ...options
1327
+ });
1328
+ return pickArray(body, "announcements");
1329
+ }
1330
+ /** Загружает баннер текущего события — виджет «портал». */
1331
+ portal(options = {}) {
1332
+ return this.http.operation("platform.portal", {
1333
+ path: "/api/v1/portal",
1334
+ ...options
1335
+ });
1336
+ }
1337
+ /**
1338
+ * Загружает состояние сервисов платформы за последние 90 суток.
1339
+ *
1340
+ * Идёт на хост `статус.итд.com` без авторизации. Ответ кэшируется сервером на минуту.
1341
+ * История по суткам приходит разреженной, ровный массив даёт `statusDays`.
1342
+ *
1343
+ * @example
1344
+ * ```ts
1345
+ * const status = await itd.platform.status();
1346
+ *
1347
+ * if (status.overall_status !== 'operational') {
1348
+ * const broken = status.services.filter((s) => s.current_status !== 'operational');
1349
+ * console.log('лежит:', broken.map((s) => s.name).join(', '));
1350
+ * }
1351
+ * ```
1352
+ */
1353
+ async status(options = {}) {
1354
+ return normalizeStatus(await this.http.operation("platform.status", {
1355
+ service: STATUS_SERVICE,
1356
+ path: "/api/status",
1357
+ ...options
1358
+ }));
1359
+ }
1360
+ };
1361
+ //#endregion
1362
+ //#region src/spans/validate.ts
1363
+ /**
1364
+ * Проверяет, что spans целиком лежат внутри текста.
1365
+ *
1366
+ * Возвращает копии, чтобы последующая мутация входного массива не меняла уже собранные данные.
1367
+ */
1368
+ function validateSpans(content, spans) {
1369
+ if (!Array.isArray(spans)) throw new ItdConfigError("spans должен быть массивом");
1370
+ if (content.length === 0 && spans.length > 0) throw new ItdConfigError("Нельзя задать spans без текста: сначала укажите непустой content");
1371
+ return spans.map((span, index) => {
1372
+ if (!span || typeof span !== "object") throw new ItdConfigError(`Некорректный span №${index + 1}: ожидался объект`);
1373
+ const candidate = span;
1374
+ if (typeof candidate.type !== "string" || !Number.isInteger(candidate.offset) || !Number.isInteger(candidate.length) || (candidate.offset ?? -1) < 0 || (candidate.length ?? 0) <= 0 || (candidate.offset ?? 0) + (candidate.length ?? 0) > content.length) throw new ItdConfigError(`Некорректный span №${index + 1}: offset и length должны указывать на непустой фрагмент content`);
1375
+ return { ...candidate };
1376
+ });
1377
+ }
1378
+ //#endregion
1379
+ //#region src/spans/parse.ts
1380
+ const HTML_BLOCK_TAGS = /* @__PURE__ */ new Set([
1381
+ "address",
1382
+ "article",
1383
+ "aside",
1384
+ "blockquote",
1385
+ "div",
1386
+ "footer",
1387
+ "h1",
1388
+ "h2",
1389
+ "h3",
1390
+ "h4",
1391
+ "h5",
1392
+ "h6",
1393
+ "header",
1394
+ "li",
1395
+ "main",
1396
+ "nav",
1397
+ "ol",
1398
+ "p",
1399
+ "pre",
1400
+ "section",
1401
+ "ul"
1402
+ ]);
1403
+ const HTML_DROPPED_TAGS = /* @__PURE__ */ new Set([
1404
+ "applet",
1405
+ "canvas",
1406
+ "embed",
1407
+ "iframe",
1408
+ "math",
1409
+ "noscript",
1410
+ "object",
1411
+ "script",
1412
+ "style",
1413
+ "svg",
1414
+ "template"
1415
+ ]);
1416
+ const HTML_ENTITIES = Object.freeze({
1417
+ amp: "&",
1418
+ apos: "'",
1419
+ gt: ">",
1420
+ lt: "<",
1421
+ nbsp: "\xA0",
1422
+ quot: "\""
1423
+ });
1424
+ function append(output, value) {
1425
+ output.content += value;
1426
+ }
1427
+ function addSpan(output, start, span) {
1428
+ const length = output.content.length - start;
1429
+ if (length > 0) output.spans.push({
1430
+ ...span,
1431
+ offset: start,
1432
+ length
1433
+ });
1434
+ }
1435
+ function finish(output) {
1436
+ const unique = /* @__PURE__ */ new Map();
1437
+ for (const span of output.spans) {
1438
+ const key = [
1439
+ span.type,
1440
+ span.offset,
1441
+ span.length,
1442
+ span.url ?? "",
1443
+ span.tag ?? "",
1444
+ span.username ?? "",
1445
+ span.id ?? ""
1446
+ ].join("\0");
1447
+ if (!unique.has(key)) unique.set(key, span);
1448
+ }
1449
+ return {
1450
+ content: output.content,
1451
+ spans: validateSpans(output.content, [...unique.values()]).sort((left, right) => left.offset - right.offset || right.length - left.length)
1452
+ };
1453
+ }
1454
+ function allowedProtocols(options) {
1455
+ const values = options.allowedLinkProtocols ?? ["http", "https"];
1456
+ return new Set(values.map((value) => `${value.toLowerCase().replace(/:$/u, "")}:`).filter((value) => value !== ":"));
1457
+ }
1458
+ function safeLink(value, protocols) {
1459
+ try {
1460
+ const url = new URL(value);
1461
+ return url.hostname !== "" && protocols.has(url.protocol.toLowerCase()) ? value : null;
1462
+ } catch {
1463
+ return null;
1464
+ }
1465
+ }
1466
+ function isEscaped(value, index) {
1467
+ let slashes = 0;
1468
+ for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) slashes += 1;
1469
+ return slashes % 2 === 1;
1470
+ }
1471
+ function findClosing(value, marker, from) {
1472
+ let cursor = from;
1473
+ while (cursor < value.length) {
1474
+ const found = value.indexOf(marker, cursor);
1475
+ if (found < 0) return -1;
1476
+ if (!isEscaped(value, found)) return found;
1477
+ cursor = found + marker.length;
1478
+ }
1479
+ return -1;
1480
+ }
1481
+ function findBracketEnd(value, from, open, close) {
1482
+ let depth = 1;
1483
+ for (let cursor = from; cursor < value.length; cursor += 1) {
1484
+ const character = value[cursor];
1485
+ if (character === "\\") {
1486
+ cursor += 1;
1487
+ continue;
1488
+ }
1489
+ if (character === open) depth += 1;
1490
+ if (character === close) {
1491
+ depth -= 1;
1492
+ if (depth === 0) return cursor;
1493
+ }
1494
+ }
1495
+ return -1;
1496
+ }
1497
+ function decodeEntities(value) {
1498
+ return value.replace(/&(#(?:x[\da-f]+|\d+)|[a-z][a-z\d]+);/giu, (raw, entity) => {
1499
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
1500
+ const code = Number.parseInt(entity.slice(2), 16);
1501
+ return Number.isFinite(code) && code <= 1114111 ? String.fromCodePoint(code) : raw;
1502
+ }
1503
+ if (entity.startsWith("#")) {
1504
+ const code = Number.parseInt(entity.slice(1), 10);
1505
+ return Number.isFinite(code) && code <= 1114111 ? String.fromCodePoint(code) : raw;
1506
+ }
1507
+ return HTML_ENTITIES[entity.toLowerCase()] ?? raw;
1508
+ });
1509
+ }
1510
+ function normalizeCodeSpan(value) {
1511
+ const normalized = value.replace(/\r\n?|\n/gu, " ");
1512
+ if (normalized.length >= 2 && normalized.startsWith(" ") && normalized.endsWith(" ") && !/^ +$/u.test(normalized)) return normalized.slice(1, -1);
1513
+ return normalized;
1514
+ }
1515
+ function markdownStyle(source, index) {
1516
+ const styles = [
1517
+ {
1518
+ marker: "**",
1519
+ type: SpanType.Bold
1520
+ },
1521
+ {
1522
+ marker: "__",
1523
+ type: SpanType.Bold
1524
+ },
1525
+ {
1526
+ marker: "~~",
1527
+ type: SpanType.Strike
1528
+ },
1529
+ {
1530
+ marker: "||",
1531
+ type: SpanType.Spoiler
1532
+ },
1533
+ {
1534
+ marker: "*",
1535
+ type: SpanType.Italic
1536
+ },
1537
+ {
1538
+ marker: "_",
1539
+ type: SpanType.Italic
1540
+ }
1541
+ ];
1542
+ for (const style of styles) {
1543
+ if (!source.startsWith(style.marker, index)) continue;
1544
+ if (style.marker.includes("_")) {
1545
+ const before = source[index - 1] ?? "";
1546
+ const after = source[index + style.marker.length] ?? "";
1547
+ if (/[\p{L}\p{N}]/u.test(before) && /[\p{L}\p{N}]/u.test(after)) continue;
1548
+ }
1549
+ return style;
1550
+ }
1551
+ }
1552
+ function parseMarkdownInline(source, output, protocols) {
1553
+ let index = 0;
1554
+ while (index < source.length) {
1555
+ const character = source[index] ?? "";
1556
+ if (character === "\\" && index + 1 < source.length) {
1557
+ append(output, source[index + 1] ?? "");
1558
+ index += 2;
1559
+ continue;
1560
+ }
1561
+ if (character === "`") {
1562
+ const run = source.slice(index).match(/^`+/u)?.[0] ?? "`";
1563
+ const close = findClosing(source, run, index + run.length);
1564
+ if (close > index + run.length) {
1565
+ const start = output.content.length;
1566
+ append(output, normalizeCodeSpan(source.slice(index + run.length, close)));
1567
+ addSpan(output, start, { type: SpanType.Monospace });
1568
+ index = close + run.length;
1569
+ continue;
1570
+ }
1571
+ }
1572
+ const image = source.startsWith("![", index);
1573
+ if ((image || character === "[") && !isEscaped(source, index)) {
1574
+ const labelStart = index + (image ? 2 : 1);
1575
+ const labelEnd = findBracketEnd(source, labelStart, "[", "]");
1576
+ if (labelEnd >= 0 && source[labelEnd + 1] === "(") {
1577
+ const destinationEnd = findBracketEnd(source, labelEnd + 2, "(", ")");
1578
+ if (destinationEnd >= 0) {
1579
+ const label = source.slice(labelStart, labelEnd);
1580
+ const destination = source.slice(labelEnd + 2, destinationEnd).trim().replace(/\\([\\)])/gu, "$1");
1581
+ const start = output.content.length;
1582
+ parseMarkdownInline(label, output, protocols);
1583
+ if (!image) {
1584
+ const url = safeLink(destination, protocols);
1585
+ if (url) addSpan(output, start, {
1586
+ type: SpanType.Link,
1587
+ url
1588
+ });
1589
+ }
1590
+ index = destinationEnd + 1;
1591
+ continue;
1592
+ }
1593
+ }
1594
+ }
1595
+ if (source.slice(index, index + 3).toLowerCase() === "<u>") {
1596
+ const close = source.toLowerCase().indexOf("</u>", index + 3);
1597
+ if (close > index + 3) {
1598
+ const start = output.content.length;
1599
+ parseMarkdownInline(source.slice(index + 3, close), output, protocols);
1600
+ addSpan(output, start, { type: SpanType.Underline });
1601
+ index = close + 4;
1602
+ continue;
1603
+ }
1604
+ }
1605
+ if (character === "<") {
1606
+ const close = source.indexOf(">", index + 1);
1607
+ if (close > index + 1) {
1608
+ const candidate = decodeEntities(source.slice(index + 1, close));
1609
+ const url = safeLink(candidate, protocols);
1610
+ if (url) {
1611
+ const start = output.content.length;
1612
+ append(output, candidate);
1613
+ addSpan(output, start, {
1614
+ type: SpanType.Link,
1615
+ url
1616
+ });
1617
+ index = close + 1;
1618
+ continue;
1619
+ }
1620
+ }
1621
+ }
1622
+ const style = markdownStyle(source, index);
1623
+ if (style) {
1624
+ const close = findClosing(source, style.marker, index + style.marker.length);
1625
+ if (close > index + style.marker.length) {
1626
+ const start = output.content.length;
1627
+ parseMarkdownInline(source.slice(index + style.marker.length, close), output, protocols);
1628
+ addSpan(output, start, { type: style.type });
1629
+ index = close + style.marker.length;
1630
+ continue;
1631
+ }
1632
+ }
1633
+ if (character === "&") {
1634
+ const entity = source.slice(index).match(/^&(?:#(?:x[\da-f]+|\d+)|[a-z][a-z\d]+);/iu)?.[0];
1635
+ if (entity) {
1636
+ append(output, decodeEntities(entity));
1637
+ index += entity.length;
1638
+ continue;
1639
+ }
1640
+ }
1641
+ append(output, character);
1642
+ index += 1;
1643
+ }
1644
+ }
1645
+ function lineEnd(source, from) {
1646
+ const found = source.indexOf("\n", from);
1647
+ return found < 0 ? source.length : found;
1648
+ }
1649
+ /**
1650
+ * Преобразует безопасное подмножество Markdown в текст и wire-spans.
1651
+ *
1652
+ * Поддерживаются bold, italic, strike, spoiler, inline/fenced code, ссылки, underline
1653
+ * через `<u>` и цитаты `>`. Неподдерживаемая и незакрытая разметка остаётся текстом.
1654
+ * У изображений сохраняется alt-текст без URL.
1655
+ */
1656
+ function parseMarkdown(source, options = {}) {
1657
+ const output = {
1658
+ content: "",
1659
+ spans: []
1660
+ };
1661
+ const protocols = allowedProtocols(options);
1662
+ let position = 0;
1663
+ while (position < source.length) {
1664
+ const end = lineEnd(source, position);
1665
+ const line = source.slice(position, end);
1666
+ const hasNewline = end < source.length;
1667
+ const fence = line.match(/^ {0,3}(`{3,}|~{3,})[^\n]*$/u)?.[1];
1668
+ if (fence) {
1669
+ let closeStart = hasNewline ? end + 1 : source.length;
1670
+ let closeEnd = closeStart;
1671
+ let found = false;
1672
+ while (closeStart < source.length) {
1673
+ closeEnd = lineEnd(source, closeStart);
1674
+ const marker = source.slice(closeStart, closeEnd).match(/^ {0,3}(`{3,}|~{3,})\s*$/u)?.[1];
1675
+ if (marker !== void 0 && marker[0] === fence[0] && marker.length >= fence.length) {
1676
+ found = true;
1677
+ break;
1678
+ }
1679
+ closeStart = closeEnd < source.length ? closeEnd + 1 : source.length;
1680
+ }
1681
+ if (found) {
1682
+ const bodyStart = end + 1;
1683
+ let body = source.slice(bodyStart, closeStart);
1684
+ if (body.endsWith("\n")) body = body.slice(0, -1);
1685
+ const start = output.content.length;
1686
+ append(output, body);
1687
+ addSpan(output, start, { type: SpanType.Monospace });
1688
+ if (closeEnd < source.length) append(output, "\n");
1689
+ position = closeEnd < source.length ? closeEnd + 1 : source.length;
1690
+ continue;
1691
+ }
1692
+ }
1693
+ const quote = line.match(/^ {0,3}> ?(.*)$/u);
1694
+ if (quote) {
1695
+ const start = output.content.length;
1696
+ parseMarkdownInline(quote[1] ?? "", output, protocols);
1697
+ addSpan(output, start, { type: SpanType.Quote });
1698
+ } else parseMarkdownInline(line, output, protocols);
1699
+ if (hasNewline) append(output, "\n");
1700
+ position = hasNewline ? end + 1 : source.length;
1701
+ }
1702
+ return finish(output);
1703
+ }
1704
+ function readAttribute(source, name) {
1705
+ const pattern = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\\x60]+))`, "iu");
1706
+ const match = source.match(pattern);
1707
+ const value = match?.[1] ?? match?.[2] ?? match?.[3];
1708
+ return value === void 0 ? void 0 : decodeEntities(value);
1709
+ }
1710
+ function htmlSpan(tag, attributes, protocols) {
1711
+ switch (tag) {
1712
+ case "b":
1713
+ case "strong": return { type: SpanType.Bold };
1714
+ case "em":
1715
+ case "i": return { type: SpanType.Italic };
1716
+ case "u": return { type: SpanType.Underline };
1717
+ case "del":
1718
+ case "s":
1719
+ case "strike": return { type: SpanType.Strike };
1720
+ case "code":
1721
+ case "pre": return { type: SpanType.Monospace };
1722
+ case "blockquote": return { type: SpanType.Quote };
1723
+ case "a": {
1724
+ const href = readAttribute(attributes, "href");
1725
+ const url = href ? safeLink(href, protocols) : null;
1726
+ return url ? {
1727
+ type: SpanType.Link,
1728
+ url
1729
+ } : void 0;
1730
+ }
1731
+ case "span": {
1732
+ const classes = readAttribute(attributes, "class")?.split(/\s+/u) ?? [];
1733
+ if (readAttribute(attributes, "data-spoiler") === "true" || classes.some((value) => value.endsWith("-spoiler"))) return { type: SpanType.Spoiler };
1734
+ if (classes.some((value) => value.endsWith("-quote"))) return { type: SpanType.Quote };
1735
+ return;
1736
+ }
1737
+ default: return;
1738
+ }
1739
+ }
1740
+ function ensureBlockBreak(output) {
1741
+ if (output.content !== "" && !output.content.endsWith("\n")) append(output, "\n");
1742
+ }
1743
+ function findHtmlTagEnd(source, from) {
1744
+ let quote;
1745
+ for (let index = from; index < source.length; index += 1) {
1746
+ const character = source[index];
1747
+ if (quote) {
1748
+ if (character === quote) quote = void 0;
1749
+ continue;
1750
+ }
1751
+ if (character === "\"" || character === "'") {
1752
+ quote = character;
1753
+ continue;
1754
+ }
1755
+ if (character === ">") return index;
1756
+ }
1757
+ return -1;
1758
+ }
1759
+ /**
1760
+ * Преобразует ограниченный HTML в обычный текст и wire-spans.
1761
+ *
1762
+ * Разрешены только семантические теги форматирования. Атрибуты событий игнорируются,
1763
+ * содержимое `script`, `style`, `iframe`, `object`, SVG и подобных активных элементов
1764
+ * удаляется. Опасный `href` сохраняет текст ссылки, но не создаёт span.
1765
+ */
1766
+ function parseHtml(source, options = {}) {
1767
+ const output = {
1768
+ content: "",
1769
+ spans: []
1770
+ };
1771
+ const protocols = allowedProtocols(options);
1772
+ const stack = [];
1773
+ let position = 0;
1774
+ const closeTag = (tag) => {
1775
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
1776
+ const open = stack[index];
1777
+ if (open?.tag !== tag) continue;
1778
+ stack.splice(index, 1);
1779
+ addSpan(output, open.start, open.span);
1780
+ return;
1781
+ }
1782
+ };
1783
+ while (position < source.length) {
1784
+ if (source.startsWith("<!--", position)) {
1785
+ const end = source.indexOf("-->", position + 4);
1786
+ position = end < 0 ? source.length : end + 3;
1787
+ continue;
1788
+ }
1789
+ if (source[position] === "<") {
1790
+ const end = findHtmlTagEnd(source, position + 1);
1791
+ if (end >= 0) {
1792
+ const parsed = source.slice(position, end + 1).match(/^<\s*(\/?)\s*([a-z][\w-]*)([\s\S]*?)\/?\s*>$/iu);
1793
+ if (parsed) {
1794
+ const closing = parsed[1] === "/";
1795
+ const tag = (parsed[2] ?? "").toLowerCase();
1796
+ const attributes = parsed[3] ?? "";
1797
+ if (!closing && HTML_DROPPED_TAGS.has(tag)) {
1798
+ const closingPattern = new RegExp(`<\\s*\\/\\s*${tag}\\s*>`, "igu");
1799
+ closingPattern.lastIndex = end + 1;
1800
+ const match = closingPattern.exec(source);
1801
+ position = match ? match.index + match[0].length : source.length;
1802
+ continue;
1803
+ }
1804
+ if (tag === "br" && !closing) {
1805
+ append(output, "\n");
1806
+ position = end + 1;
1807
+ continue;
1808
+ }
1809
+ if (tag === "img" && !closing) {
1810
+ append(output, readAttribute(attributes, "alt") ?? "");
1811
+ position = end + 1;
1812
+ continue;
1813
+ }
1814
+ if (closing) {
1815
+ closeTag(tag);
1816
+ if (HTML_BLOCK_TAGS.has(tag)) ensureBlockBreak(output);
1817
+ } else {
1818
+ if (HTML_BLOCK_TAGS.has(tag)) ensureBlockBreak(output);
1819
+ const span = htmlSpan(tag, attributes, protocols);
1820
+ if (span) stack.push({
1821
+ tag,
1822
+ start: output.content.length,
1823
+ span
1824
+ });
1825
+ }
1826
+ position = end + 1;
1827
+ continue;
1828
+ }
1829
+ }
1830
+ }
1831
+ const next = source.indexOf("<", position + 1);
1832
+ const end = next < 0 ? source.length : next;
1833
+ append(output, decodeEntities(source.slice(position, end)));
1834
+ position = end;
1835
+ }
1836
+ while (stack.length > 0) {
1837
+ const open = stack.pop();
1838
+ if (open) addSpan(output, open.start, open.span);
1839
+ }
1840
+ while (output.content.endsWith("\n")) output.content = output.content.slice(0, -1);
1841
+ return finish(output);
1842
+ }
1843
+ //#endregion
1844
+ //#region src/builders/markup.ts
1845
+ function validateMarkup(value) {
1846
+ if (!value || typeof value.content !== "string" || !Array.isArray(value.spans)) throw new ItdConfigError("Разметка должна содержать строку content и массив spans");
1847
+ return {
1848
+ content: value.content,
1849
+ spans: validateSpans(value.content, value.spans).sort((left, right) => left.offset - right.offset || right.length - left.length)
1850
+ };
1851
+ }
1852
+ function entityName(value, prefix, label) {
1853
+ const name = value.startsWith(prefix) ? value.slice(prefix.length) : value;
1854
+ if (name === "" || /\s/u.test(name)) throw new ItdConfigError(`${label} должен быть непустым и не содержать пробелов`);
1855
+ return name;
1856
+ }
1857
+ /**
1858
+ * Неизменяемый билдер текста с разметкой.
1859
+ *
1860
+ * Каждый метод дописывает фрагмент и сам считает `offset` и `length` в единицах UTF-16 —
1861
+ * именно такие индексы использует JavaScript-редактор сайта.
1862
+ */
1863
+ var MarkupBuilder = class MarkupBuilder {
1864
+ /** @internal */
1865
+ [BUILDER] = true;
1866
+ #content;
1867
+ #spans;
1868
+ /** @internal Создавайте билдер функцией {@link markup}. */
1869
+ constructor(content, spans) {
1870
+ this.#content = content;
1871
+ this.#spans = spans;
1872
+ }
1873
+ /** Дописывает обычный текст без разметки. */
1874
+ text(value) {
1875
+ return new MarkupBuilder(this.#content + value, this.#spans);
1876
+ }
1877
+ /** Дописывает переводы строк. */
1878
+ newline(count = 1) {
1879
+ if (!Number.isInteger(count) || count < 0) throw new ItdConfigError("Количество переводов строк должно быть целым неотрицательным");
1880
+ return this.text("\n".repeat(count));
1881
+ }
1882
+ /** @internal Добавляет готовый фрагмент и сдвигает его spans к текущему концу текста. */
1883
+ #formatted(value, outerSpans) {
1884
+ const fragment = typeof value === "string" ? {
1885
+ content: value,
1886
+ spans: []
1887
+ } : resolveMarkup(value);
1888
+ if (fragment.content.length === 0) throw new ItdConfigError("Размечаемый фрагмент не может быть пустым");
1889
+ const offset = this.#content.length;
1890
+ const shifted = fragment.spans.map((span) => ({
1891
+ ...span,
1892
+ offset: span.offset + offset
1893
+ }));
1894
+ const outer = outerSpans.map((span) => ({
1895
+ ...span,
1896
+ offset,
1897
+ length: fragment.content.length
1898
+ }));
1899
+ return new MarkupBuilder(this.#content + fragment.content, [
1900
+ ...this.#spans,
1901
+ ...shifted,
1902
+ ...outer
1903
+ ]);
1904
+ }
1905
+ /**
1906
+ * Дописывает фрагмент с произвольным типом разметки.
1907
+ *
1908
+ * Вложенный билдер позволяет форматировать часть фрагмента дополнительным стилем.
1909
+ * Для нескольких стилей на всём фрагменте используйте {@link styled}.
1910
+ */
1911
+ span(value, span) {
1912
+ return this.#formatted(value, [span]);
1913
+ }
1914
+ /**
1915
+ * Дописывает фрагмент с несколькими стилями на одном диапазоне.
1916
+ *
1917
+ * Для `link`, которому нужен `url`, используйте {@link link}; произвольные spans с
1918
+ * метаданными можно объединять через вложенные вызовы {@link span}.
1919
+ */
1920
+ styled(value, ...types) {
1921
+ if (types.length === 0) return this.#formatted(value, []);
1922
+ return this.#formatted(value, types.map((type) => ({ type })));
1923
+ }
1924
+ /** Дописывает `#хэштег` и сохраняет имя без решётки в `tag`. */
1925
+ hashtag(tag) {
1926
+ const name = entityName(tag, "#", "Хэштег");
1927
+ return this.span(`#${name}`, {
1928
+ type: SpanType.Hashtag,
1929
+ tag: name
1930
+ });
1931
+ }
1932
+ /** Дописывает `@username` и сохраняет имя пользователя в `username`. */
1933
+ mention(username) {
1934
+ const name = entityName(username, "@", "Упоминание");
1935
+ return this.span(`@${name}`, {
1936
+ type: SpanType.Mention,
1937
+ username: name
1938
+ });
1939
+ }
1940
+ /**
1941
+ * Дописывает ссылку.
1942
+ *
1943
+ * Для строки адрес по умолчанию становится и текстом ссылки. Вложенному форматированному
1944
+ * фрагменту URL нужно передать явно.
1945
+ */
1946
+ link(content, url) {
1947
+ const resolvedUrl = url ?? (typeof content === "string" ? content : void 0);
1948
+ if (resolvedUrl === void 0) throw new ItdConfigError("Для форматированного текста ссылки нужно явно передать URL");
1949
+ return this.span(content, {
1950
+ type: SpanType.Link,
1951
+ url: resolvedUrl
1952
+ });
1953
+ }
1954
+ bold(content) {
1955
+ return this.span(content, { type: SpanType.Bold });
1956
+ }
1957
+ italic(content) {
1958
+ return this.span(content, { type: SpanType.Italic });
1959
+ }
1960
+ underline(content) {
1961
+ return this.span(content, { type: SpanType.Underline });
1962
+ }
1963
+ strike(content) {
1964
+ return this.span(content, { type: SpanType.Strike });
1965
+ }
1966
+ spoiler(content) {
1967
+ return this.span(content, { type: SpanType.Spoiler });
1968
+ }
1969
+ monospace(content) {
1970
+ return this.span(content, { type: SpanType.Monospace });
1971
+ }
1972
+ quote(content) {
1973
+ return this.span(content, { type: SpanType.Quote });
1974
+ }
1975
+ build() {
1976
+ return validateMarkup({
1977
+ content: this.#content,
1978
+ spans: this.#spans
1979
+ });
1980
+ }
1981
+ toJSON() {
1982
+ return this.build();
1983
+ }
1984
+ };
1985
+ /** Начинает сборку текста с автоматически вычисляемыми смещениями. */
1986
+ function markup(content = "") {
1987
+ return new MarkupBuilder(content, []);
1988
+ }
1989
+ /** Приводит любую форму разметки к тексту и массиву spans. */
1990
+ function resolveMarkup(input) {
1991
+ return resolveInput(input, () => markup(), validateMarkup);
1992
+ }
1993
+ function trimUrlEnd(value) {
1994
+ let result = value;
1995
+ const pairs = [
1996
+ ["(", ")"],
1997
+ ["[", "]"],
1998
+ ["{", "}"]
1999
+ ];
2000
+ let changed;
2001
+ do {
2002
+ const before = result;
2003
+ while (/[.,!?;:…'"»”]$/u.test(result)) result = result.slice(0, -1);
2004
+ for (const [open, close] of pairs) while (result.endsWith(close) && result.split(close).length > result.split(open).length) result = result.slice(0, -1);
2005
+ changed = result !== before;
2006
+ } while (changed);
2007
+ return result;
2008
+ }
2009
+ function overlaps(span, start, end) {
2010
+ return span.offset < end && start < span.offset + span.length;
2011
+ }
2012
+ function isHttpUrl(value) {
2013
+ try {
2014
+ const url = new URL(value);
2015
+ return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
2016
+ } catch {
2017
+ return false;
2018
+ }
2019
+ }
2020
+ function previousCodePoint(text, index) {
2021
+ if (index <= 0) return "";
2022
+ const last = text.charCodeAt(index - 1);
2023
+ if (last >= 56320 && last <= 57343 && index >= 2) {
2024
+ const first = text.charCodeAt(index - 2);
2025
+ if (first >= 55296 && first <= 56319) return text.slice(index - 2, index);
2026
+ }
2027
+ return text[index - 1] ?? "";
2028
+ }
2029
+ /**
2030
+ * Находит в тексте те сущности, которые сайт получает после серверного разбора:
2031
+ * HTTP(S)-ссылки, `#хэштеги` и `@упоминания`.
2032
+ *
2033
+ * Смещения выражены в UTF-16 code units, поэтому совпадают с `String#slice`,
2034
+ * `substring`, DOM Selection и wire-форматом сайта даже при наличии эмодзи.
2035
+ */
2036
+ function autoSpans(text, options = {}) {
2037
+ const includeLinks = options.links ?? true;
2038
+ const includeHashtags = options.hashtags ?? true;
2039
+ const includeMentions = options.mentions ?? true;
2040
+ const spans = [];
2041
+ const links = [];
2042
+ for (const match of text.matchAll(/https?:\/\/[^\s<>"'`]+/giu)) {
2043
+ const raw = trimUrlEnd(match[0]);
2044
+ if (raw.length === 0 || match.index === void 0 || !isHttpUrl(raw)) continue;
2045
+ links.push({
2046
+ type: SpanType.Link,
2047
+ offset: match.index,
2048
+ length: raw.length,
2049
+ url: raw
2050
+ });
2051
+ }
2052
+ if (includeLinks) spans.push(...links);
2053
+ if (includeHashtags || includeMentions) for (const match of text.matchAll(/#([\p{L}\p{M}\p{N}_]+)|@([a-z\d][a-z\d._-]*)/giu)) {
2054
+ if (match.index === void 0) continue;
2055
+ const offset = match.index;
2056
+ const previous = previousCodePoint(text, offset);
2057
+ if (/[\p{L}\p{M}\p{N}_#@.]/u.test(previous)) continue;
2058
+ const end = offset + match[0].length;
2059
+ if (links.some((span) => overlaps(span, offset, end))) continue;
2060
+ const hashtag = match[1];
2061
+ const username = match[2]?.replace(/[._-]+$/u, "");
2062
+ if (hashtag !== void 0 && includeHashtags) spans.push({
2063
+ type: SpanType.Hashtag,
2064
+ offset,
2065
+ length: match[0].length,
2066
+ tag: hashtag
2067
+ });
2068
+ else if (username !== void 0 && includeMentions) spans.push({
2069
+ type: SpanType.Mention,
2070
+ offset,
2071
+ length: username.length + 1,
2072
+ username
2073
+ });
2074
+ }
2075
+ return spans.sort((left, right) => left.offset - right.offset || right.length - left.length);
2076
+ }
2077
+ //#endregion
2078
+ //#region src/builders/poll.ts
2079
+ /** Минимальное число вариантов ответа. */
2080
+ const MIN_OPTIONS = 2;
2081
+ /** Ограничения опроса — те же, что действуют в интерфейсе итд.com. */
2082
+ const MAX_OPTIONS = 10;
2083
+ const MAX_QUESTION_LENGTH = 200;
2084
+ const MAX_OPTION_LENGTH = 100;
2085
+ /**
2086
+ * Проверяет данные опроса.
2087
+ *
2088
+ * Применяется и к билдеру, и к обычному объекту — правила одни и те же.
2089
+ *
2090
+ * @throws {ItdConfigError} если вопрос пуст, вариантов меньше двух или есть дубли
2091
+ */
2092
+ function validatePoll(input) {
2093
+ const question = input?.question;
2094
+ if (typeof question !== "string" || question.trim() === "") throw new ItdConfigError("Опрос требует непустого вопроса");
2095
+ if (question.trim().length > MAX_QUESTION_LENGTH) throw new ItdConfigError(`Вопрос длиннее ${MAX_QUESTION_LENGTH} символов (передано ${question.trim().length})`);
2096
+ const texts = (Array.isArray(input.options) ? input.options : []).map((option, index) => {
2097
+ const text = typeof option?.text === "string" ? option.text.trim() : "";
2098
+ if (text === "") throw new ItdConfigError(`Вариант ответа №${index + 1} пуст — у каждого должен быть текст`);
2099
+ if (text.length > MAX_OPTION_LENGTH) throw new ItdConfigError(`Вариант ответа №${index + 1} длиннее ${MAX_OPTION_LENGTH} символов (передано ${text.length})`);
2100
+ return text;
2101
+ });
2102
+ if (texts.length < MIN_OPTIONS) throw new ItdConfigError(`Опрос требует минимум ${MIN_OPTIONS} варианта, передано: ${texts.length}`);
2103
+ if (texts.length > MAX_OPTIONS) throw new ItdConfigError(`Опрос допускает не больше ${MAX_OPTIONS} вариантов, передано: ${texts.length}`);
2104
+ const seen = /* @__PURE__ */ new Set();
2105
+ for (const text of texts) {
2106
+ if (seen.has(text)) throw new ItdConfigError(`Вариант «${text}» повторяется — варианты должны различаться`);
2107
+ seen.add(text);
2108
+ }
2109
+ return {
2110
+ question: question.trim(),
2111
+ options: texts.map((text) => ({ text })),
2112
+ multipleChoice: input.multipleChoice ?? false
2113
+ };
2114
+ }
2115
+ /**
2116
+ * Билдер опроса.
2117
+ *
2118
+ * Неизменяемый: каждый вызов возвращает новый экземпляр, поэтому заготовку можно
2119
+ * переиспользовать, не боясь её испортить. Создаётся функцией {@link poll}.
2120
+ */
2121
+ var PollBuilder = class PollBuilder {
2122
+ /** @internal */
2123
+ [BUILDER] = true;
2124
+ #state;
2125
+ /** @internal Создавайте билдер функцией {@link poll}. */
2126
+ constructor(state) {
2127
+ this.#state = state;
2128
+ }
2129
+ /** Задаёт вопрос. */
2130
+ question(text) {
2131
+ return new PollBuilder({
2132
+ ...this.#state,
2133
+ question: text
2134
+ });
2135
+ }
2136
+ /** Добавляет один вариант ответа. */
2137
+ option(text) {
2138
+ return new PollBuilder({
2139
+ ...this.#state,
2140
+ options: [...this.#state.options, { text }]
2141
+ });
2142
+ }
2143
+ /**
2144
+ * Добавляет несколько вариантов сразу.
2145
+ *
2146
+ * @example
2147
+ * ```ts
2148
+ * poll('ну как?').options('да', 'нет', 'не знаю');
2149
+ * ```
2150
+ */
2151
+ options(...texts) {
2152
+ return new PollBuilder({
2153
+ ...this.#state,
2154
+ options: [...this.#state.options, ...texts.map((text) => ({ text }))]
2155
+ });
2156
+ }
2157
+ /** Разрешает выбор нескольких вариантов. */
2158
+ multipleChoice(enabled = true) {
2159
+ return new PollBuilder({
2160
+ ...this.#state,
2161
+ multipleChoice: enabled
2162
+ });
2163
+ }
2164
+ build() {
2165
+ return validatePoll(this.#state);
2166
+ }
2167
+ toJSON() {
2168
+ return this.build();
2169
+ }
2170
+ };
2171
+ /**
2172
+ * Начинает сборку опроса.
2173
+ *
2174
+ * @param question вопрос; можно задать позже методом {@link PollBuilder.question}
2175
+ *
2176
+ * @example
2177
+ * ```ts
2178
+ * import { poll } from 'itd-api';
2179
+ *
2180
+ * const q = poll('Какой язык лучше?')
2181
+ * .options('TypeScript', 'JavaScript')
2182
+ * .multipleChoice();
2183
+ *
2184
+ * await itd.posts.create({ content: 'голосуем', poll: q });
2185
+ * ```
2186
+ */
2187
+ function poll(question = "") {
2188
+ return new PollBuilder({
2189
+ question,
2190
+ options: []
2191
+ });
2192
+ }
2193
+ /** Приводит любую форму входа к готовым данным опроса. */
2194
+ function resolvePoll(input) {
2195
+ return resolveInput(input, () => poll(), validatePoll);
2196
+ }
2197
+ //#endregion
2198
+ //#region src/builders/post.ts
2199
+ /** UUID любой версии. */
2200
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2201
+ const BUILD_UPDATE = Symbol.for("itd.postBuilder.update");
2202
+ /**
2203
+ * Проверяет и нормализует данные поста.
2204
+ *
2205
+ * Отдельного внимания заслуживает `wallRecipientId`: API принимает там **только UUID**,
2206
+ * а имя пользователя молча приводит к ошибке на сервере. Проверка здесь превращает
2207
+ * это в понятное сообщение до отправки запроса.
2208
+ *
2209
+ * @throws {ItdConfigError} если пост пуст или получатель стены задан именем пользователя
2210
+ */
2211
+ function validatePost(input) {
2212
+ const content = typeof input?.content === "string" ? input.content : "";
2213
+ const attachmentIds = input?.attachmentIds ?? [];
2214
+ const files = input?.files ?? [];
2215
+ const hasContent = content.trim() !== "";
2216
+ const hasAttachments = attachmentIds.length > 0 || files.length > 0;
2217
+ const hasPoll = Boolean(input?.poll);
2218
+ if (!hasContent && !hasAttachments && !hasPoll) throw new ItdConfigError("Пост пуст: нужен текст, вложение или опрос");
2219
+ const wallRecipientId = input.wallRecipientId;
2220
+ if (wallRecipientId !== void 0 && wallRecipientId !== null) {
2221
+ if (!UUID_PATTERN.test(wallRecipientId)) throw new ItdConfigError(`wallRecipientId должен быть UUID, а не именем пользователя (получено: «${wallRecipientId}»). Идентификатор можно взять из профиля: (await itd.users.get(username)).id`);
2222
+ }
2223
+ const { poll: inputPoll, ...data } = input;
2224
+ return {
2225
+ ...data,
2226
+ ...input.spans !== void 0 ? { spans: validateSpans(content, input.spans) } : {},
2227
+ ...inputPoll !== void 0 ? { poll: resolvePoll(inputPoll) } : {}
2228
+ };
2229
+ }
2230
+ /**
2231
+ * Билдер поста.
2232
+ *
2233
+ * Неизменяемый: каждый вызов возвращает новый экземпляр, поэтому заготовку можно
2234
+ * переиспользовать. Создаётся функцией {@link post}.
2235
+ *
2236
+ * @example Заготовка для нескольких постов
2237
+ * ```ts
2238
+ * const onWall = post().onWall(userId);
2239
+ *
2240
+ * await itd.posts.create(onWall.content('первый'));
2241
+ * await itd.posts.create(onWall.content('второй')); // заготовка не испорчена
2242
+ * ```
2243
+ */
2244
+ var PostBuilder = class PostBuilder {
2245
+ /** @internal */
2246
+ [BUILDER] = true;
2247
+ #state;
2248
+ /** @internal Создавайте билдер функцией {@link post}. */
2249
+ constructor(state) {
2250
+ this.#state = state;
2251
+ }
2252
+ /**
2253
+ * Задаёт текст поста, заменяя прежний вместе с его разметкой.
2254
+ *
2255
+ * Spans привязаны к конкретному тексту, поэтому после замены их нужно задать заново
2256
+ * через {@link spans}, {@link markup} или {@link autoSpans}.
2257
+ */
2258
+ content(text) {
2259
+ const state = {
2260
+ ...this.#state,
2261
+ content: text,
2262
+ contentSet: true
2263
+ };
2264
+ delete state.spans;
2265
+ return new PostBuilder(state);
2266
+ }
2267
+ /** Дописывает текст к уже заданному. */
2268
+ append(text) {
2269
+ const separator = this.#state.content === "" ? "" : "\n";
2270
+ return new PostBuilder({
2271
+ ...this.#state,
2272
+ content: this.#state.content + separator + text,
2273
+ contentSet: true
2274
+ });
2275
+ }
2276
+ /**
2277
+ * Задаёт готовую разметку текста. Смещения проверяются при {@link build}.
2278
+ *
2279
+ * Для автоматического поиска сущностей есть {@link autoSpans}, а для вычисления смещений
2280
+ * при сборке текста — {@link markup}.
2281
+ */
2282
+ spans(spans) {
2283
+ return new PostBuilder({
2284
+ ...this.#state,
2285
+ spans
2286
+ });
2287
+ }
2288
+ /**
2289
+ * Заменяет текст и разметку результатом {@link MarkupBuilder}.
2290
+ *
2291
+ * @example
2292
+ * ```ts
2293
+ * post().markup((m) => m.text('смотрите ').hashtag('котики').text(' от ').mention('nowkie'));
2294
+ * ```
2295
+ */
2296
+ markup(input) {
2297
+ const result = resolveMarkup(input);
2298
+ return new PostBuilder({
2299
+ ...this.#state,
2300
+ content: result.content,
2301
+ contentSet: true,
2302
+ spans: result.spans
2303
+ });
2304
+ }
2305
+ /** Заменяет текст и spans результатом безопасного разбора Markdown. */
2306
+ markdown(source, options = {}) {
2307
+ return this.markup(parseMarkdown(source, options));
2308
+ }
2309
+ /** Заменяет текст и spans результатом безопасного разбора ограниченного HTML. */
2310
+ html(source, options = {}) {
2311
+ return this.markup(parseHtml(source, options));
2312
+ }
2313
+ /**
2314
+ * Находит HTTP(S)-ссылки, хэштеги и упоминания в уже заданном тексте.
2315
+ *
2316
+ * Ручные стили сохраняются. Повторный вызов не дублирует уже найденные сущности.
2317
+ */
2318
+ autoSpans(options = {}) {
2319
+ const current = this.#state.spans ?? [];
2320
+ const entityTypes = /* @__PURE__ */ new Set([
2321
+ SpanType.Hashtag,
2322
+ SpanType.Mention,
2323
+ SpanType.Link
2324
+ ]);
2325
+ const detected = autoSpans(this.#state.content, options).filter((candidate) => {
2326
+ return !current.some((span) => entityTypes.has(span.type) && span.offset < candidate.offset + candidate.length && candidate.offset < span.offset + span.length);
2327
+ });
2328
+ return new PostBuilder({
2329
+ ...this.#state,
2330
+ spans: [...current, ...detected].sort((left, right) => left.offset - right.offset || right.length - left.length)
2331
+ });
2332
+ }
2333
+ /**
2334
+ * Публикует пост на стене другого пользователя.
2335
+ *
2336
+ * @param userId **UUID** пользователя; имя пользователя не подойдёт
2337
+ */
2338
+ onWall(userId) {
2339
+ return new PostBuilder({
2340
+ ...this.#state,
2341
+ wallRecipientId: userId
2342
+ });
2343
+ }
2344
+ /**
2345
+ * Прикладывает файл — он будет загружен перед публикацией.
2346
+ *
2347
+ * Порядок вызовов сохраняется в порядке вложений.
2348
+ */
2349
+ attach(file) {
2350
+ return new PostBuilder({
2351
+ ...this.#state,
2352
+ files: [...this.#state.files, file]
2353
+ });
2354
+ }
2355
+ /** Прикладывает уже загруженное вложение по его идентификатору. */
2356
+ attachId(attachmentId) {
2357
+ return new PostBuilder({
2358
+ ...this.#state,
2359
+ attachmentIds: [...this.#state.attachmentIds, attachmentId]
2360
+ });
2361
+ }
2362
+ /**
2363
+ * Добавляет опрос.
2364
+ *
2365
+ * Принимает объект, {@link PollBuilder} или функцию-настройщик.
2366
+ *
2367
+ * @example
2368
+ * ```ts
2369
+ * post('голосуем').poll((q) => q.question('ну как?').options('да', 'нет'));
2370
+ * ```
2371
+ */
2372
+ poll(input) {
2373
+ return new PostBuilder({
2374
+ ...this.#state,
2375
+ poll: resolvePoll(input)
2376
+ });
2377
+ }
2378
+ #input(includeEmptyContent = false) {
2379
+ const { content, contentSet: _contentSet, attachmentIds, files, ...rest } = this.#state;
2380
+ return {
2381
+ ...rest,
2382
+ ...content !== "" || includeEmptyContent ? { content } : {},
2383
+ ...attachmentIds.length > 0 ? { attachmentIds } : {},
2384
+ ...files.length > 0 ? { files } : {}
2385
+ };
2386
+ }
2387
+ build() {
2388
+ return validatePost(this.#input());
2389
+ }
2390
+ /** @internal Собирает данные по правилам `posts.update`, не применяя правила создания. */
2391
+ [BUILD_UPDATE]() {
2392
+ return validatePostUpdate(this.#input(this.#state.contentSet));
2393
+ }
2394
+ toJSON() {
2395
+ return this.build();
2396
+ }
2397
+ };
2398
+ /**
2399
+ * Начинает сборку поста.
2400
+ *
2401
+ * @param content текст; можно задать позже методом {@link PostBuilder.content}
2402
+ *
2403
+ * @example
2404
+ * ```ts
2405
+ * import { post } from 'itd-api';
2406
+ *
2407
+ * await itd.posts.create(
2408
+ * post('смотрите что нашёл')
2409
+ * .attach({ url: 'https://example.com/photo.jpg' })
2410
+ * .poll((q) => q.question('нравится?').options('да', 'нет')),
2411
+ * );
2412
+ * ```
2413
+ */
2414
+ function post(content) {
2415
+ return new PostBuilder({
2416
+ content: content ?? "",
2417
+ contentSet: content !== void 0,
2418
+ attachmentIds: [],
2419
+ files: []
2420
+ });
2421
+ }
2422
+ /** Приводит любую форму входа к готовым данным поста. */
2423
+ function resolvePost(input) {
2424
+ const resolved = typeof input === "function" ? input(post()) : input;
2425
+ return isBuilder(resolved) ? validatePost(resolved.build()) : validatePost(resolved);
2426
+ }
2427
+ function validatePostUpdate(input) {
2428
+ if (!input || typeof input !== "object") throw new ItdConfigError("Для обновления поста нужен объект с явно заданным content");
2429
+ const unsupported = [
2430
+ "wallRecipientId",
2431
+ "attachmentIds",
2432
+ "files",
2433
+ "poll"
2434
+ ].filter((key) => input[key] !== void 0);
2435
+ if (unsupported.length > 0) throw new ItdConfigError(`posts.update изменяет только content и spans; не поддерживаются: ${unsupported.join(", ")}`);
2436
+ if (input.content === void 0) throw new ItdConfigError("posts.update требует явно заданный content; обновление одних spans могло бы стереть текст поста");
2437
+ if (typeof input.content !== "string") throw new ItdConfigError("content обновляемого поста должен быть строкой");
2438
+ if (input.spans !== void 0 && !Array.isArray(input.spans)) throw new ItdConfigError("spans обновляемого поста должен быть массивом");
2439
+ return {
2440
+ content: input.content,
2441
+ ...input.spans !== void 0 ? { spans: validateSpans(input.content, input.spans) } : {}
2442
+ };
2443
+ }
2444
+ /** Приводит вход `posts.update` к поддерживаемым данным поста. */
2445
+ function resolvePostUpdate(input) {
2446
+ if (typeof input === "function") {
2447
+ const result = input(post());
2448
+ return isBuilder(result) ? result[BUILD_UPDATE]() : validatePostUpdate(result);
2449
+ }
2450
+ return isBuilder(input) ? input[BUILD_UPDATE]() : validatePostUpdate(input);
2451
+ }
2452
+ //#endregion
2453
+ //#region src/resources/posts.ts
2454
+ /** Курсорная позиция из параметров: если курсор задан — с него, иначе с начала. */
2455
+ function cursorStart(params) {
2456
+ return params.cursor ? { cursor: params.cursor } : {};
2457
+ }
2458
+ /**
2459
+ * Посты: лента, публикация, реакции, репосты, комментарии.
2460
+ *
2461
+ * Доступна как `itd.posts`.
2462
+ */
2463
+ var PostsResource = class extends BaseResource {
2464
+ #uploadFiles;
2465
+ /** Лента: `/api/posts`, курсорная пагинация. */
2466
+ #feed = this.paginated({
2467
+ operationId: "posts.list",
2468
+ path: () => "/api/posts",
2469
+ query: (p) => ({
2470
+ tab: p.tab,
2471
+ limit: p.limit
2472
+ }),
2473
+ start: cursorStart,
2474
+ read: (body) => readCursorPage(body, "posts"),
2475
+ mode: PaginationMode.Cursor
2476
+ });
2477
+ /** Стена пользователя: `/api/posts/user/{user}`. */
2478
+ #wall = this.paginated({
2479
+ operationId: "posts.byUser",
2480
+ path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}`,
2481
+ query: (p) => ({
2482
+ limit: p.limit,
2483
+ sort: p.sort,
2484
+ pinnedPostId: p.pinnedPostId
2485
+ }),
2486
+ start: cursorStart,
2487
+ read: (body) => readCursorPage(body, "posts"),
2488
+ mode: PaginationMode.Cursor
2489
+ });
2490
+ /** Понравившиеся посты пользователя: `/api/posts/user/{user}/liked`. */
2491
+ #liked = this.paginated({
2492
+ operationId: "posts.likedByUser",
2493
+ path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}/liked`,
2494
+ query: (p) => ({ limit: p.limit }),
2495
+ start: cursorStart,
2496
+ read: (body) => readCursorPage(body, "posts"),
2497
+ mode: PaginationMode.Cursor
2498
+ });
2499
+ /** Комментарии к посту: курсор лежит рядом со списком, поэтому свой reader. */
2500
+ #comments = this.paginated({
2501
+ operationId: "posts.comments",
2502
+ path: (p) => `/api/posts/${encodePathSegment(p.postId, "postId")}/comments`,
2503
+ query: (p) => ({
2504
+ limit: p.limit,
2505
+ sort: p.sort
2506
+ }),
2507
+ start: cursorStart,
2508
+ read: (body) => readFlatCursorPage(body, "comments"),
2509
+ mode: PaginationMode.Cursor
2510
+ });
2511
+ constructor(http, deps) {
2512
+ super(http);
2513
+ this.#uploadFiles = deps.uploadFiles;
2514
+ }
2515
+ /**
2516
+ * Загружает страницу ленты.
2517
+ *
2518
+ * @example
2519
+ * ```ts
2520
+ * const page = await itd.posts.list({ tab: FeedTab.Following, limit: 20 });
2521
+ * const next = await itd.posts.list({ tab: FeedTab.Following, cursor: page.nextCursor ?? undefined });
2522
+ * ```
2523
+ */
2524
+ list(params = {}, options = {}) {
2525
+ return this.#feed.list(params, options);
2526
+ }
2527
+ /**
2528
+ * Перебирает ленту, сама подставляя курсоры.
2529
+ *
2530
+ * @example
2531
+ * ```ts
2532
+ * for await (const post of itd.posts.iterate({ tab: 'following' })) {
2533
+ * console.log(post.author.username, post.content);
2534
+ * }
2535
+ * ```
2536
+ */
2537
+ iterate(params = {}, options = {}) {
2538
+ return this.#feed.iterate(params, options);
2539
+ }
2540
+ /**
2541
+ * Публикует пост.
2542
+ *
2543
+ * Принимает обычный объект, {@link PostBuilder} или функцию-настройщик. Файлы из поля
2544
+ * `files` загружаются автоматически, порядок вложений сохраняется.
2545
+ *
2546
+ * @example
2547
+ * ```ts
2548
+ * await itd.posts.create({ content: 'привет' });
2549
+ * await itd.posts.create((p) => p.content('привет').attach({ url: 'https://example.com/photo.jpg' }));
2550
+ * ```
2551
+ */
2552
+ async create(input, options = {}) {
2553
+ const data = resolvePost(input);
2554
+ const attachmentIds = await this.#collectAttachments(data, options);
2555
+ return this.http.operation("posts.create", {
2556
+ path: "/api/posts",
2557
+ body: {
2558
+ content: data.content ?? "",
2559
+ ...data.spans ? { spans: data.spans } : {},
2560
+ ...data.wallRecipientId ? { wallRecipientId: data.wallRecipientId } : {},
2561
+ ...attachmentIds.length > 0 ? { attachmentIds } : {},
2562
+ ...data.poll ? { poll: data.poll } : {}
2563
+ },
2564
+ ...options
2565
+ });
2566
+ }
2567
+ /**
2568
+ * Загружает один пост вместе с топовыми комментариями.
2569
+ *
2570
+ * В отличие от списков, здесь у поста заполнено поле `comments`.
2571
+ */
2572
+ get(postId, options = {}) {
2573
+ return this.http.operation("posts.get", {
2574
+ path: `/api/posts/${encodePathSegment(postId, "postId")}`,
2575
+ ...options
2576
+ });
2577
+ }
2578
+ /**
2579
+ * Редактирует текст и разметку поста.
2580
+ *
2581
+ * Как и {@link create}, принимает объект, готовый {@link PostBuilder} или
2582
+ * функцию-настройщик. Поля создания поста, которые update endpoint не поддерживает
2583
+ * (вложения, опрос и стена), отвергаются до запроса.
2584
+ */
2585
+ update(postId, input, options = {}) {
2586
+ const data = resolvePostUpdate(input);
2587
+ return this.http.operation("posts.update", {
2588
+ path: `/api/posts/${encodePathSegment(postId, "postId")}`,
2589
+ body: {
2590
+ content: data.content,
2591
+ ...data.spans ? { spans: data.spans } : {}
2592
+ },
2593
+ ...options
2594
+ });
2595
+ }
2596
+ /** Удаляет пост. Восстановить его можно через {@link restore}. */
2597
+ remove(postId, options = {}) {
2598
+ return this.http.operation("posts.remove", {
2599
+ path: `/api/posts/${encodePathSegment(postId, "postId")}`,
2600
+ ...options
2601
+ });
2602
+ }
2603
+ /** Восстанавливает удалённый пост. */
2604
+ restore(postId, options = {}) {
2605
+ return this.http.operation("posts.restore", {
2606
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/restore`,
2607
+ ...options
2608
+ });
2609
+ }
2610
+ /** Ставит реакцию на пост. */
2611
+ like(postId, options = {}) {
2612
+ return this.http.operation("posts.like", {
2613
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
2614
+ ...options
2615
+ });
2616
+ }
2617
+ /** Убирает реакцию с поста. */
2618
+ unlike(postId, options = {}) {
2619
+ return this.http.operation("posts.unlike", {
2620
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
2621
+ ...options
2622
+ });
2623
+ }
2624
+ /**
2625
+ * Делает репост с необязательным комментарием.
2626
+ *
2627
+ * Вложения к репосту не поддерживаются: сервер их игнорирует, поэтому параметров
2628
+ * для файлов здесь нет.
2629
+ */
2630
+ repost(postId, content = "", options = {}) {
2631
+ return this.http.operation("posts.repost", {
2632
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
2633
+ body: { content },
2634
+ ...options
2635
+ });
2636
+ }
2637
+ /** Отменяет репост. */
2638
+ unrepost(postId, options = {}) {
2639
+ return this.http.operation("posts.unrepost", {
2640
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
2641
+ ...options
2642
+ });
2643
+ }
2644
+ /** Закрепляет пост в профиле. */
2645
+ pin(postId, options = {}) {
2646
+ return this.http.operation("posts.pin", {
2647
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
2648
+ ...options
2649
+ });
2650
+ }
2651
+ /** Открепляет пост. */
2652
+ unpin(postId, options = {}) {
2653
+ return this.http.operation("posts.unpin", {
2654
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
2655
+ ...options
2656
+ });
2657
+ }
2658
+ /**
2659
+ * Голосует в опросе.
2660
+ *
2661
+ * @param optionIds выбранные варианты; несколько допустимы только при `multipleChoice`
2662
+ */
2663
+ vote(postId, optionIds, options = {}) {
2664
+ return this.http.operation("posts.vote", {
2665
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/poll/vote`,
2666
+ body: { optionIds },
2667
+ ...options
2668
+ });
2669
+ }
2670
+ /** Запрашивает счётчики сразу для нескольких постов. */
2671
+ async stats(ids, options = {}) {
2672
+ const body = await this.http.operation("posts.stats", {
2673
+ path: "/api/posts/stats",
2674
+ body: { ids },
2675
+ ...options
2676
+ });
2677
+ return pickArray(body, "posts");
2678
+ }
2679
+ /**
2680
+ * Загружает страницу стены пользователя.
2681
+ *
2682
+ * Это **не только его собственные посты**: сюда попадают и записи, которые другие
2683
+ * оставили на его стене — у них `author` чужой, а `wallRecipient` указывает на владельца
2684
+ * стены. Поэтому число записей обычно больше, чем `postsCount` из профиля; чтобы
2685
+ * получить только авторские посты, отфильтруйте по `post.author.id`.
2686
+ *
2687
+ * Принимает и UUID, и имя пользователя.
2688
+ */
2689
+ byUser(user, params = {}, options = {}) {
2690
+ return this.#wall.list({
2691
+ ...params,
2692
+ user
2693
+ }, options);
2694
+ }
2695
+ /** Перебирает стену пользователя. Что именно в неё входит — см. {@link byUser}. */
2696
+ iterateByUser(user, params = {}, options = {}) {
2697
+ return this.#wall.iterate({
2698
+ ...params,
2699
+ user
2700
+ }, options);
2701
+ }
2702
+ /** Загружает страницу постов, которые пользователь отметил реакцией. */
2703
+ likedByUser(user, params = {}, options = {}) {
2704
+ return this.#liked.list({
2705
+ ...params,
2706
+ user
2707
+ }, options);
2708
+ }
2709
+ /** Перебирает посты, которые пользователь отметил реакцией. */
2710
+ iterateLikedByUser(user, params = {}, options = {}) {
2711
+ return this.#liked.iterate({
2712
+ ...params,
2713
+ user
2714
+ }, options);
2715
+ }
2716
+ /**
2717
+ * Загружает страницу комментариев к посту.
2718
+ *
2719
+ * У этого эндпоинта курсор и признак продолжения лежат рядом со списком, а не внутри
2720
+ * объекта `pagination`, как у остальных, — разница скрыта внутри.
2721
+ */
2722
+ comments(postId, params = {}, options = {}) {
2723
+ return this.#comments.list({
2724
+ ...params,
2725
+ postId
2726
+ }, options);
2727
+ }
2728
+ /** Перебирает комментарии к посту. */
2729
+ iterateComments(postId, params = {}, options = {}) {
2730
+ return this.#comments.iterate({
2731
+ ...params,
2732
+ postId
2733
+ }, options);
2734
+ }
2735
+ /**
2736
+ * Комментирует пост.
2737
+ *
2738
+ * @example
2739
+ * ```ts
2740
+ * await itd.posts.comment(postId, 'согласен');
2741
+ * await itd.posts.comment(postId, (c) => c.content('смотри').attach(blob));
2742
+ * ```
2743
+ */
2744
+ async comment(postId, input, options = {}) {
2745
+ const data = resolveComment(typeof input === "string" ? { content: input } : input);
2746
+ const attachmentIds = await this.#collectAttachments(data, options);
2747
+ return this.http.operation("posts.comment", {
2748
+ path: `/api/posts/${encodePathSegment(postId, "postId")}/comments`,
2749
+ body: {
2750
+ content: data.content ?? "",
2751
+ attachmentIds
2752
+ },
2753
+ ...options
2754
+ });
2755
+ }
2756
+ /**
2757
+ * Отправляет голосовой комментарий.
2758
+ *
2759
+ * Текста у такого комментария нет: сервер ждёт пустой `content` и одно аудиовложение
2760
+ * в формате `audio/ogg`.
2761
+ *
2762
+ * @example
2763
+ * ```ts
2764
+ * import { fromPath } from 'itd-api/node';
2765
+ *
2766
+ * await itd.posts.voiceComment(postId, fromPath('./answer.ogg'));
2767
+ * ```
2768
+ */
2769
+ voiceComment(postId, audio, options = {}) {
2770
+ return this.comment(postId, {
2771
+ content: "",
2772
+ files: [audio]
2773
+ }, options);
2774
+ }
2775
+ /** Загружает файлы из входных данных и объединяет их с уже готовыми идентификаторами. */
2776
+ async #collectAttachments(data, options) {
2777
+ const existing = data.attachmentIds ?? [];
2778
+ const files = data.files ?? [];
2779
+ if (files.length === 0) return existing;
2780
+ const uploaded = await this.#uploadFiles(files, options);
2781
+ return [...existing, ...uploaded];
2782
+ }
2783
+ };
2784
+ //#endregion
2785
+ //#region src/builders/report.ts
2786
+ const REASONS = new Set(Object.values(ReportReason));
2787
+ const TARGET_TYPES = new Set(Object.values(ReportTargetType));
2788
+ /**
2789
+ * Проверяет данные жалобы.
2790
+ *
2791
+ * @throws {ItdConfigError} если объект жалобы или причина заданы неверно
2792
+ */
2793
+ function validateReport(input) {
2794
+ if (!input?.targetId || typeof input.targetId !== "string") throw new ItdConfigError("Жалоба требует идентификатор объекта (targetId)");
2795
+ if (!TARGET_TYPES.has(input.targetType)) throw new ItdConfigError(`targetType должен быть одним из ${[...TARGET_TYPES].join(", ")}, получено: ${String(input.targetType)}`);
2796
+ if (!REASONS.has(input.reason)) throw new ItdConfigError(`Неизвестная причина жалобы «${String(input.reason)}». Допустимые: ${[...REASONS].join(", ")}`);
2797
+ return input;
2798
+ }
2799
+ /**
2800
+ * Билдер жалобы.
2801
+ *
2802
+ * Точка входа задаёт объект жалобы и его тип одновременно, поэтому рассогласовать
2803
+ * `targetType` и `targetId` невозможно. Создаётся объектом {@link report}.
2804
+ */
2805
+ var ReportBuilder = class ReportBuilder {
2806
+ /** @internal */
2807
+ [BUILDER] = true;
2808
+ #state;
2809
+ /** @internal Создавайте билдер через {@link report}. */
2810
+ constructor(state) {
2811
+ this.#state = state;
2812
+ }
2813
+ /** Указывает причину жалобы. */
2814
+ reason(reason) {
2815
+ return new ReportBuilder({
2816
+ ...this.#state,
2817
+ reason
2818
+ });
2819
+ }
2820
+ /** Добавляет пояснение в свободной форме. */
2821
+ description(text) {
2822
+ return new ReportBuilder({
2823
+ ...this.#state,
2824
+ description: text
2825
+ });
2826
+ }
2827
+ build() {
2828
+ return validateReport(this.#state);
2829
+ }
2830
+ toJSON() {
2831
+ return this.build();
2832
+ }
2833
+ };
2834
+ function start(targetType, targetId) {
2835
+ return new ReportBuilder({
2836
+ targetType,
2837
+ targetId
2838
+ });
2839
+ }
2840
+ /**
2841
+ * Начинает сборку жалобы.
2842
+ *
2843
+ * Тип объекта выбирается точкой входа, так что указать идентификатор комментария
2844
+ * с типом «пост» нельзя в принципе.
2845
+ *
2846
+ * @example
2847
+ * ```ts
2848
+ * import { report, ReportReason } from 'itd-api';
2849
+ *
2850
+ * await itd.reports.create(report.post(postId).reason(ReportReason.Spam));
2851
+ * await itd.reports.create(report.user(userId).reason('fraud').description('пишет в личку'));
2852
+ * ```
2853
+ */
2854
+ const report = Object.freeze({
2855
+ /** Жалоба на пост. */
2856
+ post: (postId) => start(ReportTargetType.Post, postId),
2857
+ /** Жалоба на комментарий. */
2858
+ comment: (commentId) => start(ReportTargetType.Comment, commentId),
2859
+ /** Жалоба на пользователя. */
2860
+ user: (userId) => start(ReportTargetType.User, userId)
2861
+ });
2862
+ /** Приводит любую форму входа к готовым данным жалобы. */
2863
+ function resolveReport(input) {
2864
+ return resolveInput(input, () => new ReportBuilder({}), validateReport);
2865
+ }
2866
+ //#endregion
2867
+ //#region src/resources/reports.ts
2868
+ /**
2869
+ * Жалобы на контент и пользователей.
2870
+ *
2871
+ * Доступна как `itd.reports`.
2872
+ */
2873
+ var ReportsResource = class extends BaseResource {
2874
+ /**
2875
+ * Отправляет жалобу.
2876
+ *
2877
+ * Повторная жалоба на тот же объект отклоняется сервером с сообщением
2878
+ * «Вы уже отправляли жалобу на этот контент».
2879
+ *
2880
+ * @example
2881
+ * ```ts
2882
+ * await itd.reports.create(report.post(postId).reason('spam'));
2883
+ * await itd.reports.create({ targetType: 'user', targetId, reason: 'fraud' });
2884
+ * ```
2885
+ */
2886
+ create(input, options = {}) {
2887
+ const data = resolveReport(input);
2888
+ return this.http.operation("reports.create", {
2889
+ path: "/api/reports",
2890
+ body: data,
2891
+ ...options
2892
+ });
2893
+ }
2894
+ };
2895
+ //#endregion
2896
+ //#region src/resources/search.ts
2897
+ /**
2898
+ * Глобальный поиск.
2899
+ *
2900
+ * Доступна как `itd.search`.
2901
+ */
2902
+ var SearchResource = class extends BaseResource {
2903
+ /**
2904
+ * Ищет пользователей и хэштеги одним запросом.
2905
+ *
2906
+ * @example
2907
+ * ```ts
2908
+ * const { users, hashtags } = await itd.search.all('арт');
2909
+ * ```
2910
+ */
2911
+ async all(query, options = {}) {
2912
+ const body = await this.http.operation("search.all", {
2913
+ path: "/api/search",
2914
+ query: { q: query },
2915
+ ...options
2916
+ });
2917
+ return {
2918
+ users: pickArray(body, "users"),
2919
+ hashtags: pickArray(body, "hashtags")
2920
+ };
2921
+ }
2922
+ };
2923
+ //#endregion
2924
+ //#region src/resources/subscription.ts
2925
+ /**
2926
+ * Подписка и способы оплаты.
2927
+ *
2928
+ * Доступна как `itd.subscription`.
2929
+ */
2930
+ var SubscriptionResource = class extends BaseResource {
2931
+ /** Загружает состояние подписки и её цену. */
2932
+ status(options = {}) {
2933
+ return this.http.operation("subscription.status", {
2934
+ path: "/api/v1/subscription/",
2935
+ ...options
2936
+ });
2937
+ }
2938
+ /**
2939
+ * Запускает оплату подписки.
2940
+ *
2941
+ * Форма ответа в документации API не описана, поэтому тип результата не уточняется.
2942
+ */
2943
+ pay(options = {}) {
2944
+ return this.http.operation("subscription.pay", {
2945
+ path: "/api/v1/subscription/pay",
2946
+ ...options
2947
+ });
2948
+ }
2949
+ /** Включает или отключает автопродление. */
2950
+ setAutoRenewal(enabled, options = {}) {
2951
+ return this.http.operation("subscription.setAutoRenewal", {
2952
+ path: "/api/v1/subscription/auto-renewal",
2953
+ body: { enabled },
2954
+ ...options
2955
+ });
2956
+ }
2957
+ /** Запускает привязку карты. */
2958
+ bindCard(options = {}) {
2959
+ return this.http.operation("subscription.bindCard", {
2960
+ path: "/api/v1/subscription/bind-card",
2961
+ ...options
2962
+ });
2963
+ }
2964
+ /** Загружает список способов оплаты. Пустой массив, если карт нет. */
2965
+ async methods(options = {}) {
2966
+ const body = await this.http.operation("subscription.methods", {
2967
+ path: "/api/v1/subscription/methods",
2968
+ ...options
2969
+ });
2970
+ return Array.isArray(body) ? body : [];
2971
+ }
2972
+ /** Делает способ оплаты основным. */
2973
+ setDefaultMethod(methodId, options = {}) {
2974
+ return this.http.operation("subscription.setDefaultMethod", {
2975
+ path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}/default`,
2976
+ ...options
2977
+ });
2978
+ }
2979
+ /** Удаляет способ оплаты. */
2980
+ removeMethod(methodId, options = {}) {
2981
+ return this.http.operation("subscription.removeMethod", {
2982
+ path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}`,
2983
+ ...options
2984
+ });
2985
+ }
2986
+ };
2987
+ //#endregion
2988
+ //#region src/resources/telemetry.ts
2989
+ const DEFAULT_BATCH_SIZE = 50;
2990
+ const SYSTEM_CLOCK = Object.freeze({ now: () => Date.now() });
2991
+ function assertNonEmpty(value, name) {
2992
+ if (value.trim().length === 0) throw new ItdConfigError(`${name} должен быть непустой строкой`);
2993
+ }
2994
+ function assertFinite(value, name) {
2995
+ if (!Number.isFinite(value)) throw new ItdConfigError(`${name} должен быть конечным числом`);
2996
+ }
2997
+ function assertNonNegative(value, name) {
2998
+ assertFinite(value, name);
2999
+ if (value < 0) throw new ItdConfigError(`${name} должен быть неотрицательным числом`);
3000
+ }
3001
+ function validateDwell(entry) {
3002
+ assertNonEmpty(entry.vs, "vs");
3003
+ assertFinite(entry.enterAt, "enterAt");
3004
+ assertFinite(entry.exitAt, "exitAt");
3005
+ if (entry.exitAt < entry.enterAt) throw new ItdConfigError("exitAt не может быть раньше enterAt");
3006
+ if (entry.durationMs !== void 0) assertNonNegative(entry.durationMs, "durationMs");
3007
+ return { ...entry };
3008
+ }
3009
+ function validateInteraction(entry) {
3010
+ assertNonEmpty(entry.vs, "vs");
3011
+ assertNonEmpty(entry.postId, "postId");
3012
+ if (entry.mediaIndex !== void 0) {
3013
+ assertNonNegative(entry.mediaIndex, "mediaIndex");
3014
+ if (!Number.isInteger(entry.mediaIndex)) throw new ItdConfigError("mediaIndex должен быть целым числом");
3015
+ }
3016
+ if (entry.positionMs !== void 0) assertNonNegative(entry.positionMs, "positionMs");
3017
+ if (entry.durationMs !== void 0) assertNonNegative(entry.durationMs, "durationMs");
3018
+ return { ...entry };
3019
+ }
3020
+ function photoOpenEntry(input) {
3021
+ return validateInteraction({
3022
+ type: InteractionType.PhotoOpen,
3023
+ ...input
3024
+ });
3025
+ }
3026
+ function videoProgressEntry(input) {
3027
+ return validateInteraction({
3028
+ type: InteractionType.VideoProgress,
3029
+ ...input
3030
+ });
3031
+ }
3032
+ var ViewTrackerImpl = class {
3033
+ enteredAt;
3034
+ #clock;
3035
+ #input;
3036
+ #submit;
3037
+ #result;
3038
+ constructor(input, clock, submit) {
3039
+ assertNonEmpty(input.vs, "vs");
3040
+ this.enteredAt = clock.now();
3041
+ assertFinite(this.enteredAt, "clock.now()");
3042
+ this.#input = { ...input };
3043
+ this.#clock = clock;
3044
+ this.#submit = submit;
3045
+ }
3046
+ get finished() {
3047
+ return this.#result !== void 0;
3048
+ }
3049
+ finish(reason) {
3050
+ if (this.#result) return this.#result;
3051
+ try {
3052
+ const exitAt = this.#clock.now();
3053
+ assertFinite(exitAt, "clock.now()");
3054
+ if (exitAt < this.enteredAt) throw new ItdConfigError("часы не могут вернуться назад во время измерения просмотра");
3055
+ this.#result = Promise.resolve(this.#submit({
3056
+ ...this.#input,
3057
+ enterAt: this.enteredAt,
3058
+ exitAt,
3059
+ reason
3060
+ }));
3061
+ } catch (error) {
3062
+ this.#result = Promise.reject(error);
3063
+ }
3064
+ return this.#result;
3065
+ }
3066
+ };
3067
+ var TelemetryBatchImpl = class {
3068
+ #sendDwell;
3069
+ #sendInteraction;
3070
+ #telemetryOptions;
3071
+ #requestOptions;
3072
+ #clock;
3073
+ #maxBatchSize;
3074
+ #onClose;
3075
+ #dwell = [];
3076
+ #interactions = [];
3077
+ #state = "open";
3078
+ #flushPromise;
3079
+ #closePromise;
3080
+ #disposeCleanup = false;
3081
+ constructor(sendDwell, sendInteraction, options, requestOptions, onClose) {
3082
+ const { maxBatchSize = DEFAULT_BATCH_SIZE, clock = SYSTEM_CLOCK, ...telemetryOptions } = options;
3083
+ if (!Number.isInteger(maxBatchSize) || maxBatchSize < 1) throw new ItdConfigError("maxBatchSize должен быть целым числом от 1");
3084
+ this.#sendDwell = sendDwell;
3085
+ this.#sendInteraction = sendInteraction;
3086
+ this.#telemetryOptions = telemetryOptions;
3087
+ this.#requestOptions = requestOptions;
3088
+ this.#clock = clock;
3089
+ this.#maxBatchSize = maxBatchSize;
3090
+ this.#onClose = onClose;
3091
+ }
3092
+ get pendingDwell() {
3093
+ return this.#dwell.length;
3094
+ }
3095
+ get pendingInteractions() {
3096
+ return this.#interactions.length;
3097
+ }
3098
+ get closed() {
3099
+ return this.#state === "closed";
3100
+ }
3101
+ dwell(entry) {
3102
+ this.#assertOpen();
3103
+ const entries = Array.isArray(entry) ? entry : [entry];
3104
+ this.#dwell.push(...entries.map(validateDwell));
3105
+ return this;
3106
+ }
3107
+ interaction(entry) {
3108
+ this.#assertOpen();
3109
+ const entries = Array.isArray(entry) ? entry : [entry];
3110
+ this.#interactions.push(...entries.map(validateInteraction));
3111
+ return this;
3112
+ }
3113
+ photoOpen(input) {
3114
+ return this.interaction(photoOpenEntry(input));
3115
+ }
3116
+ videoProgress(input) {
3117
+ return this.interaction(videoProgressEntry(input));
3118
+ }
3119
+ startView(input) {
3120
+ this.#assertOpen();
3121
+ return new ViewTrackerImpl(input, this.#clock, (entry) => {
3122
+ this.dwell(entry);
3123
+ });
3124
+ }
3125
+ flush(options = {}) {
3126
+ if (this.#state === "closed") return Promise.resolve();
3127
+ if (this.#flushPromise) return this.#flushPromise;
3128
+ const tracked = this.#flushAll({
3129
+ ...this.#requestOptions,
3130
+ ...options
3131
+ }).finally(() => {
3132
+ if (this.#flushPromise === tracked) this.#flushPromise = void 0;
3133
+ });
3134
+ this.#flushPromise = tracked;
3135
+ return tracked;
3136
+ }
3137
+ close() {
3138
+ return this.#close(false);
3139
+ }
3140
+ /** Внутренняя финализация после перехода клиента в terminal state. */
3141
+ closeForDispose() {
3142
+ this.prepareForDispose();
3143
+ return this.#close(true);
3144
+ }
3145
+ /** Переключает ещё не начатые части flush на terminal-cleanup канал. */
3146
+ prepareForDispose() {
3147
+ this.#disposeCleanup = true;
3148
+ }
3149
+ #close(disposeCleanup) {
3150
+ this.#disposeCleanup ||= disposeCleanup;
3151
+ if (this.#state === "closed") return Promise.resolve();
3152
+ if (this.#closePromise) return this.#closePromise;
3153
+ this.#state = "closing";
3154
+ const tracked = this.flush().then(() => {
3155
+ this.#state = "closed";
3156
+ this.#onClose();
3157
+ }).catch((error) => {
3158
+ this.#state = "open";
3159
+ throw error;
3160
+ }).finally(() => {
3161
+ if (this.#closePromise === tracked) this.#closePromise = void 0;
3162
+ });
3163
+ this.#closePromise = tracked;
3164
+ return tracked;
3165
+ }
3166
+ async #flushAll(options) {
3167
+ while (this.#dwell.length > 0) {
3168
+ const chunk = this.#dwell.splice(0, this.#maxBatchSize);
3169
+ try {
3170
+ await this.#sendDwell(chunk, this.#telemetryOptions, options, this.#disposeCleanup);
3171
+ } catch (error) {
3172
+ this.#dwell.unshift(...chunk);
3173
+ throw error;
3174
+ }
3175
+ }
3176
+ while (this.#interactions.length > 0) {
3177
+ const chunk = this.#interactions.splice(0, this.#maxBatchSize);
3178
+ try {
3179
+ await this.#sendInteraction(chunk, this.#telemetryOptions, options, this.#disposeCleanup);
3180
+ } catch (error) {
3181
+ this.#interactions.unshift(...chunk);
3182
+ throw error;
3183
+ }
3184
+ }
3185
+ }
3186
+ #assertOpen() {
3187
+ if (this.#state !== "open") throw new ItdConfigError("накопитель телеметрии уже закрывается или закрыт");
3188
+ }
3189
+ };
3190
+ const TELEMETRY_DISPOSE_CLOSERS = /* @__PURE__ */ new WeakMap();
3191
+ const TELEMETRY_DISPOSE_PREPARERS = /* @__PURE__ */ new WeakMap();
3192
+ /**
3193
+ * Телеметрия просмотров и взаимодействий.
3194
+ *
3195
+ * Ничего не отправляет автоматически: каждый запрос, tracker или накопитель создаётся
3196
+ * явным вызовом пользователя. Доступна как `itd.telemetry`.
3197
+ */
3198
+ var TelemetryResource = class extends BaseResource {
3199
+ #sessionId;
3200
+ #batches = /* @__PURE__ */ new Set();
3201
+ constructor(http) {
3202
+ super(http);
3203
+ TELEMETRY_DISPOSE_PREPARERS.set(this, () => {
3204
+ for (const batch of this.#batches) batch.prepareForDispose();
3205
+ });
3206
+ TELEMETRY_DISPOSE_CLOSERS.set(this, () => this.#close(true));
3207
+ }
3208
+ /** Идентификатор сессии телеметрии, общий для всех событий этого ресурса. */
3209
+ get sessionId() {
3210
+ this.#sessionId ??= createDeviceId();
3211
+ return this.#sessionId;
3212
+ }
3213
+ /** Отправляет события просмотра постов (`POST /api/v1/i`). */
3214
+ dwell(entries, telemetryOptions = {}, requestOptions = {}) {
3215
+ const validated = entries.map(validateDwell);
3216
+ return this.#sendDwell(validated, telemetryOptions, requestOptions, false);
3217
+ }
3218
+ #sendDwell(entries, telemetryOptions, requestOptions, disposeCleanup) {
3219
+ const options = {
3220
+ path: "/api/v1/i",
3221
+ body: {
3222
+ sid: telemetryOptions.sid ?? this.sessionId,
3223
+ e: entries.map((entry) => ({
3224
+ md: entry.durationMs ?? entry.exitAt - entry.enterAt,
3225
+ et: entry.enterAt,
3226
+ xt: entry.exitAt,
3227
+ r: entry.reason,
3228
+ v: entry.vs,
3229
+ ...entry.sourceContext !== void 0 ? { sc: entry.sourceContext } : {},
3230
+ ...entry.source !== void 0 ? { s: entry.source } : {},
3231
+ ...entry.repeat ? { b: 1 } : {}
3232
+ }))
3233
+ },
3234
+ ...requestOptions
3235
+ };
3236
+ return disposeCleanup ? this.http.cleanupOperation("telemetry.dwell", options) : this.http.operation("telemetry.dwell", options);
3237
+ }
3238
+ /** Отправляет события взаимодействия с контентом (`POST /api/v1/x`). */
3239
+ interaction(entries, telemetryOptions = {}, requestOptions = {}) {
3240
+ const validated = entries.map(validateInteraction);
3241
+ return this.#sendInteraction(validated, telemetryOptions, requestOptions, false);
3242
+ }
3243
+ #sendInteraction(entries, telemetryOptions, requestOptions, disposeCleanup) {
3244
+ const options = {
3245
+ path: "/api/v1/x",
3246
+ body: {
3247
+ sid: telemetryOptions.sid ?? this.sessionId,
3248
+ e: entries.map((entry) => ({
3249
+ t: entry.type,
3250
+ v: entry.vs,
3251
+ ai: entry.postId,
3252
+ ...entry.mediaIndex !== void 0 ? { mi: entry.mediaIndex } : {},
3253
+ ...entry.source !== void 0 ? { s: entry.source } : {},
3254
+ ...entry.positionMs !== void 0 ? { pm: Math.round(entry.positionMs) } : {},
3255
+ ...entry.durationMs !== void 0 ? { dm: Math.round(entry.durationMs) } : {}
3256
+ }))
3257
+ },
3258
+ ...requestOptions
3259
+ };
3260
+ return disposeCleanup ? this.http.cleanupOperation("telemetry.interaction", options) : this.http.operation("telemetry.interaction", options);
3261
+ }
3262
+ /** Начинает измерять время просмотра и отправляет результат после `finish()`. */
3263
+ startView(input, options = {}, requestOptions = {}) {
3264
+ const { clock = SYSTEM_CLOCK, ...telemetryOptions } = options;
3265
+ return new ViewTrackerImpl(input, clock, async (entry) => {
3266
+ await this.dwell([entry], telemetryOptions, requestOptions);
3267
+ });
3268
+ }
3269
+ /** Отправляет событие открытия фотографии. */
3270
+ photoOpen(input, telemetryOptions = {}, requestOptions = {}) {
3271
+ return this.interaction([photoOpenEntry(input)], telemetryOptions, requestOptions);
3272
+ }
3273
+ /** Отправляет событие прогресса просмотра видео. */
3274
+ videoProgress(input, telemetryOptions = {}, requestOptions = {}) {
3275
+ return this.interaction([videoProgressEntry(input)], telemetryOptions, requestOptions);
3276
+ }
3277
+ /**
3278
+ * Создаёт накопитель с явными `flush()` и `close()`.
3279
+ *
3280
+ * Создание и добавление записей не выполняют сетевых запросов.
3281
+ */
3282
+ batch(options = {}, requestOptions = {}) {
3283
+ let batch;
3284
+ batch = new TelemetryBatchImpl((entries, telemetry, request, cleanup) => this.#sendDwell(entries, telemetry, request, cleanup), (entries, telemetry, request, cleanup) => this.#sendInteraction(entries, telemetry, request, cleanup), options, requestOptions, () => {
3285
+ this.#batches.delete(batch);
3286
+ });
3287
+ this.#batches.add(batch);
3288
+ return batch;
3289
+ }
3290
+ /** Закрывает все созданные накопители, отправляя оставшиеся записи. */
3291
+ async close() {
3292
+ return this.#close(false);
3293
+ }
3294
+ async #close(disposeCleanup) {
3295
+ const errors = (await Promise.allSettled([...this.#batches].map((batch) => disposeCleanup ? batch.closeForDispose() : batch.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
3296
+ if (errors.length > 0) throw new AggregateError(errors, "Не удалось отправить накопленную телеметрию");
3297
+ }
3298
+ };
3299
+ /** Помечает открытые накопители для terminal cleanup до первой асинхронной границы. @internal */
3300
+ function prepareTelemetryForDispose(resource) {
3301
+ if (resource) TELEMETRY_DISPOSE_PREPARERS.get(resource)?.();
3302
+ }
3303
+ /** Финализирует созданные накопители после перехода клиента в terminal state. @internal */
3304
+ function closeTelemetryForDispose(resource) {
3305
+ return resource ? TELEMETRY_DISPOSE_CLOSERS.get(resource)?.() ?? Promise.resolve() : Promise.resolve();
3306
+ }
3307
+ //#endregion
3308
+ //#region src/resources/users.ts
3309
+ /**
3310
+ * Пользователи: профили, подписки, блокировки, приватность.
3311
+ *
3312
+ * Доступна как `itd.users`.
3313
+ */
3314
+ var UsersResource = class extends BaseResource {
3315
+ #uploadFile;
3316
+ constructor(http, deps) {
3317
+ super(http);
3318
+ this.#uploadFile = deps.uploadFile;
3319
+ }
3320
+ /**
3321
+ * Списки пользователей: подписчики, подписки, заблокированные.
3322
+ *
3323
+ * Путь приходит в параметрах — так один описатель обслуживает все три эндпоинта. Имена
3324
+ * полей перечислены с запасом: списки приходят под `users`, но альтернативное имя ничего
3325
+ * не стоит и спасает, если эндпоинт назовёт список по-своему. `page` уходит в запрос, хотя
3326
+ * сервер его сейчас не читает (см. {@link followers}): когда починят — заработает само.
3327
+ */
3328
+ #userList = this.paginated({
3329
+ operationId: (p) => p.operationId,
3330
+ path: (p) => p.path,
3331
+ query: (p) => ({ limit: p.limit }),
3332
+ start: (p) => p.page !== void 0 ? { page: p.page } : {},
3333
+ read: (body) => readPagedPage(body, "users", "followers", "following", "blocked"),
3334
+ mode: PaginationMode.Page
3335
+ });
3336
+ /** Загружает свой профиль — с подпиской и признаком подтверждённого телефона. */
3337
+ me(options = {}) {
3338
+ return this.http.operation("users.me", {
3339
+ path: "/api/users/me",
3340
+ ...options
3341
+ });
3342
+ }
3343
+ /** Обновляет свой профиль. Передавайте только изменяемые поля. */
3344
+ updateMe(input, options = {}) {
3345
+ return this.http.operation("users.updateMe", {
3346
+ path: "/api/users/me",
3347
+ body: input,
3348
+ ...options
3349
+ });
3350
+ }
3351
+ /**
3352
+ * Загружает изображение и устанавливает его баннером профиля.
3353
+ *
3354
+ * Для установки используется идентификатор, полученный от `/api/files/upload`.
3355
+ * Если файл уже загружен, используйте {@link updateMe}: `{ bannerId: file.id }`.
3356
+ *
3357
+ * @example
3358
+ * ```ts
3359
+ * await itd.users.setBanner(file, { filename: 'banner.webp' });
3360
+ * ```
3361
+ */
3362
+ async setBanner(file, uploadOptions = {}, requestOptions = {}) {
3363
+ const uploaded = await this.#uploadFile(file, uploadOptions, requestOptions);
3364
+ return this.updateMe({ bannerId: uploaded.id }, requestOptions);
3365
+ }
3366
+ /** Удаляет баннер профиля, устанавливая `bannerId` в `null`. */
3367
+ removeBanner(options = {}) {
3368
+ return this.updateMe({ bannerId: null }, options);
3369
+ }
3370
+ /** Деактивирует аккаунт. Вернуть его можно через {@link restore}. */
3371
+ deactivate(options = {}) {
3372
+ return this.http.operation("users.deactivate", {
3373
+ path: "/api/users/me",
3374
+ ...options
3375
+ });
3376
+ }
3377
+ /** Восстанавливает деактивированный аккаунт. */
3378
+ restore(options = {}) {
3379
+ return this.http.operation("users.restore", {
3380
+ path: "/api/users/me/restore",
3381
+ ...options
3382
+ });
3383
+ }
3384
+ /** Создаёт профиль после регистрации. */
3385
+ createProfile(input, options = {}) {
3386
+ return this.http.operation("users.createProfile", {
3387
+ path: "/api/users/profile",
3388
+ body: input,
3389
+ ...options
3390
+ });
3391
+ }
3392
+ /**
3393
+ * Загружает профиль пользователя.
3394
+ *
3395
+ * @param user UUID **или** имя пользователя — подходит и то, и другое
3396
+ *
3397
+ * @example
3398
+ * ```ts
3399
+ * const profile = await itd.users.get('nowkie');
3400
+ * await itd.posts.create({ content: 'привет', wallRecipientId: profile.id });
3401
+ * ```
3402
+ */
3403
+ get(user, options = {}) {
3404
+ return this.http.operation("users.get", {
3405
+ path: `/api/users/${encodePathSegment(user, "user")}`,
3406
+ ...options
3407
+ });
3408
+ }
3409
+ /** Проверяет, свободно ли имя пользователя. */
3410
+ async checkUsername(username, options = {}) {
3411
+ const body = await this.http.operation("users.checkUsername", {
3412
+ path: "/api/users/check-username",
3413
+ query: { username },
3414
+ ...options
3415
+ });
3416
+ return pickBoolean(body, "available");
3417
+ }
3418
+ /** Ищет пользователей по строке запроса. */
3419
+ async search(query, params = {}, options = {}) {
3420
+ const body = await this.http.operation("users.search", {
3421
+ path: "/api/users/search",
3422
+ query: {
3423
+ q: query,
3424
+ limit: params.limit
3425
+ },
3426
+ ...options
3427
+ });
3428
+ return pickArray(body, "users");
3429
+ }
3430
+ /** Загружает рекомендации, на кого подписаться. */
3431
+ async whoToFollow(options = {}) {
3432
+ const body = await this.http.operation("users.whoToFollow", {
3433
+ path: "/api/users/suggestions/who-to-follow",
3434
+ ...options
3435
+ });
3436
+ return pickArray(body, "users");
3437
+ }
3438
+ /** Загружает рейтинг кланов. */
3439
+ async topClans(options = {}) {
3440
+ const body = await this.http.operation("users.topClans", {
3441
+ path: "/api/users/stats/top-clans",
3442
+ ...options
3443
+ });
3444
+ return pickArray(body, "clans");
3445
+ }
3446
+ /**
3447
+ * Подписывается на пользователя.
3448
+ *
3449
+ * У закрытого профиля вместо подписки отправляется заявка — это видно по полю `status`.
3450
+ */
3451
+ follow(user, options = {}) {
3452
+ return this.http.operation("users.follow", {
3453
+ path: `/api/users/${encodePathSegment(user, "user")}/follow`,
3454
+ body: {},
3455
+ ...options
3456
+ });
3457
+ }
3458
+ /** Отписывается от пользователя. */
3459
+ unfollow(user, options = {}) {
3460
+ return this.http.operation("users.unfollow", {
3461
+ path: `/api/users/${encodePathSegment(user, "user")}/follow`,
3462
+ ...options
3463
+ });
3464
+ }
3465
+ /**
3466
+ * Загружает подписчиков пользователя.
3467
+ *
3468
+ * ⚠️ **Сервер этот список не листает.** Возвращаются первые 20 записей и только они:
3469
+ * параметр `page` игнорируется (любая страница отдаёт те же записи и `pagination.page: 1`),
3470
+ * `limit` больше 20 молча уменьшается, а `hasMore` всегда `false`. Последнее честно —
3471
+ * получить продолжение нечем.
3472
+ *
3473
+ * Числу `total` доверять тоже не стоит: оно расходится с `followersCount` из профиля —
3474
+ * на проверенных аккаунтах занижено примерно на 1–4%.
3475
+ */
3476
+ followers(user, params = {}, options = {}) {
3477
+ return this.#userPage("users.followers", `/api/users/${encodePathSegment(user, "user")}/followers`, params, options);
3478
+ }
3479
+ /**
3480
+ * Перебирает подписчиков.
3481
+ *
3482
+ * ⚠️ Перебор закончится после первых 20 записей: сервер список не листает —
3483
+ * см. {@link followers}. Метод оставлен на случай, если пагинацию починят.
3484
+ */
3485
+ iterateFollowers(user, params = {}, options = {}) {
3486
+ return this.#userPaginator("users.followers", `/api/users/${encodePathSegment(user, "user")}/followers`, params, options);
3487
+ }
3488
+ /** Загружает подписки пользователя. Ограничения те же, что у {@link followers}. */
3489
+ following(user, params = {}, options = {}) {
3490
+ return this.#userPage("users.following", `/api/users/${encodePathSegment(user, "user")}/following`, params, options);
3491
+ }
3492
+ /** Перебирает подписки. Закончится после первых 20 записей — см. {@link followers}. */
3493
+ iterateFollowing(user, params = {}, options = {}) {
3494
+ return this.#userPaginator("users.following", `/api/users/${encodePathSegment(user, "user")}/following`, params, options);
3495
+ }
3496
+ /**
3497
+ * Проверяет, подписаны ли вы, сразу для нескольких пользователей.
3498
+ *
3499
+ * @returns объект «идентификатор пользователя → подписаны ли вы»
3500
+ *
3501
+ * @example
3502
+ * ```ts
3503
+ * const statuses = await itd.users.followStatus([userA, userB]);
3504
+ * // { 'b89dee4f-…': true, '35ea3059-…': false }
3505
+ * ```
3506
+ */
3507
+ followStatus(userIds, options = {}) {
3508
+ return this.http.operation("users.followStatus", {
3509
+ path: "/api/users/follow-status",
3510
+ body: { userIds },
3511
+ ...options
3512
+ });
3513
+ }
3514
+ /** Блокирует пользователя. */
3515
+ block(user, options = {}) {
3516
+ return this.http.operation("users.block", {
3517
+ path: `/api/users/${encodePathSegment(user, "user")}/block`,
3518
+ body: {},
3519
+ ...options
3520
+ });
3521
+ }
3522
+ /** Снимает блокировку. */
3523
+ unblock(user, options = {}) {
3524
+ return this.http.operation("users.unblock", {
3525
+ path: `/api/users/${encodePathSegment(user, "user")}/block`,
3526
+ ...options
3527
+ });
3528
+ }
3529
+ /** Загружает заблокированных пользователей. Ограничения те же, что у {@link followers}. */
3530
+ blocked(params = {}, options = {}) {
3531
+ return this.#userPage("users.blocked", "/api/users/me/blocked", params, options);
3532
+ }
3533
+ /** Перебирает заблокированных. Закончится после первых 20 записей — см. {@link followers}. */
3534
+ iterateBlocked(params = {}, options = {}) {
3535
+ return this.#userPaginator("users.blocked", "/api/users/me/blocked", params, options);
3536
+ }
3537
+ /** Загружает настройки приватности. */
3538
+ getPrivacy(options = {}) {
3539
+ return this.http.operation("users.getPrivacy", {
3540
+ path: "/api/users/me/privacy",
3541
+ ...options
3542
+ });
3543
+ }
3544
+ /** Обновляет настройки приватности. Передавайте только изменяемые поля. */
3545
+ updatePrivacy(input, options = {}) {
3546
+ return this.http.operation("users.updatePrivacy", {
3547
+ path: "/api/users/me/privacy",
3548
+ body: input,
3549
+ ...options
3550
+ });
3551
+ }
3552
+ /**
3553
+ * Загружает значки профиля и выбранный из них.
3554
+ *
3555
+ * `activePin` — строка-идентификатор, а не объект.
3556
+ */
3557
+ async pins(options = {}) {
3558
+ const body = await this.http.operation("users.pins", {
3559
+ path: "/api/users/me/pins",
3560
+ ...options
3561
+ });
3562
+ return {
3563
+ pins: pickArray(body, "pins"),
3564
+ activePin: pickString(body, "activePin") ?? null
3565
+ };
3566
+ }
3567
+ /** Выбирает активный значок профиля. */
3568
+ setPin(slug, options = {}) {
3569
+ return this.http.operation("users.setPin", {
3570
+ path: "/api/users/me/pin",
3571
+ body: { slug },
3572
+ ...options
3573
+ });
3574
+ }
3575
+ /** Снимает активный значок. */
3576
+ removePin(options = {}) {
3577
+ return this.http.operation("users.removePin", {
3578
+ path: "/api/users/me/pin",
3579
+ ...options
3580
+ });
3581
+ }
3582
+ #userPage(operationId, path, params, options) {
3583
+ return this.#userList.list({
3584
+ ...params,
3585
+ operationId,
3586
+ path
3587
+ }, options);
3588
+ }
3589
+ #userPaginator(operationId, path, params, options) {
3590
+ return this.#userList.iterate({
3591
+ ...params,
3592
+ operationId,
3593
+ path
3594
+ }, options);
3595
+ }
3596
+ };
3597
+ //#endregion
3598
+ //#region src/resources/verification.ts
3599
+ /**
3600
+ * Верификация профиля.
3601
+ *
3602
+ * Доступна как `itd.verification`.
3603
+ */
3604
+ var VerificationResource = class extends BaseResource {
3605
+ /** Загружает статус заявки. Значение `none` означает, что заявка не подавалась. */
3606
+ status(options = {}) {
3607
+ return this.http.operation("verification.status", {
3608
+ path: "/api/verification/status",
3609
+ ...options
3610
+ });
3611
+ }
3612
+ /** Подаёт заявку на верификацию с видео. */
3613
+ submit(videoUrl, options = {}) {
3614
+ return this.http.operation("verification.submit", {
3615
+ path: "/api/verification/submit",
3616
+ body: { videoUrl },
3617
+ ...options
3618
+ });
3619
+ }
3620
+ };
3621
+ //#endregion
3622
+ //#region src/rest/resources.ts
3623
+ /**
3624
+ * Собирает набор ресурсов, создавая каждый при первом обращении.
3625
+ *
3626
+ * Ленивость существенна: клиенту редко нужны все двенадцать разом, а закрытие не должно
3627
+ * поднимать накопитель телеметрии только ради того, чтобы его тут же закрыть.
3628
+ *
3629
+ * Набор общий для полного клиента и минимального REST-клиента — иначе список ресурсов
3630
+ * и их зависимости разъезжались бы по двум фасадам.
3631
+ */
3632
+ function createResources(deps) {
3633
+ let users;
3634
+ let posts;
3635
+ let comments;
3636
+ let files;
3637
+ let notifications;
3638
+ let hashtags;
3639
+ let search;
3640
+ let reports;
3641
+ let verification;
3642
+ let subscription;
3643
+ let platform;
3644
+ let telemetry;
3645
+ const bag = {
3646
+ get users() {
3647
+ users ??= new UsersResource(deps.http, { uploadFile });
3648
+ return users;
3649
+ },
3650
+ get posts() {
3651
+ posts ??= new PostsResource(deps.http, { uploadFiles });
3652
+ return posts;
3653
+ },
3654
+ get comments() {
3655
+ comments ??= new CommentsResource(deps.http, { uploadFiles });
3656
+ return comments;
3657
+ },
3658
+ get files() {
3659
+ files ??= new FilesResource(deps.http, { fetch: deps.fetch });
3660
+ return files;
3661
+ },
3662
+ get notifications() {
3663
+ notifications ??= new NotificationsResource(deps.http);
3664
+ return notifications;
3665
+ },
3666
+ get hashtags() {
3667
+ hashtags ??= new HashtagsResource(deps.http);
3668
+ return hashtags;
3669
+ },
3670
+ get search() {
3671
+ search ??= new SearchResource(deps.http);
3672
+ return search;
3673
+ },
3674
+ get reports() {
3675
+ reports ??= new ReportsResource(deps.http);
3676
+ return reports;
3677
+ },
3678
+ get verification() {
3679
+ verification ??= new VerificationResource(deps.http);
3680
+ return verification;
3681
+ },
3682
+ get subscription() {
3683
+ subscription ??= new SubscriptionResource(deps.http);
3684
+ return subscription;
3685
+ },
3686
+ get platform() {
3687
+ platform ??= new PlatformResource(deps.http);
3688
+ return platform;
3689
+ },
3690
+ get telemetry() {
3691
+ telemetry ??= new TelemetryResource(deps.http);
3692
+ return telemetry;
3693
+ },
3694
+ closeTelemetry: (forDispose) => forDispose ? closeTelemetryForDispose(telemetry) : telemetry?.close() ?? Promise.resolve(),
3695
+ prepareTelemetryClose: () => prepareTelemetryForDispose(telemetry)
3696
+ };
3697
+ const uploadFile = (file, uploadOptions, requestOptions) => bag.files.upload(file, uploadOptions ?? {}, requestOptions ?? {});
3698
+ const uploadFiles = (input, requestOptions) => bag.files.uploadMany(input, {}, requestOptions ?? {});
3699
+ return bag;
3700
+ }
3701
+ //#endregion
3702
+ //#region src/core/attachments/factories.ts
3703
+ function fromUrl(url, options = {}) {
3704
+ if (resolveFileStreamOptions(options, 104857600).mode === FileTransferMode.Stream) return { open: (context) => openUrlFile(url, options, context) };
3705
+ return { load: (context) => downloadFile(url, options, context) };
3706
+ }
3707
+ /**
3708
+ * Создаёт повторяемый пользовательский поток.
3709
+ *
3710
+ * Фабрика вызывается заново для каждой попытки; возвращать один и тот же поток нельзя.
3711
+ */
3712
+ function fromStream(factory, options = {}) {
3713
+ const resolved = resolveFileStreamOptions({
3714
+ ...options,
3715
+ mode: FileTransferMode.Stream
3716
+ }, void 0);
3717
+ optionalBytes(options.size, "size");
3718
+ return { open: async (context) => {
3719
+ let opened;
3720
+ try {
3721
+ opened = await factory(context);
3722
+ } catch (error) {
3723
+ if (error instanceof ItdFileError || error instanceof ItdConfigError || context.signal?.aborted) throw error;
3724
+ throw new ItdFileError("не удалось открыть поток вложения", {
3725
+ reason: ItdFileErrorReason.Read,
3726
+ retryable: true,
3727
+ cause: error
3728
+ });
3729
+ }
3730
+ const content = isReadableByteStream(opened) ? { stream: opened } : opened;
3731
+ if (!content || !isReadableByteStream(content.stream)) throw new ItdConfigError("fromStream должен вернуть ReadableStream или { stream }");
3732
+ const size = options.size ?? content.size;
3733
+ optionalBytes(size, "size");
3734
+ if (resolved.maxBytes !== void 0 && size !== void 0 && size > resolved.maxBytes) {
3735
+ await content.close?.();
3736
+ throw fileTooLarge(void 0, resolved.maxBytes, size);
3737
+ }
3738
+ const contentType = normalizeMimeType(options.contentType ?? content.contentType);
3739
+ return {
3740
+ stream: boundedFileStream(content.stream, {
3741
+ ...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
3742
+ streamBufferBytes: resolved.streamBufferBytes,
3743
+ ...context.signal ? { signal: context.signal } : {},
3744
+ retryableRead: true
3745
+ }),
3746
+ ...options.filename ?? content.filename ? { filename: options.filename ?? content.filename } : {},
3747
+ ...contentType ? { contentType } : {},
3748
+ ...size !== void 0 ? { size } : {},
3749
+ ...content.close ? { close: content.close } : {}
3750
+ };
3751
+ } };
3752
+ }
3753
+ //#endregion
3754
+ //#region src/models/guards.ts
3755
+ /**
3756
+ * Свой ли это профиль.
3757
+ *
3758
+ * @example
3759
+ * ```ts
3760
+ * if (isMyProfile(profile)) console.log(profile.subscription.isActive);
3761
+ * ```
3762
+ */
3763
+ function isMyProfile(profile) {
3764
+ return "subscription" in profile;
3765
+ }
3766
+ //#endregion
3767
+ //#region src/models/status-helpers.ts
3768
+ const STATUS_WINDOW_DAYS = 90;
3769
+ /**
3770
+ * Разворачивает историю сервиса в массив на 90 суток.
3771
+ * Сутки без данных становятся `null`.
3772
+ *
3773
+ * @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
3774
+ *
3775
+ * @example
3776
+ * ```ts
3777
+ * const status = await itd.platform.status();
3778
+ * const days = statusDays(status.services[0]);
3779
+ *
3780
+ * days[0]?.uptime; // доступность за сегодня
3781
+ * days.filter((day) => day === null).length; // за сколько суток данных нет
3782
+ * ```
3783
+ */
3784
+ function statusDays(service) {
3785
+ return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
3786
+ }
3787
+ //#endregion
3788
+ //#region src/spans/render.ts
3789
+ /** Формат результата {@link renderSpans}. */
3790
+ const SpanRenderFormat = Object.freeze({
3791
+ Html: "html",
3792
+ Markdown: "markdown",
3793
+ Ansi: "ansi"
3794
+ });
3795
+ function normalizedSpans(content, spans) {
3796
+ const result = [];
3797
+ for (const span of spans ?? []) {
3798
+ if (!span || !Number.isFinite(span.offset) || !Number.isFinite(span.length) || span.length <= 0) continue;
3799
+ const start = Math.max(0, Math.trunc(span.offset));
3800
+ const end = Math.min(content.length, Math.trunc(span.offset + span.length));
3801
+ if (start < end && start < content.length) result.push({
3802
+ span,
3803
+ start,
3804
+ end
3805
+ });
3806
+ }
3807
+ return result;
3808
+ }
3809
+ function escapeHtml(value) {
3810
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3811
+ }
3812
+ function safeHttpUrl(value) {
3813
+ if (!value) return null;
3814
+ try {
3815
+ const url = new URL(value);
3816
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
3817
+ } catch {
3818
+ return null;
3819
+ }
3820
+ }
3821
+ function safeNavigationUrl(value) {
3822
+ if (!value) return null;
3823
+ try {
3824
+ const url = new URL(value, "https://itd.invalid");
3825
+ return url.protocol === "http:" || url.protocol === "https:" ? value : null;
3826
+ } catch {
3827
+ return null;
3828
+ }
3829
+ }
3830
+ function entityValue(span, content, kind) {
3831
+ const offset = Number.isFinite(span.offset) ? Math.max(0, Math.trunc(span.offset)) : 0;
3832
+ const end = Number.isFinite(span.length) ? Math.min(content.length, offset + Math.max(0, Math.trunc(span.length))) : offset;
3833
+ const text = content.slice(offset, end);
3834
+ if (kind === "mention") return span.username ?? span.tag ?? text.replace(/^@/u, "");
3835
+ return span.tag ?? text.replace(/^#/u, "");
3836
+ }
3837
+ function mentionUrl(username, options) {
3838
+ return options.mentionUrl ? options.mentionUrl(username) : `/@${encodeURIComponent(username)}`;
3839
+ }
3840
+ function hashtagUrl(tag, options) {
3841
+ return options.hashtagUrl ? options.hashtagUrl(tag) : `/hashtag/${encodeURIComponent(tag)}`;
3842
+ }
3843
+ function classAttribute(name, options) {
3844
+ const prefix = options.classPrefix === void 0 ? "itd" : options.classPrefix;
3845
+ return prefix ? ` class="${escapeHtml(`${prefix}-${name}`)}"` : "";
3846
+ }
3847
+ function has(active, type) {
3848
+ return active.some((span) => span.type === type);
3849
+ }
3850
+ function last(active, type) {
3851
+ for (let index = active.length - 1; index >= 0; index -= 1) {
3852
+ const span = active[index];
3853
+ if (span?.type === type) return span;
3854
+ }
3855
+ }
3856
+ function renderHtmlSegment(text, active, content, options) {
3857
+ let rendered = escapeHtml(text).replace(/\n/g, "<br>");
3858
+ if (has(active, SpanType.Bold)) rendered = `<strong>${rendered}</strong>`;
3859
+ if (has(active, SpanType.Italic)) rendered = `<em>${rendered}</em>`;
3860
+ if (has(active, SpanType.Underline)) rendered = `<u>${rendered}</u>`;
3861
+ if (has(active, SpanType.Strike)) rendered = `<s>${rendered}</s>`;
3862
+ if (has(active, SpanType.Monospace)) rendered = `<code>${rendered}</code>`;
3863
+ if (has(active, SpanType.Quote)) rendered = `<span${classAttribute("quote", options)}>${rendered}</span>`;
3864
+ if (has(active, SpanType.Spoiler)) rendered = `<span${classAttribute("spoiler", options)} data-spoiler="true">${rendered}</span>`;
3865
+ const url = safeHttpUrl(last(active, SpanType.Link)?.url);
3866
+ if (url) rendered = `<a href="${escapeHtml(url)}" rel="noopener noreferrer">${rendered}</a>`;
3867
+ const mention = last(active, SpanType.Mention);
3868
+ if (mention) {
3869
+ const href = safeNavigationUrl(mentionUrl(entityValue(mention, content, "mention"), options));
3870
+ if (href) rendered = `<a href="${escapeHtml(href)}"${classAttribute("mention", options)}>${rendered}</a>`;
3871
+ }
3872
+ const hashtag = last(active, SpanType.Hashtag);
3873
+ if (hashtag) {
3874
+ const href = safeNavigationUrl(hashtagUrl(entityValue(hashtag, content, "hashtag"), options));
3875
+ if (href) rendered = `<a href="${escapeHtml(href)}"${classAttribute("hashtag", options)}>${rendered}</a>`;
3876
+ }
3877
+ return rendered;
3878
+ }
3879
+ function escapeMarkdown(value, startsAtLineStart) {
3880
+ return value.replace(/([\\`*_[\]<>~|&])/g, "\\$1").split("\n").map((line, index) => {
3881
+ if (index === 0 && !startsAtLineStart) return line;
3882
+ return line.replace(/^ {0,3}(?=#{1,6}(?:[ \t]|$))/u, "$&\\").replace(/^ {0,3}(?=[+-][ \t])/u, "$&\\").replace(/^(\s{0,3}\d{1,9})([.)])(?=[ \t])/u, "$1\\$2").replace(/^(\s{0,3})(?=-(?:[ \t]*-){2,}[ \t]*$)/u, "$1\\");
3883
+ }).join("\n");
3884
+ }
3885
+ function markdownCodeSpan(text) {
3886
+ const normalized = text.replace(/\r\n?|\n/g, " ");
3887
+ const longestRun = Math.max(0, ...Array.from(normalized.matchAll(/`+/g), (match) => match[0].length));
3888
+ const fence = "`".repeat(longestRun + 1);
3889
+ const allSpaces = /^ +$/u.test(normalized);
3890
+ return `${fence}${normalized.startsWith("`") || normalized.endsWith("`") || !allSpaces && normalized.startsWith(" ") && normalized.endsWith(" ") ? ` ${normalized} ` : normalized}${fence}`;
3891
+ }
3892
+ function markdownUrl(value) {
3893
+ return value.replace(/([\\)])/g, "\\$1");
3894
+ }
3895
+ function renderMarkdownSegment(segment, content, options) {
3896
+ const { text, active } = segment;
3897
+ const startsAtLineStart = segment.start === 0 || content[segment.start - 1] === "\n";
3898
+ let rendered = has(active, SpanType.Monospace) ? markdownCodeSpan(text) : escapeMarkdown(text, startsAtLineStart);
3899
+ if (has(active, SpanType.Bold)) rendered = `**${rendered}**`;
3900
+ if (has(active, SpanType.Italic)) rendered = `*${rendered}*`;
3901
+ if (has(active, SpanType.Underline)) rendered = `<u>${rendered}</u>`;
3902
+ if (has(active, SpanType.Strike)) rendered = `~~${rendered}~~`;
3903
+ if (has(active, SpanType.Spoiler)) rendered = `||${rendered}||`;
3904
+ const url = safeHttpUrl(last(active, SpanType.Link)?.url);
3905
+ if (url) rendered = `[${rendered}](${markdownUrl(url)})`;
3906
+ const mention = last(active, SpanType.Mention);
3907
+ if (mention) {
3908
+ const href = safeNavigationUrl(mentionUrl(entityValue(mention, content, "mention"), options));
3909
+ if (href) rendered = `[${rendered}](${markdownUrl(href)})`;
3910
+ }
3911
+ const hashtag = last(active, SpanType.Hashtag);
3912
+ if (hashtag) {
3913
+ const href = safeNavigationUrl(hashtagUrl(entityValue(hashtag, content, "hashtag"), options));
3914
+ if (href) rendered = `[${rendered}](${markdownUrl(href)})`;
3915
+ }
3916
+ return rendered;
3917
+ }
3918
+ function renderMarkdown(segments, content, options) {
3919
+ const groups = [];
3920
+ for (const segment of segments) {
3921
+ const quoted = has(segment.active, SpanType.Quote);
3922
+ const rendered = renderMarkdownSegment(segment, content, options);
3923
+ const previous = groups[groups.length - 1];
3924
+ if (previous?.quoted === quoted) previous.text += rendered;
3925
+ else groups.push({
3926
+ quoted,
3927
+ text: rendered
3928
+ });
3929
+ }
3930
+ let result = "";
3931
+ for (const [index, group] of groups.entries()) {
3932
+ const rendered = group.quoted ? group.text.split("\n").map((line) => `> ${line}`).join("\n") : group.text;
3933
+ const previous = groups[index - 1];
3934
+ if (result !== "" && (group.quoted || previous?.quoted) && !result.endsWith("\n") && !rendered.startsWith("\n")) result += "\n";
3935
+ result += rendered;
3936
+ }
3937
+ return result;
3938
+ }
3939
+ function renderAnsiSegment(text, active) {
3940
+ const codes = [];
3941
+ if (has(active, SpanType.Bold)) codes.push(1);
3942
+ if (has(active, SpanType.Italic)) codes.push(3);
3943
+ if (has(active, SpanType.Underline) || has(active, SpanType.Link)) codes.push(4);
3944
+ if (has(active, SpanType.Strike)) codes.push(9);
3945
+ if (has(active, SpanType.Spoiler)) codes.push(8);
3946
+ if (has(active, SpanType.Quote)) codes.push(2);
3947
+ if (has(active, SpanType.Mention) || has(active, SpanType.Hashtag)) codes.push(36);
3948
+ return codes.length === 0 ? text : `\u001b[${codes.join(";")}m${text}\u001b[0m`;
3949
+ }
3950
+ /**
3951
+ * Преобразует текст и wire-разметку API в безопасный HTML, Markdown или ANSI.
3952
+ *
3953
+ * Некорректные серверные spans игнорируются либо обрезаются по границам строки. Пересекающиеся
3954
+ * spans разбиваются на независимые сегменты, поэтому HTML остаётся корректно вложенным.
3955
+ * Отсутствующий массив считается пустым; формат по умолчанию — HTML.
3956
+ */
3957
+ function renderSpans(content, spans = [], options = {}) {
3958
+ const normalized = normalizedSpans(content, spans);
3959
+ const boundaries = /* @__PURE__ */ new Set([0, content.length]);
3960
+ for (const item of normalized) {
3961
+ boundaries.add(item.start);
3962
+ boundaries.add(item.end);
3963
+ }
3964
+ const points = [...boundaries].sort((left, right) => left - right);
3965
+ const segments = [];
3966
+ for (let index = 0; index < points.length - 1; index += 1) {
3967
+ const start = points[index];
3968
+ const end = points[index + 1];
3969
+ if (start === void 0 || end === void 0 || start === end) continue;
3970
+ const text = content.slice(start, end);
3971
+ const active = normalized.filter((item) => item.start <= start && item.end >= end).map((item) => item.span);
3972
+ segments.push({
3973
+ start,
3974
+ text,
3975
+ active
3976
+ });
3977
+ }
3978
+ const format = options.format ?? SpanRenderFormat.Html;
3979
+ if (format === SpanRenderFormat.Markdown) return renderMarkdown(segments, content, options);
3980
+ let result = "";
3981
+ for (const segment of segments) switch (format) {
3982
+ case SpanRenderFormat.Html:
3983
+ result += renderHtmlSegment(segment.text, segment.active, content, options);
3984
+ break;
3985
+ case SpanRenderFormat.Ansi: result += renderAnsiSegment(segment.text, segment.active);
3986
+ }
3987
+ return result;
3988
+ }
3989
+ //#endregion
3990
+ export { BaseResource as C, mapPage as E, isBuilder as S, Paginator as T, ALLOWED_MIME_TYPES as _, fromStream as a, VIDEO_MIME_TYPES as b, report as c, autoSpans as d, markup as f, utcStampToIso as g, toDate as h, isMyProfile as i, post as l, parseMarkdown as m, renderSpans as n, fromUrl as o, parseHtml as p, statusDays as r, createResources as s, SpanRenderFormat as t, poll as u, AUDIO_MIME_TYPES as v, PaginationMode as w, comment as x, IMAGE_MIME_TYPES as y };
3991
+
3992
+ //# sourceMappingURL=render-vtLixIiU.js.map