@andrey4emk/npm-app-back-b24 3.5.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ npm install @andrey4emk/npm-app-back-b24
32
32
  | --------------------------------------------- | ----------------------------- | ---------------------- |
33
33
  | `@andrey4emk/npm-app-back-b24` | всё | да |
34
34
  | `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
35
- | `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `maskUrl` | нет |
35
+ | `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `isPreConnectionError`, `maskUrl` | нет |
36
36
 
37
37
  Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
38
38
 
@@ -146,7 +146,17 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
146
146
  | Попыток | 5 |
147
147
  | Задержка | 500 мс (линейно растущая для refreshAuth) |
148
148
 
149
- Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `error` — в сообщение попадает код ошибки SDK.
149
+ Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
150
+
151
+ **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
152
+
153
+ **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
154
+
155
+ Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
156
+
157
+ Важно: **у SDK есть собственный слой ретраев**, который гейт не отменяет. При настоящем разрыве соединения (`ECONNRESET` и подобные) SDK сходит на портал до 3 раз независимо от нашей защиты. Гейт закрывает конкретный случай — `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` и самим SDK не повторяется.
158
+
159
+ **Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
150
160
 
151
161
  У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
152
162
 
@@ -662,6 +672,20 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
662
672
  // https://portal.bitrix24.ru/rest/1/***/crm.deal.get.json
663
673
  ```
664
674
 
675
+ - **`isPreConnectionError(error)`** — строгая проверка: соединение не состоялось, значит запрос **точно** не был выполнен сервером (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`; код ищется и во вложенных `originalError` / `cause`). Отличие от `isNetworkError()` в том, что таймаут и обрыв соединения тоже сетевые, но при них ответ мог потеряться уже после обработки запроса. Используйте эту проверку там, где повтор небезопасен сам по себе: создание сущностей, отправка сообщений, обмен токена.
676
+
677
+ ```js
678
+ import { isPreConnectionError } from "@andrey4emk/npm-app-back-b24";
679
+
680
+ try {
681
+ await sendSms(phone, text);
682
+ } catch (error) {
683
+ // Повторяем только если сообщение заведомо не ушло
684
+ if (isPreConnectionError(error)) await sendSms(phone, text);
685
+ else throw error;
686
+ }
687
+ ```
688
+
665
689
  - **`isNetworkError(error)`** — проверяет, является ли ошибка сетевой (стоит повторить запрос). Используется внутри `fetchRetry` и retry-обёртки `$b24`. Можно использовать в своём коде для аналогичных проверок.
666
690
 
667
691
  ```js
package/bitrix24/b24.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { B24OAuth, AjaxError, SdkError, Logger } from "@bitrix24/b24jssdk";
2
2
  import type { B24OAuthParams, B24OAuthSecret, AuthData, AjaxResult } from "@bitrix24/b24jssdk";
3
3
  import { logs } from "../logs/logs.ts";
4
- import { isNetworkError } from "../utils/fetchRetry.ts";
4
+ import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
5
5
  import Conf from "conf";
6
6
  import path from "path";
7
7
  import type { Request, Response } from "express";
@@ -37,11 +37,23 @@ const RETRY_DELAY_MS = 500;
37
37
  /** Методы B24OAuth, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
38
38
  const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
39
39
 
40
- /** Коды AxiosError, означающие проблему с сетью */
41
- const AXIOS_NETWORK_CODES = new Set(["ERR_NETWORK", "ECONNABORTED"]);
40
+ /**
41
+ * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
42
+ * и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
43
+ */
44
+ const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
42
45
 
43
- /** Коды ошибок SDK (AjaxError / RefreshTokenError), означающие транспортный сбой */
44
- const SDK_NETWORK_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
46
+ /**
47
+ * REST-методы B24, повтор которых создаёт дубликат сущности или запускает
48
+ * повторное действие: add, create, start, send, uploadfile, import, register.
49
+ *
50
+ * SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
51
+ * внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
52
+ * транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
53
+ * задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи
54
+ * (update, delete, set) повторять безопасно: повторное применение даёт то же состояние.
55
+ */
56
+ const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
45
57
 
46
58
  const confAuthB24 = new Conf({
47
59
  cwd: path.resolve(CONFIG_DIR),
@@ -230,11 +242,19 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
230
242
  return await $b24.auth.refreshAuth();
231
243
  } catch (error) {
232
244
  lastError = error;
233
- if (!isB24NetworkError(error) || attempt === RETRY_COUNT) throw error;
245
+
246
+ // Повторяем только когда соединение заведомо не состоялось.
247
+ // При таймауте или обрыве сервер мог уже провести ротацию: тогда
248
+ // локальный refresh-токен мёртв независимо от наших действий, и повтор
249
+ // не помогает, а лишь маскирует проблему серией одинаковых отказов.
250
+ // Следующая попытка всё равно будет — проактивный таймер повторит
251
+ // через 2 минуты при времени жизни токена около часа
252
+ if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
253
+
234
254
  const msg = error instanceof Error ? error.message : String(error);
235
255
  const code = error instanceof SdkError ? ` [${error.code}]` : "";
236
256
  const delayMs = RETRY_DELAY_MS * attempt;
237
- logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "error");
257
+ logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
238
258
  await delay(delayMs);
239
259
  }
240
260
  }
