@andrey4emk/npm-app-back-b24 5.0.0 → 5.1.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 CHANGED
@@ -17,6 +17,7 @@
17
17
  - [Логирование](#логирование)
18
18
  - [fetchRetry и таймауты](#fetchretry-и-таймауты)
19
19
  - [Переменные окружения](#переменные-окружения)
20
+ - [Миграция на 5.1.0](#миграция-на-510)
20
21
  - [Миграция на 5.0.0](#миграция-на-500)
21
22
  - [Миграция на 4.0.0](#миграция-на-400)
22
23
  - [Скрипты](#скрипты)
@@ -239,7 +240,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
239
240
 
240
241
  Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
241
242
 
242
- **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
243
+ **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в Телеграм-группу через ретранслятор вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
243
244
 
244
245
  **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. Обрыв приходит и когда соединение не состоялось, и когда оно случилось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
245
246
 
@@ -475,7 +476,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
475
476
  });
476
477
  ```
477
478
 
478
- **Отказ портала виден в логе.** Если портал отказал в создании задачи (нет прав, указанной группы не существует), пишется одна строка уровня `error` с заголовком задачи, номером группы и текстом ошибки. Сетевые сбои этой строки не дают: их уже записал слой повторов, и вторая ушла бы дублем в чат B24.
479
+ **Отказ портала виден в логе.** Если портал отказал в создании задачи (нет прав, указанной группы не существует), пишется одна строка уровня `error` с заголовком задачи, номером группы и текстом ошибки. Сетевые сбои этой строки не дают: их уже записал слой повторов, и вторая ушла бы дублем в Телеграм-группу через ретранслятор.
479
480
 
480
481
  **Группа задачи по умолчанию.** Если `groupId` не передан, группа берётся из переменной окружения `B24_ERROR_TASK_GROUP_ID`. Явно переданное значение всегда важнее переменной: `null` и `0` означают осознанное «без группы» и дефолт не подхватывают. Переменная читается один раз при загрузке модуля — правка `process.env` в рантайме дефолт не двигает, нужен перезапуск процесса. Если переменная не задана или непригодна (не целое больше нуля), поведение прежнее — задача создаётся без группы, плюс одна строка уровня `warn` за процесс. Строка пишется при первом вызове, которому дефолт реально понадобился: у потребителя, который всегда передаёт группу сам, в логе не появится ничего.
481
482
 
@@ -621,7 +622,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
621
622
 
622
623
  Конструктор сразу разрешает тип мессенджера для `whatsApp` и `telegram` — берёт `messenger[0].type` и держит в полях, вместо того чтобы индексировать массив при каждой отправке. Если конфига нет или список `messenger` пуст, тип остаётся неразрешённым, и **методы этого мессенджера возвращают** `{ error: true, message: "ChatApp: не разрешён тип мессенджера whatsApp (нет конфига или пуст messenger)", data: null }` — вместо запроса на URL с `undefined` в пути. Конструктор при этом **не бросает**: экземпляр обычно создаётся на уровне модуля, и бросок уронил бы загрузку всего модуля отправки вместе с соседними каналами.
623
624
 
624
- **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в чат B24) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
625
+ **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в Телеграм-группу через ретранслятор) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
625
626
 
626
627
  Отказ локальный: пустой `whatsApp` не мешает `telegram`. Но конфиг всё равно нужен для **обоих** мессенджеров, даже если проект пользуется одним — иначе второй канал молча перестанет работать, и узнать об этом можно будет только по `res.error` в логе.
627
628
 
@@ -726,7 +727,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
726
727
  }
727
728
  ```
728
729
 
729
- - **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в чат B24), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
730
+ - **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в Телеграм-группу через ретранслятор), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
730
731
 
731
732
  ```js
732
733
  const result = await smsClient.sendSms({ phone, message });
@@ -793,7 +794,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
793
794
 
794
795
  - **`Wappi`** — класс для отправки сообщений через Wappi API (WhatsApp, Telegram, Max). Экземпляр нужно создать самостоятельно.
795
796
 
796
- **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` в чат B24 — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
797
+ **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` (она уходит в Телеграм-группу через ретранслятор) — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
797
798
 
798
799
  ```js
799
800
  import { Wappi } from "@andrey4emk/npm-app-back-b24";
@@ -869,11 +870,19 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
869
870
  - Конфигурация хранится в файле `log.json` (через `conf`).
870
871
  - Каждый уровень можно включить/выключить, изменить цвет.
871
872
 
873
+ **Error-логи в Телеграм.** Если заданы обе переменные `LOG_RELAY_URL` и `LOG_RELAY_KEY`, каждый `logs.add(..., "error")` дополнительно уходит в Телеграм-группу логов через ретранслятор `tg_relay` — независимо от того, включён ли `error` в `log.json`. Тема в группе определяется ключом клиента, уровень, время и имя клиента ставит ретранслятор.
874
+
875
+ - Запрос: `POST LOG_RELAY_URL`, заголовок `Authorization: Bearer <LOG_RELAY_KEY>`, тело `{"level":"error","text":"..."}`. Успех — ответ `202`.
876
+ - В текст идёт сообщение и `jsonData` (`JSON.stringify` с отступами; `null` и `undefined` не дописываются). Пустое сообщение заменяется на `(пустое сообщение)`. Длинный текст обрезается до 3800 символов с пометкой `...(обрезано)`. Логгер не бросает исключений ни на каком `message`.
877
+ - **Телефоны маскируются** — только в Телеграме, в консоли данные исходные: `+7 916 123-45-67`, `89161234567`, `8.916.123.45.67`, `+7 916 12 34 567`, `9161234567` → `+7 ••• •••-••-67`. Разделителем между цифрами считаются пробелы, дефисы и тире, точки, `/` и скобки, но не перевод строки. Числа из 6–9 и 12+ цифр (id, суммы, метки времени) и идентификаторы с латинским префиксом (`DEAL_9161234567`) не трогаются. Известные границы: 10-значный городской номер без префикса (`4951234567`) не маскируется — его не отличить от метки времени в секундах; 10-значное число на `9` (например, ИНН) маскируется.
878
+ - Отправка без ожидания, с бюджетом 5 секунд и без повторов. Отказ ретранслятора (не `202`, таймаут, сетевой сбой) пишется одной строкой `console.error` с префиксом `Logs Relay:`; для `401` — «ключ не принят», для `503` — «ретранслятор останавливается». Сетевой сбой печатается только именем ошибки и кодом причины (`TypeError (ECONNREFUSED)`). Адрес и ключ в строку не попадают.
879
+ - Задана только одна из двух переменных, адрес не `http`/`https` или с логином и паролем, ключ с пробелами или управляющими символами — одна строка `console.warn` при старте (без самого значения) и канал выключен.
880
+
872
881
  ### fetchRetry и таймауты
873
882
 
874
883
  - **`fetchRetry(url, options?, retries?, delay?, timeoutMs?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.) и собственный таймаут запроса.
875
884
 
876
- - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
885
+ - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`, `signature`, `sessid`). Так же прячется токен бота Телеграма в пути Bot API: `https://api.telegram.org/bot<id>:<токен>/<метод>` и `/file/bot<id>:<токен>/<путь>` превращаются в `bot<id>:***`. Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
877
886
 
878
887
  ```js
879
888
  import { maskUrl } from "@andrey4emk/npm-app-back-b24";
@@ -1011,6 +1020,8 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
1011
1020
  | `APP_NAME` | Название приложения (используется в описании задач) |
1012
1021
  | `B24_ERROR_TASK_GROUP_ID` | ID группы по умолчанию для задач `errorB24()` (не задана — задача без группы) |
1013
1022
  | `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
1023
+ | `LOG_RELAY_URL` | Адрес `POST /send` ретранслятора error-логов в Телеграм (только парой с `LOG_RELAY_KEY`) |
1024
+ | `LOG_RELAY_KEY` | Ключ клиента ретранслятора; по нему ретранслятор выбирает тему в группе |
1014
1025
  | `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
1015
1026
  | `FETCH_TIMEOUT_QUICK_MS` | Бюджет `FETCH_TIMEOUTS.quick` — токены, мс (по умолчанию `10000`) |
1016
1027
  | `FETCH_TIMEOUT_API_MS` | Бюджет `FETCH_TIMEOUTS.api` — короткое чтение, мс (по умолчанию `20000`) |
@@ -1031,6 +1042,10 @@ APP_NAME=MyApp
1031
1042
  CONFIG_DIR=../config
1032
1043
  FETCH_TIMEOUT_MS=60000
1033
1044
 
1045
+ # Error-логи в Телеграм через ретранслятор (только парой; не заданы — канал выключен)
1046
+ LOG_RELAY_URL=http://tg_relay:8080/send
1047
+ LOG_RELAY_KEY=ключ-клиента
1048
+
1034
1049
  # Группа по умолчанию для задач об ошибках. ID свой у каждого портала —
1035
1050
  # посмотреть в адресе группы на портале. Не задана — задачи создаются без группы
1036
1051
  B24_ERROR_TASK_GROUP_ID=196
@@ -1046,6 +1061,17 @@ CHATAPP_PASS=your-password
1046
1061
  CHATAPP_APP_ID=your-app-id
1047
1062
  ```
1048
1063
 
1064
+ ## Миграция на 5.1.0
1065
+
1066
+ Канал error-логов переехал из чата Bitrix24 в Телеграм-группу через ретранслятор `tg_relay`. Публичный API не менялся: `logs.add(message, level?, jsonData?)` как прежде.
1067
+
1068
+ 1. **Задать `LOG_RELAY_URL` и `LOG_RELAY_KEY`** в `.env` проекта. Ключ выдаётся на ретрансляторе (`npm run issue-key -- <имя> <тема>`), тема в группе привязана к ключу. Задана одна из двух — канал выключен и при старте одна строка `warn`.
1069
+ 2. **Убрать `LOG_B24_WEBHOOK_URL` и `LOG_B24_CHAT_ID`.** Они больше не читаются; пока заданы, при старте пишется одна строка `warn` «канал Bitrix24 снят в 5.1.0».
1070
+ 3. **Контейнер проекта — в сеть `tg-relay`**, иначе адрес ретранслятора не резолвится и каждый error даст строку `Logs Relay: ошибка отправки`.
1071
+ 4. **Глушилки тестов** — гасить `LOG_RELAY_URL`/`LOG_RELAY_KEY` пустой строкой (не `delete`: dotenv вернул бы значение из `.env`). Старые `LOG_B24_*` в глушилках можно оставить — они только убирают строку `warn`.
1072
+ 5. **Телефоны в тексте error-логов маскируются** — только в Телеграме. В консоли и в журнале контейнера данные остаются исходными.
1073
+ 6. **`APP_NAME` логгер больше не читает** — имя проекта в сообщении ставит ретранслятор по ключу. Для `errorB24()` переменная по-прежнему нужна.
1074
+
1049
1075
  ## Миграция на 5.0.0
1050
1076
 
1051
1077
  Выпуск ломающий: пакет переехал на `@bitrix24/b24jssdk` 3.0.0. Вызовы методов фасада (`callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk`) не меняются.
@@ -105,7 +105,7 @@ export class B24SdkLogHandler implements Handler {
105
105
  // попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
106
106
  // выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
107
107
  // повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
108
- // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
108
+ // Уровень error пакета уходит в Телеграм через ретранслятор, поэтому маппим SDK-ERROR в warn:
109
109
  // окончательный провал вызова логирует retry-слой, ровно один раз.
110
110
  const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
111
111
  const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
@@ -456,7 +456,7 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
456
456
  const lifted = blockReason ? ` — соединение не состоялось, ограничение (${blockReason}) снято` : "";
457
457
  const pause = retryDelayFor(error, attempt);
458
458
 
459
- // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
459
+ // Промежуточные попытки — warn: в Телеграм через ретранслятор уходит только уровень error,
460
460
  // иначе один упавший вызов дал бы пять сообщений
461
461
  logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${pause}мс${lifted}`, "warn");
462
462
 
@@ -190,7 +190,7 @@ function refreshAuthWithMutex(): Promise<AuthData> {
190
190
 
191
191
  // Уровень warn, а не error, намеренно: брошенную ниже ошибку поймает
192
192
  // refreshAndSaveTokens и напишет свою строку уровня error, а error уходит
193
- // в чат B24 — иначе на одно залипание пришло бы два сообщения
193
+ // в Телеграм через ретранслятор — иначе на одно залипание пришло бы два сообщения
194
194
  logs.add(
195
195
  `refreshAuth: обмен токена не завершился за ${REFRESH_STUCK_TIMEOUT_MS / 60000} мин — ${mutexPart}, зависший запрос продолжает работу и сохранит токены сам, если ответ придёт`,
196
196
  "warn"
@@ -223,7 +223,7 @@ export async function errorB24(dataTask: ErrorTaskData): Promise<ErrorB24Result>
223
223
  //
224
224
  // Условие обязательно: сетевой отказ retry-слой уже записал сам — строкой
225
225
  // про исчерпание попыток либо про отменённый повтор. Без проверки вторая
226
- // строка ушла бы дублем в чат B24, куда уходит уровень `error`
226
+ // строка ушла бы дублем в Телеграм через ретранслятор, куда уходит уровень `error`
227
227
  if (!isB24NetworkError(error)) {
228
228
  logs.add(`errorB24: задача «${dataTask.title}» не создана (группа ${groupId ?? "не задана"}) — ${message}`, "error");
229
229
  }
@@ -441,7 +441,7 @@ export class Event {
441
441
 
442
442
  // Битой оказалась вся выборка целиком — это уже не одна кривая запись,
443
443
  // а похоже на смену формы ответа портала (переименованное поле). Уровень
444
- // error, потому что строка обязана дойти до чата B24: очередь при этом
444
+ // error, потому что строка обязана дойти до Телеграма: очередь при этом
445
445
  // продолжает вычищаться пакет за пакетом, и локальный лог никто не увидит
446
446
  if (verdicts.length > 1 && brokenVerdicts.length === verdicts.length) {
447
447
  const firstReason = brokenVerdicts.at(0)?.reason ?? "причина не определена";
@@ -492,7 +492,7 @@ export class Event {
492
492
  const message = error instanceof Error ? error.message : String(error);
493
493
 
494
494
  // Уровень warn, а не error: окончательный отказ вызова уже написал
495
- // строку error retry-слой Proxy, дубль ушёл бы в чат B24 второй раз
495
+ // строку error retry-слой Proxy, дубль ушёл бы в Телеграм через ретранслятор второй раз
496
496
  logs.add(
497
497
  `event.offline.clear: не удалось снять с резерва записи, их ${messageIdsToClear.length} — ${message}. Записи придут следующим опросом`,
498
498
  "warn"
package/logs/logs.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "path";
2
2
  import Conf from "conf";
3
3
  import dotEnv from "dotenv";
4
+ import { maskPhones } from "./maskPhones.ts";
4
5
 
5
6
  dotEnv.config();
6
7
 
@@ -19,28 +20,33 @@ interface LogLevelConfig {
19
20
  /** Полная конфигурация логирования */
20
21
  type LogConfig = Record<LogLevel, LogLevelConfig>;
21
22
 
22
- // ==================== Константы B24 Chat ====================
23
+ // ==================== Константы канала в Телеграм ====================
23
24
 
24
- /** ID бота в B24 для отправки error-логов */
25
- const B24_BOT_ID = 66600;
26
-
27
- /** CLIENT_ID бота в B24 */
28
- const B24_CLIENT_ID = "6rf74zyu842h6op1z186b08k7aavqq7z";
29
-
30
- /** Лимит длины сообщения для B24 чата (безопасный, с запасом на BB-обёртку) */
31
- const B24_TEXT_LIMIT = 4000;
25
+ /**
26
+ * Предел длины текста, отправляемого в ретранслятор. Совпадает с `ENTRY_TEXT_LIMIT`
27
+ * ретранслятора (`tg_relay/src/format.ts`): длиннее он обрезал бы запись сам, уже после
28
+ * нашей маскировки и без нашей пометки. Сообщение Телеграма — 4096 символов, остаток
29
+ * ретранслятор оставляет под заголовок записи и строку «пропущено N»
30
+ */
31
+ const RELAY_TEXT_LIMIT = 3800;
32
32
 
33
33
  /**
34
- * Бюджет времени на отправку error-лога в чат B24, мс.
34
+ * Бюджет времени на отправку одного error-лога в ретранслятор, мс.
35
35
  *
36
36
  * Своя константа, а не `FETCH_TIMEOUTS` из `utils/fetchRetry.ts`: тот модуль импортирует
37
37
  * `logs` первой строкой, и обратный импорт дал бы цикл. Дублирование одного числа
38
38
  * дешевле цикла зависимостей.
39
39
  */
40
- const B24_CHAT_TIMEOUT_MS = 5_000;
40
+ const RELAY_TIMEOUT_MS = 5_000;
41
+
42
+ /** Сколько символов тела неуспешного ответа ретранслятора показывать в `console.error` */
43
+ const RELAY_BODY_PREVIEW = 300;
41
44
 
42
- /** Сколько символов тела неуспешного ответа чата показывать в `console.error` */
43
- const B24_CHAT_BODY_PREVIEW = 300;
45
+ /** Пояснения к статусам ответа ретранслятора, которые чаще всего придётся разбирать */
46
+ const RELAY_STATUS_HINTS: Readonly<Record<number, string>> = {
47
+ 401: "ключ LOG_RELAY_KEY не принят",
48
+ 503: "ретранслятор останавливается и не принимает",
49
+ };
44
50
 
45
51
  /**
46
52
  * Ошибка — обрыв по бюджету `AbortSignal.timeout`. Проверка локальная, без импорта
@@ -50,6 +56,41 @@ function isTimeout(error: unknown): boolean {
50
56
  return (error as { name?: unknown } | null)?.name === "TimeoutError";
51
57
  }
52
58
 
59
+ /**
60
+ * Короткое описание ошибки отправки без текста сообщения: `name` и код или имя причины.
61
+ * Текст сообщения сетевой ошибки может содержать адрес запроса, а адрес и ключ канала
62
+ * в консоль не печатаются
63
+ */
64
+ function describeSendError(error: unknown): string {
65
+ const name = error instanceof Error ? error.name : typeof error;
66
+ const cause = error instanceof Error ? (error.cause as { code?: unknown; name?: unknown } | null | undefined) : undefined;
67
+ const causeLabel = cause && typeof cause === "object" ? (cause.code ?? cause.name) : undefined;
68
+ return typeof causeLabel === "string" && causeLabel ? `${name} (${causeLabel})` : name;
69
+ }
70
+
71
+ /** Ключ ретранслятора — только видимые символы ASCII, без пробелов и управляющих */
72
+ const RELAY_KEY_RE = /^[\x21-\x7e]+$/;
73
+
74
+ /**
75
+ * Проверяет адрес ретранслятора: только `http`/`https` и без логина и пароля в адресе.
76
+ * Возвращает причину отказа или `null`. Значение адреса в причину не попадает
77
+ */
78
+ function relayUrlProblem(raw: string): string | null {
79
+ let parsed: URL;
80
+ try {
81
+ parsed = new URL(raw);
82
+ } catch {
83
+ return "LOG_RELAY_URL не разбирается как адрес";
84
+ }
85
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
86
+ return "LOG_RELAY_URL должен начинаться с http:// или https://";
87
+ }
88
+ if (parsed.username || parsed.password) {
89
+ return "LOG_RELAY_URL не должен содержать логин и пароль";
90
+ }
91
+ return null;
92
+ }
93
+
53
94
  // ==================== Настройки ====================
54
95
 
55
96
  const configDir: string = process.env.CONFIG_DIR || "../config";
@@ -93,20 +134,43 @@ class LogsAPI {
93
134
 
94
135
  private readonly resetColor = "\x1b[0m";
95
136
 
96
- /** Webhook URL для отправки error-логов в B24 чат */
97
- private readonly b24WebhookUrl: string | undefined;
137
+ /** Адрес `POST /send` ретранслятора (`LOG_RELAY_URL`); `undefined` — канал выключен */
138
+ private readonly relayUrl: string | undefined;
98
139
 
99
- /** ID чата B24 для отправки error-логов (формат: chatXXXXXX) */
100
- private readonly b24ChatId: string | undefined;
101
-
102
- /** Имя приложения для заголовка сообщения */
103
- private readonly appName: string;
140
+ /** Ключ клиента ретранслятора (`LOG_RELAY_KEY`); `undefined` — канал выключен */
141
+ private readonly relayKey: string | undefined;
104
142
 
105
143
  constructor() {
106
144
  this.initConfig();
107
- this.b24WebhookUrl = process.env.LOG_B24_WEBHOOK_URL;
108
- this.b24ChatId = process.env.LOG_B24_CHAT_ID;
109
- this.appName = process.env.APP_NAME || "Unknown App";
145
+ const url = process.env.LOG_RELAY_URL?.trim() || undefined;
146
+ const key = process.env.LOG_RELAY_KEY?.trim() || undefined;
147
+
148
+ // Канал включается только парой. Половина пары — ошибка настройки: одна строка
149
+ // предупреждения и канал выключен, а не запрос без ключа или в никуда
150
+ // Непригодные значения тоже выключают канал одной строкой. Сами значения в строку
151
+ // не попадают: адрес с паролем или ключ ушли бы в журнал контейнера
152
+ let enabled = false;
153
+ if (Boolean(url) !== Boolean(key)) {
154
+ console.warn("Logs: задан только один из LOG_RELAY_URL/LOG_RELAY_KEY — отправка error-логов в Телеграм выключена");
155
+ } else if (url && key) {
156
+ const problem = relayUrlProblem(url) ?? (RELAY_KEY_RE.test(key) ? null : "LOG_RELAY_KEY содержит пробелы или управляющие символы");
157
+ if (problem) {
158
+ console.warn(`Logs: ${problem} — отправка error-логов в Телеграм выключена`);
159
+ } else {
160
+ enabled = true;
161
+ }
162
+ }
163
+ this.relayUrl = enabled ? url : undefined;
164
+ this.relayKey = enabled ? key : undefined;
165
+
166
+ // Старый канал в чат Bitrix24 снят. Строка ровно одна за процесс: конструктор
167
+ // singleton'а вызывается один раз. Без неё обновление пакета молча оставило бы
168
+ // проект без канала error-логов
169
+ if (process.env.LOG_B24_WEBHOOK_URL || process.env.LOG_B24_CHAT_ID) {
170
+ console.warn(
171
+ "Logs: канал Bitrix24 снят в 5.1.0 — LOG_B24_WEBHOOK_URL и LOG_B24_CHAT_ID больше не читаются, error-логи идут через LOG_RELAY_URL/LOG_RELAY_KEY",
172
+ );
173
+ }
110
174
  }
111
175
 
112
176
  /** Проверяем и заполняем конфиг дефолтными значениями если данных нет */
@@ -121,7 +185,7 @@ class LogsAPI {
121
185
  /**
122
186
  * Добавляет сообщение в лог
123
187
  *
124
- * Важно: при уровне `error` сообщение отправляется в B24 чат (через webhook)
188
+ * Важно: при уровне `error` сообщение отправляется в Телеграм через ретранслятор
125
189
  * независимо от настройки `error.enabled` в `log.json`. Настройка `enabled`
126
190
  * управляет только выводом в консоль. Это сделано намеренно, чтобы критические
127
191
  * ошибки всегда доходили до команды даже при отключённом консольном логировании.
@@ -132,11 +196,15 @@ class LogsAPI {
132
196
  */
133
197
  add(message: string | object, level?: LogLevel, jsonData?: unknown): void {
134
198
  const levelStr: LogLevel = level || "info";
135
- const messageText = typeof message === "string" ? message : JSON.stringify(message);
199
+ // JSON.stringify отдаёт undefined для undefined, функции и объекта с toJSON → undefined;
200
+ // тогда берём String(), иначе дальше по коду шёл бы не текст
201
+ const messageText = typeof message === "string" ? message : (JSON.stringify(message) ?? String(message));
136
202
 
137
- // Отправляем error-логи в B24 чат независимо от настроек консольного вывода
203
+ // Отправляем error-логи в Телеграм независимо от настроек консольного вывода.
204
+ // В консоль ниже уходят исходные message и jsonData — маскировка телефонов
205
+ // касается только текста для Телеграма
138
206
  if (levelStr === "error") {
139
- this.sendToB24Chat(messageText, jsonData);
207
+ this.sendToRelay(messageText, jsonData);
140
208
  }
141
209
 
142
210
  // Проверяем конфиг, и если логирование отключено, то выходим
@@ -161,117 +229,87 @@ class LogsAPI {
161
229
  }
162
230
 
163
231
  /**
164
- * Убирает BB-теги для plain text отправки (при обрезке текста)
232
+ * Готовит текст error-лога и отправляет его в ретранслятор (fire-and-forget).
233
+ *
234
+ * Телефоны маскируются ДО обрезки по длине: после обрезки хвост номера на границе
235
+ * уже не узнать шаблоном, и он ушёл бы в Телеграм открытым.
165
236
  */
166
- private stripBbCodes(text: string): string {
167
- return text.replace(/\[\/?(b|code)\]/g, "");
168
- }
237
+ private sendToRelay(message: string, jsonData?: unknown): void {
238
+ if (!this.relayUrl || !this.relayKey) return;
169
239
 
170
- /**
171
- * Отправляет error-сообщение в чат B24 через webhook (fire-and-forget)
172
- * Использует нативный fetch, а не fetchRetry/fetchWithTimeout — во избежание
173
- * циклической зависимости, поэтому и таймаут выставляется здесь вручную через
174
- * `AbortSignal.timeout`: без него зависшее соединение никого бы не разбудило,
175
- * вызов идёт без await
176
- * Ошибки не пробрасываются, только console.error
177
- */
178
- private sendToB24Chat(message: string, jsonData?: unknown): void {
179
- if (!this.b24WebhookUrl || !this.b24ChatId) return;
180
-
181
- // Нормализуем URL — гарантируем trailing slash
182
- const baseUrl = this.b24WebhookUrl.endsWith("/") ? this.b24WebhookUrl : `${this.b24WebhookUrl}/`;
183
- const url = `${baseUrl}imbot.message.add.json`;
184
-
185
- let text = `[b]ERROR[/b] | ${this.appName}\n${message}`;
186
-
187
- // Добавляем jsonData если есть
188
- if (jsonData !== undefined) {
189
- // Безопасная сериализация — логгер не должен бросать исключения
190
- let jsonStr: string;
191
- try {
192
- jsonStr = JSON.stringify(jsonData, null, 2);
193
- } catch {
194
- jsonStr = "[Не удалось сериализовать jsonData]";
240
+ // Логгер не должен бросать синхронно: его зовут из catch-блоков потребителей,
241
+ // и бросок отсюда подменил бы исходную ошибку. Отказ — только console.error
242
+ try {
243
+ let text = message.trim() ? message : "(пустое сообщение)";
244
+
245
+ // null и undefined не дописываем: строка «null» в Телеграме ничего не говорит
246
+ if (jsonData != null) {
247
+ // Безопасная сериализация: циклы бросают, функция и undefined дают undefined
248
+ try {
249
+ const serialized = JSON.stringify(jsonData, null, 2) as string | undefined;
250
+ text += `\n${serialized ?? String(jsonData)}`;
251
+ } catch {
252
+ text += "\n[Не удалось сериализовать jsonData]";
253
+ }
195
254
  }
196
- text += `\n[code]${jsonStr}[/code]`;
197
- }
198
255
 
199
- // Обрезаем до лимита B24
200
- let isTruncated = false;
201
- if (text.length > B24_TEXT_LIMIT) {
202
- const truncatedSuffix = "\n...(обрезано)";
203
- text = text.slice(0, B24_TEXT_LIMIT - truncatedSuffix.length) + truncatedSuffix;
204
- isTruncated = true;
205
- }
256
+ text = maskPhones(text);
206
257
 
207
- // Если текст обрезан — отправляем без BB-тегов, чтобы не разорвать [code]...[/code]
208
- if (isTruncated) {
209
- text = this.stripBbCodes(text);
210
- }
258
+ if (text.length > RELAY_TEXT_LIMIT) {
259
+ const truncatedSuffix = "\n...(обрезано)";
260
+ text = text.slice(0, RELAY_TEXT_LIMIT - truncatedSuffix.length) + truncatedSuffix;
261
+ }
211
262
 
212
- void this.postToB24Chat(url, text);
263
+ void this.postToRelay(this.relayUrl, this.relayKey, text);
264
+ } catch (error: unknown) {
265
+ console.error(`Logs Relay: не удалось подготовить сообщение — ${describeSendError(error)}`);
266
+ }
213
267
  }
214
268
 
215
269
  /**
216
- * Сам запрос в чат и разбор ответа. Никогда не бросает: все ошибки — в `console.error`,
217
- * не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
270
+ * Сам запрос к ретранслятору и разбор ответа. Никогда не бросает: все ошибки — в
271
+ * `console.error`, не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
272
+ *
273
+ * Нативный `fetch`, а не `fetchRetry`/`fetchWithTimeout` — во избежание циклической
274
+ * зависимости, поэтому таймаут выставляется здесь через `AbortSignal.timeout`: вызов идёт
275
+ * без `await`, и без бюджета зависшее соединение никого бы не разбудило. Сигнал действует
276
+ * и на чтение тела ответа — зависшее тело обрывается тем же бюджетом.
218
277
  *
219
- * Сигнал `AbortSignal.timeout` отдан в `fetch`, а он действует и на чтение тела
220
- * ответа: зависшее тело обрывается тем же бюджетом с `TimeoutError`. До общего `catch`
221
- * такая ошибка не доходит — у каждого чтения тела свой `.catch`, поэтому таймаут
222
- * разбирается прямо там: при `!ok` к статусу дописывается пометка «тело не дочитано»,
223
- * при 200 пишется отдельная строка «доставка не подтверждена».
278
+ * Ни адрес, ни ключ в строки ошибок не попадают: вместо текста сетевой ошибки
279
+ * (он может содержать адрес) печатается только её имя и код причины.
224
280
  */
225
- private async postToB24Chat(url: string, text: string): Promise<void> {
281
+ private async postToRelay(url: string, key: string, text: string): Promise<void> {
226
282
  try {
227
283
  const response = await fetch(url, {
228
284
  method: "POST",
229
- headers: { "Content-Type": "application/json" },
230
- body: JSON.stringify({
231
- BOT_ID: B24_BOT_ID,
232
- CLIENT_ID: B24_CLIENT_ID,
233
- DIALOG_ID: this.b24ChatId,
234
- MESSAGE: text,
235
- }),
236
- signal: AbortSignal.timeout(B24_CHAT_TIMEOUT_MS),
285
+ headers: {
286
+ "Content-Type": "application/json",
287
+ Authorization: `Bearer ${key}`,
288
+ },
289
+ body: JSON.stringify({ level: "error", text }),
290
+ signal: AbortSignal.timeout(RELAY_TIMEOUT_MS),
237
291
  });
238
292
 
239
- if (!response.ok) {
240
- // Тело — подсказка к причине отказа, его чтение отправку уронить не должно
241
- const body = await response.text().catch((error: unknown) =>
242
- isTimeout(error) ? `[тело не дочитано за ${B24_CHAT_TIMEOUT_MS / 1000}с]` : "",
243
- );
244
- const bodyPart = body ? ` — ${body.slice(0, B24_CHAT_BODY_PREVIEW)}` : "";
245
- console.error(`Logs B24 Chat: HTTP ${response.status}, сообщение не доставлено${bodyPart}`);
293
+ // 202 — принято в очередь ретранслятора; тело не нужно, соединение освобождаем
294
+ if (response.status === 202) {
295
+ await response.body?.cancel().catch(() => {});
246
296
  return;
247
297
  }
248
298
 
249
- // B24 умеет отвечать 200 с телом {error, error_description} — сообщения при этом нет.
250
- // Нечитаемый JSON — молча null (портал ответил 200, ошибки в теле не видно),
251
- // а вот зависшее тело — отказ канала: доставку подтвердить нечем, молчать нельзя
252
- let bodyTimedOut = false;
253
- const data: unknown = await response.json().catch((error: unknown) => {
254
- bodyTimedOut = isTimeout(error);
255
- return null;
256
- });
257
- if (bodyTimedOut) {
258
- console.error(
259
- `Logs B24 Chat: HTTP ${response.status}, но ответ не дочитан за ${B24_CHAT_TIMEOUT_MS / 1000}с — доставка не подтверждена`,
260
- );
261
- return;
262
- }
263
- if (data && typeof data === "object" && "error" in data) {
264
- const { error, error_description: description } = data as { error?: unknown; error_description?: unknown };
265
- const reason = description ? String(description) : String(error);
266
- console.error(`Logs B24 Chat: портал отклонил сообщение — ${reason}`);
267
- }
299
+ // Тело — подсказка к причине отказа, его чтение отправку уронить не должно
300
+ const body = await response.text().catch((error: unknown) =>
301
+ isTimeout(error) ? `[тело не дочитано за ${RELAY_TIMEOUT_MS / 1000}с]` : "",
302
+ );
303
+ const bodyPart = body ? ` — ${body.slice(0, RELAY_BODY_PREVIEW)}` : "";
304
+ const hint = RELAY_STATUS_HINTS[response.status];
305
+ const hintPart = hint ? ` (${hint})` : "";
306
+ console.error(`Logs Relay: HTTP ${response.status}, сообщение не доставлено${hintPart}${bodyPart}`);
268
307
  } catch (error: unknown) {
269
308
  if (isTimeout(error)) {
270
- console.error(`Logs B24 Chat: не ответил за ${B24_CHAT_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
309
+ console.error(`Logs Relay: не ответил за ${RELAY_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
271
310
  return;
272
311
  }
273
- const errMsg = error instanceof Error ? error.message : String(error);
274
- console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
312
+ console.error(`Logs Relay: ошибка отправки — ${describeSendError(error)}`);
275
313
  }
276
314
  }
277
315
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Маскировка российских телефонных номеров в тексте error-лога перед отправкой
3
+ * в Телеграм через ретранслятор. Внутренний модуль: из `logs.ts` не реэкспортируется
4
+ * и подпути в `exports` не имеет. Побочных эффектов нет.
5
+ *
6
+ * Номер ищется по цифрам, а не по рисунку групп: между любыми двумя цифрами может
7
+ * стоять сколько угодно разделителей — пробел, табуляция, неразрывные и узкие пробелы,
8
+ * дефис и тире всех видов (U+2010–U+2015), точка, косая черта, скобки. Так ловятся
9
+ * `+7 916 123-45-67`, `8.916.123.45.67`, `+7 916 12 34 567`, `8 9161 234567`,
10
+ * `8–916–123–45–67`, а не только «правильная» запись 3-3-2-2.
11
+ *
12
+ * Что ловит:
13
+ * - префикс `+7`, `7`, `8` или `+8` и ещё десять цифр;
14
+ * - мобильный без префикса — десять цифр, первая `9`, можно с открывающей скобкой.
15
+ *
16
+ * Границы:
17
+ * - слева от номера не может стоять цифра, латинская буква, `_` или `+` — поэтому
18
+ * не трогаются куски длинных чисел, `DEAL_9161234567`, `v8.9.16.123.45`.
19
+ * Точка и кириллица слева допустимы: `тел.89161234567` маскируется;
20
+ * - справа от номера не может стоять цифра — 12+ цифр подряд (метки времени в мс,
21
+ * длинные идентификаторы) не трогаются;
22
+ * - перевод строки разделителем не считается: соседние числа отформатированного
23
+ * JSON (`JSON.stringify(..., 2)`) не склеиваются в номер.
24
+ *
25
+ * Известные границы (сознательно):
26
+ * - 10-значный городской номер без префикса (`4951234567`) не маскируется — иначе под
27
+ * маску попали бы метки времени Unix в секундах (10 цифр, первая `1`);
28
+ * - 10-значное число на `9` (например, ИНН `9701234567`) маскируется. Это ошибка
29
+ * в безопасную сторону: лог теряет число, но номер телефона не утекает;
30
+ * - цепочка мелких чисел через точки или пробелы, где после `7`/`8`/`9` набирается
31
+ * нужное число цифр, тоже может попасть под маску.
32
+ *
33
+ * Скорость: разделители и цифры не пересекаются, поэтому каждый повтор однозначен
34
+ * и катастрофического перебора нет (строки в 30–50 тыс. символов — миллисекунды).
35
+ */
36
+
37
+ /** Разделитель между цифрами номера (без перевода строки) */
38
+ const SEP = "[ \\t\\u00a0\\u2007\\u202f\\-\\u2010-\\u2015./()]*";
39
+
40
+ /**
41
+ * Шаблон номера. Группы: 1–2 — две последние цифры номера с префиксом,
42
+ * 3–4 — две последние цифры мобильного без префикса.
43
+ */
44
+ const PHONE_RE = new RegExp(
45
+ "(?<![\\dA-Za-z_+])(?:" +
46
+ `(?:\\+7|\\+?8|7)(?:${SEP}\\d){8}${SEP}(\\d)${SEP}(\\d)` +
47
+ "|" +
48
+ `\\(?9(?:${SEP}\\d){7}${SEP}(\\d)${SEP}(\\d)` +
49
+ ")(?!\\d)",
50
+ "g",
51
+ );
52
+
53
+ /**
54
+ * Заменяет каждый найденный номер на `+7 ••• •••-••-NN`, где NN — две последние цифры.
55
+ * Хвост оставлен, чтобы по логу можно было отличить один номер от другого.
56
+ */
57
+ export function maskPhones(text: string): string {
58
+ return text.replace(PHONE_RE, (...args: unknown[]) => {
59
+ // args: совпадение, группы 1–4, смещение, исходная строка
60
+ const tail = typeof args[1] === "string" ? `${args[1]}${String(args[2])}` : `${String(args[3])}${String(args[4])}`;
61
+ return `+7 ••• •••-••-${tail}`;
62
+ });
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "sendMessage/email.ts",
48
48
  "sendMessage/wappi.ts",
49
49
  "logs/logs.ts",
50
+ "logs/maskPhones.ts",
50
51
  "utils/fetchRetry.ts"
51
52
  ],
52
53
  "dependencies": {
@@ -215,7 +215,7 @@ export class ChatApp {
215
215
  // и вводим. Остальные ошибки летят наружу как раньше
216
216
  if (!isTimeoutError(error)) throw error;
217
217
 
218
- // Уровень error — единственный, который уходит в чат B24. До появления
218
+ // Уровень error — единственный, который уходит в Телеграм через ретранслятор. До появления
219
219
  // таймаута зависание долетало исключением до потребителя и там порождало
220
220
  // задачу через errorB24(); перехватив ошибку здесь, мы обязаны сами оставить
221
221
  // след, иначе правка сделала бы отказ отправки тише, чем он был
@@ -255,7 +255,7 @@ export class Email {
255
255
  * возвращался вызывающему как `error: false`.
256
256
  *
257
257
  * Отказ логируется не здесь: результат уходит вызывающему, а уровень `error`
258
- * дублировался бы в чат B24. От таймаутов отправки в `Smsgold`/`Wappi` случай
258
+ * дублировался бы в Телеграм через ретранслятор. От таймаутов отправки в `Smsgold`/`Wappi` случай
259
259
  * отличается тем, что статус известен — сервер прямо сказал «не принял».
260
260
  */
261
261
  private checkDelivery(to: string, info: SendMailInfo): EmailResult {
@@ -163,7 +163,7 @@ export class Smsgold {
163
163
  } catch (error: unknown) {
164
164
  // Ответа не дождались: запрос уже ушёл, сообщение могло быть доставлено.
165
165
  // Это не «ошибка отправки» — статус неизвестен, и решить это кодом нельзя.
166
- // Единственное место в классе, где пишем в лог: уровень error уходит в чат B24,
166
+ // Единственное место в классе, где пишем в лог: уровень error уходит в Телеграм через ретранслятор,
167
167
  // потому что разбираться должен человек
168
168
  if (isTimeoutError(error)) {
169
169
  logs.add(
@@ -137,7 +137,7 @@ export class Wappi {
137
137
  // Остальные ошибки летят наружу как раньше
138
138
  if (!isTimeoutError(error)) throw error;
139
139
 
140
- // Уровень error — единственный, который уходит в чат B24. До появления
140
+ // Уровень error — единственный, который уходит в Телеграм через ретранслятор. До появления
141
141
  // таймаута зависание долетало исключением до потребителя и там порождало
142
142
  // задачу через errorB24(); перехватив ошибку здесь, мы обязаны сами оставить
143
143
  // след, иначе правка сделала бы отказ отправки тише, чем он был
@@ -52,7 +52,7 @@ const SUSPICIOUS_TIMEOUT_MAX_MS = 999;
52
52
  * Значение от 1 до 999 применяется как есть, но с записью `warn`: столько миллисекунд
53
53
  * не хватит ни одному живому запросу, и это типовая путаница секунд с миллисекундами.
54
54
  * Молча такое значение пропускать нельзя — на бюджетах отправки оно превращается
55
- * в массовую недоставку с «неизвестным статусом» и потоком `error` в чат портала.
55
+ * в массовую недоставку с «неизвестным статусом» и потоком `error` в Телеграм через ретранслятор.
56
56
  *
57
57
  * Экспортируется по двум причинам: одинаковая семантика для всех бюджетов пакета
58
58
  * (дефолт `fetchRetry` и каждый из `FETCH_TIMEOUTS` читаются одной и той же функцией)
@@ -228,8 +228,10 @@ export function isPreConnectionError(error: unknown): boolean {
228
228
  *
229
229
  * URL входящего вебхука B24 содержит секрет прямо в пути
230
230
  * (`https://портал.bitrix24.ru/rest/1/<секрет>/method.json`), а токен авторизации
231
- * может приходить в query-параметрах. Хост и имя метода сохраняются — они нужны
232
- * для диагностики, тело секрета заменяется на `***`.
231
+ * может приходить в query-параметрах. Bot API Телеграма тоже держит токен в пути:
232
+ * `https://api.telegram.org/bot<id>:<токен>/<метод>` и `/file/bot<id>:<токен>/<путь>`.
233
+ * Хост, id бота и имя метода сохраняются — они нужны для диагностики, тело секрета
234
+ * заменяется на `***`.
233
235
  *
234
236
  * @param url — адрес запроса в любом виде, принимаемом fetch
235
237
  * @returns строка URL, пригодная для логирования
@@ -241,6 +243,9 @@ export function maskUrl(url: string | URL | Request): string {
241
243
  raw
242
244
  // Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
243
245
  .replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
246
+ // Токен бота Телеграма: /bot<id>:<токен>/ и /file/bot<id>:<токен>/.
247
+ // Двоеточие бывает закодировано как %3A — адрес мог пройти через кодирование
248
+ .replace(/(\/bot\d+(?::|%3A))[^/?#]+/gi, "$1***")
244
249
  // Токены и подписи в query-параметрах. signature и sessid нужны отдельно:
245
250
  // ссылка на файл B24-диска несёт секрет именно в signature
246
251
  .replace(/([?&](?:auth|access_token|refresh_token|token|signature|sessid)=)[^&#]+/gi, "$1***")