@andrey4emk/npm-app-back-b24 4.0.2 → 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.
@@ -1,6 +1,7 @@
1
1
  import { AjaxError, RefreshTokenError, SdkError } from "@bitrix24/b24jssdk";
2
2
  import { logs } from "../../logs/logs.ts";
3
3
  import { isNetworkError, isPreConnectionError } from "../../utils/fetchRetry.ts";
4
+ import { isNonJsonResponseError } from "./nonJsonResponse.ts";
4
5
 
5
6
  // ==================== Типы ====================
6
7
 
@@ -20,6 +21,15 @@ export type AsyncFn = (...args: never[]) => Promise<unknown>;
20
21
  export const RETRY_COUNT = 5;
21
22
  /** Задержка между попытками (мс) */
22
23
  export const RETRY_DELAY_MS = 500;
24
+ /**
25
+ * Первая пауза при ответе портала без JSON (HTML-страница прокси, пустое тело), мс.
26
+ * Дальше пауза удваивается: 1, 2, 4, 8 с — пять попыток растягиваются на 15 секунд.
27
+ *
28
+ * Ровная пауза в 500 мс укладывала все пять попыток в 2 секунды, а сбой 29.09.2026
29
+ * длился около трёх (T-65): повторы закончились раньше, чем портал пришёл в себя.
30
+ * Транспортные сбои по-прежнему идут с ровной паузой — растягивать их причины нет.
31
+ */
32
+ export const NON_JSON_RETRY_BASE_DELAY_MS = 1000;
23
33
 
24
34
  /**
25
35
  * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
@@ -113,6 +123,16 @@ function normalizeMethodName(raw: string): string {
113
123
  return head.replace(/\.json$/, "");
114
124
  }
115
125
 
126
+ /**
127
+ * Пауза перед следующей попыткой: нарастающая для ответа без JSON, ровная для остального.
128
+ *
129
+ * @param error — ошибка упавшей попытки
130
+ * @param attempt — номер упавшей попытки, с единицы
131
+ */
132
+ export function retryDelayFor(error: unknown, attempt: number): number {
133
+ return isNonJsonResponseError(error) ? NON_JSON_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) : RETRY_DELAY_MS;
134
+ }
135
+
116
136
  /** Фраза для лога: повтор отменён, потому что вызов создаёт сущности */
117
137
  const creatingReason = (what: string): string => `вызов создаёт сущности (${what})`;
118
138
 
@@ -146,11 +166,16 @@ const reservingReason = (what: string): string => `вызов резервиру
146
166
  */
147
167
  export function isB24NetworkError(error: unknown): boolean {
148
168
  if (error instanceof AjaxError) {
149
- // Практически недостижимо (SDK бросает реальную ошибку раньше),
169
+ // Практически недостижимо в SDK 2.x и 3.0 (SDK бросает реальную ошибку раньше),
150
170
  // но если код всё же пришёл — SDK уже исчерпал свои попытки
151
171
  if (error.code === "JSSDK_CALL_ALL_ATTEMPTS_EXHAUSTED") return false;
152
172
  }
153
173
 
174
+ // Портал (или прокси перед ним) отдал не JSON — временный сбой на его стороне,
175
+ // повтор осмыслен. Сегодня такая ошибка прошла бы и веткой status 0, но держаться
176
+ // за случайность формы обёртки SDK не хочется: признак проверяется явно
177
+ if (isNonJsonResponseError(error)) return true;
178
+
154
179
  if (error instanceof SdkError) {
155
180
  const { code, originalError, status } = error;
156
181
  const originalCode = (originalError as { code?: string } | undefined)?.code;
@@ -254,7 +279,7 @@ export function refreshFailureCode(error: unknown): string {
254
279
  * Возвращает null, если форма команды не распознана.
255
280
  */
256
281
  export function extractBatchCommandMethod(cmd: unknown): string | null {
257
- // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
282
+ // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x и 3.0
258
283
  if (Array.isArray(cmd)) {
259
284
  return typeof cmd[0] === "string" ? cmd[0] : null;
260
285
  }
@@ -265,7 +290,7 @@ export function extractBatchCommandMethod(cmd: unknown): string | null {
265
290
  return typeof method === "string" ? method : null;
266
291
  }
267
292
 
268
- // Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
293
+ // Строка "crm.deal.add?ID=1" — в SDK 2.x и 3.0 эта форма не поддерживается
269
294
  // (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
270
295
  // если потребитель остался на ней со старой версии
271
296
  if (typeof cmd === "string") {
@@ -429,12 +454,13 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
429
454
 
430
455
  // Начало строки менять нельзя: по «попытка N/5 не удалась» грепают в логах
431
456
  const lifted = blockReason ? ` — соединение не состоялось, ограничение (${blockReason}) снято` : "";
457
+ const pause = retryDelayFor(error, attempt);
432
458
 
433
- // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
459
+ // Промежуточные попытки — warn: в Телеграм через ретранслятор уходит только уровень error,
434
460
  // иначе один упавший вызов дал бы пять сообщений
435
- logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс${lifted}`, "warn");
461
+ logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${pause}мс${lifted}`, "warn");
436
462
 
437
- await delay(RETRY_DELAY_MS);
463
+ await delay(pause);
438
464
  }
439
465
  }
440
466
 
@@ -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"
@@ -31,19 +31,20 @@ export interface AuthSaveResponse {
31
31
  /**
32
32
  * `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