@@ -321,19 +341,85 @@ function isB24NetworkError(error: unknown): boolean {
321
341
 
322
342
  if (error instanceof SdkError) {
323
343
  const { code, originalError, status } = error;
344
+ const originalCode = (originalError as { code?: string } | undefined)?.code;
324
345
 
325
- if (SDK_NETWORK_CODES.has(code)) return true;
346
+ if (NETWORK_ERROR_CODES.has(code)) return true;
326
347
  if (originalError && isNetworkError(originalError)) return true;
327
- if (AXIOS_NETWORK_CODES.has((originalError as any)?.code)) return true;
348
+ if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
328
349
  if (status === 0) return true;
329
350
  }
330
351
 
331
352
  return isNetworkError(error);
332
353
  }
333
354
 
355
+ /**
356
+ * Достаёт имя REST-метода из одной команды batch.
357
+ * Возвращает null, если форма команды не распознана.
358
+ */
359
+ function extractBatchCommandMethod(cmd: unknown): string | null {
360
+ // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
361
+ if (Array.isArray(cmd)) {
362
+ return typeof cmd[0] === "string" ? cmd[0] : null;
363
+ }
364
+
365
+ // Объект { method, params }
366
+ if (cmd && typeof cmd === "object") {
367
+ const method = (cmd as { method?: unknown }).method;
368
+ return typeof method === "string" ? method : null;
369
+ }
370
+
371
+ // Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
372
+ // (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
373
+ // если потребитель остался на ней со старой версии
374
+ if (typeof cmd === "string") {
375
+ return cmd;
376
+ }
377
+
378
+ return null;
379
+ }
380
+
381
+ /**
382
+ * Возвращает причину, по которой вызов запрещено повторять, либо null.
383
+ *
384
+ * Для callBatch действует правило fail-closed: если хоть одну команду разобрать
385
+ * не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
386
+ * дешевле — потребитель получит ошибку вместо тихого дубликата.
387
+ */
388
+ function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
389
+ const first = args[0];
390
+
391
+ // callMethod(method, params) и callListMethod(method, params, ...)
392
+ if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
393
+ if (typeof first !== "string") return null;
394
+ const method = first.trim();
395
+ return NON_IDEMPOTENT_METHOD_RE.test(method) ? method : null;
396
+ }
397
+
398
+ if (sdkMethod !== "callBatch") return null;
399
+
400
+ // callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
401
+ if (!first || typeof first !== "object") {
402
+ return "аргументы batch не разобраны";
403
+ }
404
+
405
+ const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
406
+ const blocking: string[] = [];
407
+
408
+ for (const cmd of commands) {
409
+ const method = extractBatchCommandMethod(cmd);
410
+
411
+ if (method === null) return "команда batch не разобрана";
412
+ if (NON_IDEMPOTENT_METHOD_RE.test(method.trim())) blocking.push(method.trim());
413
+ }
414
+
415
+ return blocking.length > 0 ? blocking.join(", ") : null;
416
+ }
417
+
334
418
  /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
335
419
  function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
