itd-api 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -27
- package/dist/events/index.cjs +53 -0
- package/dist/events/index.d.cts +3 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.js +5 -0
- package/dist/index.cjs +234 -200
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -64
- package/dist/index.d.ts +70 -64
- package/dist/index.js +202 -179
- package/dist/index.js.map +1 -1
- package/dist/node/index.cjs +6 -5
- package/dist/node/index.cjs.map +1 -1
- package/dist/node/index.d.cts +2 -2
- package/dist/node/index.d.ts +2 -2
- package/dist/node/index.js +5 -4
- package/dist/node/index.js.map +1 -1
- package/dist/rest/index.cjs +32 -26
- package/dist/rest/index.cjs.map +1 -1
- package/dist/rest/index.d.cts +9 -7
- package/dist/rest/index.d.ts +9 -7
- package/dist/rest/index.js +19 -21
- package/dist/rest/index.js.map +1 -1
- package/dist/shared/{errors-DfU8M5eS.cjs → errors-BmP3TKoW.cjs} +3 -3
- package/dist/shared/{errors-DfU8M5eS.cjs.map → errors-BmP3TKoW.cjs.map} +1 -1
- package/dist/shared/{errors-Bhrd2fJd.js → errors-GI10kZxk.js} +3 -3
- package/dist/shared/{errors-Bhrd2fJd.js.map → errors-GI10kZxk.js.map} +1 -1
- package/dist/shared/{websocket-BLR8eVJV.js → events-C5wnTXPT.js} +770 -490
- package/dist/shared/events-C5wnTXPT.js.map +1 -0
- package/dist/shared/events-CqtkP65d.d.ts +698 -0
- package/dist/shared/events-DGAWlCaI.d.cts +698 -0
- package/dist/shared/{websocket-C_eI4H2o.cjs → events-FZfnEez0.cjs} +834 -518
- package/dist/shared/events-FZfnEez0.cjs.map +1 -0
- package/dist/shared/{storage-BPJR_k4-.cjs → key-value-store-B5GYVEYZ.cjs} +2 -86
- package/dist/shared/key-value-store-B5GYVEYZ.cjs.map +1 -0
- package/dist/shared/{storage-D86edNCB.js → key-value-store-Bt2PSMxY.js} +3 -69
- package/dist/shared/key-value-store-Bt2PSMxY.js.map +1 -0
- package/dist/shared/key-value-store-COZaNHZS.d.cts +69 -0
- package/dist/shared/key-value-store-COZaNHZS.d.ts +69 -0
- package/dist/shared/{multi-storage-DccjD7Ww.d.ts → multi-storage-BEL7tcKn.d.cts} +3 -2
- package/dist/shared/{multi-storage--yTEqiod.cjs → multi-storage-CDKLa956.cjs} +11 -10
- package/dist/shared/{multi-storage--yTEqiod.cjs.map → multi-storage-CDKLa956.cjs.map} +1 -1
- package/dist/shared/{multi-storage-CjAPB5Kq.d.cts → multi-storage-LkljOSzA.d.ts} +3 -2
- package/dist/shared/{multi-storage-CkvTUC5m.js → multi-storage-SoW05uzO.js} +5 -4
- package/dist/shared/{multi-storage-CkvTUC5m.js.map → multi-storage-SoW05uzO.js.map} +1 -1
- package/dist/shared/{options-Dg5N3r1V.cjs → options-DOJYtoti.cjs} +8 -6
- package/dist/shared/options-DOJYtoti.cjs.map +1 -0
- package/dist/shared/{options-DtATYdLr.js → options-DhgGg7Ms.js} +8 -6
- package/dist/shared/options-DhgGg7Ms.js.map +1 -0
- package/dist/shared/{cookies-DZwFq6kr.cjs → redact-BP00URmQ.cjs} +312 -2
- package/dist/shared/redact-BP00URmQ.cjs.map +1 -0
- package/dist/shared/{cookies-tX2sNwxb.js → redact-Ba5iSVYZ.js} +235 -3
- package/dist/shared/redact-Ba5iSVYZ.js.map +1 -0
- package/dist/shared/{render-CgwKdOzu.d.ts → render-B4qis7Sb.d.ts} +775 -313
- package/dist/shared/{render-DO0F5YSm.d.cts → render-DDwy2Ame.d.cts} +775 -313
- package/dist/shared/{render-mcuELiYi.cjs → render-Dqi8Owqk.cjs} +1464 -662
- package/dist/shared/render-Dqi8Owqk.cjs.map +1 -0
- package/dist/shared/{render-C6HRPs10.js → render-hiWFh2s4.js} +1366 -618
- package/dist/shared/render-hiWFh2s4.js.map +1 -0
- package/dist/shared/storage-BQrtKon9.d.cts +86 -0
- package/dist/shared/storage-CTRGT5wF.d.ts +86 -0
- package/dist/shared/storage-CfHKzVHf.js +71 -0
- package/dist/shared/storage-CfHKzVHf.js.map +1 -0
- package/dist/shared/storage-QNVxzpDB.cjs +88 -0
- package/dist/shared/storage-QNVxzpDB.cjs.map +1 -0
- package/dist/shared/{url-BaMCQpYH.cjs → url-Bsb3xE5U.cjs} +577 -466
- package/dist/shared/url-Bsb3xE5U.cjs.map +1 -0
- package/dist/shared/{url-IU0xN9wX.js → url-DP33mp3s.js} +508 -409
- package/dist/shared/url-DP33mp3s.js.map +1 -0
- package/dist/shared/{url-DTfZ2toq.d.ts → url-XBsMdAcv.d.cts} +1313 -1200
- package/dist/shared/{url-DTfZ2toq.d.cts → url-XBsMdAcv.d.ts} +1313 -1200
- package/dist/web/index.cjs +5 -4
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.d.cts +1 -1
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +3 -2
- package/dist/web/index.js.map +1 -1
- package/package.json +16 -15
- package/dist/realtime/index.cjs +0 -165
- package/dist/realtime/index.cjs.map +0 -1
- package/dist/realtime/index.d.cts +0 -51
- package/dist/realtime/index.d.ts +0 -51
- package/dist/realtime/index.js +0 -120
- package/dist/realtime/index.js.map +0 -1
- package/dist/shared/auth-provider-CG8oCQ9F.cjs +0 -108
- package/dist/shared/auth-provider-CG8oCQ9F.cjs.map +0 -1
- package/dist/shared/auth-provider-mYqxsSVa.js +0 -91
- package/dist/shared/auth-provider-mYqxsSVa.js.map +0 -1
- package/dist/shared/cookies-DZwFq6kr.cjs.map +0 -1
- package/dist/shared/cookies-tX2sNwxb.js.map +0 -1
- package/dist/shared/options-Dg5N3r1V.cjs.map +0 -1
- package/dist/shared/options-DtATYdLr.js.map +0 -1
- package/dist/shared/render-C6HRPs10.js.map +0 -1
- package/dist/shared/render-mcuELiYi.cjs.map +0 -1
- package/dist/shared/storage-BPJR_k4-.cjs.map +0 -1
- package/dist/shared/storage-C_eICCep.d.cts +0 -152
- package/dist/shared/storage-C_eICCep.d.ts +0 -152
- package/dist/shared/storage-D86edNCB.js.map +0 -1
- package/dist/shared/url-BaMCQpYH.cjs.map +0 -1
- package/dist/shared/url-IU0xN9wX.js.map +0 -1
- package/dist/shared/websocket-BLR8eVJV.js.map +0 -1
- package/dist/shared/websocket-C_eI4H2o.cjs.map +0 -1
- package/dist/shared/websocket-DF7XIMiX.d.cts +0 -562
- package/dist/shared/websocket-DYKBr8HF.d.ts +0 -562
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { _ as ItdStateError, a as ItdConfigError, l as ItdFileError, s as ItdError, t as ItdAbortError, u as ItdFileErrorReason } from "./errors-
|
|
3
|
-
import {
|
|
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-
|
|
1
|
+
import { B as isRecord, C as ReportReason, E as SpanType, G as pickString, H as pickBoolean, J as identityResult, K as RetrySafety, M as defineBuiltInOperation, U as pickNumber, V as pickArray, W as pickObject, X as mergeService, Y as voidResult, c as normalizeNotification, ct as createDeadline, nt as STATUS_SERVICE, o as passthroughOperation, q as defineOperation, s as voidOperation, tt as DEFAULT_STATUS_BASE_URL, w as ReportTargetType, y as InteractionType } from "./url-DP33mp3s.js";
|
|
2
|
+
import { _ as ItdStateError, a as ItdConfigError, b as isItdApiError, l as ItdFileError, s as ItdError, t as ItdAbortError, u as ItdFileErrorReason } from "./errors-GI10kZxk.js";
|
|
3
|
+
import { a as redactUrl, b as isFile, d as encodePathSegment, r as redactErrorCause, v as createDeviceId, y as isBlob } from "./redact-Ba5iSVYZ.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-DhgGg7Ms.js";
|
|
5
|
+
import { r as createKeyValueStore, t as MemoryKeyValueStore } from "./key-value-store-Bt2PSMxY.js";
|
|
5
6
|
//#region src/core/feature-catalog.ts
|
|
6
7
|
/**
|
|
7
8
|
* Изменяемый слой над каталогом конкретного API.
|
|
@@ -32,6 +33,9 @@ var ExtensibleOperationCatalog = class {
|
|
|
32
33
|
get defaultBucket() {
|
|
33
34
|
return this.#base.defaultBucket;
|
|
34
35
|
}
|
|
36
|
+
definitionOf(id) {
|
|
37
|
+
return this.#operations.get(id) ?? this.#base.definitionOf(id);
|
|
38
|
+
}
|
|
35
39
|
retrySafetyOf(id) {
|
|
36
40
|
return this.#operations.get(id)?.retrySafety ?? this.#base.retrySafetyOf(id);
|
|
37
41
|
}
|
|
@@ -49,7 +53,10 @@ var ExtensibleOperationCatalog = class {
|
|
|
49
53
|
if (this.#base.isKnownBucket(name) || this.#bucketOwners.has(name)) throw new ItdConfigError(`feature «${owner}»: бакет «${name}» уже зарегистрирован`);
|
|
50
54
|
this.#bucketOwners.set(name, owner);
|
|
51
55
|
if (definition.limit !== void 0) this.#bucketLimits[name] = definition.limit;
|
|
52
|
-
if (definition.concurrency !== void 0) this.#bucketOverrides[name] = Object.freeze({
|
|
56
|
+
if (definition.concurrency !== void 0 || definition.rps !== void 0) this.#bucketOverrides[name] = Object.freeze({
|
|
57
|
+
...definition.concurrency === void 0 ? {} : { concurrency: definition.concurrency },
|
|
58
|
+
...definition.rps === void 0 ? {} : { rps: definition.rps }
|
|
59
|
+
});
|
|
53
60
|
return () => {
|
|
54
61
|
if (this.#bucketOwners.get(name) !== owner) return;
|
|
55
62
|
this.#bucketOwners.delete(name);
|
|
@@ -74,22 +81,311 @@ var ExtensibleOperationCatalog = class {
|
|
|
74
81
|
}
|
|
75
82
|
};
|
|
76
83
|
//#endregion
|
|
84
|
+
//#region src/core/attachments/url-source.ts
|
|
85
|
+
/** Убирает параметры MIME и приводит его к форме для сравнения. */
|
|
86
|
+
function normalizeMimeType(contentType) {
|
|
87
|
+
return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
|
|
88
|
+
}
|
|
89
|
+
/** Достаёт имя файла из пути URL. */
|
|
90
|
+
function filenameFromUrl(url) {
|
|
91
|
+
const last = url.pathname.split("/").pop();
|
|
92
|
+
if (!last) return void 0;
|
|
93
|
+
try {
|
|
94
|
+
return decodeURIComponent(last) || void 0;
|
|
95
|
+
} catch {
|
|
96
|
+
return last;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
|
|
100
|
+
function declaredSize(response, maxBytes, url) {
|
|
101
|
+
const header = response.headers.get("content-length");
|
|
102
|
+
if (header === null) return void 0;
|
|
103
|
+
const size = Number(header);
|
|
104
|
+
if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
|
|
105
|
+
if (maxBytes !== void 0 && size > maxBytes) throw fileTooLarge(url, maxBytes, size);
|
|
106
|
+
return size;
|
|
107
|
+
}
|
|
108
|
+
/** Получает HTTP-ответ источника и проверяет его статус. */
|
|
109
|
+
async function fetchFile(target, options, context) {
|
|
110
|
+
let requested;
|
|
111
|
+
try {
|
|
112
|
+
requested = new URL(target);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new ItdConfigError(`«${redactUrl(target)}» не разбирается как адрес`);
|
|
115
|
+
}
|
|
116
|
+
if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
|
|
117
|
+
let response;
|
|
118
|
+
try {
|
|
119
|
+
response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
|
|
122
|
+
const safeUrl = redactUrl(requested.href);
|
|
123
|
+
throw new ItdFileError(`не удалось получить файл по адресу ${safeUrl}`, {
|
|
124
|
+
reason: ItdFileErrorReason.Network,
|
|
125
|
+
url: safeUrl,
|
|
126
|
+
retryable: true,
|
|
127
|
+
cause: redactErrorCause(error)
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const finalUrl = response.url ? new URL(response.url) : requested;
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
await response.body?.cancel().catch(() => {});
|
|
133
|
+
const safeUrl = redactUrl(finalUrl.href);
|
|
134
|
+
throw new ItdFileError(`источник ${safeUrl} ответил статусом ${response.status}`, {
|
|
135
|
+
reason: ItdFileErrorReason.Http,
|
|
136
|
+
url: safeUrl,
|
|
137
|
+
status: response.status,
|
|
138
|
+
retryable: response.status === 408 || response.status === 429 || response.status >= 500
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const { maxBytes } = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
|
|
142
|
+
let size;
|
|
143
|
+
try {
|
|
144
|
+
size = declaredSize(response, maxBytes, finalUrl.href);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await response.body?.cancel().catch(() => {});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
response,
|
|
151
|
+
url: finalUrl,
|
|
152
|
+
size
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Читает ответ с контролем размера до создания итогового `Blob`. */
|
|
156
|
+
async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
|
|
157
|
+
if (!response.body) {
|
|
158
|
+
const blob = await response.blob();
|
|
159
|
+
if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(url, maxBytes, blob.size);
|
|
160
|
+
return blob;
|
|
161
|
+
}
|
|
162
|
+
const chunks = [];
|
|
163
|
+
const reader = boundedFileStream(response.body, {
|
|
164
|
+
...maxBytes !== void 0 ? { maxBytes } : {},
|
|
165
|
+
streamBufferBytes,
|
|
166
|
+
...signal ? { signal } : {},
|
|
167
|
+
url,
|
|
168
|
+
retryableRead: true
|
|
169
|
+
}).getReader();
|
|
170
|
+
try {
|
|
171
|
+
for (;;) {
|
|
172
|
+
const next = await reader.read();
|
|
173
|
+
if (next.done) break;
|
|
174
|
+
chunks.push(next.value);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
reader.releaseLock();
|
|
178
|
+
}
|
|
179
|
+
return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
|
|
180
|
+
}
|
|
181
|
+
/** Скачивает файл целиком с ограничением размера. @internal */
|
|
182
|
+
async function downloadFile(target, options, context) {
|
|
183
|
+
const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
|
|
184
|
+
const { response, url } = await fetchFile(target, options, context);
|
|
185
|
+
const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
|
|
186
|
+
const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
|
|
187
|
+
const filename = options.filename ?? filenameFromUrl(url);
|
|
188
|
+
return {
|
|
189
|
+
file: new Blob([blob], { type: contentType ?? "" }),
|
|
190
|
+
...filename ? { filename } : {},
|
|
191
|
+
...contentType ? { contentType } : {}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Открывает HTTP-ответ как ограниченный поток. @internal */
|
|
195
|
+
async function openUrlFile(target, options, context) {
|
|
196
|
+
const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
|
|
197
|
+
const { response, url, size } = await fetchFile(target, options, context);
|
|
198
|
+
if (!response.body) {
|
|
199
|
+
const safeUrl = redactUrl(url.href);
|
|
200
|
+
throw new ItdFileError(`источник ${safeUrl} не предоставил потоковое тело`, {
|
|
201
|
+
reason: ItdFileErrorReason.StreamUnavailable,
|
|
202
|
+
url: safeUrl
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const stream = boundedFileStream(response.body, {
|
|
206
|
+
...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
|
|
207
|
+
streamBufferBytes: resolved.streamBufferBytes,
|
|
208
|
+
...context.signal ? { signal: context.signal } : {},
|
|
209
|
+
url: url.href,
|
|
210
|
+
retryableRead: true
|
|
211
|
+
});
|
|
212
|
+
const filename = options.filename ?? filenameFromUrl(url);
|
|
213
|
+
const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
|
|
214
|
+
return {
|
|
215
|
+
stream,
|
|
216
|
+
...filename ? { filename } : {},
|
|
217
|
+
...contentType ? { contentType } : {},
|
|
218
|
+
...size !== void 0 ? { size } : {},
|
|
219
|
+
close: () => stream.cancel().catch(() => {})
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/core/attachments/resolver.ts
|
|
224
|
+
/** Возвращает функцию освобождения, которая исполняет действие не более одного раза. */
|
|
225
|
+
function closeOnce(action) {
|
|
226
|
+
let closing;
|
|
227
|
+
return () => closing ??= Promise.resolve().then(action);
|
|
228
|
+
}
|
|
229
|
+
/** Добавляет поле только для непустого значения, не выдумывая имя или MIME. */
|
|
230
|
+
function optionalText(name, value) {
|
|
231
|
+
const normalized = value?.trim();
|
|
232
|
+
return normalized ? { [name]: normalized } : {};
|
|
233
|
+
}
|
|
234
|
+
/** Единая реализация распознавания и открытия `FileInput`. @internal */
|
|
235
|
+
var DefaultFileResolver = class {
|
|
236
|
+
#fetch;
|
|
237
|
+
constructor(fetchImplementation) {
|
|
238
|
+
this.#fetch = fetchImplementation;
|
|
239
|
+
}
|
|
240
|
+
mode(input) {
|
|
241
|
+
if (typeof input !== "object" || input === null) return FileTransferMode.Buffer;
|
|
242
|
+
if ("open" in input) return FileTransferMode.Stream;
|
|
243
|
+
if ("url" in input) return resolveFileStreamOptions(input).mode;
|
|
244
|
+
return FileTransferMode.Buffer;
|
|
245
|
+
}
|
|
246
|
+
async resolve(input, options = {}, context = {}) {
|
|
247
|
+
const attempt = context.attempt ?? 1;
|
|
248
|
+
if (!Number.isInteger(attempt) || attempt < 1) throw new ItdConfigError(`attempt должен быть целым числом от 1, получено: ${attempt}`);
|
|
249
|
+
const maxBytes = optionalBytes(options.maxBytes, "maxBytes");
|
|
250
|
+
if (context.signal?.aborted) throw context.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
251
|
+
const fileContext = {
|
|
252
|
+
fetch: this.#fetch,
|
|
253
|
+
attempt,
|
|
254
|
+
...context.signal ? { signal: context.signal } : {}
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
const source = this.mode(input) === FileTransferMode.Stream ? await this.#openStream(input, options, fileContext, maxBytes) : await this.#loadBuffer(input, options, fileContext, maxBytes);
|
|
258
|
+
if (context.signal?.aborted) {
|
|
259
|
+
if (source.mode === FileTransferMode.Stream) await source.close();
|
|
260
|
+
throw context.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
261
|
+
}
|
|
262
|
+
return source;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof ItdError || context.signal?.aborted) throw error;
|
|
265
|
+
throw new ItdFileError("не удалось получить содержимое вложения", {
|
|
266
|
+
reason: ItdFileErrorReason.Read,
|
|
267
|
+
retryable: true,
|
|
268
|
+
cause: redactErrorCause(error)
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async #loadBuffer(input, options, context, maxBytes) {
|
|
273
|
+
const content = await this.#resolveBuffer(input, context);
|
|
274
|
+
if (typeof content !== "object" || content === null || !("file" in content)) throw new ItdConfigError("буферный источник должен вернуть объект с полем file");
|
|
275
|
+
if (!isBlob(content.file) && !(content.file instanceof ArrayBuffer) && !ArrayBuffer.isView(content.file)) throw new ItdConfigError("поле file должно содержать Blob, ArrayBuffer или Uint8Array");
|
|
276
|
+
const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(isBlob(content.file) ? content.file.type : void 0);
|
|
277
|
+
const blob = isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
|
|
278
|
+
if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(void 0, maxBytes, blob.size);
|
|
279
|
+
return {
|
|
280
|
+
mode: FileTransferMode.Buffer,
|
|
281
|
+
blob,
|
|
282
|
+
size: blob.size,
|
|
283
|
+
...optionalText("filename", options.filename ?? content.filename),
|
|
284
|
+
contentType
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async #openStream(input, options, context, maxBytes) {
|
|
288
|
+
const opened = await this.#resolveStream(input, context);
|
|
289
|
+
if (!opened || !isReadableByteStream(opened.stream)) {
|
|
290
|
+
await opened?.close?.();
|
|
291
|
+
throw new ItdConfigError("потоковый источник должен вернуть { stream: ReadableStream }");
|
|
292
|
+
}
|
|
293
|
+
let handedOff = false;
|
|
294
|
+
const closeOpened = closeOnce(async () => {
|
|
295
|
+
await opened.close?.();
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
optionalBytes(opened.size, "size");
|
|
299
|
+
if (maxBytes !== void 0 && opened.size !== void 0 && opened.size > maxBytes) throw fileTooLarge(void 0, maxBytes, opened.size);
|
|
300
|
+
if (options.requireKnownSize === true && opened.size === void 0) throw new ItdConfigError("размер потокового вложения должен быть известен до чтения");
|
|
301
|
+
const limits = resolveFileStreamOptions({
|
|
302
|
+
mode: FileTransferMode.Stream,
|
|
303
|
+
...maxBytes !== void 0 ? { maxBytes } : {},
|
|
304
|
+
...options.streamBufferBytes !== void 0 ? { streamBufferBytes: options.streamBufferBytes } : {}
|
|
305
|
+
});
|
|
306
|
+
const stream = isBoundedFileStream(opened.stream) && options.maxBytes === void 0 && options.streamBufferBytes === void 0 ? opened.stream : boundedFileStream(opened.stream, {
|
|
307
|
+
...limits.maxBytes !== void 0 ? { maxBytes: limits.maxBytes } : {},
|
|
308
|
+
streamBufferBytes: limits.streamBufferBytes,
|
|
309
|
+
...context.signal ? { signal: context.signal } : {}
|
|
310
|
+
});
|
|
311
|
+
let detachAbort;
|
|
312
|
+
const close = closeOnce(async () => {
|
|
313
|
+
detachAbort?.();
|
|
314
|
+
detachAbort = void 0;
|
|
315
|
+
await stream.cancel().catch(() => {});
|
|
316
|
+
await closeOpened();
|
|
317
|
+
});
|
|
318
|
+
if (context.signal) {
|
|
319
|
+
const onAbort = () => {
|
|
320
|
+
close().catch(() => {});
|
|
321
|
+
};
|
|
322
|
+
if (context.signal.aborted) {
|
|
323
|
+
await close();
|
|
324
|
+
throw context.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
325
|
+
} else {
|
|
326
|
+
context.signal.addEventListener("abort", onAbort, { once: true });
|
|
327
|
+
detachAbort = () => context.signal?.removeEventListener("abort", onAbort);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
handedOff = true;
|
|
331
|
+
return {
|
|
332
|
+
mode: FileTransferMode.Stream,
|
|
333
|
+
stream,
|
|
334
|
+
size: opened.size,
|
|
335
|
+
...optionalText("filename", options.filename ?? opened.filename),
|
|
336
|
+
contentType: normalizeMimeType(options.contentType ?? opened.contentType),
|
|
337
|
+
close
|
|
338
|
+
};
|
|
339
|
+
} finally {
|
|
340
|
+
if (!handedOff) await closeOpened();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
#resolveBuffer(input, context) {
|
|
344
|
+
if (input instanceof ArrayBuffer || ArrayBuffer.isView(input) || isBlob(input)) return {
|
|
345
|
+
file: input,
|
|
346
|
+
...isFile(input) ? { filename: input.name } : {}
|
|
347
|
+
};
|
|
348
|
+
if (typeof input !== "object" || input === null) throw new ItdConfigError(`вложение задано значением типа ${typeof input}; ожидается бинарное значение или объект { file }, { url }, { load }, { open }. Для файла на диске используйте fromPath('./photo.jpg') из itd-api/node`);
|
|
349
|
+
if ("load" in input) return input.load(context);
|
|
350
|
+
if ("url" in input) {
|
|
351
|
+
const { url, ...urlOptions } = input;
|
|
352
|
+
return downloadFile(url, urlOptions, context);
|
|
353
|
+
}
|
|
354
|
+
if ("file" in input) return input;
|
|
355
|
+
if ("open" in input) throw new ItdConfigError("потоковый источник нельзя использовать как буферный");
|
|
356
|
+
throw new ItdConfigError("вложение не распознано: ожидается { file }, { url }, { load } или { open }");
|
|
357
|
+
}
|
|
358
|
+
#resolveStream(input, context) {
|
|
359
|
+
if (typeof input === "object" && input !== null && "open" in input) return input.open(context);
|
|
360
|
+
if (typeof input === "object" && input !== null && "url" in input) {
|
|
361
|
+
const { url, ...urlOptions } = input;
|
|
362
|
+
return openUrlFile(url, urlOptions, context);
|
|
363
|
+
}
|
|
364
|
+
throw new ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
/** Создаёт механизм подготовки файлов, привязанный к настроенному `fetch` клиента. @internal */
|
|
368
|
+
function createFileResolver(fetchImplementation) {
|
|
369
|
+
return new DefaultFileResolver(fetchImplementation);
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
77
372
|
//#region src/core/features.ts
|
|
78
373
|
function requireName(value, kind) {
|
|
79
374
|
if (typeof value !== "string" || value.trim() === "") throw new ItdConfigError(`${kind} должен иметь непустое имя`);
|
|
80
375
|
return value.trim();
|
|
81
376
|
}
|
|
82
|
-
function
|
|
83
|
-
const name = requireName(value,
|
|
84
|
-
if (name
|
|
377
|
+
function requireFeatureName(value) {
|
|
378
|
+
const name = requireName(value, "Feature");
|
|
379
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new ItdConfigError(`имя feature «${name}» должно соответствовать [a-z][a-z0-9-]*`);
|
|
85
380
|
return name;
|
|
86
381
|
}
|
|
87
382
|
function validateBucket(owner, localName, bucket) {
|
|
88
383
|
if (typeof bucket !== "object" || bucket === null || Array.isArray(bucket)) throw new ItdConfigError(`feature «${owner}»: бакет «${localName}» должен быть объектом`);
|
|
89
384
|
if (bucket.limit !== void 0 && (!Number.isFinite(bucket.limit) || bucket.limit <= 0)) throw new ItdConfigError(`feature «${owner}»: limit бакета «${localName}» должен быть положительным числом`);
|
|
90
385
|
if (bucket.concurrency !== void 0 && (!Number.isInteger(bucket.concurrency) || bucket.concurrency < 1)) throw new ItdConfigError(`feature «${owner}»: concurrency бакета «${localName}» должен быть целым числом от 1`);
|
|
386
|
+
if (bucket.rps !== void 0 && (!Number.isFinite(bucket.rps) || bucket.rps <= 0)) throw new ItdConfigError(`feature «${owner}»: rps бакета «${localName}» должен быть положительным числом`);
|
|
91
387
|
}
|
|
92
|
-
/** Реестр
|
|
388
|
+
/** Реестр подключаемых модулей клиента. @internal */
|
|
93
389
|
var FeatureRegistry = class {
|
|
94
390
|
#deps;
|
|
95
391
|
#features = /* @__PURE__ */ new Map();
|
|
@@ -102,7 +398,7 @@ var FeatureRegistry = class {
|
|
|
102
398
|
this.#deps.assertActive("установить feature");
|
|
103
399
|
if (this.#disposed) throw new ItdStateError("Реестр feature уже освобождён");
|
|
104
400
|
if (typeof feature !== "object" || feature === null) throw new ItdConfigError("feature должен быть объектом");
|
|
105
|
-
const name =
|
|
401
|
+
const name = requireFeatureName(feature.name);
|
|
106
402
|
if (this.#features.has(name)) throw new ItdConfigError(`feature «${name}» уже установлен`);
|
|
107
403
|
if (typeof feature.setup !== "function") throw new ItdConfigError(`feature «${name}» должен предоставлять setup()`);
|
|
108
404
|
if (typeof feature.operations !== "object" || feature.operations === null || Array.isArray(feature.operations)) throw new ItdConfigError(`feature «${name}» должен объявлять operations`);
|
|
@@ -114,6 +410,11 @@ var FeatureRegistry = class {
|
|
|
114
410
|
const operations = /* @__PURE__ */ new Map();
|
|
115
411
|
const controller = new AbortController();
|
|
116
412
|
let committed = false;
|
|
413
|
+
const reportRollbackError = (message, error) => {
|
|
414
|
+
try {
|
|
415
|
+
this.#deps.logger?.error(message, error);
|
|
416
|
+
} catch {}
|
|
417
|
+
};
|
|
117
418
|
try {
|
|
118
419
|
for (const service of feature.services ?? []) {
|
|
119
420
|
const serviceName = requireName(service?.name, `Сервис feature «${name}»`);
|
|
@@ -147,7 +448,7 @@ var FeatureRegistry = class {
|
|
|
147
448
|
bucketNames.set(localName, globalName);
|
|
148
449
|
}
|
|
149
450
|
for (const [rawLocalName, definition] of Object.entries(feature.operations)) {
|
|
150
|
-
const localName =
|
|
451
|
+
const localName = requireName(rawLocalName, `Операция feature «${name}»`);
|
|
151
452
|
if (typeof definition !== "object" || definition === null) throw new ItdConfigError(`feature «${name}»: операция «${localName}» должна быть объектом`);
|
|
152
453
|
if (![
|
|
153
454
|
"GET",
|
|
@@ -159,19 +460,27 @@ var FeatureRegistry = class {
|
|
|
159
460
|
if (!Object.values(RetrySafety).includes(definition.retrySafety)) throw new ItdConfigError(`feature «${name}»: операция «${localName}» содержит неизвестную retrySafety`);
|
|
160
461
|
if (definition.service !== void 0 && typeof definition.service !== "string") throw new ItdConfigError(`feature «${name}»: service операции «${localName}» должен быть строкой`);
|
|
161
462
|
if (definition.bucket !== void 0 && typeof definition.bucket !== "string") throw new ItdConfigError(`feature «${name}»: bucket операции «${localName}» должен быть строкой`);
|
|
463
|
+
if (definition.read !== void 0 && typeof definition.read !== "function") throw new ItdConfigError(`feature «${name}»: read операции «${localName}» должна быть функцией`);
|
|
464
|
+
if (definition.annotations !== void 0 && (typeof definition.annotations !== "object" || definition.annotations === null || Array.isArray(definition.annotations))) throw new ItdConfigError(`feature «${name}»: annotations операции «${localName}» должны быть объектом`);
|
|
162
465
|
if (definition.service !== void 0 && !services.has(definition.service)) throw new ItdConfigError(`feature «${name}»: операция «${localName}» ссылается на необъявленный сервис «${definition.service}»`);
|
|
163
466
|
const bucket = definition.bucket === void 0 ? this.#deps.catalog.defaultBucket : bucketNames.get(definition.bucket);
|
|
164
467
|
if (bucket === void 0) throw new ItdConfigError(`feature «${name}»: операция «${localName}» ссылается на необъявленный бакет «${definition.bucket}»`);
|
|
165
468
|
const operationId = `${name}.${localName}`;
|
|
166
|
-
const
|
|
469
|
+
const contract = defineOperation(operationId, {
|
|
167
470
|
method: definition.method,
|
|
168
471
|
retrySafety: definition.retrySafety,
|
|
169
|
-
bucket
|
|
472
|
+
bucket,
|
|
473
|
+
...definition.annotations === void 0 ? {} : { annotations: definition.annotations }
|
|
474
|
+
}, definition.read ?? identityResult);
|
|
475
|
+
const unregister = this.#deps.catalog.registerOperation(name, operationId, {
|
|
476
|
+
method: contract.method,
|
|
477
|
+
retrySafety: contract.retrySafety,
|
|
478
|
+
bucket,
|
|
479
|
+
...contract.annotations === void 0 ? {} : { annotations: contract.annotations }
|
|
170
480
|
});
|
|
171
481
|
rollback.push(unregister);
|
|
172
482
|
operations.set(localName, {
|
|
173
|
-
|
|
174
|
-
method: definition.method,
|
|
483
|
+
contract,
|
|
175
484
|
service: definition.service
|
|
176
485
|
});
|
|
177
486
|
}
|
|
@@ -181,6 +490,15 @@ var FeatureRegistry = class {
|
|
|
181
490
|
signal: controller.signal,
|
|
182
491
|
clock: this.#deps.clock,
|
|
183
492
|
logger: this.#deps.logger,
|
|
493
|
+
files: Object.freeze({ resolve: (input, options, context) => {
|
|
494
|
+
try {
|
|
495
|
+
this.#deps.assertActive(`открыть файловый источник feature «${name}»`);
|
|
496
|
+
if (!committed || controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
|
|
497
|
+
return this.#deps.files.resolve(input, options, context);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return Promise.reject(error);
|
|
500
|
+
}
|
|
501
|
+
} }),
|
|
184
502
|
request: (operation, options) => {
|
|
185
503
|
try {
|
|
186
504
|
this.#deps.assertActive(`выполнить операцию feature «${name}»`);
|
|
@@ -194,10 +512,8 @@ var FeatureRegistry = class {
|
|
|
194
512
|
delete scoped.baseUrl;
|
|
195
513
|
delete scoped.retrySafety;
|
|
196
514
|
delete scoped.rateLimitBucket;
|
|
197
|
-
return this.#deps.http.
|
|
515
|
+
return this.#deps.http.execute(resolved.contract, {
|
|
198
516
|
...scoped,
|
|
199
|
-
operationId: resolved.operationId,
|
|
200
|
-
method: resolved.method,
|
|
201
517
|
...resolved.service === void 0 ? {} : { service: resolved.service }
|
|
202
518
|
});
|
|
203
519
|
} catch (error) {
|
|
@@ -207,6 +523,43 @@ var FeatureRegistry = class {
|
|
|
207
523
|
serviceBaseUrl: (serviceName) => {
|
|
208
524
|
if (!services.has(serviceName)) throw new ItdConfigError(`feature «${name}» не объявляет сервис «${serviceName}»`);
|
|
209
525
|
return this.#deps.services.resolveBaseUrl(serviceName);
|
|
526
|
+
},
|
|
527
|
+
connection: (serviceName) => {
|
|
528
|
+
if (!services.has(serviceName)) throw new ItdConfigError(`feature «${name}» не объявляет сервис «${serviceName}»`);
|
|
529
|
+
return this.#deps.connection(serviceName);
|
|
530
|
+
},
|
|
531
|
+
manage: (resource) => {
|
|
532
|
+
this.#deps.assertActive(`зарегистрировать ресурс feature «${name}»`);
|
|
533
|
+
if (controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
|
|
534
|
+
const unregister = this.#deps.manage(resource);
|
|
535
|
+
let registered = true;
|
|
536
|
+
const release = () => {
|
|
537
|
+
if (!registered) return;
|
|
538
|
+
registered = false;
|
|
539
|
+
unregister();
|
|
540
|
+
};
|
|
541
|
+
if (!committed) rollback.push(() => {
|
|
542
|
+
if (!registered) return;
|
|
543
|
+
if (committed) {
|
|
544
|
+
release();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
resource.stop();
|
|
549
|
+
} catch (error) {
|
|
550
|
+
reportRollbackError(`Не удалось остановить ресурс «${resource.kind}» при откате feature «${name}»`, error);
|
|
551
|
+
} finally {
|
|
552
|
+
release();
|
|
553
|
+
}
|
|
554
|
+
Promise.resolve().then(() => resource.drain()).catch((error) => {
|
|
555
|
+
reportRollbackError(`Не удалось освободить ресурс «${resource.kind}» при откате feature «${name}»`, error);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
return release;
|
|
559
|
+
},
|
|
560
|
+
assertActive: (action) => {
|
|
561
|
+
this.#deps.assertActive(action);
|
|
562
|
+
if (!committed || controller.signal.aborted) throw new ItdStateError(`feature «${name}» не активен`);
|
|
210
563
|
}
|
|
211
564
|
});
|
|
212
565
|
const installation = feature.setup(context);
|
|
@@ -235,36 +588,166 @@ var FeatureRegistry = class {
|
|
|
235
588
|
return this.#features.has(name);
|
|
236
589
|
}
|
|
237
590
|
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
|
-
}
|
|
591
|
+
const errors = (await Promise.allSettled([...this.#features.values()].reverse().map(async (feature) => feature.close?.()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
244
592
|
if (errors.length > 0) throw new AggregateError(errors, "Не удалось закрыть feature");
|
|
245
593
|
}
|
|
246
594
|
async dispose() {
|
|
247
595
|
if (this.#disposed) return;
|
|
248
596
|
this.#disposed = true;
|
|
249
597
|
const errors = [];
|
|
250
|
-
|
|
598
|
+
const disposals = [];
|
|
599
|
+
const features = [...this.#features.values()].reverse();
|
|
600
|
+
for (const feature of features) {
|
|
251
601
|
feature.controller.abort(new ItdAbortError(`Feature «${feature.name}» освобождён`));
|
|
252
602
|
try {
|
|
253
|
-
|
|
254
|
-
} catch (error) {
|
|
255
|
-
errors.push(error);
|
|
256
|
-
}
|
|
257
|
-
for (const cleanup of [...feature.cleanup].reverse()) try {
|
|
258
|
-
cleanup();
|
|
603
|
+
disposals.push(Promise.resolve(feature.dispose?.()).then(() => void 0));
|
|
259
604
|
} catch (error) {
|
|
260
605
|
errors.push(error);
|
|
261
606
|
}
|
|
262
607
|
}
|
|
608
|
+
for (const feature of features) for (const cleanup of [...feature.cleanup].reverse()) try {
|
|
609
|
+
cleanup();
|
|
610
|
+
} catch (error) {
|
|
611
|
+
errors.push(error);
|
|
612
|
+
}
|
|
263
613
|
this.#features.clear();
|
|
614
|
+
const results = await Promise.allSettled(disposals);
|
|
615
|
+
for (const result of results) if (result.status === "rejected") errors.push(result.reason);
|
|
264
616
|
if (errors.length > 0) throw new AggregateError(errors, "Не удалось освободить feature");
|
|
265
617
|
}
|
|
266
618
|
};
|
|
267
619
|
//#endregion
|
|
620
|
+
//#region src/core/managed-resources.ts
|
|
621
|
+
/** Реестр активных ресурсов клиента. @internal */
|
|
622
|
+
var ManagedResourceRegistry = class {
|
|
623
|
+
#resources = /* @__PURE__ */ new Set();
|
|
624
|
+
#disposed = false;
|
|
625
|
+
register(resource) {
|
|
626
|
+
if (this.#disposed) throw new ItdStateError("Lifecycle клиента уже освобождён");
|
|
627
|
+
if (typeof resource !== "object" || resource === null || typeof resource.kind !== "string" || resource.kind.trim() === "" || typeof resource.stop !== "function" || typeof resource.drain !== "function") throw new ItdConfigError("Managed resource должен предоставлять kind, stop() и drain()");
|
|
628
|
+
this.#resources.add(resource);
|
|
629
|
+
let registered = true;
|
|
630
|
+
return () => {
|
|
631
|
+
if (!registered) return;
|
|
632
|
+
registered = false;
|
|
633
|
+
this.#resources.delete(resource);
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/** Останавливает snapshot синхронно, чтобы новые auth-данные ещё не были опубликованы. */
|
|
637
|
+
stopAll() {
|
|
638
|
+
const resources = [...this.#resources];
|
|
639
|
+
const errors = [];
|
|
640
|
+
for (const resource of resources) try {
|
|
641
|
+
resource.stop();
|
|
642
|
+
} catch (error) {
|
|
643
|
+
errors.push(error);
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
resources,
|
|
647
|
+
errors
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
dispose() {
|
|
651
|
+
this.#disposed = true;
|
|
652
|
+
this.#resources.clear();
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/core/feature-host.ts
|
|
657
|
+
/**
|
|
658
|
+
* Управляет подключаемыми модулями и их ресурсами.
|
|
659
|
+
*
|
|
660
|
+
* @internal
|
|
661
|
+
*/
|
|
662
|
+
var ClientFeatureHost = class {
|
|
663
|
+
#runtime;
|
|
664
|
+
#features;
|
|
665
|
+
#resources = new ManagedResourceRegistry();
|
|
666
|
+
/** Общий файловый порт ресурсов и подключаемых модулей этого клиента. @internal */
|
|
667
|
+
files;
|
|
668
|
+
constructor(runtime, options) {
|
|
669
|
+
this.#runtime = runtime;
|
|
670
|
+
this.files = createFileResolver(runtime.config.fetch);
|
|
671
|
+
this.#features = new FeatureRegistry({
|
|
672
|
+
http: runtime.http,
|
|
673
|
+
services: runtime.services,
|
|
674
|
+
serviceOverrides: runtime.config.services,
|
|
675
|
+
catalog: options.catalog,
|
|
676
|
+
baseUrl: runtime.config.baseUrl,
|
|
677
|
+
clock: runtime.config.clock,
|
|
678
|
+
logger: runtime.config.logger,
|
|
679
|
+
files: this.files,
|
|
680
|
+
assertActive: options.assertActive,
|
|
681
|
+
connection: (serviceName) => runtime.connection(serviceName),
|
|
682
|
+
manage: (resource) => this.#resources.register(resource),
|
|
683
|
+
registerBucket: (name, definition) => runtime.registerRateLimitBucket(name, definition)
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
install(feature) {
|
|
687
|
+
return this.#features.install(feature);
|
|
688
|
+
}
|
|
689
|
+
names() {
|
|
690
|
+
return this.#features.names();
|
|
691
|
+
}
|
|
692
|
+
has(name) {
|
|
693
|
+
return this.#features.has(name);
|
|
694
|
+
}
|
|
695
|
+
/** Регистрирует ресурс самого клиента. @internal */
|
|
696
|
+
manage(resource) {
|
|
697
|
+
return this.#resources.register(resource);
|
|
698
|
+
}
|
|
699
|
+
/** Останавливает ресурсы перед сменой авторизации. */
|
|
700
|
+
stop() {
|
|
701
|
+
const { errors } = this.#resources.stopAll();
|
|
702
|
+
if (errors.length > 0) this.#runtime.config.logger?.error("Не удалось остановить ресурсы клиента", errors);
|
|
703
|
+
}
|
|
704
|
+
/** Останавливает ресурсы и ждёт функции закрытия модулей. */
|
|
705
|
+
async close() {
|
|
706
|
+
const snapshot = this.#resources.stopAll();
|
|
707
|
+
const errors = [...snapshot.errors];
|
|
708
|
+
const deadline = createDeadline(this.#runtime.config.shutdownTimeout, this.#runtime.config.clock);
|
|
709
|
+
const tasks = [...snapshot.resources.map((resource) => ({
|
|
710
|
+
kind: resource.kind,
|
|
711
|
+
promise: Promise.resolve().then(() => resource.drain())
|
|
712
|
+
})), {
|
|
713
|
+
kind: "feature lifecycle",
|
|
714
|
+
promise: Promise.resolve().then(() => this.#features.close())
|
|
715
|
+
}];
|
|
716
|
+
try {
|
|
717
|
+
const stuck = (await Promise.all(tasks.map(async ({ kind, promise }) => {
|
|
718
|
+
const result = promise.catch((error) => {
|
|
719
|
+
errors.push(error);
|
|
720
|
+
});
|
|
721
|
+
return await deadline.wait(result) ? void 0 : kind;
|
|
722
|
+
}))).filter((kind) => kind !== void 0);
|
|
723
|
+
if (stuck.length > 0) errors.push(new ItdStateError(`ресурсы (${stuck.join(", ")}) не завершились за ${this.#runtime.config.shutdownTimeout} мс`));
|
|
724
|
+
} finally {
|
|
725
|
+
deadline.cancel();
|
|
726
|
+
}
|
|
727
|
+
if (errors.length === 1) throw errors[0];
|
|
728
|
+
if (errors.length > 1) throw new AggregateError(errors, "Не удалось закрыть feature-host");
|
|
729
|
+
}
|
|
730
|
+
async dispose() {
|
|
731
|
+
this.#resources.dispose();
|
|
732
|
+
const deadline = createDeadline(this.#runtime.config.shutdownTimeout, this.#runtime.config.clock);
|
|
733
|
+
let failed = false;
|
|
734
|
+
let failure;
|
|
735
|
+
const settled = this.#features.dispose().catch((error) => {
|
|
736
|
+
failed = true;
|
|
737
|
+
failure = error;
|
|
738
|
+
});
|
|
739
|
+
try {
|
|
740
|
+
if (!await deadline.wait(settled)) throw new ItdStateError(`освобождение подключаемых модулей не завершилось за ${this.#runtime.config.shutdownTimeout} мс`);
|
|
741
|
+
} finally {
|
|
742
|
+
deadline.cancel();
|
|
743
|
+
}
|
|
744
|
+
if (failed) throw failure;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
function createFeatureHost(runtime, options) {
|
|
748
|
+
return new ClientFeatureHost(runtime, options);
|
|
749
|
+
}
|
|
750
|
+
//#endregion
|
|
268
751
|
//#region src/resources/pagination.ts
|
|
269
752
|
/** Схема пагинации эндпоинта. */
|
|
270
753
|
const PaginationMode = Object.freeze({
|
|
@@ -382,6 +865,10 @@ function readOffsetPage(body, field, offset) {
|
|
|
382
865
|
raw: body
|
|
383
866
|
};
|
|
384
867
|
}
|
|
868
|
+
/** Создаёт единый контракт пагинированной операции. */
|
|
869
|
+
function pageOperation(id, read) {
|
|
870
|
+
return defineBuiltInOperation(id, read);
|
|
871
|
+
}
|
|
385
872
|
/**
|
|
386
873
|
* Перебор страниц списка.
|
|
387
874
|
*
|
|
@@ -507,72 +994,231 @@ var BaseResource = class {
|
|
|
507
994
|
constructor(http) {
|
|
508
995
|
this.http = http;
|
|
509
996
|
}
|
|
510
|
-
/**
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
*
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
997
|
+
/** Выполняет операцию без возвращаемого значения и отбрасывает служебное тело ответа. */
|
|
998
|
+
voidOperation(operation, options) {
|
|
999
|
+
return this.http.execute(operation, options);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Собирает перебор страниц.
|
|
1003
|
+
*
|
|
1004
|
+
* @param mode схема пагинации эндпоинта
|
|
1005
|
+
* @param load загружает одну страницу для указанной позиции
|
|
1006
|
+
* @param options только управление самим перебором: предел, отмена и начальная позиция
|
|
1007
|
+
*/
|
|
1008
|
+
paginate(mode, load, options) {
|
|
1009
|
+
return new Paginator({
|
|
1010
|
+
mode,
|
|
1011
|
+
load,
|
|
1012
|
+
...options?.maxPages !== void 0 ? { maxPages: options.maxPages } : {},
|
|
1013
|
+
...options?.signal !== void 0 ? { signal: options.signal } : {},
|
|
1014
|
+
...options?.start !== void 0 ? { start: options.start } : {}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Собирает пару «загрузка страницы + перебор» из одного описания.
|
|
1019
|
+
*
|
|
1020
|
+
* Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
|
|
1021
|
+
* строятся из них.
|
|
1022
|
+
*
|
|
1023
|
+
* @example
|
|
1024
|
+
* ```ts
|
|
1025
|
+
* #feed = this.paginated<Post, FeedParams>({
|
|
1026
|
+
* operation: POSTS_LIST,
|
|
1027
|
+
* path: () => '/api/posts',
|
|
1028
|
+
* query: (p) => ({ tab: p.tab, limit: p.limit }),
|
|
1029
|
+
* start: (p) => (p.cursor ? { cursor: p.cursor } : {}),
|
|
1030
|
+
* mode: PaginationMode.Cursor,
|
|
1031
|
+
* });
|
|
1032
|
+
* ```
|
|
1033
|
+
*/
|
|
1034
|
+
paginated(spec) {
|
|
1035
|
+
const load = async (params, state, options = {}) => {
|
|
1036
|
+
const operation = typeof spec.operation === "function" ? spec.operation(params) : spec.operation;
|
|
1037
|
+
return this.http.execute(operation, {
|
|
1038
|
+
path: spec.path(params),
|
|
1039
|
+
query: withPageState(spec.query(params), state),
|
|
1040
|
+
...options
|
|
1041
|
+
});
|
|
1042
|
+
};
|
|
1043
|
+
return {
|
|
1044
|
+
list: (params, options) => load(params, spec.start(params), options),
|
|
1045
|
+
iterate: (params, options = {}) => {
|
|
1046
|
+
const { maxPages, ...requestOptions } = options;
|
|
1047
|
+
return this.paginate(spec.mode, (state) => load(params, state, requestOptions), {
|
|
1048
|
+
...maxPages !== void 0 ? { maxPages } : {},
|
|
1049
|
+
...requestOptions.signal !== void 0 ? { signal: requestOptions.signal } : {},
|
|
1050
|
+
start: spec.start(params)
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
/** Добавляет позицию страницы в параметры запроса. */
|
|
1057
|
+
function withPageState(query, state) {
|
|
1058
|
+
return {
|
|
1059
|
+
...query,
|
|
1060
|
+
...state.cursor !== void 0 ? { cursor: state.cursor } : {},
|
|
1061
|
+
...state.page !== void 0 ? { page: state.page } : {},
|
|
1062
|
+
...state.offset !== void 0 ? { offset: state.offset } : {}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
//#endregion
|
|
1066
|
+
//#region src/resources/notifications.ts
|
|
1067
|
+
const NOTIFICATIONS_LIST = pageOperation("notifications.list", (body, request) => {
|
|
1068
|
+
const offset = Number(request.query?.offset ?? 0);
|
|
1069
|
+
const page = readOffsetPage(body, "notifications", Number.isFinite(offset) ? offset : 0);
|
|
1070
|
+
return {
|
|
1071
|
+
...page,
|
|
1072
|
+
items: page.items.map(normalizeNotification)
|
|
1073
|
+
};
|
|
1074
|
+
});
|
|
1075
|
+
const NOTIFICATIONS_COUNT = defineBuiltInOperation("notifications.count", (body) => pickNumber(body, "count", 0));
|
|
1076
|
+
const markedCountOperation = (id) => defineBuiltInOperation(id, (body) => pickNumber(body, "markedCount", 0));
|
|
1077
|
+
const NOTIFICATIONS_MARK_READ = markedCountOperation("notifications.markRead");
|
|
1078
|
+
const NOTIFICATIONS_MARK_READ_BATCH = markedCountOperation("notifications.markReadBatch");
|
|
1079
|
+
const NOTIFICATIONS_MARK_ALL_READ = markedCountOperation("notifications.markAllRead");
|
|
1080
|
+
const NOTIFICATIONS_GET_SETTINGS = defineBuiltInOperation("notifications.getSettings", readSettings);
|
|
1081
|
+
const NOTIFICATIONS_UPDATE_SETTINGS = defineBuiltInOperation("notifications.updateSettings", readSettings);
|
|
1082
|
+
const NOTIFICATION_SETTING_KEYS = [
|
|
1083
|
+
"enabled",
|
|
1084
|
+
"sound",
|
|
1085
|
+
"follows",
|
|
1086
|
+
"wallPosts",
|
|
1087
|
+
"likes",
|
|
1088
|
+
"comments",
|
|
1089
|
+
"mentions"
|
|
1090
|
+
];
|
|
1091
|
+
/**
|
|
1092
|
+
* Сколько идентификаторов уходит в одном запросе на отметку прочтения.
|
|
1093
|
+
*
|
|
1094
|
+
* Столько же отправляет сайт итд.com — значит на сервере, скорее всего, есть ограничение.
|
|
1095
|
+
*/
|
|
1096
|
+
const READ_BATCH_SIZE = 20;
|
|
1097
|
+
/**
|
|
1098
|
+
* Читает настройки уведомлений.
|
|
1099
|
+
*
|
|
1100
|
+
* Сервер отдаёт плоский объект: `enabled`, `sound`, `follows`, `wallPosts`, `likes`,
|
|
1101
|
+
* `comments`, `mentions`. Отсутствующая настройка считается включённой — так же
|
|
1102
|
+
* ведёт себя сайт итд.com.
|
|
1103
|
+
*/
|
|
1104
|
+
function readSettings(body) {
|
|
1105
|
+
const settings = {};
|
|
1106
|
+
for (const key of NOTIFICATION_SETTING_KEYS) settings[key] = pickBoolean(body, key, true);
|
|
1107
|
+
return settings;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Уведомления: список, счётчик, отметки о прочтении, настройки.
|
|
1111
|
+
*
|
|
1112
|
+
* Доступна как `itd.notifications`. Все уведомления приведены к единой форме, поэтому
|
|
1113
|
+
* объекты отсюда и из потока событий можно складывать в один список.
|
|
1114
|
+
*/
|
|
1115
|
+
var NotificationsResource = class extends BaseResource {
|
|
1116
|
+
/** Уведомления: `/api/notifications/`, пагинация по смещению. */
|
|
1117
|
+
#list = this.paginated({
|
|
1118
|
+
operation: NOTIFICATIONS_LIST,
|
|
1119
|
+
path: () => "/api/notifications/",
|
|
1120
|
+
query: (p) => ({ limit: p.limit }),
|
|
1121
|
+
start: (p) => ({ offset: p.offset ?? 0 }),
|
|
1122
|
+
mode: PaginationMode.Offset
|
|
1123
|
+
});
|
|
1124
|
+
/**
|
|
1125
|
+
* Загружает страницу уведомлений.
|
|
1126
|
+
*
|
|
1127
|
+
* Пагинация здесь основана на смещении.
|
|
1128
|
+
*
|
|
1129
|
+
* @example
|
|
1130
|
+
* ```ts
|
|
1131
|
+
* const page = await itd.notifications.list({ limit: 20 });
|
|
1132
|
+
* const next = await itd.notifications.list({ limit: 20, offset: page.nextOffset });
|
|
1133
|
+
* ```
|
|
1134
|
+
*/
|
|
1135
|
+
list(params = {}, options = {}) {
|
|
1136
|
+
return this.#list.list(params, options);
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Перебирает уведомления.
|
|
1140
|
+
*
|
|
1141
|
+
* @example
|
|
1142
|
+
* ```ts
|
|
1143
|
+
* for await (const notification of itd.notifications.iterate()) {
|
|
1144
|
+
* console.log(formatNotificationText(notification));
|
|
1145
|
+
* }
|
|
1146
|
+
* ```
|
|
1147
|
+
*/
|
|
1148
|
+
iterate(params = {}, options = {}) {
|
|
1149
|
+
return this.#list.iterate(params, options);
|
|
1150
|
+
}
|
|
1151
|
+
/** Загружает число непрочитанных уведомлений. */
|
|
1152
|
+
count(options = {}) {
|
|
1153
|
+
return this.http.execute(NOTIFICATIONS_COUNT, {
|
|
1154
|
+
path: "/api/notifications/count",
|
|
1155
|
+
...options
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Отмечает уведомление прочитанным.
|
|
1160
|
+
*
|
|
1161
|
+
* @returns сколько записей отметил сервер
|
|
1162
|
+
*/
|
|
1163
|
+
markRead(notificationId, options = {}) {
|
|
1164
|
+
return this.http.execute(NOTIFICATIONS_MARK_READ, {
|
|
1165
|
+
path: `/api/notifications/${encodePathSegment(notificationId, "notificationId")}/read`,
|
|
1166
|
+
...options
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Отмечает прочитанными сразу несколько уведомлений.
|
|
1171
|
+
*
|
|
1172
|
+
* Список автоматически режется на части по 20 идентификаторов — столько же отправляет
|
|
1173
|
+
* сайт итд.com, поэтому на сервере вероятен предел. Части уходят последовательно,
|
|
1174
|
+
* результат суммируется.
|
|
1175
|
+
*
|
|
1176
|
+
* @returns сколько записей отметил сервер суммарно
|
|
1177
|
+
*/
|
|
1178
|
+
async markReadBatch(ids, options = {}) {
|
|
1179
|
+
let marked = 0;
|
|
1180
|
+
for (let index = 0; index < ids.length; index += READ_BATCH_SIZE) {
|
|
1181
|
+
const chunk = ids.slice(index, index + READ_BATCH_SIZE);
|
|
1182
|
+
marked += await this.http.execute(NOTIFICATIONS_MARK_READ_BATCH, {
|
|
1183
|
+
path: "/api/notifications/read-batch",
|
|
1184
|
+
body: { ids: chunk },
|
|
1185
|
+
...options
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
return marked;
|
|
1189
|
+
}
|
|
1190
|
+
/** Отмечает прочитанными все уведомления. */
|
|
1191
|
+
markAllRead(options = {}) {
|
|
1192
|
+
return this.http.execute(NOTIFICATIONS_MARK_ALL_READ, {
|
|
1193
|
+
path: "/api/notifications/read-all",
|
|
1194
|
+
...options
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
/** Загружает настройки уведомлений. */
|
|
1198
|
+
getSettings(options = {}) {
|
|
1199
|
+
return this.http.execute(NOTIFICATIONS_GET_SETTINGS, {
|
|
1200
|
+
path: "/api/notifications/settings",
|
|
1201
|
+
...options
|
|
524
1202
|
});
|
|
525
1203
|
}
|
|
526
1204
|
/**
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
* Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
|
|
530
|
-
* строятся из них.
|
|
1205
|
+
* Обновляет настройки уведомлений.
|
|
531
1206
|
*
|
|
532
|
-
*
|
|
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
|
-
* ```
|
|
1207
|
+
* Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
|
|
543
1208
|
*/
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
};
|
|
1209
|
+
async updateSettings(input, options = {}) {
|
|
1210
|
+
const payload = {};
|
|
1211
|
+
for (const key of NOTIFICATION_SETTING_KEYS) {
|
|
1212
|
+
const value = input[key];
|
|
1213
|
+
if (value !== void 0) payload[key] = value;
|
|
1214
|
+
}
|
|
1215
|
+
return this.http.execute(NOTIFICATIONS_UPDATE_SETTINGS, {
|
|
1216
|
+
path: "/api/notifications/settings",
|
|
1217
|
+
body: payload,
|
|
1218
|
+
...options
|
|
1219
|
+
});
|
|
565
1220
|
}
|
|
566
1221
|
};
|
|
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
1222
|
//#endregion
|
|
577
1223
|
//#region src/domain/time.ts
|
|
578
1224
|
const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
|
|
@@ -627,11 +1273,11 @@ var StatusResource = class {
|
|
|
627
1273
|
constructor(context) {
|
|
628
1274
|
this.#context = context;
|
|
629
1275
|
}
|
|
630
|
-
|
|
631
|
-
return
|
|
1276
|
+
get(options = {}) {
|
|
1277
|
+
return this.#context.request("get", {
|
|
632
1278
|
path: "/api/status",
|
|
633
1279
|
...options
|
|
634
|
-
})
|
|
1280
|
+
});
|
|
635
1281
|
}
|
|
636
1282
|
};
|
|
637
1283
|
/** Встроенный модуль страницы состояния платформы. @internal */
|
|
@@ -646,7 +1292,8 @@ function createStatusFeature() {
|
|
|
646
1292
|
operations: { get: {
|
|
647
1293
|
method: "GET",
|
|
648
1294
|
retrySafety: RetrySafety.Safe,
|
|
649
|
-
service: STATUS_SERVICE
|
|
1295
|
+
service: STATUS_SERVICE,
|
|
1296
|
+
read: (body) => normalizeStatus(body)
|
|
650
1297
|
} },
|
|
651
1298
|
setup: (context) => ({ api: new StatusResource(context) })
|
|
652
1299
|
};
|
|
@@ -811,6 +1458,13 @@ function resolveComment(input, allowReplyTo = false) {
|
|
|
811
1458
|
}
|
|
812
1459
|
//#endregion
|
|
813
1460
|
//#region src/resources/comments.ts
|
|
1461
|
+
const COMMENTS_REPLIES = pageOperation("comments.replies", (body) => readPagedPage(body, "replies"));
|
|
1462
|
+
const COMMENTS_REPLY = passthroughOperation("comments.reply");
|
|
1463
|
+
const COMMENTS_UPDATE = passthroughOperation("comments.update");
|
|
1464
|
+
const COMMENTS_RESTORE = passthroughOperation("comments.restore");
|
|
1465
|
+
const COMMENTS_LIKE = passthroughOperation("comments.like");
|
|
1466
|
+
const COMMENTS_UNLIKE = passthroughOperation("comments.unlike");
|
|
1467
|
+
const COMMENTS_REMOVE = voidOperation("comments.remove");
|
|
814
1468
|
/**
|
|
815
1469
|
* Комментарии и ответы на них.
|
|
816
1470
|
*
|
|
@@ -821,11 +1475,10 @@ var CommentsResource = class extends BaseResource {
|
|
|
821
1475
|
#uploadFiles;
|
|
822
1476
|
/** Ответы на комментарий: `/api/comments/{id}/replies`, постраничная пагинация. */
|
|
823
1477
|
#replies = this.paginated({
|
|
824
|
-
|
|
1478
|
+
operation: COMMENTS_REPLIES,
|
|
825
1479
|
path: (p) => `/api/comments/${encodePathSegment(p.commentId, "commentId")}/replies`,
|
|
826
1480
|
query: (p) => ({ limit: p.limit }),
|
|
827
1481
|
start: (p) => p.page !== void 0 ? { page: p.page } : {},
|
|
828
|
-
read: (body) => readPagedPage(body, "replies"),
|
|
829
1482
|
mode: PaginationMode.Page
|
|
830
1483
|
});
|
|
831
1484
|
constructor(http, deps) {
|
|
@@ -864,7 +1517,7 @@ var CommentsResource = class extends BaseResource {
|
|
|
864
1517
|
const existing = data.attachmentIds ?? [];
|
|
865
1518
|
const files = data.files ?? [];
|
|
866
1519
|
const attachmentIds = files.length > 0 ? [...existing, ...await this.#uploadFiles(files, options)] : existing;
|
|
867
|
-
return this.http.
|
|
1520
|
+
return this.http.execute(COMMENTS_REPLY, {
|
|
868
1521
|
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/replies`,
|
|
869
1522
|
body: {
|
|
870
1523
|
content: data.content ?? "",
|
|
@@ -876,7 +1529,7 @@ var CommentsResource = class extends BaseResource {
|
|
|
876
1529
|
}
|
|
877
1530
|
/** Редактирует текст комментария. */
|
|
878
1531
|
update(commentId, content, options = {}) {
|
|
879
|
-
return this.http.
|
|
1532
|
+
return this.http.execute(COMMENTS_UPDATE, {
|
|
880
1533
|
path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
|
|
881
1534
|
body: { content },
|
|
882
1535
|
...options
|
|
@@ -884,168 +1537,34 @@ var CommentsResource = class extends BaseResource {
|
|
|
884
1537
|
}
|
|
885
1538
|
/** Удаляет комментарий. Восстановить его можно через {@link restore}. */
|
|
886
1539
|
remove(commentId, options = {}) {
|
|
887
|
-
return this.
|
|
1540
|
+
return this.voidOperation(COMMENTS_REMOVE, {
|
|
888
1541
|
path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
|
|
889
1542
|
...options
|
|
890
1543
|
});
|
|
891
1544
|
}
|
|
892
1545
|
/** Восстанавливает удалённый комментарий. */
|
|
893
1546
|
restore(commentId, options = {}) {
|
|
894
|
-
return this.http.
|
|
1547
|
+
return this.http.execute(COMMENTS_RESTORE, {
|
|
895
1548
|
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/restore`,
|
|
896
1549
|
...options
|
|
897
1550
|
});
|
|
898
1551
|
}
|
|
899
1552
|
/** Ставит реакцию на комментарий. */
|
|
900
1553
|
like(commentId, options = {}) {
|
|
901
|
-
return this.http.
|
|
1554
|
+
return this.http.execute(COMMENTS_LIKE, {
|
|
902
1555
|
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
|
|
903
1556
|
...options
|
|
904
1557
|
});
|
|
905
1558
|
}
|
|
906
1559
|
/** Убирает реакцию с комментария. */
|
|
907
1560
|
unlike(commentId, options = {}) {
|
|
908
|
-
return this.http.
|
|
1561
|
+
return this.http.execute(COMMENTS_UNLIKE, {
|
|
909
1562
|
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
|
|
910
1563
|
...options
|
|
911
1564
|
});
|
|
912
1565
|
}
|
|
913
1566
|
};
|
|
914
1567
|
//#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
1568
|
//#region src/core/multipart.ts
|
|
1050
1569
|
const encoder = new TextEncoder();
|
|
1051
1570
|
/** Убирает возможность внедрить дополнительный multipart-заголовок через имя файла. */
|
|
@@ -1182,12 +1701,17 @@ function assertAllowedMime(mimeType, filename) {
|
|
|
1182
1701
|
if (!mimeType) throw new ItdConfigError(`Не удалось определить тип файла${filename ? ` «${filename}»` : ""}. Укажите его явно: { file, filename, contentType }.`);
|
|
1183
1702
|
if (!isAllowedMime(mimeType)) throw new ItdConfigError(`Тип «${mimeType}» не поддерживается загрузкой. Допустимые: ${ALLOWED_MIME_TYPES.join(", ")}.`);
|
|
1184
1703
|
}
|
|
1704
|
+
//#endregion
|
|
1705
|
+
//#region src/resources/files.ts
|
|
1706
|
+
const FILES_UPLOAD = passthroughOperation("files.upload");
|
|
1707
|
+
const FILES_GET = passthroughOperation("files.get");
|
|
1708
|
+
const FILES_REMOVE = voidOperation("files.remove");
|
|
1185
1709
|
/** Файлы и медиа. */
|
|
1186
1710
|
var FilesResource = class extends BaseResource {
|
|
1187
|
-
#
|
|
1711
|
+
#files;
|
|
1188
1712
|
constructor(http, deps) {
|
|
1189
1713
|
super(http);
|
|
1190
|
-
this.#
|
|
1714
|
+
this.#files = deps.files;
|
|
1191
1715
|
}
|
|
1192
1716
|
/**
|
|
1193
1717
|
* Загружает файл и возвращает его идентификатор.
|
|
@@ -1197,7 +1721,7 @@ var FilesResource = class extends BaseResource {
|
|
|
1197
1721
|
*/
|
|
1198
1722
|
upload(input, uploadOptions = {}, requestOptions = {}) {
|
|
1199
1723
|
const bodyFactory = this.#createBodyFactory(input, uploadOptions);
|
|
1200
|
-
return this.http.
|
|
1724
|
+
return this.http.execute(FILES_UPLOAD, {
|
|
1201
1725
|
path: "/api/files/upload",
|
|
1202
1726
|
bodyFactory,
|
|
1203
1727
|
...requestOptions,
|
|
@@ -1216,14 +1740,14 @@ var FilesResource = class extends BaseResource {
|
|
|
1216
1740
|
* Для ещё не прикреплённого файла сервер может ответить `404`.
|
|
1217
1741
|
*/
|
|
1218
1742
|
get(fileId, options = {}) {
|
|
1219
|
-
return this.http.
|
|
1743
|
+
return this.http.execute(FILES_GET, {
|
|
1220
1744
|
path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
|
|
1221
1745
|
...options
|
|
1222
1746
|
});
|
|
1223
1747
|
}
|
|
1224
1748
|
/** Удаляет загруженный файл. */
|
|
1225
1749
|
remove(fileId, options = {}) {
|
|
1226
|
-
return this.
|
|
1750
|
+
return this.voidOperation(FILES_REMOVE, {
|
|
1227
1751
|
path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
|
|
1228
1752
|
...options
|
|
1229
1753
|
});
|
|
@@ -1233,140 +1757,70 @@ var FilesResource = class extends BaseResource {
|
|
|
1233
1757
|
* заново, поэтому каждая транспортная попытка получает непрочитанное тело.
|
|
1234
1758
|
*/
|
|
1235
1759
|
#createBodyFactory(input, options) {
|
|
1236
|
-
const streamMode = this.#
|
|
1760
|
+
const streamMode = this.#files.mode(input) === FileTransferMode.Stream;
|
|
1237
1761
|
let buffered;
|
|
1238
1762
|
return async ({ signal, attempt }) => {
|
|
1239
|
-
|
|
1240
|
-
|
|
1763
|
+
let prepared;
|
|
1764
|
+
if (streamMode) prepared = await this.#files.resolve(input, options, {
|
|
1241
1765
|
signal,
|
|
1242
1766
|
attempt
|
|
1243
|
-
};
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
|
1767
|
+
});
|
|
1768
|
+
else {
|
|
1769
|
+
buffered ??= this.#files.resolve(input, options, {
|
|
1770
|
+
signal,
|
|
1771
|
+
attempt
|
|
1772
|
+
}).then((source) => source).catch((error) => {
|
|
1773
|
+
buffered = void 0;
|
|
1774
|
+
throw error;
|
|
1260
1775
|
});
|
|
1776
|
+
prepared = await buffered;
|
|
1261
1777
|
}
|
|
1262
|
-
if (prepared.mode ===
|
|
1778
|
+
if (prepared.mode === FileTransferMode.Buffer) {
|
|
1779
|
+
const filename = this.#filename(prepared, options);
|
|
1780
|
+
const contentType = prepared.contentType ?? mimeFromFilename(filename);
|
|
1781
|
+
if (options.validateMime !== false) assertAllowedMime(contentType, filename);
|
|
1782
|
+
const blob = !contentType || prepared.blob.type === contentType ? prepared.blob : new Blob([prepared.blob], { type: contentType });
|
|
1263
1783
|
const form = new FormData();
|
|
1264
|
-
form.set("file",
|
|
1784
|
+
form.set("file", blob, filename);
|
|
1265
1785
|
return { body: form };
|
|
1266
1786
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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 }");
|
|
1787
|
+
let multipart;
|
|
1788
|
+
try {
|
|
1789
|
+
const filename = this.#filename(prepared, options);
|
|
1790
|
+
const contentType = prepared.contentType ?? mimeFromFilename(filename);
|
|
1791
|
+
if (options.validateMime !== false) assertAllowedMime(contentType, filename);
|
|
1792
|
+
multipart = createMultipartFileBody({
|
|
1793
|
+
stream: prepared.stream,
|
|
1794
|
+
filename,
|
|
1795
|
+
contentType: contentType ?? ""
|
|
1796
|
+
});
|
|
1797
|
+
return {
|
|
1798
|
+
body: multipart.body,
|
|
1799
|
+
headers: { "Content-Type": multipart.contentType },
|
|
1800
|
+
cleanup: async () => {
|
|
1801
|
+
await multipart?.cancel();
|
|
1802
|
+
await prepared.close();
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
await prepared.close();
|
|
1807
|
+
throw error;
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1361
1810
|
}
|
|
1362
1811
|
/** Подбирает непустое имя, обязательное для multipart. */
|
|
1363
|
-
#
|
|
1364
|
-
|
|
1812
|
+
#filename(source, options) {
|
|
1813
|
+
if (options.filename) return options.filename;
|
|
1814
|
+
if (source.filename) return source.filename;
|
|
1815
|
+
const extension = options.contentType?.split(";", 1)[0]?.trim().split("/")[1];
|
|
1365
1816
|
return extension ? `file.${extension}` : "file";
|
|
1366
1817
|
}
|
|
1367
1818
|
};
|
|
1368
1819
|
//#endregion
|
|
1369
1820
|
//#region src/resources/hashtags.ts
|
|
1821
|
+
const HASHTAG_POSTS = pageOperation("hashtags.posts", (body) => readCursorPage(body, "posts"));
|
|
1822
|
+
const HASHTAGS_SEARCH = defineBuiltInOperation("hashtags.search", (body) => pickArray(body, "hashtags"));
|
|
1823
|
+
const HASHTAGS_TRENDING = defineBuiltInOperation("hashtags.trending", (body) => pickArray(body, "hashtags"));
|
|
1370
1824
|
/**
|
|
1371
1825
|
* Хэштеги.
|
|
1372
1826
|
*
|
|
@@ -1375,11 +1829,10 @@ var FilesResource = class extends BaseResource {
|
|
|
1375
1829
|
var HashtagsResource = class extends BaseResource {
|
|
1376
1830
|
/** Посты по хэштегу: `/api/hashtags/{tag}/posts`, курсорная пагинация. */
|
|
1377
1831
|
#posts = this.paginated({
|
|
1378
|
-
|
|
1832
|
+
operation: HASHTAG_POSTS,
|
|
1379
1833
|
path: (p) => `/api/hashtags/${encodePathSegment(p.tag, "tag")}/posts`,
|
|
1380
1834
|
query: (p) => ({ limit: p.limit }),
|
|
1381
1835
|
start: (p) => p.cursor ? { cursor: p.cursor } : {},
|
|
1382
|
-
read: (body) => readCursorPage(body, "posts"),
|
|
1383
1836
|
mode: PaginationMode.Cursor
|
|
1384
1837
|
});
|
|
1385
1838
|
/**
|
|
@@ -1387,8 +1840,8 @@ var HashtagsResource = class extends BaseResource {
|
|
|
1387
1840
|
*
|
|
1388
1841
|
* Без строки запроса возвращает общий список.
|
|
1389
1842
|
*/
|
|
1390
|
-
|
|
1391
|
-
|
|
1843
|
+
search(query, params = {}, options = {}) {
|
|
1844
|
+
return this.http.execute(HASHTAGS_SEARCH, {
|
|
1392
1845
|
path: "/api/hashtags",
|
|
1393
1846
|
query: {
|
|
1394
1847
|
q: query,
|
|
@@ -1396,16 +1849,14 @@ var HashtagsResource = class extends BaseResource {
|
|
|
1396
1849
|
},
|
|
1397
1850
|
...options
|
|
1398
1851
|
});
|
|
1399
|
-
return pickArray(body, "hashtags");
|
|
1400
1852
|
}
|
|
1401
1853
|
/** Загружает трендовые хэштеги. */
|
|
1402
|
-
|
|
1403
|
-
|
|
1854
|
+
trending(params = {}, options = {}) {
|
|
1855
|
+
return this.http.execute(HASHTAGS_TRENDING, {
|
|
1404
1856
|
path: "/api/hashtags/trending",
|
|
1405
1857
|
query: { limit: params.limit },
|
|
1406
1858
|
...options
|
|
1407
1859
|
});
|
|
1408
|
-
return pickArray(body, "hashtags");
|
|
1409
1860
|
}
|
|
1410
1861
|
/**
|
|
1411
1862
|
* Загружает страницу постов по хэштегу.
|
|
@@ -1428,160 +1879,11 @@ var HashtagsResource = class extends BaseResource {
|
|
|
1428
1879
|
}
|
|
1429
1880
|
};
|
|
1430
1881
|
//#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
1882
|
//#region src/resources/platform.ts
|
|
1883
|
+
const PLATFORM_CHANGELOG = defineBuiltInOperation("platform.changelog", (body) => Array.isArray(body) ? body : []);
|
|
1884
|
+
const PLATFORM_ANNOUNCEMENTS = defineBuiltInOperation("platform.announcements", (body) => pickArray(body, "announcements"));
|
|
1885
|
+
const PLATFORM_VERSION = passthroughOperation("platform.version");
|
|
1886
|
+
const PLATFORM_PORTAL = passthroughOperation("platform.portal");
|
|
1585
1887
|
/**
|
|
1586
1888
|
* Сведения о платформе: версии приложений, изменения, анонсы, баннер события.
|
|
1587
1889
|
*
|
|
@@ -1606,31 +1908,29 @@ var PlatformResource = class extends BaseResource {
|
|
|
1606
1908
|
* ```
|
|
1607
1909
|
*/
|
|
1608
1910
|
version(options = {}) {
|
|
1609
|
-
return this.http.
|
|
1911
|
+
return this.http.execute(PLATFORM_VERSION, {
|
|
1610
1912
|
path: "/api/platform/version",
|
|
1611
1913
|
skipAuth: true,
|
|
1612
1914
|
...options
|
|
1613
1915
|
});
|
|
1614
1916
|
}
|
|
1615
1917
|
/** Загружает журнал изменений. */
|
|
1616
|
-
|
|
1617
|
-
|
|
1918
|
+
changelog(options = {}) {
|
|
1919
|
+
return this.http.execute(PLATFORM_CHANGELOG, {
|
|
1618
1920
|
path: "/api/platform/changelog",
|
|
1619
1921
|
...options
|
|
1620
1922
|
});
|
|
1621
|
-
return Array.isArray(body) ? body : [];
|
|
1622
1923
|
}
|
|
1623
1924
|
/** Загружает анонсы платформы. */
|
|
1624
|
-
|
|
1625
|
-
|
|
1925
|
+
announcements(options = {}) {
|
|
1926
|
+
return this.http.execute(PLATFORM_ANNOUNCEMENTS, {
|
|
1626
1927
|
path: "/api/platform/announcements",
|
|
1627
1928
|
...options
|
|
1628
1929
|
});
|
|
1629
|
-
return pickArray(body, "announcements");
|
|
1630
1930
|
}
|
|
1631
1931
|
/** Загружает баннер текущего события — виджет «портал». */
|
|
1632
1932
|
portal(options = {}) {
|
|
1633
|
-
return this.http.
|
|
1933
|
+
return this.http.execute(PLATFORM_PORTAL, {
|
|
1634
1934
|
path: "/api/v1/portal",
|
|
1635
1935
|
...options
|
|
1636
1936
|
});
|
|
@@ -2347,7 +2647,7 @@ function autoSpans(text, options = {}) {
|
|
|
2347
2647
|
});
|
|
2348
2648
|
}
|
|
2349
2649
|
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
|
|
2650
|
+
if (includeHashtags || includeMentions) for (const match of text.matchAll(/#([\p{L}\p{M}\p{N}_]+)|@([a-z\d](?:[a-z\d_]*[a-z\d])?)/giu)) {
|
|
2351
2651
|
if (match.index === void 0) continue;
|
|
2352
2652
|
const offset = match.index;
|
|
2353
2653
|
const previous = previousCodePoint(text, offset);
|
|
@@ -2355,7 +2655,7 @@ function autoSpans(text, options = {}) {
|
|
|
2355
2655
|
const end = offset + match[0].length;
|
|
2356
2656
|
if (links.some((span) => overlaps(span, offset, end))) continue;
|
|
2357
2657
|
const hashtag = match[1];
|
|
2358
|
-
const username = match[2]
|
|
2658
|
+
const username = match[2];
|
|
2359
2659
|
if (hashtag !== void 0 && includeHashtags) spans.push({
|
|
2360
2660
|
type: SpanType.Hashtag,
|
|
2361
2661
|
offset,
|
|
@@ -2748,6 +3048,24 @@ function resolvePostUpdate(input) {
|
|
|
2748
3048
|
}
|
|
2749
3049
|
//#endregion
|
|
2750
3050
|
//#region src/resources/posts.ts
|
|
3051
|
+
const POSTS_LIST = pageOperation("posts.list", (body) => readCursorPage(body, "posts"));
|
|
3052
|
+
const POSTS_BY_USER = pageOperation("posts.byUser", (body) => readCursorPage(body, "posts"));
|
|
3053
|
+
const POSTS_LIKED_BY_USER = pageOperation("posts.likedByUser", (body) => readCursorPage(body, "posts"));
|
|
3054
|
+
const POSTS_COMMENTS = pageOperation("posts.comments", (body) => readFlatCursorPage(body, "comments"));
|
|
3055
|
+
const POSTS_STATS = defineBuiltInOperation("posts.stats", (body) => pickArray(body, "posts"));
|
|
3056
|
+
const POSTS_CREATE = passthroughOperation("posts.create");
|
|
3057
|
+
const POSTS_GET = passthroughOperation("posts.get");
|
|
3058
|
+
const POSTS_UPDATE = passthroughOperation("posts.update");
|
|
3059
|
+
const POSTS_RESTORE = passthroughOperation("posts.restore");
|
|
3060
|
+
const POSTS_LIKE = passthroughOperation("posts.like");
|
|
3061
|
+
const POSTS_UNLIKE = passthroughOperation("posts.unlike");
|
|
3062
|
+
const POSTS_REPOST = passthroughOperation("posts.repost");
|
|
3063
|
+
const POSTS_PIN = passthroughOperation("posts.pin");
|
|
3064
|
+
const POSTS_UNPIN = passthroughOperation("posts.unpin");
|
|
3065
|
+
const POSTS_VOTE = passthroughOperation("posts.vote");
|
|
3066
|
+
const POSTS_COMMENT = passthroughOperation("posts.comment");
|
|
3067
|
+
const POSTS_REMOVE = voidOperation("posts.remove");
|
|
3068
|
+
const POSTS_UNREPOST = voidOperation("posts.unrepost");
|
|
2751
3069
|
/** Курсорная позиция из параметров: если курсор задан — с него, иначе с начала. */
|
|
2752
3070
|
function cursorStart(params) {
|
|
2753
3071
|
return params.cursor ? { cursor: params.cursor } : {};
|
|
@@ -2761,19 +3079,18 @@ var PostsResource = class extends BaseResource {
|
|
|
2761
3079
|
#uploadFiles;
|
|
2762
3080
|
/** Лента: `/api/posts`, курсорная пагинация. */
|
|
2763
3081
|
#feed = this.paginated({
|
|
2764
|
-
|
|
3082
|
+
operation: POSTS_LIST,
|
|
2765
3083
|
path: () => "/api/posts",
|
|
2766
3084
|
query: (p) => ({
|
|
2767
3085
|
tab: p.tab,
|
|
2768
3086
|
limit: p.limit
|
|
2769
3087
|
}),
|
|
2770
3088
|
start: cursorStart,
|
|
2771
|
-
read: (body) => readCursorPage(body, "posts"),
|
|
2772
3089
|
mode: PaginationMode.Cursor
|
|
2773
3090
|
});
|
|
2774
3091
|
/** Стена пользователя: `/api/posts/user/{user}`. */
|
|
2775
3092
|
#wall = this.paginated({
|
|
2776
|
-
|
|
3093
|
+
operation: POSTS_BY_USER,
|
|
2777
3094
|
path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}`,
|
|
2778
3095
|
query: (p) => ({
|
|
2779
3096
|
limit: p.limit,
|
|
@@ -2781,28 +3098,25 @@ var PostsResource = class extends BaseResource {
|
|
|
2781
3098
|
pinnedPostId: p.pinnedPostId
|
|
2782
3099
|
}),
|
|
2783
3100
|
start: cursorStart,
|
|
2784
|
-
read: (body) => readCursorPage(body, "posts"),
|
|
2785
3101
|
mode: PaginationMode.Cursor
|
|
2786
3102
|
});
|
|
2787
3103
|
/** Понравившиеся посты пользователя: `/api/posts/user/{user}/liked`. */
|
|
2788
3104
|
#liked = this.paginated({
|
|
2789
|
-
|
|
3105
|
+
operation: POSTS_LIKED_BY_USER,
|
|
2790
3106
|
path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}/liked`,
|
|
2791
3107
|
query: (p) => ({ limit: p.limit }),
|
|
2792
3108
|
start: cursorStart,
|
|
2793
|
-
read: (body) => readCursorPage(body, "posts"),
|
|
2794
3109
|
mode: PaginationMode.Cursor
|
|
2795
3110
|
});
|
|
2796
3111
|
/** Комментарии к посту: курсор лежит рядом со списком, поэтому свой reader. */
|
|
2797
3112
|
#comments = this.paginated({
|
|
2798
|
-
|
|
3113
|
+
operation: POSTS_COMMENTS,
|
|
2799
3114
|
path: (p) => `/api/posts/${encodePathSegment(p.postId, "postId")}/comments`,
|
|
2800
3115
|
query: (p) => ({
|
|
2801
3116
|
limit: p.limit,
|
|
2802
3117
|
sort: p.sort
|
|
2803
3118
|
}),
|
|
2804
3119
|
start: cursorStart,
|
|
2805
|
-
read: (body) => readFlatCursorPage(body, "comments"),
|
|
2806
3120
|
mode: PaginationMode.Cursor
|
|
2807
3121
|
});
|
|
2808
3122
|
constructor(http, deps) {
|
|
@@ -2849,7 +3163,7 @@ var PostsResource = class extends BaseResource {
|
|
|
2849
3163
|
async create(input, options = {}) {
|
|
2850
3164
|
const data = resolvePost(input);
|
|
2851
3165
|
const attachmentIds = await this.#collectAttachments(data, options);
|
|
2852
|
-
return this.http.
|
|
3166
|
+
return this.http.execute(POSTS_CREATE, {
|
|
2853
3167
|
path: "/api/posts",
|
|
2854
3168
|
body: {
|
|
2855
3169
|
content: data.content ?? "",
|
|
@@ -2867,7 +3181,7 @@ var PostsResource = class extends BaseResource {
|
|
|
2867
3181
|
* В отличие от списков, здесь у поста заполнено поле `comments`.
|
|
2868
3182
|
*/
|
|
2869
3183
|
get(postId, options = {}) {
|
|
2870
|
-
return this.http.
|
|
3184
|
+
return this.http.execute(POSTS_GET, {
|
|
2871
3185
|
path: `/api/posts/${encodePathSegment(postId, "postId")}`,
|
|
2872
3186
|
...options
|
|
2873
3187
|
});
|
|
@@ -2881,7 +3195,7 @@ var PostsResource = class extends BaseResource {
|
|
|
2881
3195
|
*/
|
|
2882
3196
|
update(postId, input, options = {}) {
|
|
2883
3197
|
const data = resolvePostUpdate(input);
|
|
2884
|
-
return this.http.
|
|
3198
|
+
return this.http.execute(POSTS_UPDATE, {
|
|
2885
3199
|
path: `/api/posts/${encodePathSegment(postId, "postId")}`,
|
|
2886
3200
|
body: {
|
|
2887
3201
|
content: data.content,
|
|
@@ -2892,28 +3206,28 @@ var PostsResource = class extends BaseResource {
|
|
|
2892
3206
|
}
|
|
2893
3207
|
/** Удаляет пост. Восстановить его можно через {@link restore}. */
|
|
2894
3208
|
remove(postId, options = {}) {
|
|
2895
|
-
return this.
|
|
3209
|
+
return this.voidOperation(POSTS_REMOVE, {
|
|
2896
3210
|
path: `/api/posts/${encodePathSegment(postId, "postId")}`,
|
|
2897
3211
|
...options
|
|
2898
3212
|
});
|
|
2899
3213
|
}
|
|
2900
3214
|
/** Восстанавливает удалённый пост. */
|
|
2901
3215
|
restore(postId, options = {}) {
|
|
2902
|
-
return this.http.
|
|
3216
|
+
return this.http.execute(POSTS_RESTORE, {
|
|
2903
3217
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/restore`,
|
|
2904
3218
|
...options
|
|
2905
3219
|
});
|
|
2906
3220
|
}
|
|
2907
3221
|
/** Ставит реакцию на пост. */
|
|
2908
3222
|
like(postId, options = {}) {
|
|
2909
|
-
return this.http.
|
|
3223
|
+
return this.http.execute(POSTS_LIKE, {
|
|
2910
3224
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
|
|
2911
3225
|
...options
|
|
2912
3226
|
});
|
|
2913
3227
|
}
|
|
2914
3228
|
/** Убирает реакцию с поста. */
|
|
2915
3229
|
unlike(postId, options = {}) {
|
|
2916
|
-
return this.http.
|
|
3230
|
+
return this.http.execute(POSTS_UNLIKE, {
|
|
2917
3231
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
|
|
2918
3232
|
...options
|
|
2919
3233
|
});
|
|
@@ -2925,7 +3239,7 @@ var PostsResource = class extends BaseResource {
|
|
|
2925
3239
|
* для файлов здесь нет.
|
|
2926
3240
|
*/
|
|
2927
3241
|
repost(postId, content = "", options = {}) {
|
|
2928
|
-
return this.http.
|
|
3242
|
+
return this.http.execute(POSTS_REPOST, {
|
|
2929
3243
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
|
|
2930
3244
|
body: { content },
|
|
2931
3245
|
...options
|
|
@@ -2933,21 +3247,21 @@ var PostsResource = class extends BaseResource {
|
|
|
2933
3247
|
}
|
|
2934
3248
|
/** Отменяет репост. */
|
|
2935
3249
|
unrepost(postId, options = {}) {
|
|
2936
|
-
return this.
|
|
3250
|
+
return this.voidOperation(POSTS_UNREPOST, {
|
|
2937
3251
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
|
|
2938
3252
|
...options
|
|
2939
3253
|
});
|
|
2940
3254
|
}
|
|
2941
3255
|
/** Закрепляет пост в профиле. */
|
|
2942
3256
|
pin(postId, options = {}) {
|
|
2943
|
-
return this.http.
|
|
3257
|
+
return this.http.execute(POSTS_PIN, {
|
|
2944
3258
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
|
|
2945
3259
|
...options
|
|
2946
3260
|
});
|
|
2947
3261
|
}
|
|
2948
3262
|
/** Открепляет пост. */
|
|
2949
3263
|
unpin(postId, options = {}) {
|
|
2950
|
-
return this.http.
|
|
3264
|
+
return this.http.execute(POSTS_UNPIN, {
|
|
2951
3265
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
|
|
2952
3266
|
...options
|
|
2953
3267
|
});
|
|
@@ -2958,20 +3272,19 @@ var PostsResource = class extends BaseResource {
|
|
|
2958
3272
|
* @param optionIds выбранные варианты; несколько допустимы только при `multipleChoice`
|
|
2959
3273
|
*/
|
|
2960
3274
|
vote(postId, optionIds, options = {}) {
|
|
2961
|
-
return this.http.
|
|
3275
|
+
return this.http.execute(POSTS_VOTE, {
|
|
2962
3276
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/poll/vote`,
|
|
2963
3277
|
body: { optionIds },
|
|
2964
3278
|
...options
|
|
2965
3279
|
});
|
|
2966
3280
|
}
|
|
2967
3281
|
/** Запрашивает счётчики сразу для нескольких постов. */
|
|
2968
|
-
|
|
2969
|
-
|
|
3282
|
+
stats(ids, options = {}) {
|
|
3283
|
+
return this.http.execute(POSTS_STATS, {
|
|
2970
3284
|
path: "/api/posts/stats",
|
|
2971
3285
|
body: { ids },
|
|
2972
3286
|
...options
|
|
2973
3287
|
});
|
|
2974
|
-
return pickArray(body, "posts");
|
|
2975
3288
|
}
|
|
2976
3289
|
/**
|
|
2977
3290
|
* Загружает страницу стены пользователя.
|
|
@@ -3041,7 +3354,7 @@ var PostsResource = class extends BaseResource {
|
|
|
3041
3354
|
async comment(postId, input, options = {}) {
|
|
3042
3355
|
const data = resolveComment(typeof input === "string" ? { content: input } : input);
|
|
3043
3356
|
const attachmentIds = await this.#collectAttachments(data, options);
|
|
3044
|
-
return this.http.
|
|
3357
|
+
return this.http.execute(POSTS_COMMENT, {
|
|
3045
3358
|
path: `/api/posts/${encodePathSegment(postId, "postId")}/comments`,
|
|
3046
3359
|
body: {
|
|
3047
3360
|
content: data.content ?? "",
|
|
@@ -3162,6 +3475,7 @@ function resolveReport(input) {
|
|
|
3162
3475
|
}
|
|
3163
3476
|
//#endregion
|
|
3164
3477
|
//#region src/resources/reports.ts
|
|
3478
|
+
const REPORTS_CREATE = passthroughOperation("reports.create");
|
|
3165
3479
|
/**
|
|
3166
3480
|
* Жалобы на контент и пользователей.
|
|
3167
3481
|
*
|
|
@@ -3182,7 +3496,7 @@ var ReportsResource = class extends BaseResource {
|
|
|
3182
3496
|
*/
|
|
3183
3497
|
create(input, options = {}) {
|
|
3184
3498
|
const data = resolveReport(input);
|
|
3185
|
-
return this.http.
|
|
3499
|
+
return this.http.execute(REPORTS_CREATE, {
|
|
3186
3500
|
path: "/api/reports",
|
|
3187
3501
|
body: data,
|
|
3188
3502
|
...options
|
|
@@ -3191,6 +3505,10 @@ var ReportsResource = class extends BaseResource {
|
|
|
3191
3505
|
};
|
|
3192
3506
|
//#endregion
|
|
3193
3507
|
//#region src/resources/search.ts
|
|
3508
|
+
const SEARCH_ALL = defineBuiltInOperation("search.all", (body) => ({
|
|
3509
|
+
users: pickArray(body, "users"),
|
|
3510
|
+
hashtags: pickArray(body, "hashtags")
|
|
3511
|
+
}));
|
|
3194
3512
|
/**
|
|
3195
3513
|
* Глобальный поиск.
|
|
3196
3514
|
*
|
|
@@ -3205,20 +3523,212 @@ var SearchResource = class extends BaseResource {
|
|
|
3205
3523
|
* const { users, hashtags } = await itd.search.all('арт');
|
|
3206
3524
|
* ```
|
|
3207
3525
|
*/
|
|
3208
|
-
|
|
3209
|
-
|
|
3526
|
+
all(query, options = {}) {
|
|
3527
|
+
return this.http.execute(SEARCH_ALL, {
|
|
3210
3528
|
path: "/api/search",
|
|
3211
3529
|
query: { q: query },
|
|
3212
3530
|
...options
|
|
3213
3531
|
});
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3532
|
+
}
|
|
3533
|
+
};
|
|
3534
|
+
//#endregion
|
|
3535
|
+
//#region src/resources/shop.ts
|
|
3536
|
+
const PRODUCTS_LIST = passthroughOperation("shop.products.list");
|
|
3537
|
+
const PRODUCTS_GET = passthroughOperation("shop.products.get");
|
|
3538
|
+
const DELIVERY_COUNTRIES = passthroughOperation("shop.delivery.countries");
|
|
3539
|
+
const DELIVERY_CITIES = passthroughOperation("shop.delivery.cities");
|
|
3540
|
+
const DELIVERY_POINTS = passthroughOperation("shop.delivery.points");
|
|
3541
|
+
const DELIVERY_CALCULATE = passthroughOperation("shop.delivery.calculate");
|
|
3542
|
+
const ORDERS_CREATE = passthroughOperation("shop.orders.create");
|
|
3543
|
+
const ORDERS_LIST = passthroughOperation("shop.orders.list");
|
|
3544
|
+
const ORDERS_GET = passthroughOperation("shop.orders.get");
|
|
3545
|
+
const ORDERS_PAY = passthroughOperation("shop.orders.pay");
|
|
3546
|
+
const ORDERS_REQUEST_ACCESS_CODE = voidOperation("shop.orders.requestAccessCode");
|
|
3547
|
+
const ORDERS_VERIFY_ACCESS_CODE = passthroughOperation("shop.orders.verifyAccessCode");
|
|
3548
|
+
const CONSENTS_RECORD = voidOperation("shop.consents.record");
|
|
3549
|
+
function orderRequestOptions(options) {
|
|
3550
|
+
const { orderAccessToken, useItdAuth, headers, ...request } = options;
|
|
3551
|
+
return {
|
|
3552
|
+
...request,
|
|
3553
|
+
...useItdAuth === false ? { skipAuth: true } : {},
|
|
3554
|
+
headers: {
|
|
3555
|
+
...headers,
|
|
3556
|
+
...orderAccessToken ? { "X-Order-Token": orderAccessToken } : {}
|
|
3557
|
+
}
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
/** Каталог товаров магазина. */
|
|
3561
|
+
var ShopProductsResource = class extends BaseResource {
|
|
3562
|
+
/** Загружает все товары. */
|
|
3563
|
+
list(options = {}) {
|
|
3564
|
+
return this.http.execute(PRODUCTS_LIST, {
|
|
3565
|
+
path: "/api/v1/shop/products",
|
|
3566
|
+
raw: true,
|
|
3567
|
+
...options
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
/** Загружает товар по идентификатору. */
|
|
3571
|
+
async get(productId, options = {}) {
|
|
3572
|
+
try {
|
|
3573
|
+
return await this.http.execute(PRODUCTS_GET, {
|
|
3574
|
+
path: `/api/v1/shop/products/${encodePathSegment(productId, "идентификатор товара")}`,
|
|
3575
|
+
raw: true,
|
|
3576
|
+
...options
|
|
3577
|
+
});
|
|
3578
|
+
} catch (error) {
|
|
3579
|
+
if (isItdApiError(error) && error.status === 404) return null;
|
|
3580
|
+
throw error;
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
};
|
|
3584
|
+
/** Расчёт и выбор доставки. */
|
|
3585
|
+
var ShopDeliveryResource = class extends BaseResource {
|
|
3586
|
+
/** Загружает страны, в которые доступна доставка. */
|
|
3587
|
+
countries(options = {}) {
|
|
3588
|
+
return this.http.execute(DELIVERY_COUNTRIES, {
|
|
3589
|
+
path: "/api/v1/shop/delivery/countries",
|
|
3590
|
+
raw: true,
|
|
3591
|
+
...options
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
/** Ищет города службы доставки. */
|
|
3595
|
+
cities(query, countryCode, options = {}) {
|
|
3596
|
+
return this.http.execute(DELIVERY_CITIES, {
|
|
3597
|
+
path: "/api/v1/shop/delivery/cities",
|
|
3598
|
+
query: {
|
|
3599
|
+
q: query,
|
|
3600
|
+
country: countryCode
|
|
3601
|
+
},
|
|
3602
|
+
raw: true,
|
|
3603
|
+
...options
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
/** Загружает пункты выдачи в городе. */
|
|
3607
|
+
points(cityCode, options = {}) {
|
|
3608
|
+
return this.http.execute(DELIVERY_POINTS, {
|
|
3609
|
+
path: "/api/v1/shop/delivery/points",
|
|
3610
|
+
query: { cityCode },
|
|
3611
|
+
raw: true,
|
|
3612
|
+
...options
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
/** Рассчитывает стоимость и срок доставки. */
|
|
3616
|
+
calculate(items, destination, options = {}) {
|
|
3617
|
+
return this.http.execute(DELIVERY_CALCULATE, {
|
|
3618
|
+
path: "/api/v1/shop/delivery/calculate",
|
|
3619
|
+
body: {
|
|
3620
|
+
items,
|
|
3621
|
+
to: destination
|
|
3622
|
+
},
|
|
3623
|
+
raw: true,
|
|
3624
|
+
...options
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
};
|
|
3628
|
+
/** Создание, просмотр и оплата заказов. */
|
|
3629
|
+
var ShopOrdersResource = class extends BaseResource {
|
|
3630
|
+
/** Создаёт заказ. */
|
|
3631
|
+
create(input, options = {}) {
|
|
3632
|
+
const { idempotencyKey = createDeviceId(), headers, ...request } = options;
|
|
3633
|
+
return this.http.execute(ORDERS_CREATE, {
|
|
3634
|
+
path: "/api/v1/shop/orders",
|
|
3635
|
+
body: input,
|
|
3636
|
+
headers: {
|
|
3637
|
+
...headers,
|
|
3638
|
+
"Idempotency-Key": idempotencyKey
|
|
3639
|
+
},
|
|
3640
|
+
raw: true,
|
|
3641
|
+
...request
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
/** Загружает заказы аккаунта ИТД или владельца временного токена. */
|
|
3645
|
+
list(options = {}) {
|
|
3646
|
+
return this.http.execute(ORDERS_LIST, {
|
|
3647
|
+
path: "/api/v1/shop/orders/my",
|
|
3648
|
+
raw: true,
|
|
3649
|
+
...orderRequestOptions(options)
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
/** Загружает заказ по номеру. */
|
|
3653
|
+
get(orderNumber, options = {}) {
|
|
3654
|
+
return this.http.execute(ORDERS_GET, {
|
|
3655
|
+
path: `/api/v1/shop/orders/${encodePathSegment(orderNumber, "номер заказа")}`,
|
|
3656
|
+
raw: true,
|
|
3657
|
+
...orderRequestOptions(options)
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
/** Создаёт ссылку на оплату заказа. */
|
|
3661
|
+
pay(orderNumber, options) {
|
|
3662
|
+
const { returnUrl, ...access } = options;
|
|
3663
|
+
return this.http.execute(ORDERS_PAY, {
|
|
3664
|
+
path: `/api/v1/shop/orders/${encodePathSegment(orderNumber, "номер заказа")}/pay`,
|
|
3665
|
+
body: { returnUrl },
|
|
3666
|
+
raw: true,
|
|
3667
|
+
...orderRequestOptions(access)
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
/** Отправляет на почту код доступа к гостевым заказам. */
|
|
3671
|
+
requestAccessCode(email, options = {}) {
|
|
3672
|
+
return this.http.execute(ORDERS_REQUEST_ACCESS_CODE, {
|
|
3673
|
+
path: "/api/v1/shop/orders/lookup/request",
|
|
3674
|
+
body: { email },
|
|
3675
|
+
raw: true,
|
|
3676
|
+
skipAuth: true,
|
|
3677
|
+
...options
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
/** Проверяет код из письма и возвращает временный токен заказов. */
|
|
3681
|
+
verifyAccessCode(email, code, options = {}) {
|
|
3682
|
+
return this.http.execute(ORDERS_VERIFY_ACCESS_CODE, {
|
|
3683
|
+
path: "/api/v1/shop/orders/lookup/verify",
|
|
3684
|
+
body: {
|
|
3685
|
+
email,
|
|
3686
|
+
code
|
|
3687
|
+
},
|
|
3688
|
+
raw: true,
|
|
3689
|
+
skipAuth: true,
|
|
3690
|
+
...options
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
};
|
|
3694
|
+
/** Запись согласий магазина. */
|
|
3695
|
+
var ShopConsentsResource = class extends BaseResource {
|
|
3696
|
+
/** Записывает согласия покупателя. */
|
|
3697
|
+
record(consents, consentContext, options = {}) {
|
|
3698
|
+
return this.http.execute(CONSENTS_RECORD, {
|
|
3699
|
+
path: "/api/v1/shop/consents",
|
|
3700
|
+
body: {
|
|
3701
|
+
consents,
|
|
3702
|
+
consentContext
|
|
3703
|
+
},
|
|
3704
|
+
raw: true,
|
|
3705
|
+
...options
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
};
|
|
3709
|
+
/** Магазин ИТД. */
|
|
3710
|
+
var ShopResource = class {
|
|
3711
|
+
products;
|
|
3712
|
+
delivery;
|
|
3713
|
+
orders;
|
|
3714
|
+
consents;
|
|
3715
|
+
/** @internal */
|
|
3716
|
+
constructor(http) {
|
|
3717
|
+
this.products = new ShopProductsResource(http);
|
|
3718
|
+
this.delivery = new ShopDeliveryResource(http);
|
|
3719
|
+
this.orders = new ShopOrdersResource(http);
|
|
3720
|
+
this.consents = new ShopConsentsResource(http);
|
|
3218
3721
|
}
|
|
3219
3722
|
};
|
|
3220
3723
|
//#endregion
|
|
3221
3724
|
//#region src/resources/subscription.ts
|
|
3725
|
+
const SUBSCRIPTION_METHODS = defineBuiltInOperation("subscription.methods", (body) => Array.isArray(body) ? body : []);
|
|
3726
|
+
const SUBSCRIPTION_STATUS = passthroughOperation("subscription.status");
|
|
3727
|
+
const SUBSCRIPTION_PAY = passthroughOperation("subscription.pay");
|
|
3728
|
+
const SUBSCRIPTION_SET_AUTO_RENEWAL = passthroughOperation("subscription.setAutoRenewal");
|
|
3729
|
+
const SUBSCRIPTION_BIND_CARD = passthroughOperation("subscription.bindCard");
|
|
3730
|
+
const SUBSCRIPTION_SET_DEFAULT_METHOD = passthroughOperation("subscription.setDefaultMethod");
|
|
3731
|
+
const SUBSCRIPTION_REMOVE_METHOD = voidOperation("subscription.removeMethod");
|
|
3222
3732
|
/**
|
|
3223
3733
|
* Подписка и способы оплаты.
|
|
3224
3734
|
*
|
|
@@ -3227,7 +3737,7 @@ var SearchResource = class extends BaseResource {
|
|
|
3227
3737
|
var SubscriptionResource = class extends BaseResource {
|
|
3228
3738
|
/** Загружает состояние подписки и её цену. */
|
|
3229
3739
|
status(options = {}) {
|
|
3230
|
-
return this.http.
|
|
3740
|
+
return this.http.execute(SUBSCRIPTION_STATUS, {
|
|
3231
3741
|
path: "/api/v1/subscription/",
|
|
3232
3742
|
...options
|
|
3233
3743
|
});
|
|
@@ -3238,14 +3748,14 @@ var SubscriptionResource = class extends BaseResource {
|
|
|
3238
3748
|
* Форма ответа в документации API не описана, поэтому тип результата не уточняется.
|
|
3239
3749
|
*/
|
|
3240
3750
|
pay(options = {}) {
|
|
3241
|
-
return this.http.
|
|
3751
|
+
return this.http.execute(SUBSCRIPTION_PAY, {
|
|
3242
3752
|
path: "/api/v1/subscription/pay",
|
|
3243
3753
|
...options
|
|
3244
3754
|
});
|
|
3245
3755
|
}
|
|
3246
3756
|
/** Включает или отключает автопродление. */
|
|
3247
3757
|
setAutoRenewal(enabled, options = {}) {
|
|
3248
|
-
return this.http.
|
|
3758
|
+
return this.http.execute(SUBSCRIPTION_SET_AUTO_RENEWAL, {
|
|
3249
3759
|
path: "/api/v1/subscription/auto-renewal",
|
|
3250
3760
|
body: { enabled },
|
|
3251
3761
|
...options
|
|
@@ -3253,29 +3763,28 @@ var SubscriptionResource = class extends BaseResource {
|
|
|
3253
3763
|
}
|
|
3254
3764
|
/** Запускает привязку карты. */
|
|
3255
3765
|
bindCard(options = {}) {
|
|
3256
|
-
return this.http.
|
|
3766
|
+
return this.http.execute(SUBSCRIPTION_BIND_CARD, {
|
|
3257
3767
|
path: "/api/v1/subscription/bind-card",
|
|
3258
3768
|
...options
|
|
3259
3769
|
});
|
|
3260
3770
|
}
|
|
3261
3771
|
/** Загружает список способов оплаты. Пустой массив, если карт нет. */
|
|
3262
|
-
|
|
3263
|
-
|
|
3772
|
+
methods(options = {}) {
|
|
3773
|
+
return this.http.execute(SUBSCRIPTION_METHODS, {
|
|
3264
3774
|
path: "/api/v1/subscription/methods",
|
|
3265
3775
|
...options
|
|
3266
3776
|
});
|
|
3267
|
-
return Array.isArray(body) ? body : [];
|
|
3268
3777
|
}
|
|
3269
3778
|
/** Делает способ оплаты основным. */
|
|
3270
3779
|
setDefaultMethod(methodId, options = {}) {
|
|
3271
|
-
return this.http.
|
|
3780
|
+
return this.http.execute(SUBSCRIPTION_SET_DEFAULT_METHOD, {
|
|
3272
3781
|
path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}/default`,
|
|
3273
3782
|
...options
|
|
3274
3783
|
});
|
|
3275
3784
|
}
|
|
3276
3785
|
/** Удаляет способ оплаты. */
|
|
3277
3786
|
removeMethod(methodId, options = {}) {
|
|
3278
|
-
return this.
|
|
3787
|
+
return this.voidOperation(SUBSCRIPTION_REMOVE_METHOD, {
|
|
3279
3788
|
path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}`,
|
|
3280
3789
|
...options
|
|
3281
3790
|
});
|
|
@@ -3434,7 +3943,7 @@ var TelemetryBatchImpl = class {
|
|
|
3434
3943
|
close() {
|
|
3435
3944
|
return this.#close(false);
|
|
3436
3945
|
}
|
|
3437
|
-
/** Внутренняя
|
|
3946
|
+
/** Внутренняя отправка данных после вызова `dispose()`. */
|
|
3438
3947
|
closeForDispose() {
|
|
3439
3948
|
this.prepareForDispose();
|
|
3440
3949
|
return this.#close(true);
|
|
@@ -3530,7 +4039,7 @@ var TelemetryResource = class extends BaseResource {
|
|
|
3530
4039
|
},
|
|
3531
4040
|
...requestOptions
|
|
3532
4041
|
};
|
|
3533
|
-
return disposeCleanup ? this.http.cleanupOperation(
|
|
4042
|
+
return disposeCleanup ? this.http.cleanupOperation(TELEMETRY_DWELL, options) : this.http.execute(TELEMETRY_DWELL, options);
|
|
3534
4043
|
}
|
|
3535
4044
|
/** Отправляет события взаимодействия с контентом (`POST /api/v1/x`). */
|
|
3536
4045
|
interaction(entries, telemetryOptions = {}, requestOptions = {}) {
|
|
@@ -3554,7 +4063,7 @@ var TelemetryResource = class extends BaseResource {
|
|
|
3554
4063
|
},
|
|
3555
4064
|
...requestOptions
|
|
3556
4065
|
};
|
|
3557
|
-
return disposeCleanup ? this.http.cleanupOperation(
|
|
4066
|
+
return disposeCleanup ? this.http.cleanupOperation(TELEMETRY_INTERACTION, options) : this.http.execute(TELEMETRY_INTERACTION, options);
|
|
3558
4067
|
}
|
|
3559
4068
|
/** Начинает измерять время просмотра и отправляет результат после `finish()`. */
|
|
3560
4069
|
startView(input, options = {}, requestOptions = {}) {
|
|
@@ -3597,12 +4106,43 @@ var TelemetryResource = class extends BaseResource {
|
|
|
3597
4106
|
function prepareTelemetryForDispose(resource) {
|
|
3598
4107
|
if (resource) TELEMETRY_DISPOSE_PREPARERS.get(resource)?.();
|
|
3599
4108
|
}
|
|
3600
|
-
/**
|
|
4109
|
+
/** Отправляет и закрывает созданные накопители после вызова `dispose()`. @internal */
|
|
3601
4110
|
function closeTelemetryForDispose(resource) {
|
|
3602
4111
|
return resource ? TELEMETRY_DISPOSE_CLOSERS.get(resource)?.() ?? Promise.resolve() : Promise.resolve();
|
|
3603
4112
|
}
|
|
4113
|
+
const TELEMETRY_DWELL = defineBuiltInOperation("telemetry.dwell");
|
|
4114
|
+
const TELEMETRY_INTERACTION = defineBuiltInOperation("telemetry.interaction");
|
|
3604
4115
|
//#endregion
|
|
3605
4116
|
//#region src/resources/users.ts
|
|
4117
|
+
const userListOperation = (id) => pageOperation(id, (body) => readPagedPage(body, "users", "followers", "following", "blocked"));
|
|
4118
|
+
const USER_LIST_OPERATIONS = {
|
|
4119
|
+
"users.followers": userListOperation("users.followers"),
|
|
4120
|
+
"users.following": userListOperation("users.following"),
|
|
4121
|
+
"users.blocked": userListOperation("users.blocked")
|
|
4122
|
+
};
|
|
4123
|
+
const USERS_CHECK_USERNAME = defineBuiltInOperation("users.checkUsername", (body) => pickBoolean(body, "available"));
|
|
4124
|
+
const USERS_SEARCH = defineBuiltInOperation("users.search", (body) => pickArray(body, "users"));
|
|
4125
|
+
const USERS_WHO_TO_FOLLOW = defineBuiltInOperation("users.whoToFollow", (body) => pickArray(body, "users"));
|
|
4126
|
+
const USERS_TOP_CLANS = defineBuiltInOperation("users.topClans", (body) => pickArray(body, "clans"));
|
|
4127
|
+
const USERS_PINS = defineBuiltInOperation("users.pins", (body) => ({
|
|
4128
|
+
pins: pickArray(body, "pins"),
|
|
4129
|
+
activePin: pickString(body, "activePin") ?? null
|
|
4130
|
+
}));
|
|
4131
|
+
const USERS_ME = passthroughOperation("users.me");
|
|
4132
|
+
const USERS_UPDATE_ME = passthroughOperation("users.updateMe");
|
|
4133
|
+
const USERS_CREATE_PROFILE = passthroughOperation("users.createProfile");
|
|
4134
|
+
const USERS_GET = passthroughOperation("users.get");
|
|
4135
|
+
const USERS_FOLLOW = passthroughOperation("users.follow");
|
|
4136
|
+
const USERS_FOLLOW_STATUS = passthroughOperation("users.followStatus");
|
|
4137
|
+
const USERS_GET_PRIVACY = passthroughOperation("users.getPrivacy");
|
|
4138
|
+
const USERS_UPDATE_PRIVACY = passthroughOperation("users.updatePrivacy");
|
|
4139
|
+
const USERS_DEACTIVATE = voidOperation("users.deactivate");
|
|
4140
|
+
const USERS_RESTORE = voidOperation("users.restore");
|
|
4141
|
+
const USERS_UNFOLLOW = voidOperation("users.unfollow");
|
|
4142
|
+
const USERS_BLOCK = voidOperation("users.block");
|
|
4143
|
+
const USERS_UNBLOCK = voidOperation("users.unblock");
|
|
4144
|
+
const USERS_SET_PIN = voidOperation("users.setPin");
|
|
4145
|
+
const USERS_REMOVE_PIN = voidOperation("users.removePin");
|
|
3606
4146
|
/**
|
|
3607
4147
|
* Пользователи: профили, подписки, блокировки, приватность.
|
|
3608
4148
|
*
|
|
@@ -3623,23 +4163,22 @@ var UsersResource = class extends BaseResource {
|
|
|
3623
4163
|
* сервер его сейчас не читает (см. {@link followers}): когда починят — заработает само.
|
|
3624
4164
|
*/
|
|
3625
4165
|
#userList = this.paginated({
|
|
3626
|
-
|
|
4166
|
+
operation: (p) => p.operation,
|
|
3627
4167
|
path: (p) => p.path,
|
|
3628
4168
|
query: (p) => ({ limit: p.limit }),
|
|
3629
4169
|
start: (p) => p.page !== void 0 ? { page: p.page } : {},
|
|
3630
|
-
read: (body) => readPagedPage(body, "users", "followers", "following", "blocked"),
|
|
3631
4170
|
mode: PaginationMode.Page
|
|
3632
4171
|
});
|
|
3633
4172
|
/** Загружает свой профиль — с подпиской и признаком подтверждённого телефона. */
|
|
3634
4173
|
me(options = {}) {
|
|
3635
|
-
return this.http.
|
|
4174
|
+
return this.http.execute(USERS_ME, {
|
|
3636
4175
|
path: "/api/users/me",
|
|
3637
4176
|
...options
|
|
3638
4177
|
});
|
|
3639
4178
|
}
|
|
3640
4179
|
/** Обновляет свой профиль. Передавайте только изменяемые поля. */
|
|
3641
4180
|
updateMe(input, options = {}) {
|
|
3642
|
-
return this.http.
|
|
4181
|
+
return this.http.execute(USERS_UPDATE_ME, {
|
|
3643
4182
|
path: "/api/users/me",
|
|
3644
4183
|
body: input,
|
|
3645
4184
|
...options
|
|
@@ -3666,21 +4205,21 @@ var UsersResource = class extends BaseResource {
|
|
|
3666
4205
|
}
|
|
3667
4206
|
/** Деактивирует аккаунт. Вернуть его можно через {@link restore}. */
|
|
3668
4207
|
deactivate(options = {}) {
|
|
3669
|
-
return this.
|
|
4208
|
+
return this.voidOperation(USERS_DEACTIVATE, {
|
|
3670
4209
|
path: "/api/users/me",
|
|
3671
4210
|
...options
|
|
3672
4211
|
});
|
|
3673
4212
|
}
|
|
3674
4213
|
/** Восстанавливает деактивированный аккаунт. */
|
|
3675
4214
|
restore(options = {}) {
|
|
3676
|
-
return this.
|
|
4215
|
+
return this.voidOperation(USERS_RESTORE, {
|
|
3677
4216
|
path: "/api/users/me/restore",
|
|
3678
4217
|
...options
|
|
3679
4218
|
});
|
|
3680
4219
|
}
|
|
3681
4220
|
/** Создаёт профиль после регистрации. */
|
|
3682
4221
|
createProfile(input, options = {}) {
|
|
3683
|
-
return this.http.
|
|
4222
|
+
return this.http.execute(USERS_CREATE_PROFILE, {
|
|
3684
4223
|
path: "/api/users/profile",
|
|
3685
4224
|
body: input,
|
|
3686
4225
|
...options
|
|
@@ -3698,23 +4237,22 @@ var UsersResource = class extends BaseResource {
|
|
|
3698
4237
|
* ```
|
|
3699
4238
|
*/
|
|
3700
4239
|
get(user, options = {}) {
|
|
3701
|
-
return this.http.
|
|
4240
|
+
return this.http.execute(USERS_GET, {
|
|
3702
4241
|
path: `/api/users/${encodePathSegment(user, "user")}`,
|
|
3703
4242
|
...options
|
|
3704
4243
|
});
|
|
3705
4244
|
}
|
|
3706
4245
|
/** Проверяет, свободно ли имя пользователя. */
|
|
3707
|
-
|
|
3708
|
-
|
|
4246
|
+
checkUsername(username, options = {}) {
|
|
4247
|
+
return this.http.execute(USERS_CHECK_USERNAME, {
|
|
3709
4248
|
path: "/api/users/check-username",
|
|
3710
4249
|
query: { username },
|
|
3711
4250
|
...options
|
|
3712
4251
|
});
|
|
3713
|
-
return pickBoolean(body, "available");
|
|
3714
4252
|
}
|
|
3715
4253
|
/** Ищет пользователей по строке запроса. */
|
|
3716
|
-
|
|
3717
|
-
|
|
4254
|
+
search(query, params = {}, options = {}) {
|
|
4255
|
+
return this.http.execute(USERS_SEARCH, {
|
|
3718
4256
|
path: "/api/users/search",
|
|
3719
4257
|
query: {
|
|
3720
4258
|
q: query,
|
|
@@ -3722,23 +4260,20 @@ var UsersResource = class extends BaseResource {
|
|
|
3722
4260
|
},
|
|
3723
4261
|
...options
|
|
3724
4262
|
});
|
|
3725
|
-
return pickArray(body, "users");
|
|
3726
4263
|
}
|
|
3727
4264
|
/** Загружает рекомендации, на кого подписаться. */
|
|
3728
|
-
|
|
3729
|
-
|
|
4265
|
+
whoToFollow(options = {}) {
|
|
4266
|
+
return this.http.execute(USERS_WHO_TO_FOLLOW, {
|
|
3730
4267
|
path: "/api/users/suggestions/who-to-follow",
|
|
3731
4268
|
...options
|
|
3732
4269
|
});
|
|
3733
|
-
return pickArray(body, "users");
|
|
3734
4270
|
}
|
|
3735
4271
|
/** Загружает рейтинг кланов. */
|
|
3736
|
-
|
|
3737
|
-
|
|
4272
|
+
topClans(options = {}) {
|
|
4273
|
+
return this.http.execute(USERS_TOP_CLANS, {
|
|
3738
4274
|
path: "/api/users/stats/top-clans",
|
|
3739
4275
|
...options
|
|
3740
4276
|
});
|
|
3741
|
-
return pickArray(body, "clans");
|
|
3742
4277
|
}
|
|
3743
4278
|
/**
|
|
3744
4279
|
* Подписывается на пользователя.
|
|
@@ -3746,7 +4281,7 @@ var UsersResource = class extends BaseResource {
|
|
|
3746
4281
|
* У закрытого профиля вместо подписки отправляется заявка — это видно по полю `status`.
|
|
3747
4282
|
*/
|
|
3748
4283
|
follow(user, options = {}) {
|
|
3749
|
-
return this.http.
|
|
4284
|
+
return this.http.execute(USERS_FOLLOW, {
|
|
3750
4285
|
path: `/api/users/${encodePathSegment(user, "user")}/follow`,
|
|
3751
4286
|
body: {},
|
|
3752
4287
|
...options
|
|
@@ -3754,7 +4289,7 @@ var UsersResource = class extends BaseResource {
|
|
|
3754
4289
|
}
|
|
3755
4290
|
/** Отписывается от пользователя. */
|
|
3756
4291
|
unfollow(user, options = {}) {
|
|
3757
|
-
return this.
|
|
4292
|
+
return this.voidOperation(USERS_UNFOLLOW, {
|
|
3758
4293
|
path: `/api/users/${encodePathSegment(user, "user")}/follow`,
|
|
3759
4294
|
...options
|
|
3760
4295
|
});
|
|
@@ -3802,7 +4337,7 @@ var UsersResource = class extends BaseResource {
|
|
|
3802
4337
|
* ```
|
|
3803
4338
|
*/
|
|
3804
4339
|
followStatus(userIds, options = {}) {
|
|
3805
|
-
return this.http.
|
|
4340
|
+
return this.http.execute(USERS_FOLLOW_STATUS, {
|
|
3806
4341
|
path: "/api/users/follow-status",
|
|
3807
4342
|
body: { userIds },
|
|
3808
4343
|
...options
|
|
@@ -3810,7 +4345,7 @@ var UsersResource = class extends BaseResource {
|
|
|
3810
4345
|
}
|
|
3811
4346
|
/** Блокирует пользователя. */
|
|
3812
4347
|
block(user, options = {}) {
|
|
3813
|
-
return this.
|
|
4348
|
+
return this.voidOperation(USERS_BLOCK, {
|
|
3814
4349
|
path: `/api/users/${encodePathSegment(user, "user")}/block`,
|
|
3815
4350
|
body: {},
|
|
3816
4351
|
...options
|
|
@@ -3818,7 +4353,7 @@ var UsersResource = class extends BaseResource {
|
|
|
3818
4353
|
}
|
|
3819
4354
|
/** Снимает блокировку. */
|
|
3820
4355
|
unblock(user, options = {}) {
|
|
3821
|
-
return this.
|
|
4356
|
+
return this.voidOperation(USERS_UNBLOCK, {
|
|
3822
4357
|
path: `/api/users/${encodePathSegment(user, "user")}/block`,
|
|
3823
4358
|
...options
|
|
3824
4359
|
});
|
|
@@ -3833,14 +4368,14 @@ var UsersResource = class extends BaseResource {
|
|
|
3833
4368
|
}
|
|
3834
4369
|
/** Загружает настройки приватности. */
|
|
3835
4370
|
getPrivacy(options = {}) {
|
|
3836
|
-
return this.http.
|
|
4371
|
+
return this.http.execute(USERS_GET_PRIVACY, {
|
|
3837
4372
|
path: "/api/users/me/privacy",
|
|
3838
4373
|
...options
|
|
3839
4374
|
});
|
|
3840
4375
|
}
|
|
3841
4376
|
/** Обновляет настройки приватности. Передавайте только изменяемые поля. */
|
|
3842
4377
|
updatePrivacy(input, options = {}) {
|
|
3843
|
-
return this.http.
|
|
4378
|
+
return this.http.execute(USERS_UPDATE_PRIVACY, {
|
|
3844
4379
|
path: "/api/users/me/privacy",
|
|
3845
4380
|
body: input,
|
|
3846
4381
|
...options
|
|
@@ -3851,19 +4386,15 @@ var UsersResource = class extends BaseResource {
|
|
|
3851
4386
|
*
|
|
3852
4387
|
* `activePin` — строка-идентификатор, а не объект.
|
|
3853
4388
|
*/
|
|
3854
|
-
|
|
3855
|
-
|
|
4389
|
+
pins(options = {}) {
|
|
4390
|
+
return this.http.execute(USERS_PINS, {
|
|
3856
4391
|
path: "/api/users/me/pins",
|
|
3857
4392
|
...options
|
|
3858
4393
|
});
|
|
3859
|
-
return {
|
|
3860
|
-
pins: pickArray(body, "pins"),
|
|
3861
|
-
activePin: pickString(body, "activePin") ?? null
|
|
3862
|
-
};
|
|
3863
4394
|
}
|
|
3864
4395
|
/** Выбирает активный значок профиля. */
|
|
3865
4396
|
setPin(slug, options = {}) {
|
|
3866
|
-
return this.
|
|
4397
|
+
return this.voidOperation(USERS_SET_PIN, {
|
|
3867
4398
|
path: "/api/users/me/pin",
|
|
3868
4399
|
body: { slug },
|
|
3869
4400
|
...options
|
|
@@ -3871,7 +4402,7 @@ var UsersResource = class extends BaseResource {
|
|
|
3871
4402
|
}
|
|
3872
4403
|
/** Снимает активный значок. */
|
|
3873
4404
|
removePin(options = {}) {
|
|
3874
|
-
return this.
|
|
4405
|
+
return this.voidOperation(USERS_REMOVE_PIN, {
|
|
3875
4406
|
path: "/api/users/me/pin",
|
|
3876
4407
|
...options
|
|
3877
4408
|
});
|
|
@@ -3879,20 +4410,22 @@ var UsersResource = class extends BaseResource {
|
|
|
3879
4410
|
#userPage(operationId, path, params, options) {
|
|
3880
4411
|
return this.#userList.list({
|
|
3881
4412
|
...params,
|
|
3882
|
-
operationId,
|
|
4413
|
+
operation: USER_LIST_OPERATIONS[operationId],
|
|
3883
4414
|
path
|
|
3884
4415
|
}, options);
|
|
3885
4416
|
}
|
|
3886
4417
|
#userPaginator(operationId, path, params, options) {
|
|
3887
4418
|
return this.#userList.iterate({
|
|
3888
4419
|
...params,
|
|
3889
|
-
operationId,
|
|
4420
|
+
operation: USER_LIST_OPERATIONS[operationId],
|
|
3890
4421
|
path
|
|
3891
4422
|
}, options);
|
|
3892
4423
|
}
|
|
3893
4424
|
};
|
|
3894
4425
|
//#endregion
|
|
3895
4426
|
//#region src/resources/verification.ts
|
|
4427
|
+
const VERIFICATION_STATUS = passthroughOperation("verification.status");
|
|
4428
|
+
const VERIFICATION_SUBMIT = passthroughOperation("verification.submit");
|
|
3896
4429
|
/**
|
|
3897
4430
|
* Верификация профиля.
|
|
3898
4431
|
*
|
|
@@ -3901,14 +4434,14 @@ var UsersResource = class extends BaseResource {
|
|
|
3901
4434
|
var VerificationResource = class extends BaseResource {
|
|
3902
4435
|
/** Загружает статус заявки. Значение `none` означает, что заявка не подавалась. */
|
|
3903
4436
|
status(options = {}) {
|
|
3904
|
-
return this.http.
|
|
4437
|
+
return this.http.execute(VERIFICATION_STATUS, {
|
|
3905
4438
|
path: "/api/verification/status",
|
|
3906
4439
|
...options
|
|
3907
4440
|
});
|
|
3908
4441
|
}
|
|
3909
4442
|
/** Подаёт заявку на верификацию с видео. */
|
|
3910
4443
|
submit(videoUrl, options = {}) {
|
|
3911
|
-
return this.http.
|
|
4444
|
+
return this.http.execute(VERIFICATION_SUBMIT, {
|
|
3912
4445
|
path: "/api/verification/submit",
|
|
3913
4446
|
body: { videoUrl },
|
|
3914
4447
|
...options
|
|
@@ -3939,6 +4472,7 @@ function createResources(deps) {
|
|
|
3939
4472
|
let subscription;
|
|
3940
4473
|
let platform;
|
|
3941
4474
|
let telemetry;
|
|
4475
|
+
let shop;
|
|
3942
4476
|
const bag = {
|
|
3943
4477
|
get users() {
|
|
3944
4478
|
users ??= new UsersResource(deps.http, { uploadFile });
|
|
@@ -3953,11 +4487,11 @@ function createResources(deps) {
|
|
|
3953
4487
|
return comments;
|
|
3954
4488
|
},
|
|
3955
4489
|
get files() {
|
|
3956
|
-
files ??= new FilesResource(deps.http, {
|
|
4490
|
+
files ??= new FilesResource(deps.http, { files: deps.files });
|
|
3957
4491
|
return files;
|
|
3958
4492
|
},
|
|
3959
4493
|
get notifications() {
|
|
3960
|
-
notifications ??=
|
|
4494
|
+
notifications ??= deps.createNotifications(deps.http);
|
|
3961
4495
|
return notifications;
|
|
3962
4496
|
},
|
|
3963
4497
|
get hashtags() {
|
|
@@ -3988,6 +4522,10 @@ function createResources(deps) {
|
|
|
3988
4522
|
telemetry ??= new TelemetryResource(deps.http);
|
|
3989
4523
|
return telemetry;
|
|
3990
4524
|
},
|
|
4525
|
+
get shop() {
|
|
4526
|
+
shop ??= new ShopResource(deps.http);
|
|
4527
|
+
return shop;
|
|
4528
|
+
},
|
|
3991
4529
|
closeTelemetry: (forDispose) => forDispose ? closeTelemetryForDispose(telemetry) : telemetry?.close() ?? Promise.resolve(),
|
|
3992
4530
|
prepareTelemetryClose: () => prepareTelemetryForDispose(telemetry)
|
|
3993
4531
|
};
|
|
@@ -4021,7 +4559,7 @@ function fromStream(factory, options = {}) {
|
|
|
4021
4559
|
throw new ItdFileError("не удалось открыть поток вложения", {
|
|
4022
4560
|
reason: ItdFileErrorReason.Read,
|
|
4023
4561
|
retryable: true,
|
|
4024
|
-
cause: error
|
|
4562
|
+
cause: redactErrorCause(error)
|
|
4025
4563
|
});
|
|
4026
4564
|
}
|
|
4027
4565
|
const content = isReadableByteStream(opened) ? { stream: opened } : opened;
|
|
@@ -4061,6 +4599,36 @@ function isMyProfile(profile) {
|
|
|
4061
4599
|
return "subscription" in profile;
|
|
4062
4600
|
}
|
|
4063
4601
|
//#endregion
|
|
4602
|
+
//#region src/models/shop.ts
|
|
4603
|
+
/** Категория товара магазина. */
|
|
4604
|
+
const ShopProductCategory = Object.freeze({
|
|
4605
|
+
Apparel: "apparel",
|
|
4606
|
+
Accessories: "accessories",
|
|
4607
|
+
Print: "print"
|
|
4608
|
+
});
|
|
4609
|
+
/** Состояние товара в каталоге. */
|
|
4610
|
+
const ShopProductStatus = Object.freeze({
|
|
4611
|
+
Available: "available",
|
|
4612
|
+
Preorder: "preorder",
|
|
4613
|
+
SoldOut: "soldout"
|
|
4614
|
+
});
|
|
4615
|
+
/** Вид согласия, которое принимает покупатель. */
|
|
4616
|
+
const ShopConsentKind = Object.freeze({
|
|
4617
|
+
Offer: "offer",
|
|
4618
|
+
PersonalData: "personal_data",
|
|
4619
|
+
Cookie: "cookie"
|
|
4620
|
+
});
|
|
4621
|
+
/** Состояние заказа. */
|
|
4622
|
+
const ShopOrderStatus = Object.freeze({
|
|
4623
|
+
New: "new",
|
|
4624
|
+
Paid: "paid",
|
|
4625
|
+
Shipping: "ship",
|
|
4626
|
+
Done: "done",
|
|
4627
|
+
Cancelled: "cancelled",
|
|
4628
|
+
Refunded: "refunded",
|
|
4629
|
+
Expired: "expired"
|
|
4630
|
+
});
|
|
4631
|
+
//#endregion
|
|
4064
4632
|
//#region src/models/status-helpers.ts
|
|
4065
4633
|
const STATUS_WINDOW_DAYS = 90;
|
|
4066
4634
|
/**
|
|
@@ -4082,6 +4650,186 @@ function statusDays(service) {
|
|
|
4082
4650
|
return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
|
|
4083
4651
|
}
|
|
4084
4652
|
//#endregion
|
|
4653
|
+
//#region src/shop/order-access.ts
|
|
4654
|
+
function copySession(session) {
|
|
4655
|
+
return { ...session };
|
|
4656
|
+
}
|
|
4657
|
+
/** Хранилище доступа к заказам в памяти процесса. */
|
|
4658
|
+
var MemoryShopOrderAccessStorage = class {
|
|
4659
|
+
#session;
|
|
4660
|
+
constructor(initial = null) {
|
|
4661
|
+
this.#session = initial ? copySession(initial) : null;
|
|
4662
|
+
}
|
|
4663
|
+
get() {
|
|
4664
|
+
return this.#session ? copySession(this.#session) : null;
|
|
4665
|
+
}
|
|
4666
|
+
set(session) {
|
|
4667
|
+
this.#session = copySession(session);
|
|
4668
|
+
}
|
|
4669
|
+
clear() {
|
|
4670
|
+
this.#session = null;
|
|
4671
|
+
}
|
|
4672
|
+
};
|
|
4673
|
+
/** Создаёт хранилище доступа к заказам поверх `KeyValueStore`. */
|
|
4674
|
+
function createShopOrderAccessStorage(store, options = {}) {
|
|
4675
|
+
const key = options.key ?? "shop-order-access";
|
|
4676
|
+
if (typeof key !== "string" || key.length === 0) throw new ItdConfigError("ключ хранилища доступа к заказам должен быть непустой строкой");
|
|
4677
|
+
const backend = createKeyValueStore(store);
|
|
4678
|
+
return {
|
|
4679
|
+
async get() {
|
|
4680
|
+
const session = await backend.get(key);
|
|
4681
|
+
return session ? copySession(session) : null;
|
|
4682
|
+
},
|
|
4683
|
+
set: (session) => backend.set(key, copySession(session)),
|
|
4684
|
+
clear: () => backend.delete(key)
|
|
4685
|
+
};
|
|
4686
|
+
}
|
|
4687
|
+
/** Создаёт независимое хранилище доступа к заказам в памяти. */
|
|
4688
|
+
function createMemoryShopOrderAccessStorage() {
|
|
4689
|
+
return createShopOrderAccessStorage(new MemoryKeyValueStore());
|
|
4690
|
+
}
|
|
4691
|
+
//#endregion
|
|
4692
|
+
//#region src/shop/feature.ts
|
|
4693
|
+
const Operation = Object.freeze({
|
|
4694
|
+
RequestCode: "request-code",
|
|
4695
|
+
VerifyCode: "verify-code",
|
|
4696
|
+
List: "list",
|
|
4697
|
+
Get: "get",
|
|
4698
|
+
Pay: "pay"
|
|
4699
|
+
});
|
|
4700
|
+
function requireEmail(value) {
|
|
4701
|
+
const email = value.trim();
|
|
4702
|
+
if (!email?.includes("@")) throw new ItdConfigError("укажите адрес почты");
|
|
4703
|
+
return email;
|
|
4704
|
+
}
|
|
4705
|
+
/** Создаёт подключаемый модуль для доступа к заказам по коду из письма. */
|
|
4706
|
+
function createShopFeature(options = {}) {
|
|
4707
|
+
return {
|
|
4708
|
+
name: "shop",
|
|
4709
|
+
buckets: {
|
|
4710
|
+
[Operation.RequestCode]: { limit: 4 },
|
|
4711
|
+
[Operation.VerifyCode]: { limit: 13 },
|
|
4712
|
+
[Operation.List]: { limit: 150 },
|
|
4713
|
+
[Operation.Get]: { limit: 150 },
|
|
4714
|
+
[Operation.Pay]: { limit: 13 }
|
|
4715
|
+
},
|
|
4716
|
+
operations: {
|
|
4717
|
+
[Operation.RequestCode]: {
|
|
4718
|
+
method: "POST",
|
|
4719
|
+
retrySafety: RetrySafety.Unsafe,
|
|
4720
|
+
read: voidResult,
|
|
4721
|
+
bucket: Operation.RequestCode
|
|
4722
|
+
},
|
|
4723
|
+
[Operation.VerifyCode]: {
|
|
4724
|
+
method: "POST",
|
|
4725
|
+
retrySafety: RetrySafety.Unsafe,
|
|
4726
|
+
bucket: Operation.VerifyCode
|
|
4727
|
+
},
|
|
4728
|
+
[Operation.List]: {
|
|
4729
|
+
method: "GET",
|
|
4730
|
+
retrySafety: RetrySafety.Safe,
|
|
4731
|
+
bucket: Operation.List
|
|
4732
|
+
},
|
|
4733
|
+
[Operation.Get]: {
|
|
4734
|
+
method: "GET",
|
|
4735
|
+
retrySafety: RetrySafety.Safe,
|
|
4736
|
+
bucket: Operation.Get
|
|
4737
|
+
},
|
|
4738
|
+
[Operation.Pay]: {
|
|
4739
|
+
method: "POST",
|
|
4740
|
+
retrySafety: RetrySafety.Unsafe,
|
|
4741
|
+
bucket: Operation.Pay
|
|
4742
|
+
}
|
|
4743
|
+
},
|
|
4744
|
+
setup(context) {
|
|
4745
|
+
const storage = options.storage ?? createMemoryShopOrderAccessStorage();
|
|
4746
|
+
let pendingEmail = null;
|
|
4747
|
+
const readSession = async () => {
|
|
4748
|
+
const session = await storage.get();
|
|
4749
|
+
if (!session) return null;
|
|
4750
|
+
if (session.expiresAt > context.clock.now()) return { ...session };
|
|
4751
|
+
await storage.clear();
|
|
4752
|
+
return null;
|
|
4753
|
+
};
|
|
4754
|
+
const requireSession = async () => {
|
|
4755
|
+
const session = await readSession();
|
|
4756
|
+
if (!session) throw new ItdConfigError("доступ к заказам отсутствует или истёк; запросите новый код");
|
|
4757
|
+
return session;
|
|
4758
|
+
};
|
|
4759
|
+
const withAccess = async (run) => {
|
|
4760
|
+
const session = await requireSession();
|
|
4761
|
+
try {
|
|
4762
|
+
return await run(session.token);
|
|
4763
|
+
} catch (error) {
|
|
4764
|
+
if (isItdApiError(error) && error.status === 401) await storage.clear();
|
|
4765
|
+
throw error;
|
|
4766
|
+
}
|
|
4767
|
+
};
|
|
4768
|
+
const request = (operation, token, requestOptions) => context.request(operation, {
|
|
4769
|
+
...requestOptions,
|
|
4770
|
+
headers: {
|
|
4771
|
+
...requestOptions.headers,
|
|
4772
|
+
"X-Order-Token": token
|
|
4773
|
+
},
|
|
4774
|
+
skipAuth: true,
|
|
4775
|
+
raw: true
|
|
4776
|
+
});
|
|
4777
|
+
return { api: Object.freeze({
|
|
4778
|
+
async requestCode(email, requestOptions = {}) {
|
|
4779
|
+
const normalized = requireEmail(email);
|
|
4780
|
+
await context.request(Operation.RequestCode, {
|
|
4781
|
+
path: "/api/v1/shop/orders/lookup/request",
|
|
4782
|
+
body: { email: normalized },
|
|
4783
|
+
skipAuth: true,
|
|
4784
|
+
raw: true,
|
|
4785
|
+
...requestOptions
|
|
4786
|
+
});
|
|
4787
|
+
pendingEmail = normalized;
|
|
4788
|
+
},
|
|
4789
|
+
async verifyCode(code, requestOptions = {}) {
|
|
4790
|
+
if (!pendingEmail) throw new ItdConfigError("сначала запросите код для адреса почты");
|
|
4791
|
+
const verified = await context.request(Operation.VerifyCode, {
|
|
4792
|
+
path: "/api/v1/shop/orders/lookup/verify",
|
|
4793
|
+
body: {
|
|
4794
|
+
email: pendingEmail,
|
|
4795
|
+
code
|
|
4796
|
+
},
|
|
4797
|
+
skipAuth: true,
|
|
4798
|
+
raw: true,
|
|
4799
|
+
...requestOptions
|
|
4800
|
+
});
|
|
4801
|
+
const session = {
|
|
4802
|
+
email: pendingEmail,
|
|
4803
|
+
token: verified.token,
|
|
4804
|
+
expiresAt: context.clock.now() + verified.expiresInSec * 1e3
|
|
4805
|
+
};
|
|
4806
|
+
await storage.set(session);
|
|
4807
|
+
pendingEmail = null;
|
|
4808
|
+
return { ...session };
|
|
4809
|
+
},
|
|
4810
|
+
session: readSession,
|
|
4811
|
+
async clear() {
|
|
4812
|
+
pendingEmail = null;
|
|
4813
|
+
await storage.clear();
|
|
4814
|
+
},
|
|
4815
|
+
list: (requestOptions = {}) => withAccess((token) => request(Operation.List, token, {
|
|
4816
|
+
path: "/api/v1/shop/orders/my",
|
|
4817
|
+
...requestOptions
|
|
4818
|
+
})),
|
|
4819
|
+
get: (orderNumber, requestOptions = {}) => withAccess((token) => request(Operation.Get, token, {
|
|
4820
|
+
path: `/api/v1/shop/orders/${encodePathSegment(orderNumber, "номер заказа")}`,
|
|
4821
|
+
...requestOptions
|
|
4822
|
+
})),
|
|
4823
|
+
pay: (orderNumber, returnUrl, requestOptions = {}) => withAccess((token) => request(Operation.Pay, token, {
|
|
4824
|
+
path: `/api/v1/shop/orders/${encodePathSegment(orderNumber, "номер заказа")}/pay`,
|
|
4825
|
+
body: { returnUrl },
|
|
4826
|
+
...requestOptions
|
|
4827
|
+
}))
|
|
4828
|
+
}) };
|
|
4829
|
+
}
|
|
4830
|
+
};
|
|
4831
|
+
}
|
|
4832
|
+
//#endregion
|
|
4085
4833
|
//#region src/spans/render.ts
|
|
4086
4834
|
/** Формат результата {@link renderSpans}. */
|
|
4087
4835
|
const SpanRenderFormat = Object.freeze({
|
|
@@ -4284,6 +5032,6 @@ function renderSpans(content, spans = [], options = {}) {
|
|
|
4284
5032
|
return result;
|
|
4285
5033
|
}
|
|
4286
5034
|
//#endregion
|
|
4287
|
-
export {
|
|
5035
|
+
export { toDate as A, ALLOWED_MIME_TYPES as C, comment as D, VIDEO_MIME_TYPES as E, Paginator as F, mapPage as I, createFeatureHost as L, NotificationsResource as M, BaseResource as N, isBuilder as O, PaginationMode as P, ExtensibleOperationCatalog as R, parseMarkdown as S, IMAGE_MIME_TYPES as T, post as _, createMemoryShopOrderAccessStorage as a, markup as b, ShopConsentKind as c, ShopProductStatus as d, isMyProfile as f, report as g, createResources as h, MemoryShopOrderAccessStorage as i, utcStampToIso as j, createStatusFeature as k, ShopOrderStatus as l, fromUrl as m, renderSpans as n, createShopOrderAccessStorage as o, fromStream as p, createShopFeature as r, statusDays as s, SpanRenderFormat as t, ShopProductCategory as u, poll as v, AUDIO_MIME_TYPES as w, parseHtml as x, autoSpans as y };
|
|
4288
5036
|
|
|
4289
|
-
//# sourceMappingURL=render-
|
|
5037
|
+
//# sourceMappingURL=render-hiWFh2s4.js.map
|