@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.
- package/README.md +69 -14
- package/bitrix24/b24/facade.ts +20 -14
- package/bitrix24/b24/instance.ts +11 -2
- package/bitrix24/b24/nonJsonResponse.ts +161 -0
- package/bitrix24/b24/proxy.ts +6 -6
- package/bitrix24/b24/retry.ts +32 -6
- package/bitrix24/b24/tokens.ts +1 -1
- package/bitrix24/b24/types.ts +19 -18
- package/bitrix24/errTaskB24.ts +1 -1
- package/bitrix24/eventB24.ts +4 -4
- package/logs/logs.ts +153 -115
- package/logs/maskPhones.ts +63 -0
- package/package.json +6 -4
- package/sendMessage/chatApp.ts +1 -1
- package/sendMessage/email.ts +1 -1
- package/sendMessage/smsgold.ts +3 -3
- package/sendMessage/wappi.ts +1 -1
- package/utils/fetchRetry.ts +8 -3
package/bitrix24/b24/retry.ts
CHANGED
|
@@ -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: в
|
|
459
|
+
// Промежуточные попытки — warn: в Телеграм через ретранслятор уходит только уровень error,
|
|
434
460
|
// иначе один упавший вызов дал бы пять сообщений
|
|
435
|
-
logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${
|
|
461
|
+
logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${pause}мс${lifted}`, "warn");
|
|
436
462
|
|
|
437
|
-
await delay(
|
|
463
|
+
await delay(pause);
|
|
438
464
|
}
|
|
439
465
|
}
|
|
440
466
|
|
package/bitrix24/b24/tokens.ts
CHANGED
|
@@ -190,7 +190,7 @@ function refreshAuthWithMutex(): Promise<AuthData> {
|
|
|
190
190
|
|
|
191
191
|
// Уровень warn, а не error, намеренно: брошенную ниже ошибку поймает
|
|
192
192
|
// refreshAndSaveTokens и напишет свою строку уровня error, а error уходит
|
|
193
|
-
// в
|
|
193
|
+
// в Телеграм через ретранслятор — иначе на одно залипание пришло бы два сообщения
|
|
194
194
|
logs.add(
|
|
195
195
|
`refreshAuth: обмен токена не завершился за ${REFRESH_STUCK_TIMEOUT_MS / 60000} мин — ${mutexPart}, зависший запрос продолжает работу и сохранит токены сам, если ответ придёт`,
|
|
196
196
|
"warn"
|
package/bitrix24/b24/types.ts
CHANGED
|
@@ -31,19 +31,20 @@ export interface AuthSaveResponse {
|
|
|
31
31
|
/**
|
|
32
32
|
* `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
|
|
33
33
|
*
|
|
34
|
-
* SDK
|
|
35
|
-
*
|
|
36
|
-
* не меняются, а зависимости от
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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`
|
|
68
|
+
* Объявляет `callMethod` сам: в `B24OAuth` этого метода нет с SDK 3.0.0.
|
|
68
69
|
*
|
|
69
70
|
* Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
|
|
70
71
|
* `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
|
|
71
|
-
* наличия в обеих
|
|
72
|
+
* наличия в обеих ветках.
|
|
72
73
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
74
|
+
* Голый `B24OAuth` под этот тип с SDK 3.0.0 не подходит — передавать нужно `$b24`
|
|
75
|
+
* (или любой свой объект с `callMethod`).
|
|
75
76
|
*/
|
|
76
77
|
export interface B24MethodCaller {
|
|
77
|
-
// Дженерика
|
|
78
|
-
//
|
|
78
|
+
// Дженерика нет, чтобы не расширять публичный тип без нужды: внутренние
|
|
79
|
+
// классы читают ответ через getResultData(), параметр типа им не нужен
|
|
79
80
|
callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
|
|
80
81
|
}
|
|
81
82
|
|
package/bitrix24/errTaskB24.ts
CHANGED
|
@@ -223,7 +223,7 @@ export async function errorB24(dataTask: ErrorTaskData): Promise<ErrorB24Result>
|
|
|
223
223
|
//
|
|
224
224
|
// Условие обязательно: сетевой отказ retry-слой уже записал сам — строкой
|
|
225
225
|
// про исчерпание попыток либо про отменённый повтор. Без проверки вторая
|
|
226
|
-
// строка ушла бы дублем в
|
|
226
|
+
// строка ушла бы дублем в Телеграм через ретранслятор, куда уходит уровень `error`
|
|
227
227
|
if (!isB24NetworkError(error)) {
|
|
228
228
|
logs.add(`errorB24: задача «${dataTask.title}» не создана (группа ${groupId ?? "не задана"}) — ${message}`, "error");
|
|
229
229
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -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
|
-
//
|
|
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, потому что строка обязана дойти до
|
|
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, дубль ушёл бы в
|
|
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
|
-
// ==================== Константы
|
|
23
|
+
// ==================== Константы канала в Телеграм ====================
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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-лога в
|
|
34
|
+
* Бюджет времени на отправку одного error-лога в ретранслятор, мс.
|
|
35
35
|
*
|
|
36
36
|
* Своя константа, а не `FETCH_TIMEOUTS` из `utils/fetchRetry.ts`: тот модуль импортирует
|
|
37
37
|
* `logs` первой строкой, и обратный импорт дал бы цикл. Дублирование одного числа
|
|
38
38
|
* дешевле цикла зависимостей.
|
|
39
39
|
*/
|
|
40
|
-
const
|
|
40
|
+
const RELAY_TIMEOUT_MS = 5_000;
|
|
41
|
+
|
|
42
|
+
/** Сколько символов тела неуспешного ответа ретранслятора показывать в `console.error` */
|
|
43
|
+
const RELAY_BODY_PREVIEW = 300;
|
|
41
44
|
|
|
42
|
-
/**
|
|
43
|
-
const
|
|
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
|
-
/**
|
|
97
|
-
private readonly
|
|
137
|
+
/** Адрес `POST /send` ретранслятора (`LOG_RELAY_URL`); `undefined` — канал выключен */
|
|
138
|
+
private readonly relayUrl: string | undefined;
|
|
98
139
|
|
|
99
|
-
/**
|
|
100
|
-
private readonly
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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` сообщение отправляется в
|
|
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
|
-
|
|
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-логи в
|
|
203
|
+
// Отправляем error-логи в Телеграм независимо от настроек консольного вывода.
|
|
204
|
+
// В консоль ниже уходят исходные message и jsonData — маскировка телефонов
|
|
205
|
+
// касается только текста для Телеграма
|
|
138
206
|
if (levelStr === "error") {
|
|
139
|
-
this.
|
|
207
|
+
this.sendToRelay(messageText, jsonData);
|
|
140
208
|
}
|
|
141
209
|
|
|
142
210
|
// Проверяем конфиг, и если логирование отключено, то выходим
|
|
@@ -161,117 +229,87 @@ class LogsAPI {
|
|
|
161
229
|
}
|
|
162
230
|
|
|
163
231
|
/**
|
|
164
|
-
*
|
|
232
|
+
* Готовит текст error-лога и отправляет его в ретранслятор (fire-and-forget).
|
|
233
|
+
*
|
|
234
|
+
* Телефоны маскируются ДО обрезки по длине: после обрезки хвост номера на границе
|
|
235
|
+
* уже не узнать шаблоном, и он ушёл бы в Телеграм открытым.
|
|
165
236
|
*/
|
|
166
|
-
private
|
|
167
|
-
|
|
168
|
-
}
|
|
237
|
+
private sendToRelay(message: string, jsonData?: unknown): void {
|
|
238
|
+
if (!this.relayUrl || !this.relayKey) return;
|
|
169
239
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
* Сам запрос
|
|
217
|
-
* не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
|
|
270
|
+
* Сам запрос к ретранслятору и разбор ответа. Никогда не бросает: все ошибки — в
|
|
271
|
+
* `console.error`, не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
|
|
272
|
+
*
|
|
273
|
+
* Нативный `fetch`, а не `fetchRetry`/`fetchWithTimeout` — во избежание циклической
|
|
274
|
+
* зависимости, поэтому таймаут выставляется здесь через `AbortSignal.timeout`: вызов идёт
|
|
275
|
+
* без `await`, и без бюджета зависшее соединение никого бы не разбудило. Сигнал действует
|
|
276
|
+
* и на чтение тела ответа — зависшее тело обрывается тем же бюджетом.
|
|
218
277
|
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* такая ошибка не доходит — у каждого чтения тела свой `.catch`, поэтому таймаут
|
|
222
|
-
* разбирается прямо там: при `!ok` к статусу дописывается пометка «тело не дочитано»,
|
|
223
|
-
* при 200 пишется отдельная строка «доставка не подтверждена».
|
|
278
|
+
* Ни адрес, ни ключ в строки ошибок не попадают: вместо текста сетевой ошибки
|
|
279
|
+
* (он может содержать адрес) печатается только её имя и код причины.
|
|
224
280
|
*/
|
|
225
|
-
private async
|
|
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: {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
309
|
+
console.error(`Logs Relay: не ответил за ${RELAY_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
|
|
271
310
|
return;
|
|
272
311
|
}
|
|
273
|
-
|
|
274
|
-
console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
|
|
312
|
+
console.error(`Logs Relay: ошибка отправки — ${describeSendError(error)}`);
|
|
275
313
|
}
|
|
276
314
|
}
|
|
277
315
|
}
|