@andrey4emk/npm-app-back-b24 3.6.0 → 3.6.1
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 +9 -3
- package/bitrix24/b24.ts +90 -37
- package/bitrix24/eventB24.ts +34 -12
- package/package.json +1 -1
- package/utils/fetchRetry.ts +4 -3
package/README.md
CHANGED
|
@@ -146,11 +146,17 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
|
|
|
146
146
|
| Попыток | 5 |
|
|
147
147
|
| Задержка | 500 мс (линейно растущая для refreshAuth) |
|
|
148
148
|
|
|
149
|
-
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
|
|
149
|
+
Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
**Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
154
|
+
|
|
155
|
+
Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
|
|
156
|
+
|
|
157
|
+
Важно: **у SDK есть собственный слой ретраев**, который гейт не отменяет. При настоящем разрыве соединения (`ECONNRESET` и подобные) SDK сходит на портал до 3 раз независимо от нашей защиты. Гейт закрывает конкретный случай — `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` и самим SDK не повторяется.
|
|
158
|
+
|
|
159
|
+
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
|
|
154
160
|
|
|
155
161
|
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
|
|
156
162
|
|
package/bitrix24/b24.ts
CHANGED
|
@@ -44,15 +44,16 @@ const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"])
|
|
|
44
44
|
const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* REST-методы B24, повтор которых создаёт дубликат
|
|
47
|
+
* REST-методы B24, повтор которых создаёт дубликат сущности или запускает
|
|
48
|
+
* повторное действие: add, create, start, send, uploadfile, import, register.
|
|
48
49
|
*
|
|
49
50
|
* SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
|
|
50
51
|
* внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
|
|
51
52
|
* транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
|
|
52
|
-
* задачу, второй
|
|
53
|
-
* безопасно: повторное применение даёт то же состояние.
|
|
53
|
+
* задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи
|
|
54
|
+
* (update, delete, set) повторять безопасно: повторное применение даёт то же состояние.
|
|
54
55
|
*/
|
|
55
|
-
const NON_IDEMPOTENT_METHOD_RE = /\.(add|uploadfile|import|register)(\?|$)/i;
|
|
56
|
+
const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
|
|
56
57
|
|
|
57
58
|
const confAuthB24 = new Conf({
|
|
58
59
|
cwd: path.resolve(CONFIG_DIR),
|
|
@@ -242,10 +243,12 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
|
|
|
242
243
|
} catch (error) {
|
|
243
244
|
lastError = error;
|
|
244
245
|
|
|
245
|
-
// Повторяем только когда соединение заведомо не
|
|
246
|
-
// или обрыве сервер мог уже провести
|
|
247
|
-
//
|
|
248
|
-
//
|
|
246
|
+
// Повторяем только когда соединение заведомо не состоялось.
|
|
247
|
+
// При таймауте или обрыве сервер мог уже провести ротацию: тогда
|
|
248
|
+
// локальный refresh-токен мёртв независимо от наших действий, и повтор
|
|
249
|
+
// не помогает, а лишь маскирует проблему серией одинаковых отказов.
|
|
250
|
+
// Следующая попытка всё равно будет — проактивный таймер повторит
|
|
251
|
+
// через 2 минуты при времени жизни токена около часа
|
|
249
252
|
if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
|
|
250
253
|
|
|
251
254
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -349,37 +352,74 @@ function isB24NetworkError(error: unknown): boolean {
|
|
|
349
352
|
return isNetworkError(error);
|
|
350
353
|
}
|
|
351
354
|
|
|
352
|
-
/**
|
|
353
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Достаёт имя REST-метода из одной команды batch.
|
|
357
|
+
* Возвращает null, если форма команды не распознана.
|
|
358
|
+
*/
|
|
359
|
+
function extractBatchCommandMethod(cmd: unknown): string | null {
|
|
360
|
+
// Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
|
|
361
|
+
if (Array.isArray(cmd)) {
|
|
362
|
+
return typeof cmd[0] === "string" ? cmd[0] : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Объект { method, params }
|
|
366
|
+
if (cmd && typeof cmd === "object") {
|
|
367
|
+
const method = (cmd as { method?: unknown }).method;
|
|
368
|
+
return typeof method === "string" ? method : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
|
|
372
|
+
// (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
|
|
373
|
+
// если потребитель остался на ней со старой версии
|
|
374
|
+
if (typeof cmd === "string") {
|
|
375
|
+
return cmd;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Возвращает причину, по которой вызов запрещено повторять, либо null.
|
|
383
|
+
*
|
|
384
|
+
* Для callBatch действует правило fail-closed: если хоть одну команду разобрать
|
|
385
|
+
* не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
|
|
386
|
+
* дешевле — потребитель получит ошибку вместо тихого дубликата.
|
|
387
|
+
*/
|
|
388
|
+
function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
|
|
354
389
|
const first = args[0];
|
|
355
390
|
|
|
356
391
|
// callMethod(method, params) и callListMethod(method, params, ...)
|
|
357
392
|
if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
|
|
358
|
-
|
|
393
|
+
if (typeof first !== "string") return null;
|
|
394
|
+
const method = first.trim();
|
|
395
|
+
return NON_IDEMPOTENT_METHOD_RE.test(method) ? method : null;
|
|
359
396
|
}
|
|
360
397
|
|
|
361
|
-
|
|
362
|
-
if (sdkMethod === "callBatch") {
|
|
363
|
-
const commands: unknown[] = Array.isArray(first) ? first : first && typeof first === "object" ? Object.values(first) : [];
|
|
398
|
+
if (sdkMethod !== "callBatch") return null;
|
|
364
399
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
400
|
+
// callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
|
|
401
|
+
if (!first || typeof first !== "object") {
|
|
402
|
+
return "аргументы batch не разобраны";
|
|
368
403
|
}
|
|
369
404
|
|
|
370
|
-
|
|
371
|
-
|
|
405
|
+
const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
|
|
406
|
+
const blocking: string[] = [];
|
|
372
407
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
408
|
+
for (const cmd of commands) {
|
|
409
|
+
const method = extractBatchCommandMethod(cmd);
|
|
410
|
+
|
|
411
|
+
if (method === null) return "команда batch не разобрана";
|
|
412
|
+
if (NON_IDEMPOTENT_METHOD_RE.test(method.trim())) blocking.push(method.trim());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return blocking.length > 0 ? blocking.join(", ") : null;
|
|
376
416
|
}
|
|
377
417
|
|
|
378
418
|
/** Оборачивает async-функцию retry-логикой при сетевых ошибках */
|
|
379
419
|
function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
|
|
380
420
|
return (async (...args: any[]) => {
|
|
381
421
|
// Состав вызова между попытками не меняется — разбираем один раз
|
|
382
|
-
const
|
|
422
|
+
const blockReason = getRetryBlockReason(methodName, args);
|
|
383
423
|
let lastError: unknown;
|
|
384
424
|
|
|
385
425
|
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
@@ -388,26 +428,32 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: a
|
|
|
388
428
|
} catch (error: unknown) {
|
|
389
429
|
lastError = error;
|
|
390
430
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
431
|
+
// Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
|
|
432
|
+
// вызывающему коду как есть, он решает, что с ней делать
|
|
433
|
+
if (!isB24NetworkError(error)) throw error;
|
|
394
434
|
|
|
395
435
|
const msg = error instanceof Error ? error.message : String(error);
|
|
396
436
|
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
397
437
|
|
|
438
|
+
// Окончательные отказы логируем на error: собственные модули пакета
|
|
439
|
+
// (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
|
|
440
|
+
// но не пишут в лог — без этих строк сбой $b24 не виден нигде
|
|
441
|
+
|
|
442
|
+
if (attempt === RETRY_COUNT) {
|
|
443
|
+
logs.add(`$b24.${methodName}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
|
|
398
447
|
// Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
|
|
399
448
|
// а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
|
|
400
449
|
// не повторяем — лучше вернуть ошибку, чем создать дубликат
|
|
401
|
-
if (
|
|
402
|
-
logs.add(
|
|
403
|
-
`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${nonIdempotent.join(", ")}) — ${msg}`,
|
|
404
|
-
"warn"
|
|
405
|
-
);
|
|
450
|
+
if (blockReason) {
|
|
451
|
+
logs.add(`$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) — ${msg}`, "error");
|
|
406
452
|
throw error;
|
|
407
453
|
}
|
|
408
454
|
|
|
409
|
-
//
|
|
410
|
-
//
|
|
455
|
+
// Промежуточные попытки — warn: в чат B24 уходит только уровень error,
|
|
456
|
+
// иначе один упавший вызов дал бы пять сообщений
|
|
411
457
|
logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
|
|
412
458
|
|
|
413
459
|
await delay(RETRY_DELAY_MS);
|
|
@@ -497,10 +543,17 @@ if (_b24Raw) {
|
|
|
497
543
|
setupRefreshCallback(_b24Raw);
|
|
498
544
|
|
|
499
545
|
// Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
|
|
500
|
-
// запросом к oauth.bitrix.info.
|
|
501
|
-
//
|
|
546
|
+
// запросом к oauth.bitrix.info. try/finally обязателен — если обновление всё же
|
|
547
|
+
// бросит (например, битый log.json уронит логгер), без него таймер не стартует
|
|
548
|
+
// никогда и процесс проживёт без обновления токена до перезапуска
|
|
502
549
|
void (async () => {
|
|
503
|
-
|
|
504
|
-
|
|
550
|
+
try {
|
|
551
|
+
await refreshAndSaveTokens();
|
|
552
|
+
} catch (error: unknown) {
|
|
553
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
554
|
+
logs.add(`Начальное обновление токенов упало: ${msg}`, "error");
|
|
555
|
+
} finally {
|
|
556
|
+
startProactiveRefresh();
|
|
557
|
+
}
|
|
505
558
|
})();
|
|
506
559
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -62,6 +62,21 @@ interface OfflineEventsResponse {
|
|
|
62
62
|
|
|
63
63
|
const SYSTEM_USER_ID = "138"; // ID системного пользователя для фильтрации
|
|
64
64
|
|
|
65
|
+
// ==================== Утилиты ====================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Приводит MESSAGES из события к массиву.
|
|
69
|
+
*
|
|
70
|
+
* B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
|
|
71
|
+
* сериализует разреженный массив), поэтому опираться на одну форму нельзя.
|
|
72
|
+
* Неизвестная форма даёт пустой массив, а не исключение.
|
|
73
|
+
*/
|
|
74
|
+
function normalizeMessages(messages: unknown): any[] {
|
|
75
|
+
if (Array.isArray(messages)) return messages;
|
|
76
|
+
if (messages && typeof messages === "object") return Object.values(messages);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
// ==================== Класс ====================
|
|
66
81
|
|
|
67
82
|
/**
|
|
@@ -156,19 +171,26 @@ export class Event {
|
|
|
156
171
|
data.processId = arrOfflineEvents.process_id;
|
|
157
172
|
// Событие может принести несколько сообщений. Раньше бралось только
|
|
158
173
|
// первое, а остальные терялись безвозвратно — событие после обработки
|
|
159
|
-
// удаляется из очереди B24
|
|
174
|
+
// удаляется из очереди B24.
|
|
175
|
+
//
|
|
176
|
+
// Разбор намеренно устойчив к форме и к битым элементам: исключение
|
|
177
|
+
// здесь означало бы, что get() вернёт ошибку, потребитель не вызовет
|
|
178
|
+
// clear(), и те же события придут следующим опросом — очередь встала бы
|
|
179
|
+
// навсегда на одном кривом сообщении
|
|
160
180
|
data.message = allEvents.flatMap((event) => {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
const eventData = event.EVENT_DATA ?? {};
|
|
182
|
+
|
|
183
|
+
return normalizeMessages(eventData.MESSAGES)
|
|
184
|
+
.filter((messageData) => messageData?.chat && messageData?.message)
|
|
185
|
+
.map((messageData) => ({
|
|
186
|
+
connectorId: eventData.CONNECTOR,
|
|
187
|
+
lineId: eventData.LINE,
|
|
188
|
+
chatId: messageData.chat.id,
|
|
189
|
+
text: messageData.message.text || null,
|
|
190
|
+
file: this.convertB24FilesToTelegramFormat(messageData.message.files),
|
|
191
|
+
attachments: messageData.message.attachments || null,
|
|
192
|
+
im: messageData.im || null,
|
|
193
|
+
}));
|
|
172
194
|
});
|
|
173
195
|
}
|
|
174
196
|
|
package/package.json
CHANGED
package/utils/fetchRetry.ts
CHANGED
|
@@ -36,7 +36,7 @@ export function isNetworkError(error: unknown): boolean {
|
|
|
36
36
|
* Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
|
|
37
37
|
* хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
|
|
38
38
|
*/
|
|
39
|
-
const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH"];
|
|
39
|
+
const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "EAI_NODATA", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN"];
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Проверяет, что соединение не состоялось — запрос точно не был выполнен сервером.
|
|
@@ -79,8 +79,9 @@ export function maskUrl(url: string | URL | Request): string {
|
|
|
79
79
|
raw
|
|
80
80
|
// Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
|
|
81
81
|
.replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
|
|
82
|
-
// Токены в query
|
|
83
|
-
|
|
82
|
+
// Токены и подписи в query-параметрах. signature и sessid нужны отдельно:
|
|
83
|
+
// ссылка на файл B24-диска несёт секрет именно в signature
|
|
84
|
+
.replace(/([?&](?:auth|access_token|refresh_token|token|signature|sessid)=)[^&#]+/gi, "$1***")
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|