itd-api 0.7.0 → 0.7.2

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