@andrey4emk/npm-app-back-b24 3.4.0 → 3.6.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 +49 -2
- package/bitrix24/b24.ts +93 -17
- package/bitrix24/eventB24.ts +7 -4
- package/package.json +4 -2
- package/sendMessage/smsgold.ts +5 -1
- package/utils/fetchRetry.ts +53 -1
package/README.md
CHANGED
|
@@ -26,6 +26,24 @@
|
|
|
26
26
|
npm install @andrey4emk/npm-app-back-b24
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Точки входа
|
|
30
|
+
|
|
31
|
+
| Импорт | Что отдаёт | Поднимает OAuth-модуль |
|
|
32
|
+
| --------------------------------------------- | ----------------------------- | ---------------------- |
|
|
33
|
+
| `@andrey4emk/npm-app-back-b24` | всё | да |
|
|
34
|
+
| `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
|
|
35
|
+
| `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `isNetworkError`, `isPreConnectionError`, `maskUrl` | нет |
|
|
36
|
+
|
|
37
|
+
Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
// Проект без OAuth: ничего лишнего не поднимается
|
|
41
|
+
import { logs } from "@andrey4emk/npm-app-back-b24/logs";
|
|
42
|
+
import { fetchRetry, maskUrl } from "@andrey4emk/npm-app-back-b24/fetchRetry";
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Корневой импорт при этом остаётся безопасным: если `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET` не заданы, пакет считает, что OAuth в проекте не используется, молча выставляет `$b24 = null` и пишет об этом только в `debug`.
|
|
46
|
+
|
|
29
47
|
## Использование
|
|
30
48
|
|
|
31
49
|
```js
|
|
@@ -46,6 +64,7 @@ import {
|
|
|
46
64
|
logs,
|
|
47
65
|
fetchRetry,
|
|
48
66
|
isNetworkError,
|
|
67
|
+
maskUrl,
|
|
49
68
|
} from "@andrey4emk/npm-app-back-b24";
|
|
50
69
|
|
|
51
70
|
// $b24 — готовый экземпляр B24OAuth (или null, если токены не настроены)
|
|
@@ -116,6 +135,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
116
135
|
**Особенности:**
|
|
117
136
|
- При обновлении токенов SDK автоматически сохраняет их в файл через callback `setCallbackRefreshAuth`.
|
|
118
137
|
- Окружение (`DEV`/`PROD`) определяется переменной `APP_ENV` — используется как ключ секции в конфиге авторизации.
|
|
138
|
+
- Признак того, что проект использует OAuth, — заданные `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET`. Если их нет, `$b24` молча становится `null` (уровень `debug`). Если они заданы, но токенов в `authB24.json` не хватает, пишется `error` — это уже настоящая проблема конфигурации.
|
|
119
139
|
|
|
120
140
|
**Retry при сетевых ошибках:**
|
|
121
141
|
|
|
@@ -126,7 +146,11 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
126
146
|
| Попыток | 5 |
|
|
127
147
|
| Задержка | 500 мс (линейно растущая для refreshAuth) |
|
|
128
148
|
|
|
129
|
-
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `
|
|
149
|
+
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `warn` — в сообщение попадает код ошибки SDK. Уровень `warn` выбран намеренно: `error` уходит в чат B24, и пять попыток одного упавшего вызова превращались в пять сообщений. Финальный провал приходит вызывающему коду исключением и логируется им один раз.
|
|
150
|
+
|
|
151
|
+
**Создающие вызовы не повторяются.** Если в вызове есть метод, создающий сущность (`*.add`, `*.uploadfile`, `*.import`, `*.register` — в том числе внутри `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
152
|
+
|
|
153
|
+
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`, см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию, а ответ потеряться: повтор вернул бы `invalid_grant` и оставил в конфиге мёртвый `refresh_token`.
|
|
130
154
|
|
|
131
155
|
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
|
|
132
156
|
|
|
@@ -633,6 +657,29 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
633
657
|
|
|
634
658
|
- **`fetchRetry(url, options?, retries?, delay?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.).
|
|
635
659
|
|
|
660
|
+
- **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
|
|
661
|
+
|
|
662
|
+
```js
|
|
663
|
+
import { maskUrl } from "@andrey4emk/npm-app-back-b24";
|
|
664
|
+
|
|
665
|
+
logs.add(`Запрос не прошёл — ${maskUrl(url)}`, "warn");
|
|
666
|
+
// https://portal.bitrix24.ru/rest/1/***/crm.deal.get.json
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **`isPreConnectionError(error)`** — строгая проверка: соединение не состоялось, значит запрос **точно** не был выполнен сервером (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`; код ищется и во вложенных `originalError` / `cause`). Отличие от `isNetworkError()` в том, что таймаут и обрыв соединения тоже сетевые, но при них ответ мог потеряться уже после обработки запроса. Используйте эту проверку там, где повтор небезопасен сам по себе: создание сущностей, отправка сообщений, обмен токена.
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
import { isPreConnectionError } from "@andrey4emk/npm-app-back-b24";
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
await sendSms(phone, text);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
// Повторяем только если сообщение заведомо не ушло
|
|
678
|
+
if (isPreConnectionError(error)) await sendSms(phone, text);
|
|
679
|
+
else throw error;
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
636
683
|
- **`isNetworkError(error)`** — проверяет, является ли ошибка сетевой (стоит повторить запрос). Используется внутри `fetchRetry` и retry-обёртки `$b24`. Можно использовать в своём коде для аналогичных проверок.
|
|
637
684
|
|
|
638
685
|
```js
|
|
@@ -661,7 +708,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
661
708
|
- **Обрабатываемые сетевые ошибки:**
|
|
662
709
|
`ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`, `ENETUNREACH`, `EAI_AGAIN`, `UND_ERR_CONNECT_TIMEOUT`, `UND_ERR_SOCKET`, `TypeError`, а также ошибки с сообщениями `fetch failed`, `network`, `socket`.
|
|
663
710
|
|
|
664
|
-
- **Логирование:** при каждой неудачной попытке записывает сообщение через `logs.add()` с уровнем `
|
|
711
|
+
- **Логирование:** при каждой неудачной попытке записывает сообщение через `logs.add()` с уровнем `warn`. Адрес запроса перед записью пропускается через `maskUrl()`, поэтому секрет вебхука в лог не попадает.
|
|
665
712
|
|
|
666
713
|
```js
|
|
667
714
|
import { fetchRetry } from "@andrey4emk/npm-app-back-b24";
|
package/bitrix24/b24.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { B24OAuth, AjaxError, SdkError, Logger } from "@bitrix24/b24jssdk";
|
|
2
2
|
import type { B24OAuthParams, B24OAuthSecret, AuthData, AjaxResult } from "@bitrix24/b24jssdk";
|
|
3
3
|
import { logs } from "../logs/logs.ts";
|
|
4
|
-
import { isNetworkError } from "../utils/fetchRetry.ts";
|
|
4
|
+
import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
|
|
5
5
|
import Conf from "conf";
|
|
6
6
|
import path from "path";
|
|
7
7
|
import type { Request, Response } from "express";
|
|
@@ -37,11 +37,22 @@ const RETRY_DELAY_MS = 500;
|
|
|
37
37
|
/** Методы B24OAuth, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
|
|
38
38
|
const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
|
|
42
|
+
* и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
|
|
43
|
+
*/
|
|
44
|
+
const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
|
|
42
45
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
46
|
+
/**
|
|
47
|
+
* REST-методы B24, повтор которых создаёт дубликат сущности.
|
|
48
|
+
*
|
|
49
|
+
* SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
|
|
50
|
+
* внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
|
|
51
|
+
* транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
|
|
52
|
+
* задачу, второй файл и т.д. Идемпотентные записи (update, delete, set) повторять
|
|
53
|
+
* безопасно: повторное применение даёт то же состояние.
|
|
54
|
+
*/
|
|
55
|
+
const NON_IDEMPOTENT_METHOD_RE = /\.(add|uploadfile|import|register)(\?|$)/i;
|
|
45
56
|
|
|
46
57
|
const confAuthB24 = new Conf({
|
|
47
58
|
cwd: path.resolve(CONFIG_DIR),
|
|
@@ -86,11 +97,12 @@ export function getResultData<T = any>(response: AjaxResult, methodName: string)
|
|
|
86
97
|
// ==================== Создание экземпляра B24OAuth ====================
|
|
87
98
|
|
|
88
99
|
function createB24Instance(): B24OAuth | null {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
// Проект вообще не использует OAuth: ходит в B24 по входящему вебхуку либо берёт
|
|
101
|
+
// из пакета только logs/fetchRetry. Отсутствие авторизации для него — норма, а не
|
|
102
|
+
// ошибка, поэтому уровень debug: иначе ложная строка попадает в мониторинг.
|
|
103
|
+
// Признак «OAuth задуман» — наличие credentials приложения в .env.
|
|
104
|
+
if (!CLIENT_ID && !CLIENT_SECRET) {
|
|
105
|
+
logs.add("OAuth Bitrix24 не настроен (APP_B24_CLIENT_ID и APP_B24_CLIENT_SECRET не заданы), $b24 = null", "debug");
|
|
94
106
|
return null;
|
|
95
107
|
}
|
|
96
108
|
|
|
@@ -99,6 +111,14 @@ function createB24Instance(): B24OAuth | null {
|
|
|
99
111
|
return null;
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
const store = confAuthB24.store as Record<string, AuthData>;
|
|
115
|
+
const authConfig = store[APP_ENV];
|
|
116
|
+
|
|
117
|
+
if (!authConfig?.domain || !authConfig?.access_token || !authConfig?.refresh_token) {
|
|
118
|
+
logs.add("В конфиге authB24 не хватает данных для авторизации. Сохрани токены через клиент и перезагрузи докер", "error");
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
const domain = cleanDomain(authConfig.domain);
|
|
103
123
|
|
|
104
124
|
const authParams: B24OAuthParams = {
|
|
@@ -221,11 +241,17 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
|
|
|
221
241
|
return await $b24.auth.refreshAuth();
|
|
222
242
|
} catch (error) {
|
|
223
243
|
lastError = error;
|
|
224
|
-
|
|
244
|
+
|
|
245
|
+
// Повторяем только когда соединение заведомо не состоялось: при таймауте
|
|
246
|
+
// или обрыве сервер мог уже провести ротацию, ответ потеряться, и повтор
|
|
247
|
+
// вернёт invalid_grant — refresh-токен окажется сожжён, потребуется
|
|
248
|
+
// ручная переавторизация
|
|
249
|
+
if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
|
|
250
|
+
|
|
225
251
|
const msg = error instanceof Error ? error.message : String(error);
|
|
226
252
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
227
253
|
const delayMs = RETRY_DELAY_MS * attempt;
|
|
228
|
-
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "
|
|
254
|
+
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
|
|
229
255
|
await delay(delayMs);
|
|
230
256
|
}
|
|
231
257
|
}
|
|
@@ -312,19 +338,48 @@ function isB24NetworkError(error: unknown): boolean {
|
|
|
312
338
|
|
|
313
339
|
if (error instanceof SdkError) {
|
|
314
340
|
const { code, originalError, status } = error;
|
|
341
|
+
const originalCode = (originalError as { code?: string } | undefined)?.code;
|
|
315
342
|
|
|
316
|
-
if (
|
|
343
|
+
if (NETWORK_ERROR_CODES.has(code)) return true;
|
|
317
344
|
if (originalError && isNetworkError(originalError)) return true;
|
|
318
|
-
if (
|
|
345
|
+
if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
|
|
319
346
|
if (status === 0) return true;
|
|
320
347
|
}
|
|
321
348
|
|
|
322
349
|
return isNetworkError(error);
|
|
323
350
|
}
|
|
324
351
|
|
|
352
|
+
/** Достаёт имена REST-методов B24 из аргументов вызова SDK */
|
|
353
|
+
function extractB24Methods(sdkMethod: string, args: any[]): string[] {
|
|
354
|
+
const first = args[0];
|
|
355
|
+
|
|
356
|
+
// callMethod(method, params) и callListMethod(method, params, ...)
|
|
357
|
+
if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
|
|
358
|
+
return typeof first === "string" ? [first] : [];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
|
|
362
|
+
if (sdkMethod === "callBatch") {
|
|
363
|
+
const commands: unknown[] = Array.isArray(first) ? first : first && typeof first === "object" ? Object.values(first) : [];
|
|
364
|
+
|
|
365
|
+
return commands
|
|
366
|
+
.map((cmd) => (typeof cmd === "string" ? cmd : (cmd as { method?: unknown })?.method))
|
|
367
|
+
.filter((method): method is string => typeof method === "string");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Возвращает методы вызова, повтор которых создал бы дубликаты сущностей */
|
|
374
|
+
function getNonIdempotentMethods(sdkMethod: string, args: any[]): string[] {
|
|
375
|
+
return extractB24Methods(sdkMethod, args).filter((method) => NON_IDEMPOTENT_METHOD_RE.test(method));
|
|
376
|
+
}
|
|
377
|
+
|
|
325
378
|
/** Оборачивает async-функцию retry-логикой при сетевых ошибках */
|
|
326
379
|
function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
|
|
327
380
|
return (async (...args: any[]) => {
|
|
381
|
+
// Состав вызова между попытками не меняется — разбираем один раз
|
|
382
|
+
const nonIdempotent = getNonIdempotentMethods(methodName, args);
|
|
328
383
|
let lastError: unknown;
|
|
329
384
|
|
|
330
385
|
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
@@ -339,7 +394,21 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
|
|
|
339
394
|
|
|
340
395
|
const msg = error instanceof Error ? error.message : String(error);
|
|
341
396
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
342
|
-
|
|
397
|
+
|
|
398
|
+
// Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
|
|
399
|
+
// а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
|
|
400
|
+
// не повторяем — лучше вернуть ошибку, чем создать дубликат
|
|
401
|
+
if (nonIdempotent.length > 0) {
|
|
402
|
+
logs.add(
|
|
403
|
+
`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${nonIdempotent.join(", ")}) — ${msg}`,
|
|
404
|
+
"warn"
|
|
405
|
+
);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Уровень warn: промежуточные попытки не должны улетать в чат B24,
|
|
410
|
+
// финальный провал прилетит вызывающему коду исключением
|
|
411
|
+
logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
|
|
343
412
|
|
|
344
413
|
await delay(RETRY_DELAY_MS);
|
|
345
414
|
}
|
|
@@ -410,7 +479,7 @@ export async function reinitializeB24(): Promise<SaveResult> {
|
|
|
410
479
|
$b24 = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
|
|
411
480
|
|
|
412
481
|
if (!_b24Raw || !$b24) {
|
|
413
|
-
return { error: true, message: "Не удалось пересоздать $b24 — проверь authB24.json" };
|
|
482
|
+
return { error: true, message: "Не удалось пересоздать $b24 — проверь authB24.json и APP_B24_CLIENT_ID/APP_B24_CLIENT_SECRET" };
|
|
414
483
|
}
|
|
415
484
|
|
|
416
485
|
setupRefreshCallback(_b24Raw);
|
|
@@ -426,5 +495,12 @@ export async function reinitializeB24(): Promise<SaveResult> {
|
|
|
426
495
|
|
|
427
496
|
if (_b24Raw) {
|
|
428
497
|
setupRefreshCallback(_b24Raw);
|
|
429
|
-
|
|
498
|
+
|
|
499
|
+
// Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
|
|
500
|
+
// запросом к oauth.bitrix.info. refreshAndSaveTokens() исключений не бросает —
|
|
501
|
+
// ошибку возвращает в SaveResult и пишет в лог сам
|
|
502
|
+
void (async () => {
|
|
503
|
+
await refreshAndSaveTokens();
|
|
504
|
+
startProactiveRefresh();
|
|
505
|
+
})();
|
|
430
506
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -154,10 +154,13 @@ export class Event {
|
|
|
154
154
|
|
|
155
155
|
if (allEvents.length > 0) {
|
|
156
156
|
data.processId = arrOfflineEvents.process_id;
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// Событие может принести несколько сообщений. Раньше бралось только
|
|
158
|
+
// первое, а остальные терялись безвозвратно — событие после обработки
|
|
159
|
+
// удаляется из очереди B24
|
|
160
|
+
data.message = allEvents.flatMap((event) => {
|
|
161
|
+
const messages = event.EVENT_DATA.MESSAGES ?? [];
|
|
159
162
|
|
|
160
|
-
return {
|
|
163
|
+
return messages.map((messageData: any) => ({
|
|
161
164
|
connectorId: event.EVENT_DATA.CONNECTOR,
|
|
162
165
|
lineId: event.EVENT_DATA.LINE,
|
|
163
166
|
chatId: messageData.chat.id,
|
|
@@ -165,7 +168,7 @@ export class Event {
|
|
|
165
168
|
file: this.convertB24FilesToTelegramFormat(messageData.message.files),
|
|
166
169
|
attachments: messageData.message.attachments || null,
|
|
167
170
|
im: messageData.im || null,
|
|
168
|
-
};
|
|
171
|
+
}));
|
|
169
172
|
});
|
|
170
173
|
}
|
|
171
174
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrey4emk/npm-app-back-b24",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Bitrix24 OAuth helpers for Node.js projects",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
8
|
+
".": "./index.ts",
|
|
9
|
+
"./logs": "./logs/logs.ts",
|
|
10
|
+
"./fetchRetry": "./utils/fetchRetry.ts"
|
|
9
11
|
},
|
|
10
12
|
"scripts": {
|
|
11
13
|
"test": "echo \"No tests\" && exit 0",
|
package/sendMessage/smsgold.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { B24OAuth } from "@bitrix24/b24jssdk";
|
|
2
2
|
import { getResultData } from "../bitrix24/b24.ts";
|
|
3
|
+
import { fetchRetry } from "../utils/fetchRetry.ts";
|
|
3
4
|
|
|
4
5
|
// ==================== Типы ====================
|
|
5
6
|
|
|
@@ -150,7 +151,10 @@ export class Smsgold {
|
|
|
150
151
|
|
|
151
152
|
private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
|
|
152
153
|
try {
|
|
153
|
-
|
|
154
|
+
// Скачивание файла идемпотентно — повтор при сетевом сбое безопасен.
|
|
155
|
+
// Отправка SMS (fetch выше по коду) намеренно остаётся без retry:
|
|
156
|
+
// при потерянном ответе повтор отправил бы сообщение второй раз
|
|
157
|
+
const response = await fetchRetry(fileUrl);
|
|
154
158
|
if (!response.ok) {
|
|
155
159
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
156
160
|
}
|
package/utils/fetchRetry.ts
CHANGED
|
@@ -32,6 +32,58 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
|
|
37
|
+
* хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
|
|
38
|
+
*/
|
|
39
|
+
const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Проверяет, что соединение не состоялось — запрос точно не был выполнен сервером.
|
|
43
|
+
*
|
|
44
|
+
* Отличается от `isNetworkError()` строгостью: таймаут и обрыв соединения тоже сетевые,
|
|
45
|
+
* но при них ответ мог потеряться уже после того, как сервер обработал запрос.
|
|
46
|
+
* Используйте эту проверку вместо `isNetworkError()` там, где повтор небезопасен сам
|
|
47
|
+
* по себе: обмен refresh-токена, создание сущностей, отправка сообщений.
|
|
48
|
+
*
|
|
49
|
+
* @param error — пойманная ошибка
|
|
50
|
+
* @returns true, если повторить запрос заведомо безопасно
|
|
51
|
+
*/
|
|
52
|
+
export function isPreConnectionError(error: unknown): boolean {
|
|
53
|
+
if (!error || typeof error !== "object") return false;
|
|
54
|
+
|
|
55
|
+
const codes: unknown[] = [
|
|
56
|
+
(error as { code?: unknown }).code,
|
|
57
|
+
(error as { originalError?: { code?: unknown } }).originalError?.code,
|
|
58
|
+
(error as { cause?: { code?: unknown } }).cause?.code,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
return codes.some((code) => typeof code === "string" && PRE_CONNECTION_ERROR_CODES.includes(code));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Маскирует секреты в URL перед записью в лог.
|
|
66
|
+
*
|
|
67
|
+
* URL входящего вебхука B24 содержит секрет прямо в пути
|
|
68
|
+
* (`https://портал.bitrix24.ru/rest/1/<секрет>/method.json`), а токен авторизации
|
|
69
|
+
* может приходить в query-параметрах. Хост и имя метода сохраняются — они нужны
|
|
70
|
+
* для диагностики, тело секрета заменяется на `***`.
|
|
71
|
+
*
|
|
72
|
+
* @param url — адрес запроса в любом виде, принимаемом fetch
|
|
73
|
+
* @returns строка URL, пригодная для логирования
|
|
74
|
+
*/
|
|
75
|
+
export function maskUrl(url: string | URL | Request): string {
|
|
76
|
+
const raw = typeof url === "string" ? url : url instanceof URL ? url.href : url.url;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
raw
|
|
80
|
+
// Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
|
|
81
|
+
.replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
|
|
82
|
+
// Токены в query-параметрах
|
|
83
|
+
.replace(/([?&](?:auth|access_token|refresh_token|token)=)[^&#]+/gi, "$1***")
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
35
87
|
/**
|
|
36
88
|
* Обёртка над fetch с повторными попытками при сетевых ошибках.
|
|
37
89
|
* Повторяет запрос только при проблемах с сетью (TypeError, ECONNRESET и т.д.),
|
|
@@ -63,7 +115,7 @@ export async function fetchRetry(
|
|
|
63
115
|
}
|
|
64
116
|
|
|
65
117
|
logs.add(
|
|
66
|
-
`fetchRetry: попытка ${attempt}/${retries} не удалась (${lastError.message}), повтор через ${delay}мс — ${
|
|
118
|
+
`fetchRetry: попытка ${attempt}/${retries} не удалась (${lastError.message}), повтор через ${delay}мс — ${maskUrl(url)}`,
|
|
67
119
|
"warn"
|
|
68
120
|
);
|
|
69
121
|
|