@andrey4emk/npm-app-back-b24 3.8.2 → 4.0.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.
@@ -0,0 +1,371 @@
1
+ import { Result } from "@bitrix24/b24jssdk";
2
+ import type { AjaxResult, TypeCallParams, B24OAuth } from "@bitrix24/b24jssdk";
3
+ import { logs } from "../../logs/logs.ts";
4
+ import { getRetryBlockReason, runWithRetry } from "./retry.ts";
5
+ import type { FacadeMethods, BatchCalls, V2Envelope } from "./types.ts";
6
+
7
+ // ==================== Константы ====================
8
+
9
+ /** Потолок числа страниц в callListMethod — страховка от бесконечной пагинации */
10
+ export const LIST_MAX_PAGES = 1000;
11
+
12
+ /**
13
+ * Потолок числа страниц keyset-цикла в fetchListMethod — страховка от зацикливания,
14
+ * а не лимит выборки.
15
+ *
16
+ * Своя константа, не общая LIST_MAX_PAGES: у callListMethod потолок продиктован ростом
17
+ * массива в памяти, он копит весь список. fetchListMethod — генератор, он существует
18
+ * ровно для выборок, которые в память не помещаются, и потолок в 1000 страниц оборвал бы
19
+ * законную выборку на крупном портале. Миллион записей недостижим для законного вызова
20
+ * и достижим для зацикленного за секунды.
21
+ */
22
+ export const FETCH_LIST_MAX_PAGES = 20000;
23
+
24
+ // ==================== Разбор ответа SDK ====================
25
+
26
+ /**
27
+ * Достаёт result из ответа SDK.
28
+ *
29
+ * SDK при «мягких» ошибках (ERROR_ENTITY_NOT_FOUND, BITRIX_REST_V3_EXCEPTION_*)
30
+ * не бросает исключение, а возвращает AjaxResult с ошибкой.
31
+ * Функция превращает такой ответ в понятную ошибку вместо падения на undefined.
32
+ * Также отсекает случай, когда запрос успешен, но result пустой (null/undefined).
33
+ *
34
+ * Дефолт дженерика — `any`, и это осознанно: потребители пишут
35
+ * `getResultData(response, "tasks.task.add").task.id` без параметра типа.
36
+ * Замена дефолта на `unknown` сломала бы каждое такое обращение, ничего не дав
37
+ * взамен: узкий тип задаётся на месте вызова — `getResultData<Deal>(response, method)`.
38
+ *
39
+ * @param response — результат $b24.callMethod()
40
+ * @param methodName — имя метода B24, попадёт в текст ошибки
41
+ */
42
+ export function getResultData<T = any>(response: AjaxResult, methodName: string): T {
43
+ if (!response.isSuccess) {
44
+ const messages = response.getErrorMessages().join("; ") || "неизвестная ошибка";
45
+ throw new Error(`${methodName}: Bitrix24 вернул ошибку — ${messages}`);
46
+ }
47
+
48
+ const data = response.getData();
49
+ if (data?.result == null) {
50
+ throw new Error(`${methodName}: Bitrix24 вернул пустой ответ`);
51
+ }
52
+
53
+ return data.result as T;
54
+ }
55
+
56
+ /**
57
+ * Читает сырой конверт ответа из AjaxResult.
58
+ *
59
+ * `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
60
+ * в ней нет намеренно (в restApi:v3 их не существует). С версии 2.2.0 SDK снял
61
+ * `isMore()`/`getTotal()`/`getNext()` с удаления и объявил их постоянными читателями
62
+ * конверта restApi:v2, но числового смещения среди них так и нет: `isMore()` отвечает
63
+ * только «есть ли ещё», а `getNext(http)` сам делает следующий запрос, мимо нашего
64
+ * прогресса и retry. Поэтому конверт читаем напрямую: `_data` объявлен `protected`,
65
+ * а не приватным полем класса, поэтому в рантайме доступен.
66
+ *
67
+ * Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
68
+ * она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
69
+ */
70
+ export function readV2Envelope(response: AjaxResult, method: string): V2Envelope {
71
+ const envelope = (response as unknown as { _data?: unknown })._data;
72
+
73
+ if (!envelope || typeof envelope !== "object") {
74
+ throw new Error(`${method}: не удалось прочитать конверт ответа Bitrix24 (AjaxResult._data недоступен) — изменился внутренний формат SDK`);
75
+ }
76
+
77
+ return envelope as V2Envelope;
78
+ }
79
+
80
+ // ==================== Фасад поверх actions.v2.* ====================
81
+
82
+ /**
83
+ * Собственные реализации пяти методов, которые SDK 2.x помечает к удалению.
84
+ * Повторяют `AbstractB24` дословно, включая дефолты и порядок аргументов.
85
+ *
86
+ * Позиционные сигнатуры сохранены один в один: на этом держится то, что гейт
87
+ * идемпотентности (`getRetryBlockReason`) видит те же аргументы, что и раньше,
88
+ * — retry оборачивает фасад снаружи, а преобразование в объект опций
89
+ * происходит уже внутри.
90
+ *
91
+ * `any` в сигнатурах ниже (`callMethod<T = any>`, `params?: any`, `AsyncGenerator<any[]>`,
92
+ * `Array<any>`, а также `call.make<any>` внутри `fetchListMethod` — тип страницы
93
+ * задаёт вызывающий) оставлен НАМЕРЕННО и снятию по T-53 не подлежит. Это дефолты дженериков
94
+ * публичной поверхности, дословно повторяющие `AbstractB24`: на них стоит код потребителей
95
+ * (`for await (const chunk of $b24.fetchListMethod(...)) chunk.forEach((x) => x.ID)`).
96
+ * `unknown` в дефолте уронил бы каждое такое место, не дав взамен ничего — узкий тип
97
+ * задаётся параметром типа на месте вызова (`callMethod<Deal>(...)`).
98
+ */
99
+ export function createFacade(target: B24OAuth): FacadeMethods {
100
+ // Геттер actions при каждом обращении проверяет инициализацию экземпляра —
101
+ // читаем его в момент вызова, а не один раз при создании фасада
102
+ const v2 = () => target.actions.v2;
103
+
104
+ const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
105
+ const merged: TypeCallParams = { ...params };
106
+
107
+ // Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod.
108
+ // typeof, а не только Number.isInteger: последний ничего не сужает, и при
109
+ // exactOptionalPropertyTypes у потребителя присваивание number | undefined не проходит
110
+ if (!("start" in merged && Number.isInteger(merged.start)) && typeof start === "number" && Number.isInteger(start)) {
111
+ merged.start = start;
112
+ }
113
+
114
+ return v2().call.make<T>({ method, params: merged });
115
+ };
116
+
117
+ /**
118
+ * Собственный офсетный цикл, а не `actions.v2.callList.make`.
119
+ *
120
+ * У `callList.make` keyset-пагинация: он шлёт `start: -1`, навязывает
121
+ * `order: { ID: 'ASC' }` и добавляет в фильтр `>ID`. Пользовательский `order`
122
+ * при этом молча игнорируется, а колбэка `progress` там нет вовсе. Делегирование
123
+ * туда изменило бы поведение постраничных выборок у потребителей.
124
+ */
125
+ const callListMethod = async (
126
+ method: string,
127
+ params?: object,
128
+ progress?: null | ((progress: number) => void),
129
+ customKeyForResult?: string | null
130
+ ): Promise<Result> => {
131
+ const result = new Result();
132
+ const onProgress = typeof progress === "function" ? progress : null;
133
+
134
+ onProgress?.(0);
135
+
136
+ const list: unknown[] = [];
137
+ let start = 0;
138
+ let completed = false;
139
+
140
+ for (let page = 1; page <= LIST_MAX_PAGES; page++) {
141
+ const response = await v2().call.make({ method, params: { ...params, start } });
142
+
143
+ // SDK в своём callListMethod этой проверки не делает и падает TypeError
144
+ // на getData().result, когда портал вернул «мягкую» ошибку
145
+ if (!response.isSuccess) {
146
+ throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
147
+ }
148
+
149
+ // Читаем конверт целиком: getData() режет ответ до { result, time },
150
+ // а пагинация держится на next, которого там нет
151
+ const envelope = readV2Envelope(response, method);
152
+ const payload = envelope.result;
153
+ const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
154
+
155
+ if (!Array.isArray(chunk)) {
156
+ throw new Error(`${method}: ответ не является списком`);
157
+ }
158
+
159
+ for (const item of chunk) {
160
+ list.push(item);
161
+ }
162
+
163
+ // Единственное правило остановки — отсутствие next в конверте. Считать
164
+ // смещение самим (start += chunk.length) нельзя: портал ставит next = start + 50
165
+ // независимо от того, сколько строк реально отдал, и на странице, укороченной
166
+ // правами доступа или фильтром, собственный счётчик отстаёт — перекрытие
167
+ // читается второй раз и записи дублируются. Метод, который игнорирует start
168
+ // и отдаёт весь список одной страницей, next не присылает и останавливается здесь же.
169
+ if (!Number.isInteger(envelope.next)) {
170
+ completed = true;
171
+ break;
172
+ }
173
+
174
+ const nextStart = Number(envelope.next);
175
+
176
+ // next обязан расти. Портал, вернувший прежнее или меньшее смещение,
177
+ // иначе гонял бы одну и ту же страницу до потолка, раздувая список
178
+ if (nextStart <= start) {
179
+ throw new Error(`${method}: портал вернул непродвигающийся next (${nextStart}) при start ${start} — пагинация зациклилась`);
180
+ }
181
+
182
+ start = nextStart;
183
+
184
+ if (onProgress) {
185
+ const total = Number(envelope.total) || 0;
186
+ onProgress(total > 0 ? Math.round((100 * list.length) / total) : 100);
187
+ }
188
+ }
189
+
190
+ // Обрезанный список, отданный как успешный, — худший исход: потребитель
191
+ // примет неполную выборку за полную. Поэтому бросаем, а не логируем:
192
+ // addError() на базовом Result бесполезен — getData() отдаёт данные
193
+ // независимо от наличия ошибок, и существующие потребители её не увидят
194
+ if (!completed) {
195
+ throw new Error(`${method}: достигнут потолок в ${LIST_MAX_PAGES} страниц, портал продолжает отдавать next — выборка прервана как неполная`);
196
+ }
197
+
198
+ onProgress?.(100);
199
+ result.setData(list);
200
+ return result;
201
+ };
202
+
203
+ /**
204
+ * Собственный keyset-цикл, а не `actions.v2.fetchList.make`.
205
+ *
206
+ * Делегирование не давало повторить страницу: генератор нельзя обернуть
207
+ * `withRetry` снаружи (обёртка в async-функцию ломает `for await`), поэтому
208
+ * одиночный сетевой сбой на двенадцатой странице обрывал всю выборку, а уже
209
+ * прочитанные одиннадцать потребитель либо терял, либо принимал за полную.
210
+ * Здесь повторяется страница: курсор двигается только после успешной страницы,
211
+ * поэтому отданные страницы не теряются и не дублируются.
212
+ *
213
+ * Форма запроса взята из `fetch-list.mjs`: `start: -1`, навязанный
214
+ * `order: { <курсор>: "ASC" }`, фильтр `>курсор`. Отступления от SDK — пять,
215
+ * все намеренные:
216
+ * 1. остановка только на пустой странице, а не на короткой: страница, укороченная
217
+ * правами доступа, у портала штатна, и правило SDK молча обрезало бы выборку;
218
+ * 2. нечитаемый курсор — бросок, а не предупреждение и тихая остановка:
219
+ * после пункта 1 он означает гарантированно неполную выборку;
220
+ * 3. потолок страниц (`FETCH_LIST_MAX_PAGES`) — у SDK цикл ничем не ограничен;
221
+ * 4. проверка роста курсора — портал, отбросивший неизвестный ключ фильтра,
222
+ * иначе отдавал бы одну и ту же страницу вечно;
223
+ * 5. пустая строка в `customKeyForResult` трактуется как «ключ не передан».
224
+ *
225
+ * Плюс диагностика: ошибки называют метод B24 вместо `TypeError` из недр SDK
226
+ * и `SdkError` с кодом SDK.
227
+ *
228
+ * Ограничение: один и тот же ключ идёт и на чтение курсора, и в `order`/`filter`.
229
+ * Методы, у которых имя поля в ответе отличается от сортируемого (`tasks.task.list`:
230
+ * ответ `id`, фильтр `ID`), этим методом не выбираются — для них `callListMethod`
231
+ * или `actions.v2.fetchList.make` с `cursorIdKey`.
232
+ */
233
+ async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
234
+ // Отдельного cursorIdKey, как в опциях SDK, у нас нет и не будет: позиционная
235
+ // сигнатура deprecated-метода его не содержала, а добавление пятого аргумента —
236
+ // расширение публичного API. У SDK он по умолчанию тоже равен idKey
237
+ const cursorKey = idKey ?? "ID";
238
+ const moreKey = `>${cursorKey}`;
239
+ const source: Record<string, unknown> = { ...(params ?? {}) };
240
+
241
+ // Раньше это предупреждение приходило от логгера SDK, теперь пишем сами
242
+ if ("order" in source && source["order"]) {
243
+ logs.add(`${method}: параметр order игнорируется — keyset-пагинация сортирует по «${cursorKey}» ASC, сужать выборку следует через filter`, "warn");
244
+ }
245
+
246
+ const { order: _ignoredOrder, filter: userFilter, ...restParams } = source;
247
+
248
+ // Ключ курсора в пользовательском фильтре перетирается на каждой странице.
249
+ // Молчать здесь нельзя: рядом про order предупреждение есть, и асимметрия
250
+ // читалась бы как «фильтр по курсору пользователю оставлен»
251
+ if (userFilter && typeof userFilter === "object" && moreKey in (userFilter as Record<string, unknown>)) {
252
+ logs.add(`${method}: ключ «${moreKey}» в фильтре перетирается курсором пагинации — сузить выборку по нему нельзя`, "warn");
253
+ }
254
+
255
+ // Курсор: значение, от которого идёт следующая страница. Оно же лежит в фильтре
256
+ // запроса, отдельная переменная нужна для проверки роста
257
+ let cursor = 0;
258
+
259
+ // Фильтр — новый объект: мутировать переданный вызывающим нельзя, SDK этого тоже
260
+ // не делает, а курсор в этом объекте переписывается на каждой странице
261
+ const requestParams: Record<string, unknown> & { filter: Record<string, unknown> } = {
262
+ ...restParams,
263
+ order: { [cursorKey]: "ASC" },
264
+ filter: { ...((userFilter as Record<string, unknown> | undefined) ?? {}), [moreKey]: cursor },
265
+ start: -1,
266
+ };
267
+
268
+ // Список — чтение и дубликатов не создаёт, но гейт пусть стоит: если потребитель
269
+ // передаст создающий метод, повтор должен блокироваться, как везде.
270
+ // Состав вызова между страницами не меняется, поэтому считаем один раз
271
+ const blockReason = getRetryBlockReason("callMethod", [method]);
272
+
273
+ // Страховка от вечного цикла: портал, отбросивший неизвестный ключ фильтра,
274
+ // отдаёт одну и ту же страницу без ошибки и без строки в логе
275
+ let completed = false;
276
+
277
+ for (let page = 1; page <= FETCH_LIST_MAX_PAGES; page++) {
278
+ // Замыкание читает requestParams в момент вызова: объект один и тот же,
279
+ // курсор в нём двигается только после успешной страницы — поэтому повтор
280
+ // попадает ровно в ту же страницу, а не в следующую
281
+ const response = await runWithRetry(
282
+ () => v2().call.make<any>({ method, params: requestParams as TypeCallParams }),
283
+ "$b24.fetchListMethod",
284
+ blockReason
285
+ );
286
+
287
+ // Формулировка дословно как в callListMethod — один стиль ошибок на оба
288
+ // списочных метода. Сетевой такая ошибка не считается, повтора не будет:
289
+ // «мягкая» ошибка портала от повтора не исправится
290
+ if (!response.isSuccess) {
291
+ throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
292
+ }
293
+
294
+ const data = response.getData();
295
+ if (!data) {
296
+ completed = true;
297
+ break;
298
+ }
299
+
300
+ const payload = data.result;
301
+ const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
302
+
303
+ // Отступление от SDK намеренное и такое же, как в callListMethod:
304
+ // там на этом месте падал TypeError из недр библиотеки
305
+ if (!Array.isArray(chunk)) {
306
+ throw new Error(`${method}: ответ не является списком`);
307
+ }
308
+
309
+ // Единственное правило остановки — пустая страница. SDK на этом месте
310
+ // останавливается ещё и на короткой (меньше 50 строк), но у портала
311
+ // страница, укороченная правами доступа, штатна — из-за неё в callListMethod
312
+ // запрещено считать смещение самим. Цена нового правила — один лишний
313
+ // запрос на всю выборку, всегда пустой; цена старого — молча отданные
314
+ // сорок семь записей вместо нескольких тысяч
315
+ if (chunk.length === 0) {
316
+ completed = true;
317
+ break;
318
+ }
319
+
320
+ yield chunk;
321
+
322
+ // noUncheckedIndexedAccess: индексация массива даёт T | undefined
323
+ const lastItem = chunk[chunk.length - 1] as Record<string, unknown> | undefined;
324
+ const cursorValue = Number.parseInt(String(lastItem?.[cursorKey]), 10);
325
+
326
+ // Страница непустая, а продолжить её нечем: остановиться тихо — значит
327
+ // отдать неполную выборку как полную. Бросаем по тому же правилу,
328
+ // что и callListMethod
329
+ if (!Number.isFinite(cursorValue)) {
330
+ throw new Error(`${method}: в строках ответа нет числового идентификатора по ключу «${cursorKey}» — выборка прервана как неполная`);
331
+ }
332
+
333
+ // Курсор обязан расти. Портал, отбросивший неизвестный ключ фильтра,
334
+ // иначе гонял бы одну и ту же страницу до потолка
335
+ if (cursorValue <= cursor) {
336
+ throw new Error(`${method}: портал вернул непродвигающийся курсор (${cursorValue}) при предыдущем ${cursor} — пагинация зациклилась`);
337
+ }
338
+
339
+ cursor = cursorValue;
340
+ requestParams.filter[moreKey] = cursor;
341
+ }
342
+
343
+ // Обоснование броска то же, что у потолка в callListMethod: обрезанная выборка,
344
+ // отданная как полная, — худший исход. Тихая остановка здесь неотличима
345
+ // от нормального завершения `for await`
346
+ if (!completed) {
347
+ throw new Error(`${method}: достигнут потолок в ${FETCH_LIST_MAX_PAGES} страниц, портал продолжает отдавать данные — выборка прервана как неполная`);
348
+ }
349
+ }
350
+
351
+ const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result> => {
352
+ // Дефолты подставляем мы: batch.make своих не имеет, он просто
353
+ // расширяет переданный объект опций версией API
354
+ return v2().batch.make({
355
+ calls: calls as BatchCalls,
356
+ options: {
357
+ isHaltOnError: isHaltOnError ?? true,
358
+ returnAjaxResult: returnAjaxResult ?? false,
359
+ },
360
+ });
361
+ };
362
+
363
+ const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result> => {
364
+ // isHaltOnError передаём как есть, без ?? true — дословно по AbstractB24.
365
+ // returnAjaxResult не передаём: batchByChunk.make жёстко ставит false сам,
366
+ // а его тип опций этот ключ не принимает
367
+ return v2().batchByChunk.make({ calls, options: { isHaltOnError } });
368
+ };
369
+
370
+ return { callMethod, callListMethod, fetchListMethod, callBatch, callBatchByChunk } satisfies FacadeMethods;
371
+ }
@@ -0,0 +1,186 @@
1
+ import { B24OAuth, Logger, LogLevel } from "@bitrix24/b24jssdk";
2
+ import type { B24OAuthParams, B24OAuthSecret, AuthData, Handler, LogRecord, Formatter, RestrictionParams } from "@bitrix24/b24jssdk";
3
+ import { logs } from "../../logs/logs.ts";
4
+ import { APP_ENV, CLIENT_ID, CLIENT_SECRET, confAuthB24, cleanDomain } from "./config.ts";
5
+
6
+ // ==================== Константы ====================
7
+
8
+ /**
9
+ * Параметры ограничителя SDK. Передаются третьим аргументом конструктора B24OAuth
10
+ * и сливаются там с дефолтами `ParamsFactory.getDefault()`.
11
+ *
12
+ * Единый инвариант: **транспортные сбои SDK не повторяет вообще, повторяет только
13
+ * наш слой**, где работает гейт NON_IDEMPOTENT_METHOD_RE. У SDK нет понятия
14
+ * «создающий вызов»: оборванный crm.deal.add он повторил бы трижды и создал три сделки.
15
+ *
16
+ * Одного `retryOnNetworkError: false` для этого мало. Он добавляет в жёсткий список
17
+ * ровно два кода — NETWORK_ERROR и REQUEST_TIMEOUT, — а SDK конвертирует в них только
18
+ * `ERR_NETWORK` и `ECONNABORTED`. Остальные транспортные ошибки Node доезжают до
19
+ * лимитера под своим кодом (`ECONNRESET` при обрыве сокета, `ERR_BAD_RESPONSE` при
20
+ * 502/504 от шлюза), не находятся ни в жёстком, ни в мягком списке и потому считаются
21
+ * временными — то есть повторяются с backoff. Замерено на локальном сервере, рвущем
22
+ * соединение после приёма запроса: без hardErrorCodes портал выполняет crm.deal.add
23
+ * три раза, с ними — один. На SDK 2.0.0 был один: там транспорт маскировался в
24
+ * JSSDK_UNKNOWN_ERROR, а тот во встроенном жёстком списке.
25
+ *
26
+ * `ERR_NETWORK` и `ECONNABORTED` в списке не нужны: до лимитера они не доживают.
27
+ *
28
+ * Плата за список: `ECONNREFUSED`/`ENOTFOUND`/`EAI_AGAIN` доказывают, что запрос не ушёл,
29
+ * и SDK мог бы повторить их безопасно даже для создающих вызовов. Отказываемся сознательно —
30
+ * ровно так вёл себя 2.0.0, а один понятный инвариант дороже трёх сэкономленных попыток.
31
+ */
32
+ export const SDK_RESTRICTION_PARAMS = {
33
+ retryOnNetworkError: false,
34
+ hardErrorCodes: [
35
+ "ECONNRESET",
36
+ "ECONNREFUSED",
37
+ "ENOTFOUND",
38
+ "ETIMEDOUT",
39
+ "EHOSTUNREACH",
40
+ "ENETUNREACH",
41
+ "EAI_AGAIN",
42
+ "EPIPE",
43
+ "EPROTO",
44
+ "ERR_BAD_RESPONSE",
45
+ ],
46
+ } as const satisfies RestrictionParams;
47
+
48
+ // ==================== Логгер SDK ====================
49
+
50
+ /**
51
+ * Мост из логгера SDK в `logs` пакета.
52
+ *
53
+ * Порог WARNING обязателен: SDK пишет `post/send` и `post/response` на уровне `info`
54
+ * на каждый запрос и `http batch request starting/completed` на `debug` — без фильтра
55
+ * это залило бы лог.
56
+ *
57
+ * `AbstractHandler` объявлен в типах SDK, но в рантайме не экспортируется,
58
+ * поэтому реализуем интерфейс `Handler` обычным классом.
59
+ */
60
+ export class B24SdkLogHandler implements Handler {
61
+ private formatter: Formatter | null = null;
62
+
63
+ isHandling(level: LogLevel): boolean {
64
+ return level >= LogLevel.WARNING;
65
+ }
66
+
67
+ shouldBubble(): boolean {
68
+ return true;
69
+ }
70
+
71
+ setFormatter(formatter: Formatter): void {
72
+ this.formatter = formatter;
73
+ }
74
+
75
+ getFormatter(): Formatter | null {
76
+ return this.formatter;
77
+ }
78
+
79
+ async handle(record: LogRecord): Promise<boolean> {
80
+ // Бросать отсюда нельзя ни при каких обстоятельствах: Logger.log() делает
81
+ // await handle(), но лимитер зовёт логгер без await — исключение стало бы
82
+ // unhandled rejection и на дефолтных настройках Node убило бы процесс.
83
+ // Путь к броску реален: logs.add() читает conf, а тот перечитывает файл
84
+ // на каждом обращении и падает на битом log.json. В catch пишем через
85
+ // console.error, а не через logs — иначе рискуем зациклиться на той же ошибке.
86
+ try {
87
+ const status = Number(record.context?.status);
88
+
89
+ // Лимитер SDK через error() пишет любой 4xx кроме 408/429 как
90
+ // «non-retryable client error». Портал отдаёт 400 на штатные «мягкие»
91
+ // ошибки (несуществующая сущность), которые README описывает как
92
+ // нормальный путь через getResultData(): проверка существования в цикле
93
+ // дала бы строку на итерацию. Такие сообщения — уровень debug.
94
+ //
95
+ // 408 и 429 из правила исключены на будущее, сегодня эта ветка недостижима:
96
+ // status попадает в контекст записи уровня WARNING и выше ровно в одном месте
97
+ // SDK — #logNonRetryableClientError, а он вызывается из-под условия, которое
98
+ // 408 и 429 уже исключает. Сообщения лимитера про 429/503 идут другим путём,
99
+ // без status в контексте, и маппятся в warn независимо от этой строки.
100
+ // Условие оставлено потому, что фейлит в безопасную сторону: начни SDK класть
101
+ // status в такие записи — таймаут и упор в лимит будут видны, а не утонут в debug.
102
+ //
103
+ // Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
104
+ // попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
105
+ // выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
106
+ // повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
107
+ // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
108
+ // окончательный провал вызова логирует retry-слой, ровно один раз.
109
+ const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
110
+ const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
111
+
112
+ // Контекст SDK (requestId, method, code, wait, status) уже прогнан
113
+ // через redactSensitiveParams — токены в него не попадают
114
+ const context = record.context && Object.keys(record.context).length > 0 ? record.context : undefined;
115
+
116
+ logs.add(`SDK B24: ${record.message}`, level, context);
117
+ } catch (error: unknown) {
118
+ const msg = error instanceof Error ? error.message : String(error);
119
+ console.error(`B24SdkLogHandler: не удалось записать сообщение SDK — ${msg}`);
120
+ }
121
+
122
+ return true;
123
+ }
124
+ }
125
+
126
+ // ==================== Создание экземпляра B24OAuth ====================
127
+
128
+ export function createB24Instance(): B24OAuth | null {
129
+ // Проект вообще не использует OAuth: ходит в B24 по входящему вебхуку либо берёт
130
+ // из пакета только logs/fetchRetry. Отсутствие авторизации для него — норма, а не
131
+ // ошибка, поэтому уровень debug: иначе ложная строка попадает в мониторинг.
132
+ // Признак «OAuth задуман» — наличие credentials приложения в .env.
133
+ if (!CLIENT_ID && !CLIENT_SECRET) {
134
+ logs.add("OAuth Bitrix24 не настроен (APP_B24_CLIENT_ID и APP_B24_CLIENT_SECRET не заданы), $b24 = null", "debug");
135
+ return null;
136
+ }
137
+
138
+ if (!CLIENT_ID || !CLIENT_SECRET) {
139
+ logs.add("Не заданы APP_B24_CLIENT_ID или APP_B24_CLIENT_SECRET в .env", "error");
140
+ return null;
141
+ }
142
+
143
+ const store = confAuthB24.store as Record<string, AuthData>;
144
+ const authConfig = store[APP_ENV];
145
+
146
+ if (!authConfig?.domain || !authConfig?.access_token || !authConfig?.refresh_token) {
147
+ logs.add("В конфиге authB24 не хватает данных для авторизации. Сохрани токены через клиент и перезагрузи докер", "error");
148
+ return null;
149
+ }
150
+
151
+ const domain = cleanDomain(authConfig.domain);
152
+
153
+ const authParams: B24OAuthParams = {
154
+ applicationToken: "",
155
+ userId: 0,
156
+ memberId: authConfig.member_id,
157
+ accessToken: authConfig.access_token,
158
+ refreshToken: authConfig.refresh_token,
159
+ expires: authConfig.expires,
160
+ expiresIn: authConfig.expires_in || 1800,
161
+ scope: "",
162
+ domain,
163
+ clientEndpoint: `https://${domain}/rest/`,
164
+ serverEndpoint: "https://oauth.bitrix.info/rest/",
165
+ status: "L",
166
+ issuer: "store",
167
+ };
168
+
169
+ const secret: B24OAuthSecret = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET };
170
+
171
+ // Параметры ограничителя задаём третьим аргументом конструктора: SDK сливает их
172
+ // с дефолтами (`{ ...ParamsFactory.getDefault(), ...restrictionParams }` в AbstractHttp)
173
+ // и передаёт обоим http-клиентам, v2 и v3, ещё до того как экземпляр можно использовать.
174
+ // Через setRestrictionManagerParams() было бы окно между созданием и настройкой,
175
+ // а результат вызова к тому же непроверяем: он завершается Promise.allSettled и
176
+ // не отклоняется никогда.
177
+ const b24 = new B24OAuth(authParams, secret, { restrictionParams: SDK_RESTRICTION_PARAMS });
178
+
179
+ // Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
180
+ // callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
181
+ // Дефолтный NullLogger не годится — LoggerFactory.forcedLog пишет мимо логгера
182
+ // прямо в console.warn, но после перехода на фасад этот путь у нас недостижим.
183
+ b24.setLogger(Logger.create("npm-app-back-b24").pushHandler(new B24SdkLogHandler()));
184
+
185
+ return b24;
186
+ }