336
420
  return (async (...args: any[]) => {
421
+ // Состав вызова между попытками не меняется — разбираем один раз
422
+ const blockReason = getRetryBlockReason(methodName, args);
337
423
  let lastError: unknown;
338
424
 
339
425
  for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
@@ -342,13 +428,33 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
342
428
  } catch (error: unknown) {
343
429
  lastError = error;
344
430
 
345
- if (!isB24NetworkError(error) || attempt === RETRY_COUNT) {
346
- throw error;
347
- }
431
+ // Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
432
+ // вызывающему коду как есть, он решает, что с ней делать
433
+ if (!isB24NetworkError(error)) throw error;
348
434
 
349
435
  const msg = error instanceof Error ? error.message : String(error);
350
436
  const code = error instanceof SdkError ? ` [${error.code}]` : "";
351
- logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "error");
437
+
438
+ // Окончательные отказы логируем на error: собственные модули пакета
439
+ // (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
440
+ // но не пишут в лог — без этих строк сбой $b24 не виден нигде
441
+
442
+ if (attempt === RETRY_COUNT) {
443
+ logs.add(`$b24.${methodName}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
444
+ throw error;
445
+ }
446
+
447
+ // Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
448
+ // а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
449
+ // не повторяем — лучше вернуть ошибку, чем создать дубликат
450
+ if (blockReason) {
451
+ logs.add(`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) — ${msg}`, "error");
452
+ throw error;
453
+ }
454
+
455
+ // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
456
+ // иначе один упавший вызов дал бы пять сообщений
457
+ logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
352
458
 
353
459
  await delay(RETRY_DELAY_MS);
354
460
  }
@@ -435,5 +541,19 @@ export async function reinitializeB24(): Promise<SaveResult> {
435
541
 
436
542
  if (_b24Raw) {
437
543
  setupRefreshCallback(_b24Raw);
438
- refreshAndSaveTokens().then(() => startProactiveRefresh());
544
+
545
+ // Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
546
+ // запросом к oauth.bitrix.info. try/finally обязателен — если обновление всё же
547
+ // бросит (например, битый log.json уронит логгер), без него таймер не стартует
548
+ // никогда и процесс проживёт без обновления токена до перезапуска
549
+ void (async () => {
550
+ try {
551
+ await refreshAndSaveTokens();
552
+ } catch (error: unknown) {
553
+ const msg = error instanceof Error ? error.message : String(error);
554
+ logs.add(`Начальное обновление токенов упало: ${msg}`, "error");
555
+ } finally {
556
+ startProactiveRefresh();
557
+ }
558
+ })();
439
559
  }
@@ -62,6 +62,21 @@ interface OfflineEventsResponse {
62
62
 
63
63
  const SYSTEM_USER_ID = "138"; // ID системного пользователя для фильтрации
64
64
 
65
+ // ==================== Утилиты ====================
66
+
67
+ /**
68
+ * Приводит MESSAGES из события к массиву.
69
+ *
70
+ * B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
71
+ * сериализует разреженный массив), поэтому опираться на одну форму нельзя.
72
+ * Неизвестная форма даёт пустой массив, а не исключение.
73
+ */
74
+ function normalizeMessages(messages: unknown): any[] {
75
+ if (Array.isArray(messages)) return messages;
76
+ if (messages && typeof messages === "object") return Object.values(messages);
77
+ return [];
78
+ }
79
+
65
80
  // ==================== Класс ====================
66
81
 
67
82
  /**
@@ -154,18 +169,28 @@ export class Event {
154
169
 
155
170
  if (allEvents.length > 0) {
156
171
  data.processId = arrOfflineEvents.process_id;
157
- data.message = allEvents.map((event) => {
158
- const messageData = event.EVENT_DATA.MESSAGES[0];
159
-
160
- return {
161
- connectorId: event.EVENT_DATA.CONNECTOR,
162
- lineId: event.EVENT_DATA.LINE,
163
- chatId: messageData.chat.id,
164
- text: messageData.message.text || null,
165
- file: this.convertB24FilesToTelegramFormat(messageData.message.files),
166
- attachments: messageData.message.attachments || null,
167
- im: messageData.im || null,
168
- };
172
+ // Событие может принести несколько сообщений. Раньше бралось только
173
+ // первое, а остальные терялись безвозвратно — событие после обработки
174
+ // удаляется из очереди B24.
175
+ //
176
+ // Разбор намеренно устойчив к форме и к битым элементам: исключение
177
+ // здесь означало бы, что get() вернёт ошибку, потребитель не вызовет
178
+ // clear(), и те же события придут следующим опросом — очередь встала бы
179
+ // навсегда на одном кривом сообщении
180
+ data.message = allEvents.flatMap((event) => {
181
+ const eventData = event.EVENT_DATA ?? {};
182
+
183
+ return normalizeMessages(eventData.MESSAGES)
184
+ .filter((messageData) => messageData?.chat && messageData?.message)
185
+ .map((messageData) => ({
186
+ connectorId: eventData.CONNECTOR,
187
+ lineId: eventData.LINE,
188
+ chatId: messageData.chat.id,
189
+ text: messageData.message.text || null,
190
+ file: this.convertB24FilesToTelegramFormat(messageData.message.files),
191
+ attachments: messageData.message.attachments || null,
192
+ im: messageData.im || null,
193
+ }));
169
194
  });
170
195
  }
171
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import type { B24OAuth } from "@bitrix24/b24jssdk";
2
2
  import { getResultData } from "../bitrix24/b24.ts";
3
+ import { fetchRetry } from "../utils/fetchRetry.ts";
3
4
 
4
5
  // ==================== Типы ====================
5
6
 
@@ -150,7 +151,10 @@ export class Smsgold {
150
151
 
151
152
  private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
152
153
  try {
153
- const response = await fetch(fileUrl);
154
+ // Скачивание файла идемпотентно — повтор при сетевом сбое безопасен.
155
+ // Отправка SMS (fetch выше по коду) намеренно остаётся без retry:
156
+ // при потерянном ответе повтор отправил бы сообщение второй раз
157
+ const response = await fetchRetry(fileUrl);
154
158
  if (!response.ok) {
155
159
  throw new Error(`HTTP error! status: ${response.status}`);
156
160
  }
@@ -32,6 +32,35 @@ export function isNetworkError(error: unknown): boolean {
32
32
  return false;
33
33
  }
34
34
 
35
+ /**
36
+ * Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
37
+ * хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
38
+ */
39
+ const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "EAI_NODATA", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN"];
40
+
41
+ /**
42
+ * Проверяет, что соединение не состоялось — запрос точно не был выполнен сервером.
43
+ *
44
+ * Отличается от `isNetworkError()` строгостью: таймаут и обрыв соединения тоже сетевые,
45
+ * но при них ответ мог потеряться уже после того, как сервер обработал запрос.
46
+ * Используйте эту проверку вместо `isNetworkError()` там, где повтор небезопасен сам
47
+ * по себе: обмен refresh-токена, создание сущностей, отправка сообщений.
48
+ *
49
+ * @param error — пойманная ошибка
50
+ * @returns true, если повторить запрос заведомо безопасно
51
+ */
52
+ export function isPreConnectionError(error: unknown): boolean {
53
+ if (!error || typeof error !== "object") return false;
54
+
55
+ const codes: unknown[] = [
56
+ (error as { code?: unknown }).code,
57
+ (error as { originalError?: { code?: unknown } }).originalError?.code,
58
+ (error as { cause?: { code?: unknown } }).cause?.code,
59
+ ];
60
+
61
+ return codes.some((code) => typeof code === "string" && PRE_CONNECTION_ERROR_CODES.includes(code));
62
+ }
63
+
35
64
  /**
36
65
  * Маскирует секреты в URL перед записью в лог.
37
66
  *
@@ -50,8 +79,9 @@ export function maskUrl(url: string | URL | Request): string {
50
79
  raw
51
80
  // Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
52
81
  .replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
53
- // Токены в query-параметрах
54
- .replace(/([?&](?:auth|access_token|refresh_token|token)=)[^&#]+/gi, "$1***")
82
+ // Токены и подписи в query-параметрах. signature и sessid нужны отдельно:
83
+ // ссылка на файл B24-диска несёт секрет именно в signature
84
+ .replace(/([?&](?:auth|access_token|refresh_token|token|signature|sessid)=)[^&#]+/gi, "$1***")
55
85
  );
56
86
  }
57
87