@andrey4emk/npm-app-back-b24 5.0.0 → 5.2.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";
@@ -133,8 +133,11 @@ export function retryDelayFor(error: unknown, attempt: number): number {
133
133
  return isNonJsonResponseError(error) ? NON_JSON_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) : RETRY_DELAY_MS;
134
134
  }
135
135
 
136
+ /** Начало фразы про создающий вызов — по нему уровень отмены повтора отличает её от резервирующей */
137
+ const CREATING_REASON_HEAD = "вызов создаёт сущности (";
138
+
136
139
  /** Фраза для лога: повтор отменён, потому что вызов создаёт сущности */
137
- const creatingReason = (what: string): string => `вызов создаёт сущности (${what})`;
140
+ const creatingReason = (what: string): string => `${CREATING_REASON_HEAD}${what})`;
138
141
 
139
142
  /** Фраза для лога: повтор отменён, потому что вызов резервирует данные на портале */
140
143
  const reservingReason = (what: string): string => `вызов резервирует данные на портале (${what})`;
@@ -447,8 +450,12 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
447
450
  // потеряться уже после того, как портал его выполнил. Различить нельзя,
448
451
  // поэтому вызовы под ограничением не повторяем — лучше вернуть ошибку,
449
452
  // чем создать дубликат или потерять зарезервированный пакет
453
+ // Отмена только резервирующего вызова (event.offline.get) — warn: приложение и так
454
+ // пишет свою строку error о том же сбое, а две строки дали бы два сообщения в
455
+ // Телеграме. Отмена создающего остаётся error: сущность могла не создаться
450
456
  if (blockReason && !proven) {
451
- logs.add(`${label}${code}: повтор отменён, ${blockReason} — ${msg}`, "error");
457
+ const level = blockReason.includes(CREATING_REASON_HEAD) ? "error" : "warn";
458
+ logs.add(`${label}${code}: повтор отменён, ${blockReason} — ${msg}`, level);
452
459
  throw error;
453
460
  }
454
461
 
@@ -456,7 +463,7 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
456
463
  const lifted = blockReason ? ` — соединение не состоялось, ограничение (${blockReason}) снято` : "";
457
464
  const pause = retryDelayFor(error, attempt);
458
465
 
459
- // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
466
+ // Промежуточные попытки — warn: в Телеграм через ретранслятор уходит только уровень error,
460
467
  // иначе один упавший вызов дал бы пять сообщений
