@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 CHANGED
@@ -17,6 +17,8 @@
17
17
  - [Логирование](#логирование)
18
18
  - [fetchRetry и таймауты](#fetchretry-и-таймауты)
19
19
  - [Переменные окружения](#переменные-окружения)
20
+ - [Миграция на 5.1.0](#миграция-на-510)
21
+ - [Миграция на 5.0.0](#миграция-на-500)
20
22
  - [Миграция на 4.0.0](#миграция-на-400)
21
23
  - [Скрипты](#скрипты)
22
24
  - [Лицензия](#лицензия)
@@ -27,6 +29,8 @@
27
29
  npm install @andrey4emk/npm-app-back-b24
28
30
  ```
29
31
 
32
+ Нужен Node.js 22 или новее (с 5.0.0 — его требует `@bitrix24/b24jssdk` 3.0.0).
33
+
30
34
  `nodemailer` — **опциональная peer-зависимость**: её ставит потребитель, и только если пользуется классом `Email`. Всем остальным она не нужна и не приезжает.
31
35
 
32
36
  ```bash
@@ -207,7 +211,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
207
211
 
208
212
  **Методы `callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk` — фасад пакета.**
209
213
 
210
- SDK 2.x помечает эти пять методов к удалению в следующем major и на каждый вызов пишет предупреждение об устаревании. Пакет реализует их сам поверх `actions.v2.*`, поэтому вызовы у потребителей не меняются, а зависимости от удаляемого API у пакета больше нет. Сигнатуры и типы возврата прежние — правок в проектах не требуется.
214
+ В SDK 3.0.0 этих пяти методов нет. Пакет реализует их сам поверх `actions.v2.*`, поэтому вызовы у потребителей не меняются, а зависимости от удалённого API у пакета нет. Сигнатуры и типы возврата прежние — правок в проектах не требуется.
211
215
 
212
216
  Поведение прежнее у четырёх методов из пяти. **`callListMethod` отличается в двух местах:** он бросает внятную ошибку с именем метода там, где SDK падал `TypeError` из своих недр (портал вернул «мягкую» ошибку, ответ не список, конверт нечитаем, `next` не растёт), и у него есть потолок в 1000 страниц — при упоре в него вызов тоже бросает, а не отдаёт молча обрезанный список. Если у вас есть `try`/`catch` вокруг постраничных выборок, текст ошибки изменится; если его нет — падение станет заметнее, но не появится там, где раньше всё работало.
213
217
 
@@ -236,7 +240,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
236
240
 
237
241
  Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
238
242
 
239
- **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
243
+ **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в Телеграм-группу через ретранслятор вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
240
244
 
241
245
  **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. Обрыв приходит и когда соединение не состоялось, и когда оно случилось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
242
246
 
@@ -252,9 +256,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
252
256
 
253
257
  **Провал обмена refresh-токена повторов не получает.** Когда обмен падает внутри вызова, попытка ровно одна, а в лог идёт строка уровня `error` с исходным кодом и статусом ответа oauth-хоста: `обновление токена не удалось (invalid_grant/400), повтор небезопасен: сервер мог провести ротацию`. Под правило попадает **любой** отказ обмена, кроме доказанного pre-connection, — и мёртвый токен после ротации, и транзиентные 429/503 от oauth-хоста: различить их по ответу нельзя. Если ротация прошла, локальный `refresh_token` мёртв независимо от повтора и нужна переавторизация приложения на портале. DNS-сбой на oauth-хосте под это правило не попадает: соединение не состоялось, значит повторяем.
254
258
 
255
- Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
259
+ Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x и 3.0) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
256
260
 
257
- Важно: **транспортные сбои SDK не повторяет вообще** — пакет задаёт параметры ограничителя при создании клиента. Иначе гейт был бы дырявым: SDK не различает идемпотентные и создающие вызовы и повторил бы оборванный `crm.deal.add` до трёх раз внутри себя, куда наша защита не дотягивается. Измерено: без этих параметров обрыв соединения, 502 и 504 дают по три выполнения на портале, с ними — по одному.
261
+ Важно: **транспортные сбои SDK не повторяет вообще** — пакет задаёт параметры ограничителя при создании клиента. Иначе гейт был бы дырявым: SDK не различает идемпотентные и создающие вызовы и повторил бы оборванный `crm.deal.add` до трёх раз внутри себя, куда наша защита не дотягивается. Измерено: без этих параметров обрыв соединения, 502 и 504 дают по три выполнения на портале, с ними — по одному. На SDK 3.0.0 замер повторён и закреплён тестом `tests/sdk-executions.test.ts`: создающий вызов выполняется ровно один раз во всех сценариях сбоя.
258
262
 
259
263
  Повторы SDK при 429 и 503 сохранены: портал отвечает отказом, не выполнив запрос.
260
264
 
@@ -274,7 +278,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
274
278
 
275
279
  **«Мягкие» ошибки Bitrix24:**
276
280
 
277
- Часть ошибок (`ERROR_ENTITY_NOT_FOUND`, `BITRIX_REST_V3_EXCEPTION_*`) SDK не бросает исключением, а возвращает как `AjaxResult` с ошибкой: `isSuccess === false`, и тогда `response.getData()` вернёт `undefined`. Отдельный случай — успешный ответ, у которого сам `result` пустой (`null`/`undefined`). Чтобы не падать на `undefined`, разбирайте ответы через `getResultData()` — он покрывает обе ситуации и бросает понятную ошибку с именем метода.
281
+ Часть ошибок (`ERROR_ENTITY_NOT_FOUND`, `BITRIX_REST_V3_EXCEPTION_*`) SDK не бросает исключением, а возвращает как `AjaxResult` с ошибкой: `isSuccess === false`, и тогда `response.getData()` вернёт `undefined`. У REST v3 (`$b24.actions.v3.*`) с SDK 3.0.0 «мягкой» стала ошибка 4xx, кроме 401, 408 и 429. Так ведут себя только ответы, пришедшие в конверте v3, с кодом вне жёсткого списка SDK (нехватка scope, `ACCESS_DENIED`, `NOT_FOUND`, `INVALID_ARG_VALUE`) и вне `hardErrorCodes` пакета; коды из этих списков по-прежнему бросаются, как и плоский ответ v2 от шлюза. Поэтому нужны и `catch`, и проверка `isSuccess`. Отдельный случай — успешный ответ, у которого сам `result` пустой (`null`/`undefined`). Чтобы не падать на `undefined`, разбирайте ответы через `getResultData()` — он покрывает обе ситуации и бросает понятную ошибку с именем метода.
282
+
283
+ **Ответ портала без JSON-конверта.** Если портал или прокси перед ним отдал со статусом 200 HTML-страницу, пустое тело или JSON без `result` и `error` (`{}`, `{ time }`), вызов бросает `JSSDK_UNKNOWN_ERROR` с понятным текстом: `портал вернул не JSON: HTTP 200, text/html, «<!DOCTYPE html>…»` или `портал вернул JSON без result и error: HTTP 200, …`. Без этой проверки SDK 3.0.0 принял бы такой ответ за успех — `crm.deal.add` вернул бы `{}` вместо ID сделки. Чтение повторяется с паузами 1-2-4-8 с, создающие вызовы не повторяются. Без конверта законно отвечают `rest.documentation.openapi` (через `$b24` не вызвать) и бинарные загрузки с `responseType`, отличным от `json` (`deferredBatch.download`), — их перехватчик пропускает.
278
284
 
279
285
  **Проактивное обновление токена:**
280
286
 
@@ -382,7 +388,18 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
382
388
 
383
389
  Без обёртки вызов `$b24.actions.*` идёт мимо защиты: ни повторов при сетевых сбоях, ни блокировки повтора для создающих методов.
384
390
 
385
- - **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`. Все пять сигнатур объявлены в интерфейсе явно, а не взяты по наследству: в SDK 3.x этих методов у базового класса не будет, и без явных объявлений тип потерял бы весь фасад. Держит их тест `tests/client-type.test.ts`: он читает текст `types.ts` (пропажу объявления система типов не видит — метод пока наследуется) и сверяет кортежи параметров строгим равенством типов, поэтому потеря необязательного аргумента или расширение параметра до `any` роняют `npx tsc --noEmit`.
391
+ **REST v3: проверяйте `isSuccess`.** С SDK 3.0.0 ошибки 4xx у `actions.v3.*` (кроме 401/408/429) не бросаются, а приходят в `AjaxResult` с `isSuccess === false`. `catch` вокруг `callProtected` их не поймает. Так ведут себя только ответы, пришедшие в конверте v3, с кодом вне жёсткого списка SDK (нехватка scope, `ACCESS_DENIED`, `NOT_FOUND`, `INVALID_ARG_VALUE`) и вне `hardErrorCodes` пакета; коды из этих списков по-прежнему бросаются, как и плоский ответ v2 от шлюза. Поэтому нужны и `catch`, и проверка `isSuccess`:
392
+
393
+ ```ts
394
+ const response = await callProtected(() => $b24!.actions.v3.call.make({ method: "tasks.task.get", params }), "tasks.task.get");
395
+ if (!response.isSuccess) {
396
+ throw new Error(`tasks.task.get: ${response.getErrorMessages().join("; ")}`);
397
+ }
398
+ ```
399
+
400
+ Или разбирайте через `getResultData(response, method)` — он бросит понятную ошибку сам. `callProtected` такой результат не повторяет: это не бросок, и 4xx от повтора не исправится. `actions.v3.callList.make` при ошибке отдаёт прочитанные строки **вместе** с ошибкой — тоже проверяйте `isSuccess`. Генераторы `fetchList`/`fetchTail` в `callProtected` не оборачиваются. `idempotencyKey` у `actions.v3.call.make` пакет пока не учитывает: блокировка повтора решается по имени метода.
401
+
402
+ - **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`. Все пять сигнатур объявлены в интерфейсе явно: в SDK 3.0.0 у базового класса этих методов нет, и пропажу объявления ловит тайпчек. Результаты `callListMethod`, `callBatch` и `callBatchByChunk` — `Promise<Result<any>>`, как и на 4.x: у самого SDK дефолт `Result<T>` теперь `unknown`. Тест `tests/client-type.test.ts` сверяет кортежи параметров и типы результата строгим равенством типов, поэтому потеря необязательного аргумента, расширение параметра до `any` или откат результата на голый `Result` роняют `npx tsc --noEmit`.
386
403
 
387
404
  ```ts
388
405
  import type { B24Client } from "@andrey4emk/npm-app-back-b24";
@@ -459,7 +476,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
459
476
  });
460
477
  ```
461
478
 
462
- **Отказ портала виден в логе.** Если портал отказал в создании задачи (нет прав, указанной группы не существует), пишется одна строка уровня `error` с заголовком задачи, номером группы и текстом ошибки. Сетевые сбои этой строки не дают: их уже записал слой повторов, и вторая ушла бы дублем в чат B24.
479
+ **Отказ портала виден в логе.** Если портал отказал в создании задачи (нет прав, указанной группы не существует), пишется одна строка уровня `error` с заголовком задачи, номером группы и текстом ошибки. Сетевые сбои этой строки не дают: их уже записал слой повторов, и вторая ушла бы дублем в Телеграм-группу через ретранслятор.
463
480
 
464
481
  **Группа задачи по умолчанию.** Если `groupId` не передан, группа берётся из переменной окружения `B24_ERROR_TASK_GROUP_ID`. Явно переданное значение всегда важнее переменной: `null` и `0` означают осознанное «без группы» и дефолт не подхватывают. Переменная читается один раз при загрузке модуля — правка `process.env` в рантайме дефолт не двигает, нужен перезапуск процесса. Если переменная не задана или непригодна (не целое больше нуля), поведение прежнее — задача создаётся без группы, плюс одна строка уровня `warn` за процесс. Строка пишется при первом вызове, которому дефолт реально понадобился: у потребителя, который всегда передаёт группу сам, в логе не появится ничего.
465
482
 
@@ -477,7 +494,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
477
494
  }
478
495
  ```
479
496
 
480
- - **`Event`** — класс для работы с офлайн-событиями Bitrix24. Можно создать свой экземпляр, передав `B24OAuth`.
497
+ - **`Event`** — класс для работы с офлайн-событиями Bitrix24. Можно создать свой экземпляр, передав `$b24` (голый `B24OAuth` с 5.0.0 не подходит — в нём нет `callMethod`).
481
498
 
482
499
  ```js
483
500
  import { Event } from "@andrey4emk/npm-app-back-b24";
@@ -605,7 +622,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
605
622
 
606
623
  Конструктор сразу разрешает тип мессенджера для `whatsApp` и `telegram` — берёт `messenger[0].type` и держит в полях, вместо того чтобы индексировать массив при каждой отправке. Если конфига нет или список `messenger` пуст, тип остаётся неразрешённым, и **методы этого мессенджера возвращают** `{ error: true, message: "ChatApp: не разрешён тип мессенджера whatsApp (нет конфига или пуст messenger)", data: null }` — вместо запроса на URL с `undefined` в пути. Конструктор при этом **не бросает**: экземпляр обычно создаётся на уровне модуля, и бросок уронил бы загрузку всего модуля отправки вместе с соседними каналами.
607
624
 
608
- **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в чат B24) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
625
+ **Таймаут отправки — статус неизвестен.** Запросы идут с бюджетом времени (10 секунд на работу с токеном, 20 на проверку номера, 30 на отправку сообщения, 60 на отправку файла). Если ответа не дождались, отправка **могла состояться** — методы возвращают `{ error: true, message: "ChatApp: таймаут ... — статус неизвестен", data: null }`, а не бросают исключение. Повторять такую отправку нельзя: клиент получит второе сообщение. Пакет дополнительно пишет строку уровня `error` (она уходит в Телеграм-группу через ретранслятор) — решение о повторе принимает человек. Раньше зависание долетало до вызывающего кода исключением через пять минут; теперь оно возвращается объектом через тридцать секунд, поэтому запись в лог обязательна: без неё отказ стал бы тише, чем был.
609
626
 
610
627
  Отказ локальный: пустой `whatsApp` не мешает `telegram`. Но конфиг всё равно нужен для **обоих** мессенджеров, даже если проект пользуется одним — иначе второй канал молча перестанет работать, и узнать об этом можно будет только по `res.error` в логе.
611
628
 
@@ -681,7 +698,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
681
698
 
682
699
  const smsClient = new Smsgold(
683
700
  { user: "smsgold_login", pass: "smsgold_password" },
684
- b24Instance, // опционально — экземпляр B24OAuth для загрузки файлов на диск
701
+ b24Instance, // опционально — $b24 для загрузки файлов на диск (голый B24OAuth с 5.0.0 не подходит)
685
702
  123456 // опционально — ID папки на диске B24 для вложений, по умолчанию 2792881
686
703
  );
687
704
  ```
@@ -710,7 +727,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
710
727
  }
711
728
  ```
712
729
 
713
- - **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в чат B24), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
730
+ - **Таймаут и неизвестный статус доставки.** Отправка идёт без повторов с бюджетом 30 секунд. Если SMSGold не ответил, запрос уже ушёл — сообщение **могло быть доставлено**, и повторять отправку нельзя. Такой результат отличается от обычной ошибки: `unknownDelivery: true` и текст «SMSGold не ответил за 30с — статус отправки неизвестен». Дополнительно пакет пишет строку уровня `error` (она уходит в Телеграм-группу через ретранслятор), потому что решение о повторе должен принимать человек, а не код. Это единственное место, где `Smsgold` пишет в лог сам.
714
731
 
715
732
  ```js
716
733
  const result = await smsClient.sendSms({ phone, message });
@@ -777,7 +794,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
777
794
 
778
795
  - **`Wappi`** — класс для отправки сообщений через Wappi API (WhatsApp, Telegram, Max). Экземпляр нужно создать самостоятельно.
779
796
 
780
- **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` в чат B24 — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
797
+ **Таймаут отправки — статус неизвестен.** Как и у `ChatApp`: бюджет 20 секунд на проверку номера, 30 на сообщение, 60 на файл. При таймауте отправки метод возвращает `{ error: true, message: "Wappi: таймаут ... — статус неизвестен", data: null }` и пишет строку уровня `error` (она уходит в Телеграм-группу через ретранслятор) — сообщение могло уйти, повторять нельзя. Отдельно про Telegram: `phoneCheckWappi` при таймауте проверки контакта **не создаёт контакт**, а возвращает ошибку. Иначе не дождавшись ответа мы бы завели контакт на номер, который мог уже быть в адресной книге, и объявили номер проверенным без проверки.
781
798
 
782
799
  ```js
783
800
  import { Wappi } from "@andrey4emk/npm-app-back-b24";
@@ -853,11 +870,19 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
853
870
  - Конфигурация хранится в файле `log.json` (через `conf`).
854
871
  - Каждый уровень можно включить/выключить, изменить цвет.
855
872
 
873
+ **Error-логи в Телеграм.** Если заданы обе переменные `LOG_RELAY_URL` и `LOG_RELAY_KEY`, каждый `logs.add(..., "error")` дополнительно уходит в Телеграм-группу логов через ретранслятор `tg_relay` — независимо от того, включён ли `error` в `log.json`. Тема в группе определяется ключом клиента, уровень, время и имя клиента ставит ретранслятор.
874
+
875
+ - Запрос: `POST LOG_RELAY_URL`, заголовок `Authorization: Bearer <LOG_RELAY_KEY>`, тело `{"level":"error","text":"..."}`. Успех — ответ `202`.
876
+ - В текст идёт сообщение и `jsonData` (`JSON.stringify` с отступами; `null` и `undefined` не дописываются). Пустое сообщение заменяется на `(пустое сообщение)`. Длинный текст обрезается до 3800 символов с пометкой `...(обрезано)`. Логгер не бросает исключений ни на каком `message`.
877
+ - **Телефоны маскируются** — только в Телеграме, в консоли данные исходные: `+7 916 123-45-67`, `89161234567`, `8.916.123.45.67`, `+7 916 12 34 567`, `9161234567` → `+7 ••• •••-••-67`. Разделителем между цифрами считаются пробелы, дефисы и тире, точки, `/` и скобки, но не перевод строки. Числа из 6–9 и 12+ цифр (id, суммы, метки времени) и идентификаторы с латинским префиксом (`DEAL_9161234567`) не трогаются. Известные границы: 10-значный городской номер без префикса (`4951234567`) не маскируется — его не отличить от метки времени в секундах; 10-значное число на `9` (например, ИНН) маскируется.
878
+ - Отправка без ожидания, с бюджетом 5 секунд и без повторов. Отказ ретранслятора (не `202`, таймаут, сетевой сбой) пишется одной строкой `console.error` с префиксом `Logs Relay:`; для `401` — «ключ не принят», для `503` — «ретранслятор останавливается». Сетевой сбой печатается только именем ошибки и кодом причины (`TypeError (ECONNREFUSED)`). Адрес и ключ в строку не попадают.
879
+ - Задана только одна из двух переменных, адрес не `http`/`https` или с логином и паролем, ключ с пробелами или управляющими символами — одна строка `console.warn` при старте (без самого значения) и канал выключен.
880
+
856
881
  ### fetchRetry и таймауты
857
882
 
858
883
  - **`fetchRetry(url, options?, retries?, delay?, timeoutMs?)`** — обёртка над `fetch` с повторными попытками при сетевых ошибках. HTTP-ошибки (4xx, 5xx) **не** вызывают повторных попыток — повторяются только сетевые сбои (TypeError, ECONNRESET, ETIMEDOUT и т.д.) и собственный таймаут запроса.
859
884
 
860
- - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`). Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
885
+ - **`maskUrl(url)`** — маскирует секреты в адресе перед записью в лог. URL входящего вебхука B24 содержит секрет прямо в пути (`/rest/1/<секрет>/method.json`), а токен авторизации может приходить в query-параметрах (`auth`, `access_token`, `refresh_token`, `token`, `signature`, `sessid`). Так же прячется токен бота Телеграма в пути Bot API: `https://api.telegram.org/bot<id>:<токен>/<метод>` и `/file/bot<id>:<токен>/<путь>` превращаются в `bot<id>:***`. Хост и имя метода сохраняются, тело секрета заменяется на `***`. Используется внутри `fetchRetry`; применяйте в своём коде везде, где логируете адреса запросов к B24.
861
886
 
862
887
  ```js
863
888
  import { maskUrl } from "@andrey4emk/npm-app-back-b24";
@@ -995,6 +1020,8 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
995
1020
  | `APP_NAME` | Название приложения (используется в описании задач) |
996
1021
  | `B24_ERROR_TASK_GROUP_ID` | ID группы по умолчанию для задач `errorB24()` (не задана — задача без группы) |
997
1022
  | `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
1023
+ | `LOG_RELAY_URL` | Адрес `POST /send` ретранслятора error-логов в Телеграм (только парой с `LOG_RELAY_KEY`) |
1024
+ | `LOG_RELAY_KEY` | Ключ клиента ретранслятора; по нему ретранслятор выбирает тему в группе |
998
1025
  | `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
999
1026
  | `FETCH_TIMEOUT_QUICK_MS` | Бюджет `FETCH_TIMEOUTS.quick` — токены, мс (по умолчанию `10000`) |
1000
1027
  | `FETCH_TIMEOUT_API_MS` | Бюджет `FETCH_TIMEOUTS.api` — короткое чтение, мс (по умолчанию `20000`) |
@@ -1015,6 +1042,10 @@ APP_NAME=MyApp
1015
1042
  CONFIG_DIR=../config
1016
1043
  FETCH_TIMEOUT_MS=60000
1017
1044
 
1045
+ # Error-логи в Телеграм через ретранслятор (только парой; не заданы — канал выключен)
1046
+ LOG_RELAY_URL=http://tg_relay:8080/send
1047
+ LOG_RELAY_KEY=ключ-клиента
1048
+
1018
1049
  # Группа по умолчанию для задач об ошибках. ID свой у каждого портала —
1019
1050
  # посмотреть в адресе группы на портале. Не задана — задачи создаются без группы
1020
1051
  B24_ERROR_TASK_GROUP_ID=196
@@ -1030,6 +1061,30 @@ CHATAPP_PASS=your-password
1030
1061
  CHATAPP_APP_ID=your-app-id
1031
1062
  ```
1032
1063
 
1064
+ ## Миграция на 5.1.0
1065
+
1066
+ Канал error-логов переехал из чата Bitrix24 в Телеграм-группу через ретранслятор `tg_relay`. Публичный API не менялся: `logs.add(message, level?, jsonData?)` как прежде.
1067
+
1068
+ 1. **Задать `LOG_RELAY_URL` и `LOG_RELAY_KEY`** в `.env` проекта. Ключ выдаётся на ретрансляторе (`npm run issue-key -- <имя> <тема>`), тема в группе привязана к ключу. Задана одна из двух — канал выключен и при старте одна строка `warn`.
1069
+ 2. **Убрать `LOG_B24_WEBHOOK_URL` и `LOG_B24_CHAT_ID`.** Они больше не читаются; пока заданы, при старте пишется одна строка `warn` «канал Bitrix24 снят в 5.1.0».
1070
+ 3. **Контейнер проекта — в сеть `tg-relay`**, иначе адрес ретранслятора не резолвится и каждый error даст строку `Logs Relay: ошибка отправки`.
1071
+ 4. **Глушилки тестов** — гасить `LOG_RELAY_URL`/`LOG_RELAY_KEY` пустой строкой (не `delete`: dotenv вернул бы значение из `.env`). Старые `LOG_B24_*` в глушилках можно оставить — они только убирают строку `warn`.
1072
+ 5. **Телефоны в тексте error-логов маскируются** — только в Телеграме. В консоли и в журнале контейнера данные остаются исходными.
1073
+ 6. **`APP_NAME` логгер больше не читает** — имя проекта в сообщении ставит ретранслятор по ключу. Для `errorB24()` переменная по-прежнему нужна.
1074
+
1075
+ ## Миграция на 5.0.0
1076
+
1077
+ Выпуск ломающий: пакет переехал на `@bitrix24/b24jssdk` 3.0.0. Вызовы методов фасада (`callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk`) не меняются.
1078
+
1079
+ 1. **Node.js 22 или новее** — этого требует SDK 3.0.0.
1080
+ 2. **Свою копию SDK поднять одновременно с пакетом.** Проектам, которые держат `@bitrix24/b24jssdk` своей зависимостью ради типов (`b24_rnp`, `b24_money`, `b24_analytics`), — до 3.0.0. Иначе в дереве будут две копии SDK с несовместимыми типами `B24OAuth`, и разбор ошибок через `instanceof` промахнётся.
1081
+ 3. **Свои вызовы `$b24.actions.v3.*` проверять на `isSuccess`**, в том числе внутри `callProtected`: ошибки 4xx, кроме 401/408/429, больше не бросаются (см. «REST v3: проверяйте `isSuccess`»). Так ведут себя только ответы, пришедшие в конверте v3, с кодом вне жёсткого списка SDK (нехватка scope, `ACCESS_DENIED`, `NOT_FOUND`, `INVALID_ARG_VALUE`) и вне `hardErrorCodes` пакета; коды из этих списков по-прежнему бросаются, как и плоский ответ v2 от шлюза. Поэтому нужны и `catch`, и проверка `isSuccess`.
1082
+ 4. **Удалённое API SDK мимо `$b24`** — `callMethod` у собственного `B24OAuth`, `LoggerBrowser`, `AbstractB24.batchSize` — перевести на `actions.v2.*`, `LoggerFactory` и литерал `50` соответственно.
1083
+ 5. **В `new Event(...)` и `new Smsgold(..., b24)` передавать `$b24`**, а не голый `B24OAuth`: в нём больше нет `callMethod`.
1084
+ 6. **Тип `$b24` в своих обёртках (`getB24`/`ensureB24`/`b24`) — `B24Client` из пакета, а не `B24OAuth` из SDK.** После подъёма своей копии SDK до 3.0.0 у `B24OAuth` нет `callMethod`/`callListMethod`/`callBatch`, и обёртка с таким типом перестанет компилироваться.
1085
+ 7. **`Result` самого SDK по умолчанию `unknown`.** У методов фасада `any` сохранён, правок не нужно; у своего кода поверх `actions.*` указывайте параметр типа (`Result<Deal[]>`).
1086
+ 8. **Ответ портала «200 без `result`» — всегда ошибка с понятным текстом** («портал вернул JSON без result и error» или «портал вернул не JSON»). На 4.x это была та же ошибка с текстом про `'operating'` либо «Bitrix24 вернул пустой ответ». Если вы разбирали эти тексты — замените разбор. Без конверта законно отвечают `rest.documentation.openapi` (через `$b24` не вызвать) и бинарные загрузки с `responseType`, отличным от `json` (`deferredBatch.download`), — их перехватчик пропускает.
1087
+
1033
1088
  ## Миграция на 4.0.0
1034
1089
 
1035
1090
  Выпуск ломающий. Правок у потребителя четыре, все механические.
@@ -1105,7 +1160,7 @@ CHATAPP_APP_ID=your-app-id
1105
1160
 
1106
1161
  ## Скрипты
1107
1162
 
1108
- - `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=20.3.0"` — это требование к потребителю пакета, а не к разработке). Сети тесты не касаются, в тарбол папка не попадает.
1163
+ - `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=22.0.0"` — требование к потребителю пакета, его диктует SDK 3.0.0). Сети тесты не касаются, в тарбол папка не попадает.
1109
1164
  - `npm run pack:dry` — предпросмотр содержимого пакета перед публикацией.
1110
1165
 
1111
1166
  ## Лицензия
@@ -1,5 +1,5 @@
1
1
  import { Result } from "@bitrix24/b24jssdk";
2
- import type { AjaxResult, TypeCallParams, B24OAuth } from "@bitrix24/b24jssdk";
2
+ import type { AjaxResult, TypeCallParamsV2, B24OAuth } from "@bitrix24/b24jssdk";
3
3
  import { logs } from "../../logs/logs.ts";
4
4
  import { getRetryBlockReason, runWithRetry } from "./retry.ts";
5
5
  import type { FacadeMethods, BatchCalls, V2Envelope } from "./types.ts";
@@ -62,7 +62,8 @@ export function getResultData<T = any>(response: AjaxResult, methodName: string)
62
62
  * конверта restApi:v2, но числового смещения среди них так и нет: `isMore()` отвечает
63
63
  * только «есть ли ещё», а `getNext(http)` сам делает следующий запрос, мимо нашего
64
64
  * прогресса и retry. Поэтому конверт читаем напрямую: `_data` объявлен `protected`,
65
- * а не приватным полем класса, поэтому в рантайме доступен.
65
+ * а не приватным полем класса, поэтому в рантайме доступен. В 3.0.0 всё это на месте:
66
+ * `_data` тот же, `getData()` по-прежнему режет конверт до `{ result, time }`.
66
67
  *
67
68
  * Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
68
69
  * она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
@@ -80,7 +81,7 @@ export function readV2Envelope(response: AjaxResult, method: string): V2Envelope
80
81
  // ==================== Фасад поверх actions.v2.* ====================
81
82
 
82
83
  /**
83
- * Собственные реализации пяти методов, которые SDK 2.x помечает к удалению.
84
+ * Собственные реализации пяти методов, удалённых в SDK 3.0.0.
84
85
  * Повторяют `AbstractB24` дословно, включая дефолты и порядок аргументов.
85
86
  *
86
87
  * Позиционные сигнатуры сохранены один в один: на этом держится то, что гейт
@@ -95,6 +96,10 @@ export function readV2Envelope(response: AjaxResult, method: string): V2Envelope
95
96
  * (`for await (const chunk of $b24.fetchListMethod(...)) chunk.forEach((x) => x.ID)`).
96
97
  * `unknown` в дефолте уронил бы каждое такое место, не дав взамен ничего — узкий тип
97
98
  * задаётся параметром типа на месте вызова (`callMethod<Deal>(...)`).
99
+ *
100
+ * По той же причине `Promise<Result<any>>` у `callListMethod`, `callBatch` и `callBatchByChunk`:
101
+ * с SDK 3 дефолт `Result<T>` — `unknown`, и голый `Result` уронил бы код потребителей вида
102
+ * `(await $b24.callListMethod(...)).getData().forEach(...)`.
98
103
  */
99
104
  export function createFacade(target: B24OAuth): FacadeMethods {
100
105
  // Геттер actions при каждом обращении проверяет инициализацию экземпляра —
@@ -102,7 +107,7 @@ export function createFacade(target: B24OAuth): FacadeMethods {
102
107
  const v2 = () => target.actions.v2;
103
108
 
104
109
  const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
105
- const merged: TypeCallParams = { ...params };
110
+ const merged: TypeCallParamsV2 = { ...params };
106
111
 
107
112
  // Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod.
108
113
  // typeof, а не только Number.isInteger: последний ничего не сужает, и при
@@ -127,7 +132,7 @@ export function createFacade(target: B24OAuth): FacadeMethods {
127
132
  params?: object,
128
133
  progress?: null | ((progress: number) => void),
129
134
  customKeyForResult?: string | null
130
- ): Promise<Result> => {
135
+ ): Promise<Result<any>> => {
131
136
  const result = new Result();
132
137
  const onProgress = typeof progress === "function" ? progress : null;
133
138
 
@@ -211,16 +216,17 @@ export function createFacade(target: B24OAuth): FacadeMethods {
211
216
  * поэтому отданные страницы не теряются и не дублируются.
212
217
  *
213
218
  * Форма запроса взята из `fetch-list.mjs`: `start: -1`, навязанный
214
- * `order: { <курсор>: "ASC" }`, фильтр `>курсор`. Отступления от SDK — пять,
219
+ * `order: { <курсор>: "ASC" }`, фильтр `>курсор`. Отступления от SDK — три,
215
220
  * все намеренные:
216
221
  * 1. остановка только на пустой странице, а не на короткой: страница, укороченная
217
222
  * правами доступа, у портала штатна, и правило SDK молча обрезало бы выборку;
218
223
  * 2. нечитаемый курсор — бросок, а не предупреждение и тихая остановка:
219
224
  * после пункта 1 он означает гарантированно неполную выборку;
220
- * 3. потолок страниц (`FETCH_LIST_MAX_PAGES`) — у SDK цикл ничем не ограничен;
221
- * 4. проверка роста курсора — портал, отбросивший неизвестный ключ фильтра,
222
- * иначе отдавал бы одну и ту же страницу вечно;
223
- * 5. пустая строка в `customKeyForResult` трактуется как «ключ не передан».
225
+ * 3. пустая строка в `customKeyForResult` трактуется как «ключ не передан».
226
+ *
227
+ * Потолок страниц (`FETCH_LIST_MAX_PAGES`) и проверка роста курсора (портал,
228
+ * отбросивший неизвестный ключ фильтра, иначе отдавал бы одну и ту же страницу
229
+ * вечно) есть и у SDK 3.0.0, у нас свои пороги и свой текст ошибки.
224
230
  *
225
231
  * Плюс диагностика: ошибки называют метод B24 вместо `TypeError` из недр SDK
226
232
  * и `SdkError` с кодом SDK.
@@ -232,7 +238,7 @@ export function createFacade(target: B24OAuth): FacadeMethods {
232
238
  */
233
239
  async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
234
240
  // Отдельного cursorIdKey, как в опциях SDK, у нас нет и не будет: позиционная
235
- // сигнатура deprecated-метода его не содержала, а добавление пятого аргумента —
241
+ // сигнатура удалённого метода SDK его не содержала, а добавление пятого аргумента —
236
242
  // расширение публичного API. У SDK он по умолчанию тоже равен idKey
237
243
  const cursorKey = idKey ?? "ID";
238
244
  const moreKey = `>${cursorKey}`;
@@ -279,7 +285,7 @@ export function createFacade(target: B24OAuth): FacadeMethods {
279
285
  // курсор в нём двигается только после успешной страницы — поэтому повтор
280
286
  // попадает ровно в ту же страницу, а не в следующую
281
287
  const response = await runWithRetry(
282
- () => v2().call.make<any>({ method, params: requestParams as TypeCallParams }),
288
+ () => v2().call.make<any>({ method, params: requestParams as TypeCallParamsV2 }),
283
289
  "$b24.fetchListMethod",
284
290
  blockReason
285
291
  );
@@ -348,7 +354,7 @@ export function createFacade(target: B24OAuth): FacadeMethods {
348
354
  }
349
355
  }
350
356
 
351
- const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result> => {
357
+ const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result<any>> => {
352
358
  // Дефолты подставляем мы: batch.make своих не имеет, он просто
353
359
  // расширяет переданный объект опций версией API
354
360
  return v2().batch.make({
@@ -360,7 +366,7 @@ export function createFacade(target: B24OAuth): FacadeMethods {
360
366
  });
361
367
  };
362
368
 
363
- const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result> => {
369
+ const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result<any>> => {
364
370
  // isHaltOnError передаём как есть, без ?? true — дословно по AbstractB24.
365
371
  // returnAjaxResult не передаём: batchByChunk.make жёстко ставит false сам,
366
372
  // а его тип опций этот ключ не принимает
@@ -1,6 +1,7 @@
1
- import { B24OAuth, Logger, LogLevel } from "@bitrix24/b24jssdk";
1
+ import { ApiVersion, B24OAuth, Logger, LogLevel } from "@bitrix24/b24jssdk";
2
2
  import type { B24OAuthParams, B24OAuthSecret, AuthData, Handler, LogRecord, Formatter, RestrictionParams } from "@bitrix24/b24jssdk";
3
3
  import { logs } from "../../logs/logs.ts";
4
+ import { installJsonGuard } from "./nonJsonResponse.ts";
4
5
  import { APP_ENV, CLIENT_ID, CLIENT_SECRET, confAuthB24, cleanDomain } from "./config.ts";
5
6
 
6
7
  // ==================== Константы ====================
@@ -104,7 +105,7 @@ export class B24SdkLogHandler implements Handler {
104
105
  // попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
105
106
  // выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
106
107
  // повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
107
- // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
108
+ // Уровень error пакета уходит в Телеграм через ретранслятор, поэтому маппим SDK-ERROR в warn:
108
109
  // окончательный провал вызова логирует retry-слой, ровно один раз.
109
110
  const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
110
111
  const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
@@ -182,5 +183,13 @@ export function createB24Instance(): B24OAuth | null {
182
183
  // прямо в console.warn, но после перехода на фасад этот путь у нас недостижим.
183
184
  b24.setLogger(Logger.create("npm-app-back-b24").pushHandler(new B24SdkLogHandler()));
184
185
 
186
+ // Ответ без JSON-конверта (HTML-страница прокси, пустое тело, JSON без result и error)
187
+ // превращаем в понятную ошибку со статусом и началом тела: SDK 3 принял бы такой
188
+ // ответ за успех (T-65, #469). Перехватчик обязателен. Ставим на оба клиента:
189
+ // v3 ходит через свой axios. Обмен refresh-токена идёт отдельным экземпляром
190
+ // axios и проверку не получает
191
+ installJsonGuard(b24.getHttpClient(ApiVersion.v2).ajaxClient);
192
+ installJsonGuard(b24.getHttpClient(ApiVersion.v3).ajaxClient);
193
+
185
194
  return b24;
186
195
  }
@@ -0,0 +1,161 @@
1
+ import { SdkError } from "@bitrix24/b24jssdk";
2
+
3
+ /**
4
+ * Распознавание ответа портала, который не является JSON-конвертом REST.
5
+ *
6
+ * Сценарий из логов 29.09.2026 (T-65): портал или прокси на его пути несколько секунд
7
+ * отдавал HTML-страницу со статусом 200. axios с настройками по умолчанию нечитаемый
8
+ * JSON не бросает, а молча отдаёт строку. SDK 2.x падал на ней в лимитере с безликим
9
+ * «Cannot destructure property 'operating'». SDK 3.0.0 не падает вовсе: любое тело без
10
+ * ключа `result` он заворачивает в `result` и отдаёт как успех (#469). HTML-страница
11
+ * прокси приходит потребителю строкой в `getData().result`, а ответ `{}` или `{ time }`
12
+ * превращает `crm.deal.add` в «успешный» вызов без ID сделки.
13
+ *
14
+ * Поэтому проверка стоит раньше SDK — перехватчиком ответа на axios-клиенте самого SDK
15
+ * (официальная точка доступа `getHttpClient(version).ajaxClient`). Она единственное, что
16
+ * отделяет страницу прокси от «успешного» ответа. Пропускается только объект с ключом
17
+ * `result` или `error` — успех (`{ result, time }`) или ошибка (`{ error, error_description }`
18
+ * у v2, `{ error: { code, message } }` у v3). Всё остальное — строка, пустое тело, массив,
19
+ * объект без конверта — превращается в B24NonJsonResponseError со статусом, типом содержимого
20
+ * и началом тела. SDK заворачивает её в `JSSDK_UNKNOWN_ERROR` (он в его жёстком списке — сам
21
+ * SDK не повторяет) и кладёт целиком в `originalError`, а retry-слой пакета по ней узнаёт
22
+ * временный сбой портала и повторяет с нарастающей паузой.
23
+ *
24
+ * Без конверта законно отвечают `rest.documentation.openapi` (через `$b24` не вызвать) и
25
+ * бинарные загрузки с `responseType`, отличным от `json` (`deferredBatch.download`), — их
26
+ * перехватчик пропускает. Сами запросы REST у SDK `responseType` не задают.
27
+ */
28
+
29
+ /** Сколько символов тела попадает в сообщение об ошибке */
30
+ export const NON_JSON_BODY_PREVIEW = 200;
31
+
32
+ /**
33
+ * Вид сбоя: `not-json` — тело не объект (HTML, пустое, обрезанное),
34
+ * `no-envelope` — разобранный JSON без ключей `result` и `error`
35
+ */
36
+ export type NonJsonResponseKind = "not-json" | "no-envelope";
37
+
38
+ /** Ответ портала без JSON-конверта REST: HTML-страница прокси, пустое тело, JSON без result и error */
39
+ export class B24NonJsonResponseError extends Error {
40
+ /** HTTP-статус ответа */
41
+ readonly status: number;
42
+ /** Заголовок Content-Type, если пришёл */
43
+ readonly contentType: string;
44
+ /** Начало тела без лишних пробелов, не длиннее NON_JSON_BODY_PREVIEW */
45
+ readonly bodyPreview: string;
46
+ /** Вид сбоя */
47
+ readonly kind: NonJsonResponseKind;
48
+
49
+ constructor(status: number, contentType: string, bodyPreview: string, kind: NonJsonResponseKind = "not-json") {
50
+ const type = contentType || "тип не указан";
51
+ const body = bodyPreview ? `«${bodyPreview}»` : "пустое тело";
52
+ const what = kind === "no-envelope" ? "JSON без result и error" : "не JSON";
53
+ super(`портал вернул ${what}: HTTP ${status}, ${type}, ${body}`);
54
+ this.name = "B24NonJsonResponseError";
55
+ this.status = status;
56
+ this.contentType = contentType;
57
+ this.bodyPreview = bodyPreview;
58
+ this.kind = kind;
59
+ }
60
+ }
61
+
62
+ /** Минимальная форма ответа axios, которую читает перехватчик */
63
+ export interface AxiosLikeResponse {
64
+ status: number;
65
+ data: unknown;
66
+ headers?: unknown;
67
+ /** Настройки запроса: `responseType` говорит, какое тело ждёт вызывающий */
68
+ config?: { responseType?: string };
69
+ }
70
+
71
+ /**
72
+ * Минимальная форма axios-клиента: только регистрация перехватчика ответа.
73
+ * Параметр типа — конкретный тип ответа клиента (у axios — AxiosResponse): перехватчик
74
+ * обязан вернуть ровно его, а не урезанную структурную форму.
75
+ */
76
+ export interface AxiosLikeClient<R extends AxiosLikeResponse> {
77
+ interceptors: {
78
+ response: {
79
+ use: (onFulfilled: (response: R) => R) => unknown;
80
+ };
81
+ };
82
+ }
83
+
84
+ /** Читает Content-Type и из AxiosHeaders (метод get), и из обычного объекта */
85
+ function readContentType(headers: unknown): string {
86
+ if (!headers || typeof headers !== "object") return "";
87
+
88
+ const getter = (headers as { get?: unknown }).get;
89
+ const value = typeof getter === "function" ? getter.call(headers, "content-type") : (headers as Record<string, unknown>)["content-type"];
90
+
91
+ return typeof value === "string" ? value : "";
92
+ }
93
+
94
+ /** Тело как текст: разобранный JSON без конверта показываем как JSON, а не «[object Object]» */
95
+ function bodyToText(data: unknown): string {
96
+ if (typeof data === "string") return data;
97
+ if (typeof data !== "object") return String(data);
98
+
99
+ try {
100
+ return JSON.stringify(data) ?? String(data);
101
+ } catch {
102
+ return String(data);
103
+ }
104
+ }
105
+
106
+ /** Начало тела одной строкой: переводы строк и повторные пробелы схлопываются */
107
+ function previewBody(data: unknown): string {
108
+ if (data === undefined || data === null) return "";
109
+
110
+ const flat = bodyToText(data).replace(/\s+/g, " ").trim();
111
+
112
+ return flat.length > NON_JSON_BODY_PREVIEW ? `${flat.slice(0, NON_JSON_BODY_PREVIEW)}…` : flat;
113
+ }
114
+
115
+ /**
116
+ * Пропускает ответ дальше, если в теле объект с ключом `result` или `error`,
117
+ * иначе бросает B24NonJsonResponseError.
118
+ *
119
+ * Объект с конвертом — единственная законная форма тела REST: и успех (`{result, time}`),
120
+ * и ошибка (`{error, error_description}` или `{error: {code, message}}`). Строка означает,
121
+ * что axios не смог разобрать JSON, пустое тело — что ответа по сути нет. Объект без обоих
122
+ * ключей (`{}`, `{ time }`) и массив SDK 3 принял бы за успех с мусором в `result`.
123
+ *
124
+ * Исключение — запрос, который сам попросил не JSON (`responseType` задан и не `json`):
125
+ * так SDK скачивает файл в `deferredBatch.download` (`arraybuffer`). Такой ответ
126
+ * пропускается без проверки.
127
+ */
128
+ export function assertJsonResponse<R extends AxiosLikeResponse>(response: R): R {
129
+ const responseType = response.config?.responseType;
130
+ if (responseType && responseType !== "json") return response;
131
+
132
+ const { data } = response;
133
+ const status = response.status;
134
+ const contentType = readContentType(response.headers);
135
+
136
+ if (data === null || typeof data !== "object") {
137
+ throw new B24NonJsonResponseError(status, contentType, previewBody(data), "not-json");
138
+ }
139
+
140
+ if (!Array.isArray(data) && ("result" in data || "error" in data)) return response;
141
+
142
+ throw new B24NonJsonResponseError(status, contentType, previewBody(data), "no-envelope");
143
+ }
144
+
145
+ /** Ставит проверку тела на axios-клиент SDK */
146
+ export function installJsonGuard<R extends AxiosLikeResponse>(client: AxiosLikeClient<R>): void {
147
+ client.interceptors.response.use((response) => assertJsonResponse(response));
148
+ }
149
+
150
+ /**
151
+ * Пришла ли ошибка от ответа портала без JSON-конверта.
152
+ *
153
+ * Смотрит и на саму ошибку, и на `originalError` — SDK кладёт нашу ошибку туда,
154
+ * заворачивая её в `JSSDK_UNKNOWN_ERROR`.
155
+ */
156
+ export function isNonJsonResponseError(error: unknown): boolean {
157
+ if (error instanceof B24NonJsonResponseError) return true;
158
+ if (!(error instanceof SdkError)) return false;
159
+
160
+ return error.originalError instanceof B24NonJsonResponseError;
161
+ }
@@ -16,8 +16,8 @@ const RETRYABLE_METHOD_NAMES = ["callMethod", "callListMethod", "callBatch"] as
16
16
  * Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
17
17
  *
18
18
  * Шире, чем RETRYABLE_METHOD_NAMES: `fetchListMethod` и `callBatchByChunk` мы реализуем,
19
- * но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
20
- * SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
19
+ * но не ретраим. `callBatchByChunk` в наборе обязателен: в SDK 3.0.0 метода на цели нет,
20
+ * и без него `Reflect.get` вернул бы `undefined`.
21
21
  *
22
22
  * `satisfies` ловит опечатку и переименование: имя, которого нет среди методов фасада,
23
23
  * уронит тайпчек здесь, а не молча уедет мимо Proxy в SDK.
@@ -78,8 +78,8 @@ export function wrapB24WithRetry(b24: B24OAuth): B24Client {
78
78
 
79
79
  return new Proxy(b24, {
80
80
  get(target, prop) {
81
- // Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
82
- // deprecated-методы, иначе мы отдавали бы их, а не свои
81
+ // Имена фасада проверяем до Reflect.get. SDK 3.0.0 их не объявляет, проверка
82
+ // остаётся на случай, если имя вернётся в SDK: отдавать надо своё, а не его
83
83
  if (typeof prop === "string" && isFacadeMethod(prop)) {
84
84
  if (!methodCache.has(prop)) {
85
85
  // Индексируем внутри ветки: после сужения prop до RetryableMethodName
@@ -109,8 +109,8 @@ export function wrapB24WithRetry(b24: B24OAuth): B24Client {
109
109
  return methodCache.get(prop);
110
110
  },
111
111
 
112
- // Страховка на будущее: когда SDK уберёт методы из прототипа,
113
- // "callMethod" in $b24 обязано остаться истинным (eventB24.ts проверяет
112
+ // Нужна: в SDK 3.0.0 методов фасада на цели нет, а
113
+ // "callMethod" in $b24 обязано быть истинным (eventB24.ts проверяет
114
114
  // наличие метода перед работой). Цель расширяема, лишние true законны
115
115
  has(target, prop) {
116
116
  if (typeof prop === "string" && FACADE_METHODS.has(prop)) return true;