@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 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 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `warn` — в сообщение попадает код ошибки SDK. Уровень `warn` выбран намеренно: `error` уходит в чат B24, и пять попыток одного упавшего вызова превращались в пять сообщений. Финальный провал приходит вызывающему коду исключением и логируется им один раз.
149
+ Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
150
150
 
151
- **Создающие вызовы не повторяются.** Если в вызове есть метод, создающий сущность (`*.add`, `*.uploadfile`, `*.import`, `*.register` в том числе внутри `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
151
+ **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказна `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
152
152
 
153
- **Обмен refresh-токена** повторяется по более строгому правилу только если соединение заведомо не состоялось (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`, см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию, а ответ потеряться: повтор вернул бы `invalid_grant` и оставил в конфиге мёртвый `refresh_token`.
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
- * задачу, второй файл и т.д. Идемпотентные записи (update, delete, set) повторять
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
- // вернёт invalid_grant — refresh-токен окажется сожжён, потребуется
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
- /** Достаёт имена REST-методов B24 из аргументов вызова SDK */
353
- function extractB24Methods(sdkMethod: string, args: any[]): string[] {
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
- return typeof first === "string" ? [first] : [];
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
- // callBatch(calls, ...) команды приходят массивом либо объектом-словарём
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
- return commands
366
- .map((cmd) => (typeof cmd === "string" ? cmd : (cmd as { method?: unknown })?.method))
367
- .filter((method): method is string => typeof method === "string");
400
+ // callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
401
+ if (!first || typeof first !== "object") {
402
+ return "аргументы batch не разобраны";
368
403
  }
369
404
 
370
- return [];
371
- }
405
+ const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
406
+ const blocking: string[] = [];
372
407
 
373
- /** Возвращает методы вызова, повтор которых создал бы дубликаты сущностей */
374
- function getNonIdempotentMethods(sdkMethod: string, args: any[]): string[] {
375
- return extractB24Methods(sdkMethod, args).filter((method) => NON_IDEMPOTENT_METHOD_RE.test(method));
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 nonIdempotent = getNonIdempotentMethods(methodName, args);
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
- if (!isB24NetworkError(error) || attempt === RETRY_COUNT) {
392
- throw error;
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 (nonIdempotent.length > 0) {
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
- // Уровень warn: промежуточные попытки не должны улетать в чат B24,
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. refreshAndSaveTokens() исключений не бросает
501
- // ошибку возвращает в SaveResult и пишет в лог сам
546
+ // запросом к oauth.bitrix.info. try/finally обязателен если обновление всё же
547
+ // бросит (например, битый log.json уронит логгер), без него таймер не стартует
548
+ // никогда и процесс проживёт без обновления токена до перезапуска
502
549
  void (async () => {
503
- await refreshAndSaveTokens();
504
- startProactiveRefresh();
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
  }
@@ -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 messages = event.EVENT_DATA.MESSAGES ?? [];
162
-
163
- return messages.map((messageData: any) => ({
164
- connectorId: event.EVENT_DATA.CONNECTOR,
165
- lineId: event.EVENT_DATA.LINE,
166
- chatId: messageData.chat.id,
167
- text: messageData.message.text || null,
168
- file: this.convertB24FilesToTelegramFormat(messageData.message.files),
169
- attachments: messageData.message.attachments || null,
170
- im: messageData.im || null,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -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
- .replace(/([?&](?:auth|access_token|refresh_token|token)=)[^&#]+/gi, "$1***")
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