461
468
  logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${pause}мс${lifted}`, "warn");
462
469
 
@@ -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
41
 
42
- /** Сколько символов тела неуспешного ответа чата показывать в `console.error` */
43
- const B24_CHAT_BODY_PREVIEW = 300;
42
+ /** Сколько символов тела неуспешного ответа ретранслятора показывать в `console.error` */
43
+ const RELAY_BODY_PREVIEW = 300;
44
+
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,61 @@ 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
+ /**
72
+ * Признаки разового сетевого сбоя в тексте error-лога: обрыв, таймаут, недоступный хост.
73
+ * Проверяется текст, а не тип ошибки: строки о сбое пишут и сами приложения
74
+ * («Не удалось получить события. read ECONNRESET»), типа ошибки у логгера нет
75
+ */
76
+ const NETWORK_FAILURE_RE =
77
+ /ECONNRESET|socket hang up|ETIMEDOUT|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|EAI_AGAIN|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|Request timeout exceeded|fetch failed|other side closed|Error connecting to authorization server/i;
78
+
79
+ /**
80
+ * Строки с сетевым признаком, которые уходят сразу и по одной: отменённое создание
81
+ * сущности, провал обмена токена, неизвестный статус доставки — их разбирает человек
82
+ */
83
+ const NETWORK_KEEP_RE = /вызов создаёт сущности|обновление токена не удалось|статус неизвестен/;
84
+
85
+ /** Окно, за которое считаются сетевые сбои, и пауза между сводками, мс */
86
+ const NETWORK_WINDOW_MS = 15 * 60_000;
87
+
88
+ /** Со скольких сетевых сбоев за окно в Телеграм уходит сводка */
89
+ const NETWORK_THRESHOLD = 3;
90
+
91
+ /** Ключ ретранслятора — только видимые символы ASCII, без пробелов и управляющих */
92
+ const RELAY_KEY_RE = /^[\x21-\x7e]+$/;
93
+
94
+ /**
95
+ * Проверяет адрес ретранслятора: только `http`/`https` и без логина и пароля в адресе.
96
+ * Возвращает причину отказа или `null`. Значение адреса в причину не попадает
97
+ */
98
+ function relayUrlProblem(raw: string): string | null {
99
+ let parsed: URL;
100
+ try {
101
+ parsed = new URL(raw);
102
+ } catch {
103
+ return "LOG_RELAY_URL не разбирается как адрес";
104
+ }
105
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
106
+ return "LOG_RELAY_URL должен начинаться с http:// или https://";
107
+ }
108
+ if (parsed.username || parsed.password) {
109
+ return "LOG_RELAY_URL не должен содержать логин и пароль";
110
+ }
111
+ return null;
112
+ }
113
+
53
114
  // ==================== Настройки ====================
54
115
 
55
116
  const configDir: string = process.env.CONFIG_DIR || "../config";
@@ -93,20 +154,49 @@ class LogsAPI {
93
154
 
94
155
  private readonly resetColor = "\x1b[0m";
95
156
 
96
- /** Webhook URL для отправки error-логов в B24 чат */
97
- private readonly b24WebhookUrl: string | undefined;
157
+ /** Адрес `POST /send` ретранслятора (`LOG_RELAY_URL`); `undefined` — канал выключен */
158
+ private readonly relayUrl: string | undefined;
98
159
 
99
- /** ID чата B24 для отправки error-логов (формат: chatXXXXXX) */
100
- private readonly b24ChatId: string | undefined;
160
+ /** Ключ клиента ретранслятора (`LOG_RELAY_KEY`); `undefined` — канал выключен */
161
+ private readonly relayKey: string | undefined;
101
162
 
102
- /** Имя приложения для заголовка сообщения */
103
- private readonly appName: string;
163
+ /** Время сетевых сбоев за последние `NETWORK_WINDOW_MS`, мс */
164
+ private networkFailures: number[] = [];
165
+
166
+ /** Когда ушла последняя сводка сетевых сбоев, мс */
167
+ private lastNetworkSummaryAt = Number.NEGATIVE_INFINITY;
104
168
 
105
169
  constructor() {
106
170
  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";
171
+ const url = process.env.LOG_RELAY_URL?.trim() || undefined;
172
+ const key = process.env.LOG_RELAY_KEY?.trim() || undefined;
173
+
174
+ // Канал включается только парой. Половина пары — ошибка настройки: одна строка
175
+ // предупреждения и канал выключен, а не запрос без ключа или в никуда
176
+ // Непригодные значения тоже выключают канал одной строкой. Сами значения в строку
177
+ // не попадают: адрес с паролем или ключ ушли бы в журнал контейнера
178
+ let enabled = false;
179
+ if (Boolean(url) !== Boolean(key)) {
180
+ console.warn("Logs: задан только один из LOG_RELAY_URL/LOG_RELAY_KEY — отправка error-логов в Телеграм выключена");
181
+ } else if (url && key) {
182
+ const problem = relayUrlProblem(url) ?? (RELAY_KEY_RE.test(key) ? null : "LOG_RELAY_KEY содержит пробелы или управляющие символы");
183
+ if (problem) {
184
+ console.warn(`Logs: ${problem} — отправка error-логов в Телеграм выключена`);
185
+ } else {
186
+ enabled = true;
187
+ }
188
+ }
189
+ this.relayUrl = enabled ? url : undefined;
190
+ this.relayKey = enabled ? key : undefined;
191
+
192
+ // Старый канал в чат Bitrix24 снят. Строка ровно одна за процесс: конструктор
193
+ // singleton'а вызывается один раз. Без неё обновление пакета молча оставило бы
194
+ // проект без канала error-логов
195
+ if (process.env.LOG_B24_WEBHOOK_URL || process.env.LOG_B24_CHAT_ID) {
196
+ console.warn(
197
+ "Logs: канал Bitrix24 снят в 5.1.0 — LOG_B24_WEBHOOK_URL и LOG_B24_CHAT_ID больше не читаются, error-логи идут через LOG_RELAY_URL/LOG_RELAY_KEY",
198
+ );
199
+ }
110
200
  }
111
201
 
112
202
  /** Проверяем и заполняем конфиг дефолтными значениями если данных нет */
@@ -121,10 +211,11 @@ class LogsAPI {
121
211
  /**
122
212
  * Добавляет сообщение в лог
123
213
  *
124
- * Важно: при уровне `error` сообщение отправляется в B24 чат (через webhook)
214
+ * Важно: при уровне `error` сообщение отправляется в Телеграм через ретранслятор
125
215
  * независимо от настройки `error.enabled` в `log.json`. Настройка `enabled`
126
216
  * управляет только выводом в консоль. Это сделано намеренно, чтобы критические
127
217
  * ошибки всегда доходили до команды даже при отключённом консольном логировании.
218
+ * Исключение — разовые сетевые сбои: они уходят сводкой, см. `routeToRelay()`.
128
219
  *
129
220
  * @param message - Текст сообщения или объект для вывода
130
221
  * @param level - Уровень логирования (trace, debug, info, warn, error)
@@ -132,11 +223,15 @@ class LogsAPI {
132
223
  */
133
224
  add(message: string | object, level?: LogLevel, jsonData?: unknown): void {
134
225
  const levelStr: LogLevel = level || "info";
135
- const messageText = typeof message === "string" ? message : JSON.stringify(message);
226
+ // JSON.stringify отдаёт undefined для undefined, функции и объекта с toJSON → undefined;
227
+ // тогда берём String(), иначе дальше по коду шёл бы не текст
228
+ const messageText = typeof message === "string" ? message : (JSON.stringify(message) ?? String(message));
136
229
 
137
- // Отправляем error-логи в B24 чат независимо от настроек консольного вывода
230
+ // Отправляем error-логи в Телеграм независимо от настроек консольного вывода.
231
+ // В консоль ниже уходят исходные message и jsonData — маскировка телефонов
232
+ // касается только текста для Телеграма
138
233
  if (levelStr === "error") {
139
- this.sendToB24Chat(messageText, jsonData);
234
+ this.routeToRelay(messageText, jsonData);
140
235
  }
141
236
 
142
237
  // Проверяем конфиг, и если логирование отключено, то выходим
@@ -161,117 +256,117 @@ class LogsAPI {
161
256
  }
162
257
 
163
258
  /**
164
- * Убирает BB-теги для plain text отправки (при обрезке текста)
259
+ * Решает, уходит ли error-лог в Телеграм сразу.
260
+ *
261
+ * Разовый сетевой сбой проходит сам, а сообщение о нём в Телеграме — шум: 25.10.2026
262
+ * один недоступный сервер авторизации дал 14 сообщений подряд. Поэтому строка
263
+ * с сетевым признаком остаётся только в консоли, а в Телеграм уходит одна сводка,
264
+ * когда за 15 минут набралось 3 сбоя и больше; следующая — не раньше чем через 15 минут.
265
+ * Длинный обрыв так и виден — сводкой раз в 15 минут.
165
266
  */
166
- private stripBbCodes(text: string): string {
167
- return text.replace(/\[\/?(b|code)\]/g, "");
267
+ private routeToRelay(message: string, jsonData?: unknown): void {
268
+ if (!this.relayUrl || !this.relayKey) return;
269
+
270
+ if (!NETWORK_FAILURE_RE.test(message) || NETWORK_KEEP_RE.test(message)) {
271
+ this.sendToRelay(message, jsonData);
272
+ return;
273
+ }
274
+
275
+ const now = Date.now();
276
+ this.networkFailures = this.networkFailures.filter((at) => now - at < NETWORK_WINDOW_MS);
277
+ this.networkFailures.push(now);
278
+
279
+ if (this.networkFailures.length < NETWORK_THRESHOLD) return;
280
+ if (now - this.lastNetworkSummaryAt < NETWORK_WINDOW_MS) return;
281
+
282
+ this.lastNetworkSummaryAt = now;
283
+ const minutes = NETWORK_WINDOW_MS / 60_000;
284
+ const summary = `Сетевые сбои: ${this.networkFailures.length} за ${minutes} мин (одиночные в Телеграм не отправляются, все строки — в логе контейнера). Последний:\n${message}`;
285
+ this.sendToRelay(summary, jsonData);
168
286
  }
169
287
 
170
288
  /**
171
- * Отправляет error-сообщение в чат B24 через webhook (fire-and-forget)
172
- * Использует нативный fetch, а не fetchRetry/fetchWithTimeout — во избежание
173
- * циклической зависимости, поэтому и таймаут выставляется здесь вручную через
174
- * `AbortSignal.timeout`: без него зависшее соединение никого бы не разбудило,
175
- * вызов идёт без await
176
- * Ошибки не пробрасываются, только console.error
289
+ * Готовит текст error-лога и отправляет его в ретранслятор (fire-and-forget).
290
+ *
291
+ * Телефоны маскируются ДО обрезки по длине: после обрезки хвост номера на границе
292
+ * уже не узнать шаблоном, и он ушёл бы в Телеграм открытым.
177
293
  */
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]";
294
+ private sendToRelay(message: string, jsonData?: unknown): void {
295
+ if (!this.relayUrl || !this.relayKey) return;
296
+
297
+ // Логгер не должен бросать синхронно: его зовут из catch-блоков потребителей,
298
+ // и бросок отсюда подменил бы исходную ошибку. Отказ — только console.error
299
+ try {
300
+ let text = message.trim() ? message : "(пустое сообщение)";
301
+
302
+ // null и undefined не дописываем: строка «null» в Телеграме ничего не говорит
303
+ if (jsonData != null) {
304
+ // Безопасная сериализация: циклы бросают, функция и undefined дают undefined
305
+ try {
306
+ const serialized = JSON.stringify(jsonData, null, 2) as string | undefined;
307
+ text += `\n${serialized ?? String(jsonData)}`;
308
+ } catch {
309
+ text += "\n[Не удалось сериализовать jsonData]";
310
+ }
195
311
  }
196
- text += `\n[code]${jsonStr}[/code]`;
197
- }
198
312
 
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
- }
313
+ text = maskPhones(text);
206
314
 
207
- // Если текст обрезан — отправляем без BB-тегов, чтобы не разорвать [code]...[/code]
208
- if (isTruncated) {
209
- text = this.stripBbCodes(text);
210
- }
315
+ if (text.length > RELAY_TEXT_LIMIT) {
316
+ const truncatedSuffix = "\n...(обрезано)";
317
+ text = text.slice(0, RELAY_TEXT_LIMIT - truncatedSuffix.length) + truncatedSuffix;
318
+ }
211
319
 
212
- void this.postToB24Chat(url, text);
320
+ void this.postToRelay(this.relayUrl, this.relayKey, text);
321
+ } catch (error: unknown) {
322
+ console.error(`Logs Relay: не удалось подготовить сообщение — ${describeSendError(error)}`);
323
+ }
213
324
  }
214
325
 
215
326
  /**
216
- * Сам запрос в чат и разбор ответа. Никогда не бросает: все ошибки — в `console.error`,
217
- * не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
327
+ * Сам запрос к ретранслятору и разбор ответа. Никогда не бросает: все ошибки — в
328
+ * `console.error`, не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
329
+ *
330
+ * Нативный `fetch`, а не `fetchRetry`/`fetchWithTimeout` — во избежание циклической
331
+ * зависимости, поэтому таймаут выставляется здесь через `AbortSignal.timeout`: вызов идёт
332
+ * без `await`, и без бюджета зависшее соединение никого бы не разбудило. Сигнал действует
333
+ * и на чтение тела ответа — зависшее тело обрывается тем же бюджетом.
218
334
  *
219
- * Сигнал `AbortSignal.timeout` отдан в `fetch`, а он действует и на чтение тела
220
- * ответа: зависшее тело обрывается тем же бюджетом с `TimeoutError`. До общего `catch`
221
- * такая ошибка не доходит — у каждого чтения тела свой `.catch`, поэтому таймаут
222
- * разбирается прямо там: при `!ok` к статусу дописывается пометка «тело не дочитано»,
223
- * при 200 пишется отдельная строка «доставка не подтверждена».
335
+ * Ни адрес, ни ключ в строки ошибок не попадают: вместо текста сетевой ошибки
336
+ * (он может содержать адрес) печатается только её имя и код причины.
224
337
  */
225
- private async postToB24Chat(url: string, text: string): Promise<void> {
338
+ private async postToRelay(url: string, key: string, text: string): Promise<void> {
226
339
  try {
227
340
  const response = await fetch(url, {
228
341
  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),
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ Authorization: `Bearer ${key}`,
345
+ },
346
+ body: JSON.stringify({ level: "error", text }),
347
+ signal: AbortSignal.timeout(RELAY_TIMEOUT_MS),
237
348
  });
238
349
 
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}`);
350
+ // 202 — принято в очередь ретранслятора; тело не нужно, соединение освобождаем
351
+ if (response.status === 202) {
352
+ await response.body?.cancel().catch(() => {});
246
353
  return;
247
354
  }
248
355
 
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
- }
356
+ // Тело — подсказка к причине отказа, его чтение отправку уронить не должно
357
+ const body = await response.text().catch((error: unknown) =>
358
+ isTimeout(error) ? `[тело не дочитано за ${RELAY_TIMEOUT_MS / 1000}с]` : "",
359
+ );
360
+ const bodyPart = body ? ` — ${body.slice(0, RELAY_BODY_PREVIEW)}` : "";
361
+ const hint = RELAY_STATUS_HINTS[response.status];
362
+ const hintPart = hint ? ` (${hint})` : "";
363
+ console.error(`Logs Relay: HTTP ${response.status}, сообщение не доставлено${hintPart}${bodyPart}`);
268
364
  } catch (error: unknown) {
269
365
  if (isTimeout(error)) {
270
- console.error(`Logs B24 Chat: не ответил за ${B24_CHAT_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
366
+ console.error(`Logs Relay: не ответил за ${RELAY_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
271
367
  return;
272
368
  }
273
- const errMsg = error instanceof Error ? error.message : String(error);
274
- console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
369
+ console.error(`Logs Relay: ошибка отправки — ${describeSendError(error)}`);
275
370
  }
276
371
  }
277
372
  }
@@ -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.2.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***")