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