@andrey4emk/npm-app-back-b24 4.0.1 → 5.0.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,7 @@
17
17
  - [Логирование](#логирование)
18
18
  - [fetchRetry и таймауты](#fetchretry-и-таймауты)
19
19
  - [Переменные окружения](#переменные-окружения)
20
+ - [Миграция на 5.0.0](#миграция-на-500)
20
21
  - [Миграция на 4.0.0](#миграция-на-400)
21
22
  - [Скрипты](#скрипты)
22
23
  - [Лицензия](#лицензия)
@@ -27,6 +28,8 @@
27
28
  npm install @andrey4emk/npm-app-back-b24
28
29
  ```
29
30
 
31
+ Нужен Node.js 22 или новее (с 5.0.0 — его требует `@bitrix24/b24jssdk` 3.0.0).
32
+
30
33
  `nodemailer` — **опциональная peer-зависимость**: её ставит потребитель, и только если пользуется классом `Email`. Всем остальным она не нужна и не приезжает.
31
34
 
32
35
  ```bash
@@ -207,7 +210,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
207
210
 
208
211
  **Методы `callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk` — фасад пакета.**
209
212
 
210
- SDK 2.x помечает эти пять методов к удалению в следующем major и на каждый вызов пишет предупреждение об устаревании. Пакет реализует их сам поверх `actions.v2.*`, поэтому вызовы у потребителей не меняются, а зависимости от удаляемого API у пакета больше нет. Сигнатуры и типы возврата прежние — правок в проектах не требуется.
213
+ В SDK 3.0.0 этих пяти методов нет. Пакет реализует их сам поверх `actions.v2.*`, поэтому вызовы у потребителей не меняются, а зависимости от удалённого API у пакета нет. Сигнатуры и типы возврата прежние — правок в проектах не требуется.
211
214
 
212
215
  Поведение прежнее у четырёх методов из пяти. **`callListMethod` отличается в двух местах:** он бросает внятную ошибку с именем метода там, где SDK падал `TypeError` из своих недр (портал вернул «мягкую» ошибку, ответ не список, конверт нечитаем, `next` не растёт), и у него есть потолок в 1000 страниц — при упоре в него вызов тоже бросает, а не отдаёт молча обрезанный список. Если у вас есть `try`/`catch` вокруг постраничных выборок, текст ошибки изменится; если его нет — падение станет заметнее, но не появится там, где раньше всё работало.
213
216
 
@@ -252,9 +255,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
252
255
 
253
256
  **Провал обмена refresh-токена повторов не получает.** Когда обмен падает внутри вызова, попытка ровно одна, а в лог идёт строка уровня `error` с исходным кодом и статусом ответа oauth-хоста: `обновление токена не удалось (invalid_grant/400), повтор небезопасен: сервер мог провести ротацию`. Под правило попадает **любой** отказ обмена, кроме доказанного pre-connection, — и мёртвый токен после ротации, и транзиентные 429/503 от oauth-хоста: различить их по ответу нельзя. Если ротация прошла, локальный `refresh_token` мёртв независимо от повтора и нужна переавторизация приложения на портале. DNS-сбой на oauth-хосте под это правило не попадает: соединение не состоялось, значит повторяем.
254
257
 
255
- Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
258
+ Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x и 3.0) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
256
259
 
257
- Важно: **транспортные сбои SDK не повторяет вообще** — пакет задаёт параметры ограничителя при создании клиента. Иначе гейт был бы дырявым: SDK не различает идемпотентные и создающие вызовы и повторил бы оборванный `crm.deal.add` до трёх раз внутри себя, куда наша защита не дотягивается. Измерено: без этих параметров обрыв соединения, 502 и 504 дают по три выполнения на портале, с ними — по одному.
260
+ Важно: **транспортные сбои SDK не повторяет вообще** — пакет задаёт параметры ограничителя при создании клиента. Иначе гейт был бы дырявым: SDK не различает идемпотентные и создающие вызовы и повторил бы оборванный `crm.deal.add` до трёх раз внутри себя, куда наша защита не дотягивается. Измерено: без этих параметров обрыв соединения, 502 и 504 дают по три выполнения на портале, с ними — по одному. На SDK 3.0.0 замер повторён и закреплён тестом `tests/sdk-executions.test.ts`: создающий вызов выполняется ровно один раз во всех сценариях сбоя.
258
261
 
259
262
  Повторы SDK при 429 и 503 сохранены: портал отвечает отказом, не выполнив запрос.
260
263
 
@@ -274,7 +277,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
274
277
 
275
278
  **«Мягкие» ошибки Bitrix24:**
276
279
 
277
- Часть ошибок (`ERROR_ENTITY_NOT_FOUND`, `BITRIX_REST_V3_EXCEPTION_*`) SDK не бросает исключением, а возвращает как `AjaxResult` с ошибкой: `isSuccess === false`, и тогда `response.getData()` вернёт `undefined`. Отдельный случай — успешный ответ, у которого сам `result` пустой (`null`/`undefined`). Чтобы не падать на `undefined`, разбирайте ответы через `getResultData()` — он покрывает обе ситуации и бросает понятную ошибку с именем метода.
280
+ Часть ошибок (`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()` — он покрывает обе ситуации и бросает понятную ошибку с именем метода.
281
+
282
+ **Ответ портала без 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
283
 
279
284
  **Проактивное обновление токена:**
280
285
 
@@ -382,7 +387,18 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
382
387
 
383
388
  Без обёртки вызов `$b24.actions.*` идёт мимо защиты: ни повторов при сетевых сбоях, ни блокировки повтора для создающих методов.
384
389
 
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`.
390
+ **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`:
391
+
392
+ ```ts
393
+ const response = await callProtected(() => $b24!.actions.v3.call.make({ method: "tasks.task.get", params }), "tasks.task.get");
394
+ if (!response.isSuccess) {
395
+ throw new Error(`tasks.task.get: ${response.getErrorMessages().join("; ")}`);
396
+ }
397
+ ```
398
+
399
+ Или разбирайте через `getResultData(response, method)` — он бросит понятную ошибку сам. `callProtected` такой результат не повторяет: это не бросок, и 4xx от повтора не исправится. `actions.v3.callList.make` при ошибке отдаёт прочитанные строки **вместе** с ошибкой — тоже проверяйте `isSuccess`. Генераторы `fetchList`/`fetchTail` в `callProtected` не оборачиваются. `idempotencyKey` у `actions.v3.call.make` пакет пока не учитывает: блокировка повтора решается по имени метода.
400
+
401
+ - **`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
402
 
387
403
  ```ts
388
404
  import type { B24Client } from "@andrey4emk/npm-app-back-b24";
@@ -477,7 +493,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
477
493
  }
478
494
  ```
479
495
 
480
- - **`Event`** — класс для работы с офлайн-событиями Bitrix24. Можно создать свой экземпляр, передав `B24OAuth`.
496
+ - **`Event`** — класс для работы с офлайн-событиями Bitrix24. Можно создать свой экземпляр, передав `$b24` (голый `B24OAuth` с 5.0.0 не подходит — в нём нет `callMethod`).
481
497
 
482
498
  ```js
483
499
  import { Event } from "@andrey4emk/npm-app-back-b24";
@@ -681,7 +697,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
681
697
 
682
698
  const smsClient = new Smsgold(
683
699
  { user: "smsgold_login", pass: "smsgold_password" },
684
- b24Instance, // опционально — экземпляр B24OAuth для загрузки файлов на диск
700
+ b24Instance, // опционально — $b24 для загрузки файлов на диск (голый B24OAuth с 5.0.0 не подходит)
685
701
  123456 // опционально — ID папки на диске B24 для вложений, по умолчанию 2792881
686
702
  );
687
703
  ```
@@ -1030,6 +1046,19 @@ CHATAPP_PASS=your-password
1030
1046
  CHATAPP_APP_ID=your-app-id
1031
1047
  ```
1032
1048
 
1049
+ ## Миграция на 5.0.0
1050
+
1051
+ Выпуск ломающий: пакет переехал на `@bitrix24/b24jssdk` 3.0.0. Вызовы методов фасада (`callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk`) не меняются.
1052
+
1053
+ 1. **Node.js 22 или новее** — этого требует SDK 3.0.0.
1054
+ 2. **Свою копию SDK поднять одновременно с пакетом.** Проектам, которые держат `@bitrix24/b24jssdk` своей зависимостью ради типов (`b24_rnp`, `b24_money`, `b24_analytics`), — до 3.0.0. Иначе в дереве будут две копии SDK с несовместимыми типами `B24OAuth`, и разбор ошибок через `instanceof` промахнётся.
1055
+ 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`.
1056
+ 4. **Удалённое API SDK мимо `$b24`** — `callMethod` у собственного `B24OAuth`, `LoggerBrowser`, `AbstractB24.batchSize` — перевести на `actions.v2.*`, `LoggerFactory` и литерал `50` соответственно.
1057
+ 5. **В `new Event(...)` и `new Smsgold(..., b24)` передавать `$b24`**, а не голый `B24OAuth`: в нём больше нет `callMethod`.
1058
+ 6. **Тип `$b24` в своих обёртках (`getB24`/`ensureB24`/`b24`) — `B24Client` из пакета, а не `B24OAuth` из SDK.** После подъёма своей копии SDK до 3.0.0 у `B24OAuth` нет `callMethod`/`callListMethod`/`callBatch`, и обёртка с таким типом перестанет компилироваться.
1059
+ 7. **`Result` самого SDK по умолчанию `unknown`.** У методов фасада `any` сохранён, правок не нужно; у своего кода поверх `actions.*` указывайте параметр типа (`Result<Deal[]>`).
1060
+ 8. **Ответ портала «200 без `result`» — всегда ошибка с понятным текстом** («портал вернул JSON без result и error» или «портал вернул не JSON»). На 4.x это была та же ошибка с текстом про `'operating'` либо «Bitrix24 вернул пустой ответ». Если вы разбирали эти тексты — замените разбор. Без конверта законно отвечают `rest.documentation.openapi` (через `$b24` не вызвать) и бинарные загрузки с `responseType`, отличным от `json` (`deferredBatch.download`), — их перехватчик пропускает.
1061
+
1033
1062
  ## Миграция на 4.0.0
1034
1063
 
1035
1064
  Выпуск ломающий. Правок у потребителя четыре, все механические.
@@ -1105,7 +1134,7 @@ CHATAPP_APP_ID=your-app-id
1105
1134
 
1106
1135
  ## Скрипты
1107
1136
 
1108
- - `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=20.3.0"` — это требование к потребителю пакета, а не к разработке). Сети тесты не касаются, в тарбол папка не попадает.
1137
+ - `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=22.0.0"` — требование к потребителю пакета, его диктует SDK 3.0.0). Сети тесты не касаются, в тарбол папка не попадает.
1109
1138
  - `npm run pack:dry` — предпросмотр содержимого пакета перед публикацией.
1110
1139
 
1111
1140
  ## Лицензия
@@ -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
  // ==================== Константы ====================
@@ -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;
@@ -1,6 +1,7 @@
1
1
  import { AjaxError, RefreshTokenError, SdkError } from "@bitrix24/b24jssdk";
2
2
  import { logs } from "../../logs/logs.ts";
3
3
  import { isNetworkError, isPreConnectionError } from "../../utils/fetchRetry.ts";
4
+ import { isNonJsonResponseError } from "./nonJsonResponse.ts";
4
5
 
5
6
  // ==================== Типы ====================
6
7
 
@@ -20,6 +21,15 @@ export type AsyncFn = (...args: never[]) => Promise<unknown>;
20
21
  export const RETRY_COUNT = 5;
21
22
  /** Задержка между попытками (мс) */
22
23
  export const RETRY_DELAY_MS = 500;
24
+ /**
25
+ * Первая пауза при ответе портала без JSON (HTML-страница прокси, пустое тело), мс.
26
+ * Дальше пауза удваивается: 1, 2, 4, 8 с — пять попыток растягиваются на 15 секунд.
27
+ *
28
+ * Ровная пауза в 500 мс укладывала все пять попыток в 2 секунды, а сбой 29.09.2026
29
+ * длился около трёх (T-65): повторы закончились раньше, чем портал пришёл в себя.
30
+ * Транспортные сбои по-прежнему идут с ровной паузой — растягивать их причины нет.
31
+ */
32
+ export const NON_JSON_RETRY_BASE_DELAY_MS = 1000;
23
33
 
24
34
  /**
25
35
  * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
@@ -113,6 +123,16 @@ function normalizeMethodName(raw: string): string {
113
123
  return head.replace(/\.json$/, "");
114
124
  }
115
125
 
126
+ /**
127
+ * Пауза перед следующей попыткой: нарастающая для ответа без JSON, ровная для остального.
128
+ *
129
+ * @param error — ошибка упавшей попытки
130
+ * @param attempt — номер упавшей попытки, с единицы
131
+ */
132
+ export function retryDelayFor(error: unknown, attempt: number): number {
133
+ return isNonJsonResponseError(error) ? NON_JSON_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) : RETRY_DELAY_MS;
134
+ }
135
+
116
136
  /** Фраза для лога: повтор отменён, потому что вызов создаёт сущности */
117
137
  const creatingReason = (what: string): string => `вызов создаёт сущности (${what})`;
118
138
 
@@ -146,11 +166,16 @@ const reservingReason = (what: string): string => `вызов резервиру
146
166
  */
147
167
  export function isB24NetworkError(error: unknown): boolean {
148
168
  if (error instanceof AjaxError) {
149
- // Практически недостижимо (SDK бросает реальную ошибку раньше),
169
+ // Практически недостижимо в SDK 2.x и 3.0 (SDK бросает реальную ошибку раньше),
150
170
  // но если код всё же пришёл — SDK уже исчерпал свои попытки
151
171
  if (error.code === "JSSDK_CALL_ALL_ATTEMPTS_EXHAUSTED") return false;
152
172
  }
153
173
 
174
+ // Портал (или прокси перед ним) отдал не JSON — временный сбой на его стороне,
175
+ // повтор осмыслен. Сегодня такая ошибка прошла бы и веткой status 0, но держаться
176
+ // за случайность формы обёртки SDK не хочется: признак проверяется явно
177
+ if (isNonJsonResponseError(error)) return true;
178
+
154
179
  if (error instanceof SdkError) {
155
180
  const { code, originalError, status } = error;
156
181
  const originalCode = (originalError as { code?: string } | undefined)?.code;
@@ -254,7 +279,7 @@ export function refreshFailureCode(error: unknown): string {
254
279
  * Возвращает null, если форма команды не распознана.
255
280
  */
256
281
  export function extractBatchCommandMethod(cmd: unknown): string | null {
257
- // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
282
+ // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x и 3.0
258
283
  if (Array.isArray(cmd)) {
259
284
  return typeof cmd[0] === "string" ? cmd[0] : null;
260
285
  }
@@ -265,7 +290,7 @@ export function extractBatchCommandMethod(cmd: unknown): string | null {
265
290
  return typeof method === "string" ? method : null;
266
291
  }
267
292
 
268
- // Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
293
+ // Строка "crm.deal.add?ID=1" — в SDK 2.x и 3.0 эта форма не поддерживается
269
294
  // (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
270
295
  // если потребитель остался на ней со старой версии
271
296
  if (typeof cmd === "string") {
@@ -429,12 +454,13 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
429
454
 
430
455
  // Начало строки менять нельзя: по «попытка N/5 не удалась» грепают в логах
431
456
  const lifted = blockReason ? ` — соединение не состоялось, ограничение (${blockReason}) снято` : "";
457
+ const pause = retryDelayFor(error, attempt);
432
458
 
433
459
  // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
434
460
  // иначе один упавший вызов дал бы пять сообщений
435
- logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс${lifted}`, "warn");
461
+ logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${pause}мс${lifted}`, "warn");
436
462
 
437
- await delay(RETRY_DELAY_MS);
463
+ await delay(pause);
438
464
  }
439
465
  }
440
466
 
@@ -31,19 +31,20 @@ export interface AuthSaveResponse {
31
31
  /**
32
32
  * `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
33
33
  *
34
- * SDK 2.x помечает `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
35
- * к удалению в следующем major. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
36
- * не меняются, а зависимости от удаляемого API у нас больше нет.
34
+ * В SDK 3.0.0 `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
35
+ * из `AbstractB24` удалены. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
36
+ * не меняются, а зависимости от удалённого API у нас нет.
37
37
  *
38
38
  * Тип расширяет `B24OAuth`, поэтому `$b24` по-прежнему передаётся туда, где ждут `B24OAuth`
39
39
  * (`new Smsgold(auth, $b24)`, `new Event($b24)`).
40
40
  *
41
- * Пять сигнатур ниже объявлены НАМЕРЕННО и сегодня дублируют то, что и так приходит
42
- * по наследству от `B24OAuth`. Удалять их нельзя: в SDK 3.x этих методов в `AbstractB24`
43
- * не будет, и вместе с ними из типа пропадёт вся поверхность фасада — а тайпчек на
44
- * SDK 2.2.0 останется зелёным, то есть пропажа обнаружится только у потребителя.
45
- * Страховка от «уборки дублей» — `tests/client-type.test.ts`: он читает текст этого
46
- * файла и падает, если хоть одно объявление отсюда исчезло.
41
+ * Пять сигнатур ниже — единственный источник поверхности фасада: по наследству от `B24OAuth`
42
+ * они больше не приходят. Пропажу любого объявления ловит тайпчек самого пакета —
43
+ * `FacadeMethods` ниже и эталон в `tests/client-type.test.ts` берут их через `Pick`.
44
+ *
45
+ * `Promise<Result<any>>`, а не голый `Result`: с SDK 3 дефолт `Result<T>` — `unknown`,
46
+ * и код потребителей `(await $b24.callListMethod(...)).getData().forEach(...)` перестал бы
47
+ * компилироваться. Обоснование то же, что у остальных `any` ниже.
47
48
  *
48
49
  * `any` в сигнатурах оставлен НАМЕРЕННО и снятию по T-53 не подлежит: это дефолты
49
50
  * дженериков публичной поверхности, дословно повторяющие `AbstractB24`. На них стоит
@@ -54,28 +55,28 @@ export interface AuthSaveResponse {
54
55
  */
55
56
  export interface B24Client extends B24OAuth {
56
57
  callMethod<T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>>;
57
- callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result>;
58
+ callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result<any>>;
58
59
  fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]>;
59
- callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result>;
60
- callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result>;
60
+ callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result<any>>;
61
+ callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result<any>>;
61
62
  }
62
63
 
63
64
  /**
64
65
  * Минимальный контракт «умеет вызывать REST-методы B24».
65
66
  *
66
67
  * Нужен внутренним классам (`Event`, `Smsgold`), которые принимают экземпляр извне.
67
- * Объявляет `callMethod` сам, поэтому переживёт удаление метода из `B24OAuth`.
68
+ * Объявляет `callMethod` сам: в `B24OAuth` этого метода нет с SDK 3.0.0.
68
69
  *
69
70
  * Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
70
71
  * `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
71
- * наличия в обеих ветках — то есть после удаления из SDK тайпчек сломается всё равно.
72
+ * наличия в обеих ветках.
72
73
  *
73
- * Структурный тип шире `B24OAuth`, поэтому публичные сигнатуры конструкторов
74
- * не сужаются: и `B24OAuth`, и `$b24`, и любой свой объект с `callMethod` подходят.
74
+ * Голый `B24OAuth` под этот тип с SDK 3.0.0 не подходит — передавать нужно `$b24`
75
+ * (или любой свой объект с `callMethod`).
75
76
  */
76
77
  export interface B24MethodCaller {
77
- // Дженерика здесь быть не должно: у SDK метод не generic, и `B24OAuth`
78
- // потребителя перестал бы подходить под этот тип. Проверено тайпчеком
78
+ // Дженерика нет, чтобы не расширять публичный тип без нужды: внутренние
79
+ // классы читают ответ через getResultData(), параметр типа им не нужен
79
80
  callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
80
81
  }
81
82
 
@@ -232,8 +232,8 @@ function classifyEntityEvent(raw: unknown, requestedEventName: string): EntityVe
232
232
  * Класс для работы с офлайн-событиями Bitrix24
233
233
  */
234
234
  export class Event {
235
- // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
236
- // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
235
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам — метода в B24OAuth
236
+ // нет с SDK 3.0.0, поэтому передавать нужно $b24, а не голый B24OAuth
237
237
  private b24: B24MethodCaller;
238
238
 
239
239
  /** Расширения видео-файлов */
package/logs/logs.ts CHANGED
@@ -39,6 +39,17 @@ const B24_TEXT_LIMIT = 4000;
39
39
  */
40
40
  const B24_CHAT_TIMEOUT_MS = 5_000;
41
41
 
42
+ /** Сколько символов тела неуспешного ответа чата показывать в `console.error` */
43
+ const B24_CHAT_BODY_PREVIEW = 300;
44
+
45
+ /**
46
+ * Ошибка — обрыв по бюджету `AbortSignal.timeout`. Проверка локальная, без импорта
47
+ * `isTimeoutError` из `utils/fetchRetry.ts` — тот модуль импортирует `logs`, был бы цикл
48
+ */
49
+ function isTimeout(error: unknown): boolean {
50
+ return (error as { name?: unknown } | null)?.name === "TimeoutError";
51
+ }
52
+
42
53
  // ==================== Настройки ====================
43
54
 
44
55
  const configDir: string = process.env.CONFIG_DIR || "../config";
@@ -198,31 +209,70 @@ class LogsAPI {
198
209
  text = this.stripBbCodes(text);
199
210
  }
200
211
 
201
- fetch(url, {
202
- method: "POST",
203
- headers: { "Content-Type": "application/json" },
204
- body: JSON.stringify({
205
- BOT_ID: B24_BOT_ID,
206
- CLIENT_ID: B24_CLIENT_ID,
207
- DIALOG_ID: this.b24ChatId,
208
- MESSAGE: text,
209
- }),
210
- signal: AbortSignal.timeout(B24_CHAT_TIMEOUT_MS),
211
- })
212
- .then((response) => {
213
- if (!response.ok) {
214
- console.error(`Logs B24 Chat: HTTP ${response.status}`);
215
- }
216
- })
217
- .catch((error: unknown) => {
218
- // Проверка локальная, без импорта isTimeoutError — тот модуль импортирует logs
219
- if ((error as { name?: unknown })?.name === "TimeoutError") {
220
- console.error(`Logs B24 Chat: не ответил за ${B24_CHAT_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
221
- return;
222
- }
223
- const errMsg = error instanceof Error ? error.message : String(error);
224
- console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
212
+ void this.postToB24Chat(url, text);
213
+ }
214
+
215
+ /**
216
+ * Сам запрос в чат и разбор ответа. Никогда не бросает: все ошибки — в `console.error`,
217
+ * не через `logs`, иначе отказ канала порождал бы новый error и зацикливался.
218
+ *
219
+ * Сигнал `AbortSignal.timeout` отдан в `fetch`, а он действует и на чтение тела
220
+ * ответа: зависшее тело обрывается тем же бюджетом с `TimeoutError`. До общего `catch`
221
+ * такая ошибка не доходит — у каждого чтения тела свой `.catch`, поэтому таймаут
222
+ * разбирается прямо там: при `!ok` к статусу дописывается пометка «тело не дочитано»,
223
+ * при 200 пишется отдельная строка «доставка не подтверждена».
224
+ */
225
+ private async postToB24Chat(url: string, text: string): Promise<void> {
226
+ try {
227
+ const response = await fetch(url, {
228
+ method: "POST",
229
+ headers: { "Content-Type": "application/json" },
230
+ body: JSON.stringify({
231
+ BOT_ID: B24_BOT_ID,
232
+ CLIENT_ID: B24_CLIENT_ID,
233
+ DIALOG_ID: this.b24ChatId,
234
+ MESSAGE: text,
235
+ }),
236
+ signal: AbortSignal.timeout(B24_CHAT_TIMEOUT_MS),
237
+ });
238
+
239
+ if (!response.ok) {
240
+ // Тело — подсказка к причине отказа, его чтение отправку уронить не должно
241
+ const body = await response.text().catch((error: unknown) =>
242
+ isTimeout(error) ? `[тело не дочитано за ${B24_CHAT_TIMEOUT_MS / 1000}с]` : "",
243
+ );
244
+ const bodyPart = body ? ` — ${body.slice(0, B24_CHAT_BODY_PREVIEW)}` : "";
245
+ console.error(`Logs B24 Chat: HTTP ${response.status}, сообщение не доставлено${bodyPart}`);
246
+ return;
247
+ }
248
+
249
+ // B24 умеет отвечать 200 с телом {error, error_description} — сообщения при этом нет.
250
+ // Нечитаемый JSON — молча null (портал ответил 200, ошибки в теле не видно),
251
+ // а вот зависшее тело — отказ канала: доставку подтвердить нечем, молчать нельзя
252
+ let bodyTimedOut = false;
253
+ const data: unknown = await response.json().catch((error: unknown) => {
254
+ bodyTimedOut = isTimeout(error);
255
+ return null;
225
256
  });
257
+ if (bodyTimedOut) {
258
+ console.error(
259
+ `Logs B24 Chat: HTTP ${response.status}, но ответ не дочитан за ${B24_CHAT_TIMEOUT_MS / 1000}с — доставка не подтверждена`,
260
+ );
261
+ return;
262
+ }
263
+ if (data && typeof data === "object" && "error" in data) {
264
+ const { error, error_description: description } = data as { error?: unknown; error_description?: unknown };
265
+ const reason = description ? String(description) : String(error);
266
+ console.error(`Logs B24 Chat: портал отклонил сообщение — ${reason}`);
267
+ }
268
+ } catch (error: unknown) {
269
+ if (isTimeout(error)) {
270
+ console.error(`Logs B24 Chat: не ответил за ${B24_CHAT_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
271
+ return;
272
+ }
273
+ const errMsg = error instanceof Error ? error.message : String(error);
274
+ console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
275
+ }
226
276
  }
227
277
  }
228
278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "4.0.1",
3
+ "version": "5.0.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "keywords": [],
22
22
  "engines": {
23
- "node": ">=20.3.0"
23
+ "node": ">=22.0.0"
24
24
  },
25
25
  "author": "andrey4emk",
26
26
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "bitrix24/b24/config.ts",
36
36
  "bitrix24/b24/state.ts",
37
37
  "bitrix24/b24/retry.ts",
38
+ "bitrix24/b24/nonJsonResponse.ts",
38
39
  "bitrix24/b24/facade.ts",
39
40
  "bitrix24/b24/proxy.ts",
40
41
  "bitrix24/b24/instance.ts",
@@ -49,14 +50,14 @@
49
50
  "utils/fetchRetry.ts"
50
51
  ],
51
52
  "dependencies": {
52
- "@bitrix24/b24jssdk": "2.2.0",
53
+ "@bitrix24/b24jssdk": "3.0.0",
53
54
  "conf": "15.1.0",
54
55
  "dotenv": "17.4.2",
55
56
  "luxon": "3.7.2"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/express": "5.0.6",
59
- "@types/luxon": "3.7.4",
60
+ "@types/luxon": "3.7.6",
60
61
  "@types/node": "25.9.5",
61
62
  "@types/nodemailer": "8.0.1",
62
63
  "nodemailer": "9.0.5"
@@ -59,8 +59,8 @@ interface SmsResult {
59
59
  export class Smsgold {
60
60
  private user: string;
61
61
  private pass: string;
62
- // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
63
- // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
62
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам — метода в B24OAuth
63
+ // нет с SDK 3.0.0, поэтому передавать нужно $b24, а не голый B24OAuth
64
64
  private b24: B24MethodCaller | null;
65
65
  // ID папки на диске B24 для вложений SMS — параметр, а не константа модуля
66
66
  private uploadFolderId: number;