@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 +37 -8
- package/bitrix24/b24/facade.ts +20 -14
- package/bitrix24/b24/instance.ts +10 -1
- package/bitrix24/b24/nonJsonResponse.ts +161 -0
- package/bitrix24/b24/proxy.ts +6 -6
- package/bitrix24/b24/retry.ts +31 -5
- package/bitrix24/b24/types.ts +19 -18
- package/bitrix24/eventB24.ts +2 -2
- package/logs/logs.ts +74 -24
- package/package.json +5 -4
- package/sendMessage/smsgold.ts +2 -2
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
|
|
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
|
-
|
|
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, // опционально —
|
|
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: ">=
|
|
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
|
## Лицензия
|
package/bitrix24/b24/facade.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Result } from "@bitrix24/b24jssdk";
|
|
2
|
-
import type { AjaxResult,
|
|
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
|
-
* Собственные реализации пяти методов,
|
|
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:
|
|
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.
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
-
// сигнатура
|
|
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
|
|
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
|
// а его тип опций этот ключ не принимает
|
package/bitrix24/b24/instance.ts
CHANGED
|
@@ -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
|
+
}
|
package/bitrix24/b24/proxy.ts
CHANGED
|
@@ -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` в наборе
|
|
20
|
-
*
|
|
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
|
|
82
|
-
//
|
|
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
|
-
//
|
|
113
|
-
// "callMethod" in $b24 обязано
|
|
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;
|
package/bitrix24/b24/retry.ts
CHANGED
|
@@ -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}), повтор через ${
|
|
461
|
+
logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${pause}мс${lifted}`, "warn");
|
|
436
462
|
|
|
437
|
-
await delay(
|
|
463
|
+
await delay(pause);
|
|
438
464
|
}
|
|
439
465
|
}
|
|
440
466
|
|
package/bitrix24/b24/types.ts
CHANGED
|
@@ -31,19 +31,20 @@ export interface AuthSaveResponse {
|
|
|
31
31
|
/**
|
|
32
32
|
* `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
|
|
33
33
|
*
|
|
34
|
-
* SDK
|
|
35
|
-
*
|
|
36
|
-
* не меняются, а зависимости от
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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`
|
|
68
|
+
* Объявляет `callMethod` сам: в `B24OAuth` этого метода нет с SDK 3.0.0.
|
|
68
69
|
*
|
|
69
70
|
* Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
|
|
70
71
|
* `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
|
|
71
|
-
* наличия в обеих
|
|
72
|
+
* наличия в обеих ветках.
|
|
72
73
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
74
|
+
* Голый `B24OAuth` под этот тип с SDK 3.0.0 не подходит — передавать нужно `$b24`
|
|
75
|
+
* (или любой свой объект с `callMethod`).
|
|
75
76
|
*/
|
|
76
77
|
export interface B24MethodCaller {
|
|
77
|
-
// Дженерика
|
|
78
|
-
//
|
|
78
|
+
// Дженерика нет, чтобы не расширять публичный тип без нужды: внутренние
|
|
79
|
+
// классы читают ответ через getResultData(), параметр типа им не нужен
|
|
79
80
|
callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
|
|
80
81
|
}
|
|
81
82
|
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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": "
|
|
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": ">=
|
|
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": "
|
|
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.
|
|
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"
|
package/sendMessage/smsgold.ts
CHANGED
|
@@ -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
|
-
//
|
|
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;
|