33
33
  *
34
- * SDK 2.x помечает `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
35
- * к удалению в следующем major. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
36
- * не меняются, а зависимости от удаляемого API у нас больше нет.
34
+ * В SDK 3.0.0 `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
35
+ * из `AbstractB24` удалены. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
36
+ * не меняются, а зависимости от удалённого API у нас нет.
37
37
  *
38
38
  * Тип расширяет `B24OAuth`, поэтому `$b24` по-прежнему передаётся туда, где ждут `B24OAuth`
39
39
  * (`new Smsgold(auth, $b24)`, `new Event($b24)`).
40
40
  *
41
- * Пять сигнатур ниже объявлены НАМЕРЕННО и сегодня дублируют то, что и так приходит
42
- * по наследству от `B24OAuth`. Удалять их нельзя: в SDK 3.x этих методов в `AbstractB24`
43
- * не будет, и вместе с ними из типа пропадёт вся поверхность фасада — а тайпчек на
44
- * SDK 2.2.0 останется зелёным, то есть пропажа обнаружится только у потребителя.
45
- * Страховка от «уборки дублей» — `tests/client-type.test.ts`: он читает текст этого
46
- * файла и падает, если хоть одно объявление отсюда исчезло.
41
+ * Пять сигнатур ниже — единственный источник поверхности фасада: по наследству от `B24OAuth`
42
+ * они больше не приходят. Пропажу любого объявления ловит тайпчек самого пакета —
43
+ * `FacadeMethods` ниже и эталон в `tests/client-type.test.ts` берут их через `Pick`.
44
+ *
45
+ * `Promise<Result<any>>`, а не голый `Result`: с SDK 3 дефолт `Result<T>` — `unknown`,
46
+ * и код потребителей `(await $b24.callListMethod(...)).getData().forEach(...)` перестал бы
47
+ * компилироваться. Обоснование то же, что у остальных `any` ниже.
47
48
  *
48
49
  * `any` в сигнатурах оставлен НАМЕРЕННО и снятию по T-53 не подлежит: это дефолты
49
50
  * дженериков публичной поверхности, дословно повторяющие `AbstractB24`. На них стоит
@@ -54,28 +55,28 @@ export interface AuthSaveResponse {
54
55
  */
55
56
  export interface B24Client extends B24OAuth {
56
57
  callMethod<T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>>;
57
- callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result>;
58
+ callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result<any>>;
58
59
  fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]>;
59
- callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result>;
60
- callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result>;
60
+ callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result<any>>;
61
+ callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result<any>>;
61
62
  }
62
63
 
63
64
  /**
64
65
  * Минимальный контракт «умеет вызывать REST-методы B24».
65
66
  *
66
67
  * Нужен внутренним классам (`Event`, `Smsgold`), которые принимают экземпляр извне.
67
- * Объявляет `callMethod` сам, поэтому переживёт удаление метода из `B24OAuth`.
68
+ * Объявляет `callMethod` сам: в `B24OAuth` этого метода нет с SDK 3.0.0.
68
69
  *
69
70
  * Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
70
71
  * `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
71
- * наличия в обеих ветках — то есть после удаления из SDK тайпчек сломается всё равно.
72
+ * наличия в обеих ветках.
72
73
  *
73
- * Структурный тип шире `B24OAuth`, поэтому публичные сигнатуры конструкторов
74
- * не сужаются: и `B24OAuth`, и `$b24`, и любой свой объект с `callMethod` подходят.
74
+ * Голый `B24OAuth` под этот тип с SDK 3.0.0 не подходит — передавать нужно `$b24`
75
+ * (или любой свой объект с `callMethod`).
75
76
  */
76
77
  export interface B24MethodCaller {
77
- // Дженерика здесь быть не должно: у SDK метод не generic, и `B24OAuth`
78
- // потребителя перестал бы подходить под этот тип. Проверено тайпчеком
78
+ // Дженерика нет, чтобы не расширять публичный тип без нужды: внутренние
79
+ // классы читают ответ через getResultData(), параметр типа им не нужен
79
80
  callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
80
81
  }
81
82
 
@@ -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
  }
@@ -232,8 +232,8 @@ function classifyEntityEvent(raw: unknown, requestedEventName: string): EntityVe
232
232
  * Класс для работы с офлайн-событиями Bitrix24
233
233
  */
234
234
  export class Event {
235
- // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
236
- // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
235
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам — метода в B24OAuth
236
+ // нет с SDK 3.0.0, поэтому передавать нужно $b24, а не голый B24OAuth
237
237
  private b24: B24MethodCaller;
238
238
 
239
239
  /** Расширения видео-файлов */
@@ -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
  }