@andrey4emk/npm-app-back-b24 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  reinitializeB24,
55
55
  stopProactiveRefresh,
56
56
  getResultData,
57
+ callProtected,
57
58
  errorB24,
58
59
  event,
59
60
  Event,
@@ -122,7 +123,7 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
122
123
 
123
124
  Модуль работает с токенами через файл `authB24.json` (используется библиотека `conf`). Модель БД не требуется.
124
125
 
125
- - **`$b24`** — готовый экземпляр `B24OAuth`, создаётся автоматически при импорте на основе сохранённых токенов. Если данные авторизации отсутствуют — `null`.
126
+ - **`$b24`** — готовый экземпляр `B24Client` (`B24OAuth` плюс методы фасада, см. ниже), создаётся автоматически при импорте на основе сохранённых токенов. Если данные авторизации отсутствуют — `null`.
126
127
 
127
128
  ```js
128
129
  import { $b24 } from "@andrey4emk/npm-app-back-b24";
@@ -137,23 +138,43 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
137
138
  - Окружение (`DEV`/`PROD`) определяется переменной `APP_ENV` — используется как ключ секции в конфиге авторизации.
138
139
  - Признак того, что проект использует OAuth, — заданные `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET`. Если их нет, `$b24` молча становится `null` (уровень `debug`). Если они заданы, но токенов в `authB24.json` не хватает, пишется `error` — это уже настоящая проблема конфигурации.
139
140
 
141
+ **Методы `callMethod`, `callListMethod`, `fetchListMethod`, `callBatch`, `callBatchByChunk` — фасад пакета.**
142
+
143
+ SDK 2.x помечает эти пять методов к удалению в следующем major и на каждый вызов пишет предупреждение об устаревании. Пакет реализует их сам поверх `actions.v2.*`, поэтому вызовы у потребителей не меняются, а зависимости от удаляемого API у пакета больше нет. Сигнатуры и типы возврата прежние — правок в проектах не требуется.
144
+
145
+ Поведение прежнее у четырёх методов из пяти. **`callListMethod` отличается в двух местах:** он бросает внятную ошибку с именем метода там, где SDK падал `TypeError` из своих недр (портал вернул «мягкую» ошибку, ответ не список, конверт нечитаем, `next` не растёт), и у него есть потолок в 1000 страниц — при упоре в него вызов тоже бросает, а не отдаёт молча обрезанный список. Если у вас есть `try`/`catch` вокруг постраничных выборок, текст ошибки изменится; если его нет — падение станет заметнее, но не появится там, где раньше всё работало.
146
+
147
+ Прямой доступ к `$b24.actions.*` остаётся: это сырой SDK **без** retry и гейта идемпотентности. Если защита нужна, оборачивайте вызов в `callProtected()`.
148
+
140
149
  **Retry при сетевых ошибках:**
141
150
 
142
- Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`). Метод `fetchListMethod` из ретраев исключён — это async-генератор, оборачивать его в async-функцию нельзя; постраничные запросы SDK ретраит сам.
151
+ Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`). Метод `fetchListMethod` из ретраев исключён — это async-генератор, оборачивать его в async-функцию нельзя. `callBatchByChunk` пакет реализует, но не ретраит.
152
+
153
+ `callListMethod` повторяется целиком: при сетевом сбое пагинация начинается с нулевой страницы заново. Постраничный retry был бы сменой семантики, поэтому поведение оставлено прежним.
143
154
 
144
155
  | Параметр | Значение |
145
156
  | -------------- | ----------------------------------------- |
146
157
  | Попыток | 5 |
147
158
  | Задержка | 500 мс (линейно растущая для refreshAuth) |
148
159
 
149
- Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток. Каждая неудачная попытка логируется через `logs.add()` с уровнем `warn` — в сообщение попадает код ошибки SDK. Уровень `warn` выбран намеренно: `error` уходит в чат B24, и пять попыток одного упавшего вызова превращались в пять сообщений. Финальный провал приходит вызывающему коду исключением и логируется им один раз.
160
+ Retry срабатывает только при сетевых проблемах (`ECONNRESET`, `ETIMEDOUT`, `ERR_NETWORK` и т.д.). HTTP-ошибки (400, 500) и ошибки бизнес-логики Bitrix24 **не** вызывают повторных попыток.
161
+
162
+ **Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
163
+
164
+ **Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя — хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
150
165
 
151
- **Создающие вызовы не повторяются.** Если в вызове есть метод, создающий сущность (`*.add`, `*.uploadfile`, `*.import`, `*.register` в том числе внутри `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: при ответе портала «200 без поля `result`» (заглушка прокси или WAF) SDK падает в собственной ветке логирования и отдаёт ошибку со `status: 0`, неотличимую от транспортного сбоя хотя запрос уже выполнен. Повтор в такой ситуации создавал до пяти дубликатов задачи или файла. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
166
+ Команды `callBatch` разбираются в обеих формахкортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
152
167
 
153
- **Обмен refresh-токена** повторяется по более строгому правилу только если соединение заведомо не состоялось (`ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`, `ENETUNREACH`, см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию, а ответ потеряться: повтор вернул бы `invalid_grant` и оставил в конфиге мёртвый `refresh_token`.
168
+ Важно: **у SDK есть собственный слой ретраев**, который гейт не отменяет. При настоящем разрыве соединения (`ECONNRESET` и подобные) SDK сходит на портал до 3 раз независимо от нашей защиты. Гейт закрывает конкретный случай `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` и самим SDK не повторяется.
169
+
170
+ **Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
154
171
 
155
172
  У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но он работает только при rate limit (HTTP 429, 503) и при распознанных `NETWORK_ERROR` / `REQUEST_TIMEOUT`. Замаскированные транспортные сбои приходят с кодом `JSSDK_UNKNOWN_ERROR`, который входит в `BUILT_IN_HARD_ERROR_CODES` — на таких ошибках SDK делает одну попытку, и повторяет только Proxy.
156
173
 
174
+ **Диагностика SDK.** Начиная с 3.7.0 собственные сообщения SDK от уровня `WARNING` и выше попадают в лог пакета с префиксом `SDK B24:` вместе с контекстом (`requestId`, `method`, `status`, код ошибки) — например предупреждение `fetchList` про игнорируемый `order` или про остановку пагинации, когда `idKey` не совпал с полем ответа. Порог нужен: на уровне `info` SDK пишет две строки на каждый запрос. Ошибки SDK приходят как `warn`, а не `error` — это диагностика отдельной неуспешной попытки, а окончательный провал вызова один раз пишет retry-слой.
175
+
176
+ Ответы со статусом 4xx уходят на `debug`: лимитер SDK пишет через свой `error()` любой 4xx кроме 408 и 429, а Bitrix24 отдаёт 400 на штатные «мягкие» ошибки вроде несуществующей сущности. Без этого проверка существования в цикле давала бы строку на каждой итерации. Разбирайте такие ответы через `getResultData()` — это нормальный путь, а не сбой.
177
+
157
178
  **«Мягкие» ошибки Bitrix24:**
158
179
 
159
180
  Часть ошибок (`ERROR_ENTITY_NOT_FOUND`, `BITRIX_REST_V3_EXCEPTION_*`) SDK не бросает исключением, а возвращает как `AjaxResult` с ошибкой: `isSuccess === false`, и тогда `response.getData()` вернёт `undefined`. Отдельный случай — успешный ответ, у которого сам `result` пустой (`null`/`undefined`). Чтобы не падать на `undefined`, разбирайте ответы через `getResultData()` — он покрывает обе ситуации и бросает понятную ошибку с именем метода.
@@ -238,6 +259,40 @@ const response = await fetchRetry("https://api.example.com/data", { method: "GET
238
259
  const deal = getResultData(response, "crm.deal.get");
239
260
  ```
240
261
 
262
+ - **`callProtected(run, methodName)`** — прогоняет произвольный вызов `$b24.actions.*` через тот же retry и гейт идемпотентности, что и методы фасада. Нужен, когда требуется API, которого у фасада нет: `FilterV3`, keyset-пагинация (`callTail`/`fetchTail`), `aggregate`.
263
+
264
+ - **Параметры:**
265
+ - `run` (`() => Promise<T>`) — сам вызов.
266
+ - `methodName` (`string | string[]`) — имя REST-метода B24 (**не** метода SDK) либо список имён, если внутри batch: по ним гейт решает, создаёт ли вызов сущность.
267
+ - **Возвращает:** промис с результатом `run`.
268
+
269
+ ```js
270
+ import { $b24, callProtected } from "@andrey4emk/npm-app-back-b24";
271
+
272
+ const response = await callProtected(
273
+ () => $b24.actions.v3.call.make({ method: "crm.item.list", params }),
274
+ "crm.item.list"
275
+ );
276
+
277
+ // Для batch перечисляйте методы команд, а не "batch"
278
+ const batchResponse = await callProtected(
279
+ () => $b24.actions.v3.batch.make({ calls }),
280
+ ["crm.item.get", "crm.item.add"]
281
+ );
282
+ ```
283
+
284
+ **Гейт работает fail-closed.** Если список имён пуст или хоть одно имя не похоже на REST-метод (легальные всегда с точкой — `crm.deal.add`, `disk.folder.uploadfile`), вызов считается создающим и не повторяется. Поэтому `callProtected(() => batch.make({ calls }), "batch")` защиту не обойдёт: имя `batch` не разбирается, и батч с `crm.deal.add` внутри не будет повторён пять раз при `status: 0`. Цена ошибки в эту сторону — лишняя необработанная сетевая ошибка; в обратную — дубликаты сущностей.
285
+
286
+ Без обёртки вызов `$b24.actions.*` идёт мимо защиты: ни повторов при сетевых сбоях, ни блокировки повтора для создающих методов.
287
+
288
+ - **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`.
289
+
290
+ ```ts
291
+ import type { B24Client } from "@andrey4emk/npm-app-back-b24";
292
+
293
+ function sync(client: B24Client) { /* ... */ }
294
+ ```
295
+
241
296
  ### Задачи ошибок
242
297
 
243
298
  - **`errorB24(dataTask)`** — создаёт служебную задачу в Bitrix24 при ошибках/событиях. Использует глобальный `$b24`.
package/bitrix24/b24.ts CHANGED
@@ -1,5 +1,17 @@
1
- import { B24OAuth, AjaxError, SdkError, Logger } from "@bitrix24/b24jssdk";
2
- import type { B24OAuthParams, B24OAuthSecret, AuthData, AjaxResult } from "@bitrix24/b24jssdk";
1
+ import { B24OAuth, AjaxError, SdkError, Logger, LogLevel, Result } from "@bitrix24/b24jssdk";
2
+ import type {
3
+ B24OAuthParams,
4
+ B24OAuthSecret,
5
+ AuthData,
6
+ AjaxResult,
7
+ TypeCallParams,
8
+ BatchCommandsArrayUniversal,
9
+ BatchCommandsObjectUniversal,
10
+ BatchNamedCommandsUniversal,
11
+ Handler,
12
+ LogRecord,
13
+ Formatter,
14
+ } from "@bitrix24/b24jssdk";
3
15
  import { logs } from "../logs/logs.ts";
4
16
  import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
5
17
  import Conf from "conf";
@@ -17,6 +29,49 @@ interface SaveResult {
17
29
  message: string;
18
30
  }
19
31
 
32
+ /**
33
+ * `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
34
+ *
35
+ * SDK 2.x помечает `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
36
+ * к удалению в следующем major. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
37
+ * не меняются, а зависимости от удаляемого API у нас больше нет.
38
+ *
39
+ * Тип расширяет `B24OAuth`, поэтому `$b24` по-прежнему передаётся туда, где ждут `B24OAuth`
40
+ * (`new Smsgold(auth, $b24)`, `new Event($b24)`).
41
+ */
42
+ export interface B24Client extends B24OAuth {
43
+ callMethod<T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>>;
44
+ callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result>;
45
+ fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]>;
46
+ callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result>;
47
+ callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result>;
48
+ }
49
+
50
+ /**
51
+ * Минимальный контракт «умеет вызывать REST-методы B24».
52
+ *
53
+ * Нужен внутренним классам (`Event`, `Smsgold`), которые принимают экземпляр извне.
54
+ * Объявляет `callMethod` сам, поэтому переживёт удаление метода из `B24OAuth`.
55
+ *
56
+ * Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
57
+ * `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
58
+ * наличия в обеих ветках — то есть после удаления из SDK тайпчек сломается всё равно.
59
+ *
60
+ * Структурный тип шире `B24OAuth`, поэтому публичные сигнатуры конструкторов
61
+ * не сужаются: и `B24OAuth`, и `$b24`, и любой свой объект с `callMethod` подходят.
62
+ */
63
+ export interface B24MethodCaller {
64
+ // Дженерика здесь быть не должно: у SDK метод не generic, и `B24OAuth`
65
+ // потребителя перестал бы подходить под этот тип. Проверено тайпчеком
66
+ callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
67
+ }
68
+
69
+ /** Набор методов фасада — то, что Proxy отдаёт вместо реализаций SDK */
70
+ type FacadeMethods = Pick<B24Client, "callMethod" | "callListMethod" | "fetchListMethod" | "callBatch" | "callBatchByChunk">;
71
+
72
+ /** Формы списка команд batch, которые принимает actions.v2 */
73
+ type BatchCalls = BatchCommandsArrayUniversal | BatchCommandsObjectUniversal | BatchNamedCommandsUniversal;
74
+
20
75
  // ==================== Константы ====================
21
76
 
22
77
  const CONFIG_DIR = process.env.CONFIG_DIR || "../config";
@@ -34,9 +89,21 @@ const RETRY_COUNT = 5;
34
89
  /** Задержка между попытками (мс) */
35
90
  const RETRY_DELAY_MS = 500;
36
91
 
37
- /** Методы B24OAuth, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
92
+ /** Методы фасада, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
38
93
  const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
39
94
 
95
+ /**
96
+ * Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
97
+ *
98
+ * Шире, чем RETRYABLE_METHODS: `fetchListMethod` и `callBatchByChunk` мы реализуем,
99
+ * но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
100
+ * SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
101
+ */
102
+ const FACADE_METHODS = new Set(["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"]);
103
+
104
+ /** Потолок числа страниц в callListMethod — страховка от бесконечной пагинации */
105
+ const LIST_MAX_PAGES = 1000;
106
+
40
107
  /**
41
108
  * Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
42
109
  * и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
@@ -44,15 +111,16 @@ const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"])
44
111
  const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
45
112
 
46
113
  /**
47
- * REST-методы B24, повтор которых создаёт дубликат сущности.
114
+ * REST-методы B24, повтор которых создаёт дубликат сущности или запускает
115
+ * повторное действие: add, create, start, send, uploadfile, import, register.
48
116
  *
49
117
  * SDK 2.x при ответе портала «200 без поля result» (заглушка прокси или WAF) падает
50
118
  * внутри собственной логирующей ветки и отдаёт ошибку со status 0 — неотличимую от
51
119
  * транспортного сбоя. Запрос при этом уже выполнен, поэтому retry создаст вторую
52
- * задачу, второй файл и т.д. Идемпотентные записи (update, delete, set) повторять
53
- * безопасно: повторное применение даёт то же состояние.
120
+ * задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи
121
+ * (update, delete, set) повторять безопасно: повторное применение даёт то же состояние.
54
122
  */
55
- const NON_IDEMPOTENT_METHOD_RE = /\.(add|uploadfile|import|register)(\?|$)/i;
123
+ const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
56
124
 
57
125
  const confAuthB24 = new Conf({
58
126
  cwd: path.resolve(CONFIG_DIR),
@@ -94,6 +162,102 @@ export function getResultData<T = any>(response: AjaxResult, methodName: string)
94
162
  return data.result as T;
95
163
  }
96
164
 
165
+ /** Конверт ответа restApi:v2 — то, что прислал портал, до того как AjaxResult его урезал */
166
+ interface V2Envelope {
167
+ result?: unknown;
168
+ next?: unknown;
169
+ total?: unknown;
170
+ }
171
+
172
+ /**
173
+ * Читает сырой конверт ответа из AjaxResult.
174
+ *
175
+ * `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
176
+ * в ней нет намеренно (в restApi:v3 их не существует), а `isMore()`/`getTotal()`/`getNext()`
177
+ * помечены `@removed 2.0.0` и читают тот же приватный конверт. Единственный способ узнать
178
+ * смещение следующей страницы, не опираясь на удаляемое API, — прочитать конверт напрямую:
179
+ * `_data` объявлен `protected`, а не приватным полем класса, поэтому в рантайме доступен.
180
+ *
181
+ * Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
182
+ * она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
183
+ */
184
+ function readV2Envelope(response: AjaxResult, method: string): V2Envelope {
185
+ const envelope = (response as unknown as { _data?: unknown })._data;
186
+
187
+ if (!envelope || typeof envelope !== "object") {
188
+ throw new Error(`${method}: не удалось прочитать конверт ответа Bitrix24 (AjaxResult._data недоступен) — изменился внутренний формат SDK`);
189
+ }
190
+
191
+ return envelope as V2Envelope;
192
+ }
193
+
194
+ // ==================== Логгер SDK ====================
195
+
196
+ /**
197
+ * Мост из логгера SDK в `logs` пакета.
198
+ *
199
+ * Порог WARNING обязателен: SDK пишет `post/send` и `post/response` на уровне `info`
200
+ * на каждый запрос и `http batch request starting/completed` на `debug` — без фильтра
201
+ * это залило бы лог.
202
+ *
203
+ * `AbstractHandler` объявлен в типах SDK, но в рантайме не экспортируется,
204
+ * поэтому реализуем интерфейс `Handler` обычным классом.
205
+ */
206
+ class B24SdkLogHandler implements Handler {
207
+ private formatter: Formatter | null = null;
208
+
209
+ isHandling(level: LogLevel): boolean {
210
+ return level >= LogLevel.WARNING;
211
+ }
212
+
213
+ shouldBubble(): boolean {
214
+ return true;
215
+ }
216
+
217
+ setFormatter(formatter: Formatter): void {
218
+ this.formatter = formatter;
219
+ }
220
+
221
+ getFormatter(): Formatter | null {
222
+ return this.formatter;
223
+ }
224
+
225
+ async handle(record: LogRecord): Promise<boolean> {
226
+ // Бросать отсюда нельзя ни при каких обстоятельствах: Logger.log() делает
227
+ // await handle(), но лимитер зовёт логгер без await — исключение стало бы
228
+ // unhandled rejection и на дефолтных настройках Node убило бы процесс.
229
+ // Путь к броску реален: logs.add() читает conf, а тот перечитывает файл
230
+ // на каждом обращении и падает на битом log.json. В catch пишем через
231
+ // console.error, а не через logs — иначе рискуем зациклиться на той же ошибке.
232
+ try {
233
+ const status = Number(record.context?.status);
234
+
235
+ // Лимитер SDK через error() пишет любой 4xx кроме 408/429 как
236
+ // «non-retryable client error». Портал отдаёт 400 на штатные «мягкие»
237
+ // ошибки (несуществующая сущность), которые README описывает как
238
+ // нормальный путь через getResultData(): проверка существования в цикле
239
+ // дала бы строку на итерацию. Такие сообщения — уровень debug.
240
+ //
241
+ // Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
242
+ // попытки, а таких попыток на один вызов до пятнадцати (3 внутри SDK × 5 наших).
243
+ // Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
244
+ // окончательный провал вызова логирует retry-слой, и он туда попадёт ровно один раз.
245
+ const level = status >= 400 && status < 500 ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
246
+
247
+ // Контекст SDK (requestId, method, code, wait, status) уже прогнан
248
+ // через redactSensitiveParams — токены в него не попадают
249
+ const context = record.context && Object.keys(record.context).length > 0 ? record.context : undefined;
250
+
251
+ logs.add(`SDK B24: ${record.message}`, level, context);
252
+ } catch (error: unknown) {
253
+ const msg = error instanceof Error ? error.message : String(error);
254
+ console.error(`B24SdkLogHandler: не удалось записать сообщение SDK — ${msg}`);
255
+ }
256
+
257
+ return true;
258
+ }
259
+ }
260
+
97
261
  // ==================== Создание экземпляра B24OAuth ====================
98
262
 
99
263
  function createB24Instance(): B24OAuth | null {
@@ -141,10 +305,11 @@ function createB24Instance(): B24OAuth | null {
141
305
 
142
306
  const b24 = new B24OAuth(authParams, secret);
143
307
 
144
- // Логгер без хендлеров: SDK 2.x на каждый вызов callMethod/callBatch пишет
145
- // предупреждение об устаревании напрямую в console.warn, если логгер — NullLogger.
146
- // Любой другой логгер получает запись через свой интерфейс, поэтому консоль остаётся чистой.
147
- b24.setLogger(Logger.create("npm-app-back-b24"));
308
+ // Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
309
+ // callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
310
+ // Дефолтный NullLogger не годится LoggerFactory.forcedLog пишет мимо логгера
311
+ // прямо в console.warn, но после перехода на фасад этот путь у нас недостижим.
312
+ b24.setLogger(Logger.create("npm-app-back-b24").pushHandler(new B24SdkLogHandler()));
148
313
 
149
314
  return b24;
150
315
  }
@@ -242,10 +407,12 @@ async function refreshAuthWithMutex(): Promise<AuthData> {
242
407
  } catch (error) {
243
408
  lastError = error;
244
409
 
245
- // Повторяем только когда соединение заведомо не состоялось: при таймауте
246
- // или обрыве сервер мог уже провести ротацию, ответ потеряться, и повтор
247
- // вернёт invalid_grant — refresh-токен окажется сожжён, потребуется
248
- // ручная переавторизация
410
+ // Повторяем только когда соединение заведомо не состоялось.
411
+ // При таймауте или обрыве сервер мог уже провести ротацию: тогда
412
+ // локальный refresh-токен мёртв независимо от наших действий, и повтор
413
+ // не помогает, а лишь маскирует проблему серией одинаковых отказов.
414
+ // Следующая попытка всё равно будет — проактивный таймер повторит
415
+ // через 2 минуты при времени жизни токена около часа
249
416
  if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
250
417
 
251
418
  const msg = error instanceof Error ? error.message : String(error);
@@ -349,91 +516,340 @@ function isB24NetworkError(error: unknown): boolean {
349
516
  return isNetworkError(error);
350
517
  }
351
518
 
352
- /** Достаёт имена REST-методов B24 из аргументов вызова SDK */
353
- function extractB24Methods(sdkMethod: string, args: any[]): string[] {
519
+ /**
520
+ * Достаёт имя REST-метода из одной команды batch.
521
+ * Возвращает null, если форма команды не распознана.
522
+ */
523
+ function extractBatchCommandMethod(cmd: unknown): string | null {
524
+ // Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
525
+ if (Array.isArray(cmd)) {
526
+ return typeof cmd[0] === "string" ? cmd[0] : null;
527
+ }
528
+
529
+ // Объект { method, params }
530
+ if (cmd && typeof cmd === "object") {
531
+ const method = (cmd as { method?: unknown }).method;
532
+ return typeof method === "string" ? method : null;
533
+ }
534
+
535
+ // Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
536
+ // (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
537
+ // если потребитель остался на ней со старой версии
538
+ if (typeof cmd === "string") {
539
+ return cmd;
540
+ }
541
+
542
+ return null;
543
+ }
544
+
545
+ /**
546
+ * Возвращает причину, по которой вызов запрещено повторять, либо null.
547
+ *
548
+ * Для callBatch действует правило fail-closed: если хоть одну команду разобрать
549
+ * не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
550
+ * дешевле — потребитель получит ошибку вместо тихого дубликата.
551
+ */
552
+ function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
354
553
  const first = args[0];
355
554
 
356
555
  // callMethod(method, params) и callListMethod(method, params, ...)
357
556
  if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
358
- return typeof first === "string" ? [first] : [];
557
+ if (typeof first !== "string") return null;
558
+ const method = first.trim();
559
+ return NON_IDEMPOTENT_METHOD_RE.test(method) ? method : null;
359
560
  }
360
561
 
562
+ if (sdkMethod !== "callBatch") return null;
563
+
361
564
  // callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
362
- if (sdkMethod === "callBatch") {
363
- const commands: unknown[] = Array.isArray(first) ? first : first && typeof first === "object" ? Object.values(first) : [];
565
+ if (!first || typeof first !== "object") {
566
+ return "аргументы batch не разобраны";
567
+ }
568
+
569
+ const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
570
+ const blocking: string[] = [];
364
571
 
365
- return commands
366
- .map((cmd) => (typeof cmd === "string" ? cmd : (cmd as { method?: unknown })?.method))
367
- .filter((method): method is string => typeof method === "string");
572
+ for (const cmd of commands) {
573
+ const method = extractBatchCommandMethod(cmd);
574
+
575
+ if (method === null) return "команда batch не разобрана";
576
+ if (NON_IDEMPOTENT_METHOD_RE.test(method.trim())) blocking.push(method.trim());
368
577
  }
369
578
 
370
- return [];
579
+ return blocking.length > 0 ? blocking.join(", ") : null;
371
580
  }
372
581
 
373
- /** Возвращает методы вызова, повтор которых создал бы дубликаты сущностей */
374
- function getNonIdempotentMethods(sdkMethod: string, args: any[]): string[] {
375
- return extractB24Methods(sdkMethod, args).filter((method) => NON_IDEMPOTENT_METHOD_RE.test(method));
582
+ /**
583
+ * Общий цикл повторов при сетевых ошибках.
584
+ *
585
+ * @param fn — вызов без аргументов, уже замкнутый на нужные параметры
586
+ * @param label — префикс лог-строк, по нему в логе видно источник повтора
587
+ * @param blockReason — причина, по которой повтор запрещён (создающий вызов), либо null
588
+ */
589
+ async function runWithRetry<T>(fn: () => Promise<T>, label: string, blockReason: string | null): Promise<T> {
590
+ let lastError: unknown;
591
+
592
+ for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
593
+ try {
594
+ return await fn();
595
+ } catch (error: unknown) {
596
+ lastError = error;
597
+
598
+ // Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
599
+ // вызывающему коду как есть, он решает, что с ней делать
600
+ if (!isB24NetworkError(error)) throw error;
601
+
602
+ const msg = error instanceof Error ? error.message : String(error);
603
+ const code = error instanceof SdkError ? ` [${error.code}]` : "";
604
+
605
+ // Окончательные отказы логируем на error: собственные модули пакета
606
+ // (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
607
+ // но не пишут в лог — без этих строк сбой $b24 не виден нигде
608
+
609
+ if (attempt === RETRY_COUNT) {
610
+ logs.add(`${label}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
611
+ throw error;
612
+ }
613
+
614
+ // Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
615
+ // а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
616
+ // не повторяем — лучше вернуть ошибку, чем создать дубликат
617
+ if (blockReason) {
618
+ logs.add(`${label}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) — ${msg}`, "error");
619
+ throw error;
620
+ }
621
+
622
+ // Промежуточные попытки — warn: в чат B24 уходит только уровень error,
623
+ // иначе один упавший вызов дал бы пять сообщений
624
+ logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
625
+
626
+ await delay(RETRY_DELAY_MS);
627
+ }
628
+ }
629
+
630
+ // Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
631
+ throw lastError;
376
632
  }
377
633
 
378
634
  /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
379
635
  function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
380
636
  return (async (...args: any[]) => {
381
637
  // Состав вызова между попытками не меняется — разбираем один раз
382
- const nonIdempotent = getNonIdempotentMethods(methodName, args);
383
- let lastError: unknown;
638
+ const blockReason = getRetryBlockReason(methodName, args);
639
+ return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
640
+ }) as T;
641
+ }
384
642
 
385
- for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
386
- try {
387
- return await fn.apply(context, args);
388
- } catch (error: unknown) {
389
- lastError = error;
643
+ /**
644
+ * Прогоняет произвольный вызов `actions.*` через тот же retry и гейт идемпотентности,
645
+ * что и методы фасада. Прямой `$b24.actions.v3.call.make()` идёт мимо защиты — если она
646
+ * нужна (например, ради `FilterV3` или keyset-пагинации), вызов оборачивают этой функцией.
647
+ *
648
+ * @param run — сам вызов, например `() => $b24.actions.v3.call.make({ method, params })`
649
+ * @param methodName — имя REST-метода B24 (не метода SDK) либо список имён, если внутри
650
+ * batch: по ним гейт решает, создаёт ли вызов сущность
651
+ *
652
+ * @example
653
+ * const response = await callProtected(
654
+ * () => $b24!.actions.v3.call.make({ method: "crm.item.list", params }),
655
+ * "crm.item.list"
656
+ * );
657
+ *
658
+ * @example
659
+ * const response = await callProtected(
660
+ * () => $b24!.actions.v3.batch.make({ calls }),
661
+ * ["crm.item.get", "crm.item.add"]
662
+ * );
663
+ */
664
+ export async function callProtected<T>(run: () => Promise<T>, methodName: string | string[]): Promise<T> {
665
+ const names = (Array.isArray(methodName) ? methodName : [methodName]).map((name) => String(name).trim()).filter((name) => name.length > 0);
390
666
 
391
- if (!isB24NetworkError(error) || attempt === RETRY_COUNT) {
392
- throw error;
393
- }
667
+ // Fail-closed, как в гейте callBatch: список пуст или имя не похоже на REST-метод
668
+ // (легальные имена всегда с точкой — crm.deal.add, disk.folder.uploadfile) — считаем
669
+ // вызов создающим. Иначе callProtected(() => batch.make({ calls }), "batch") прошёл бы
670
+ // мимо гейта, и батч с crm.deal.add внутри повторился бы до пяти раз при status 0.
671
+ const isUnparsed = names.length === 0 || names.some((name) => !name.includes("."));
672
+ const blocking = names.filter((name) => NON_IDEMPOTENT_METHOD_RE.test(name));
394
673
 
395
- const msg = error instanceof Error ? error.message : String(error);
396
- const code = error instanceof SdkError ? ` [${error.code}]` : "";
397
-
398
- // Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
399
- // а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
400
- // не повторяем лучше вернуть ошибку, чем создать дубликат
401
- if (nonIdempotent.length > 0) {
402
- logs.add(
403
- `$b24.${methodName}${code}: повтор отменён, вызов создаёт сущности (${nonIdempotent.join(", ")}) ${msg}`,
404
- "warn"
405
- );
406
- throw error;
407
- }
674
+ const blockReason = isUnparsed ? "имя метода не разобрано" : blocking.length > 0 ? blocking.join(", ") : null;
675
+
676
+ return runWithRetry(run, `actions:${names.join(", ") || "имя не указано"}`, blockReason);
677
+ }
678
+
679
+ // ==================== Фасад поверх actions.v2.* ====================
680
+
681
+ /**
682
+ * Собственные реализации пяти методов, которые SDK 2.x помечает к удалению.
683
+ * Повторяют `AbstractB24` дословно, включая дефолты и порядок аргументов.
684
+ *
685
+ * Позиционные сигнатуры сохранены один в один: на этом держится то, что гейт
686
+ * идемпотентности (`getRetryBlockReason`) видит те же аргументы, что и раньше,
687
+ * — retry оборачивает фасад снаружи, а преобразование в объект опций
688
+ * происходит уже внутри.
689
+ */
690
+ function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any> {
691
+ // Геттер actions при каждом обращении проверяет инициализацию экземпляра —
692
+ // читаем его в момент вызова, а не один раз при создании фасада
693
+ const v2 = () => target.actions.v2;
694
+
695
+ const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
696
+ const merged: TypeCallParams = { ...params };
697
+
698
+ // Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod
699
+ if (!("start" in merged && Number.isInteger(merged.start)) && Number.isInteger(start)) {
700
+ merged.start = start;
701
+ }
702
+
703
+ return v2().call.make<T>({ method, params: merged });
704
+ };
408
705
 
409
- // Уровень warn: промежуточные попытки не должны улетать в чат B24,
410
- // финальный провал прилетит вызывающему коду исключением
411
- logs.add(`$b24.${methodName}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
706
+ /**
707
+ * Собственный офсетный цикл, а не `actions.v2.callList.make`.
708
+ *
709
+ * У `callList.make` keyset-пагинация: он шлёт `start: -1`, навязывает
710
+ * `order: { ID: 'ASC' }` и добавляет в фильтр `>ID`. Пользовательский `order`
711
+ * при этом молча игнорируется, а колбэка `progress` там нет вовсе. Делегирование
712
+ * туда изменило бы поведение постраничных выборок у потребителей.
713
+ */
714
+ const callListMethod = async (
715
+ method: string,
716
+ params?: object,
717
+ progress?: null | ((progress: number) => void),
718
+ customKeyForResult?: string | null
719
+ ): Promise<Result> => {
720
+ const result = new Result();
721
+ const onProgress = typeof progress === "function" ? progress : null;
722
+
723
+ onProgress?.(0);
724
+
725
+ const list: unknown[] = [];
726
+ let start = 0;
727
+ let completed = false;
728
+
729
+ for (let page = 1; page <= LIST_MAX_PAGES; page++) {
730
+ const response = await v2().call.make({ method, params: { ...params, start } });
731
+
732
+ // SDK в своём callListMethod этой проверки не делает и падает TypeError
733
+ // на getData().result, когда портал вернул «мягкую» ошибку
734
+ if (!response.isSuccess) {
735
+ throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
736
+ }
737
+
738
+ // Читаем конверт целиком: getData() режет ответ до { result, time },
739
+ // а пагинация держится на next, которого там нет
740
+ const envelope = readV2Envelope(response, method);
741
+ const payload = envelope.result;
742
+ const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
743
+
744
+ if (!Array.isArray(chunk)) {
745
+ throw new Error(`${method}: ответ не является списком`);
746
+ }
747
+
748
+ for (const item of chunk) {
749
+ list.push(item);
750
+ }
751
+
752
+ // Единственное правило остановки — отсутствие next в конверте. Считать
753
+ // смещение самим (start += chunk.length) нельзя: портал ставит next = start + 50
754
+ // независимо от того, сколько строк реально отдал, и на странице, укороченной
755
+ // правами доступа или фильтром, собственный счётчик отстаёт — перекрытие
756
+ // читается второй раз и записи дублируются. Метод, который игнорирует start
757
+ // и отдаёт весь список одной страницей, next не присылает и останавливается здесь же.
758
+ if (!Number.isInteger(envelope.next)) {
759
+ completed = true;
760
+ break;
761
+ }
762
+
763
+ const nextStart = Number(envelope.next);
412
764
 
413
- await delay(RETRY_DELAY_MS);
765
+ // next обязан расти. Портал, вернувший прежнее или меньшее смещение,
766
+ // иначе гонял бы одну и ту же страницу до потолка, раздувая список
767
+ if (nextStart <= start) {
768
+ throw new Error(`${method}: портал вернул непродвигающийся next (${nextStart}) при start ${start} — пагинация зациклилась`);
769
+ }
770
+
771
+ start = nextStart;
772
+
773
+ if (onProgress) {
774
+ const total = Number(envelope.total) || 0;
775
+ onProgress(total > 0 ? Math.round((100 * list.length) / total) : 100);
414
776
  }
415
777
  }
416
778
 
417
- // Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
418
- throw lastError;
419
- }) as T;
779
+ // Обрезанный список, отданный как успешный, худший исход: потребитель
780
+ // примет неполную выборку за полную. Поэтому бросаем, а не логируем:
781
+ // addError() на базовом Result бесполезен — getData() отдаёт данные
782
+ // независимо от наличия ошибок, и существующие потребители её не увидят
783
+ if (!completed) {
784
+ throw new Error(`${method}: достигнут потолок в ${LIST_MAX_PAGES} страниц, портал продолжает отдавать next — выборка прервана как неполная`);
785
+ }
786
+
787
+ onProgress?.(100);
788
+ result.setData(list);
789
+ return result;
790
+ };
791
+
792
+ async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
793
+ yield* v2().fetchList.make<any>({
794
+ method,
795
+ params,
796
+ idKey,
797
+ customKeyForResult: customKeyForResult === null ? undefined : customKeyForResult,
798
+ });
799
+ }
800
+
801
+ const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result> => {
802
+ // Дефолты подставляем мы: batch.make своих не имеет, он просто
803
+ // расширяет переданный объект опций версией API
804
+ return v2().batch.make({
805
+ calls: calls as BatchCalls,
806
+ options: {
807
+ isHaltOnError: isHaltOnError ?? true,
808
+ returnAjaxResult: returnAjaxResult ?? false,
809
+ },
810
+ });
811
+ };
812
+
813
+ const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result> => {
814
+ // isHaltOnError передаём как есть, без ?? true — дословно по AbstractB24.
815
+ // returnAjaxResult не передаём: batchByChunk.make жёстко ставит false сам,
816
+ // а его тип опций этот ключ не принимает
817
+ return v2().batchByChunk.make({ calls, options: { isHaltOnError } });
818
+ };
819
+
820
+ return { callMethod, callListMethod, fetchListMethod, callBatch, callBatchByChunk } satisfies FacadeMethods;
420
821
  }
421
822
 
422
823
  /**
423
824
  * Proxy-обёртка вокруг B24OAuth.
424
825
  *
425
- * Методы из RETRYABLE_METHODS оборачиваются retry-логикой, все остальные
426
- * привязываются к оригинальному объекту через bind: класс B24OAuth использует
427
- * приватные поля (#authOAuthManager), и при вызове метода с this === Proxy
428
- * движок бросает "Cannot read private member".
826
+ * Имена из FACADE_METHODS резолвятся в собственные реализации пакета поверх
827
+ * `actions.v2.*`; из них методы RETRYABLE_METHODS дополнительно оборачиваются
828
+ * retry-логикой. Все остальные методы привязываются к оригинальному объекту
829
+ * через bind: класс B24OAuth использует приватные поля (#authOAuthManager),
830
+ * и при вызове метода с this === Proxy движок бросает "Cannot read private member".
429
831
  *
430
832
  * Обёртки кешируются — по одной на метод, чтобы не ломать сравнение по ссылке.
431
833
  */
432
- function wrapB24WithRetry(b24: B24OAuth): B24OAuth {
834
+ function wrapB24WithRetry(b24: B24OAuth): B24Client {
433
835
  const methodCache = new Map<string, Function>();
836
+ // Фасад создаётся один раз на экземпляр: он замкнут на конкретный target,
837
+ // а пересоздание на каждом обращении ломало бы кеш и сравнение по ссылке
838
+ const facade = createFacade(b24);
434
839
 
435
840
  return new Proxy(b24, {
436
841
  get(target, prop) {
842
+ // Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
843
+ // deprecated-методы, иначе мы отдавали бы их, а не свои
844
+ if (typeof prop === "string" && FACADE_METHODS.has(prop)) {
845
+ if (!methodCache.has(prop)) {
846
+ const impl = facade[prop]!;
847
+ methodCache.set(prop, RETRYABLE_METHODS.has(prop) ? withRetry(impl, target, prop) : impl);
848
+ }
849
+
850
+ return methodCache.get(prop);
851
+ }
852
+
437
853
  // Передаём target третьим аргументом: геттеры (например, auth)
438
854
  // тоже должны исполняться с this === target
439
855
  const value = Reflect.get(target, prop, target);
@@ -451,13 +867,21 @@ function wrapB24WithRetry(b24: B24OAuth): B24OAuth {
451
867
 
452
868
  return methodCache.get(prop);
453
869
  },
454
- });
870
+
871
+ // Страховка на будущее: когда SDK уберёт методы из прототипа,
872
+ // "callMethod" in $b24 обязано остаться истинным (eventB24.ts проверяет
873
+ // наличие метода перед работой). Цель расширяема, лишние true законны
874
+ has(target, prop) {
875
+ if (typeof prop === "string" && FACADE_METHODS.has(prop)) return true;
876
+ return Reflect.has(target, prop);
877
+ },
878
+ }) as B24Client;
455
879
  }
456
880
 
457
881
  // ==================== Инициализация ====================
458
882
 
459
883
  let _b24Raw = createB24Instance();
460
- export let $b24 = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
884
+ export let $b24: B24Client | null = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
461
885
 
462
886
  /** Настраивает колбэк автосохранения токенов на экземпляре B24OAuth */
463
887
  function setupRefreshCallback(raw: B24OAuth): void {
@@ -497,10 +921,17 @@ if (_b24Raw) {
497
921
  setupRefreshCallback(_b24Raw);
498
922
 
499
923
  // Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
500
- // запросом к oauth.bitrix.info. refreshAndSaveTokens() исключений не бросает
501
- // ошибку возвращает в SaveResult и пишет в лог сам
924
+ // запросом к oauth.bitrix.info. try/finally обязателен если обновление всё же
925
+ // бросит (например, битый log.json уронит логгер), без него таймер не стартует
926
+ // никогда и процесс проживёт без обновления токена до перезапуска
502
927
  void (async () => {
503
- await refreshAndSaveTokens();
504
- startProactiveRefresh();
928
+ try {
929
+ await refreshAndSaveTokens();
930
+ } catch (error: unknown) {
931
+ const msg = error instanceof Error ? error.message : String(error);
932
+ logs.add(`Начальное обновление токенов упало: ${msg}`, "error");
933
+ } finally {
934
+ startProactiveRefresh();
935
+ }
505
936
  })();
506
937
  }
@@ -1,5 +1,4 @@
1
- import type { B24OAuth } from "@bitrix24/b24jssdk";
2
- import { $b24, getResultData } from "./b24.ts";
1
+ import { $b24, getResultData, type B24MethodCaller } from "./b24.ts";
3
2
 
4
3
  // ==================== Типы ====================
5
4
 
@@ -62,20 +61,37 @@ interface OfflineEventsResponse {
62
61
 
63
62
  const SYSTEM_USER_ID = "138"; // ID системного пользователя для фильтрации
64
63
 
64
+ // ==================== Утилиты ====================
65
+
66
+ /**
67
+ * Приводит MESSAGES из события к массиву.
68
+ *
69
+ * B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
70
+ * сериализует разреженный массив), поэтому опираться на одну форму нельзя.
71
+ * Неизвестная форма даёт пустой массив, а не исключение.
72
+ */
73
+ function normalizeMessages(messages: unknown): any[] {
74
+ if (Array.isArray(messages)) return messages;
75
+ if (messages && typeof messages === "object") return Object.values(messages);
76
+ return [];
77
+ }
78
+
65
79
  // ==================== Класс ====================
66
80
 
67
81
  /**
68
82
  * Класс для работы с офлайн-событиями Bitrix24
69
83
  */
70
84
  export class Event {
71
- private b24: B24OAuth;
85
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
86
+ // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
87
+ private b24: B24MethodCaller;
72
88
 
73
89
  /** Расширения видео-файлов */
74
90
  private static readonly VIDEO_EXTENSIONS = ["mp4", "mov", "avi", "mkv", "webm", "3gp", "m4v"];
75
91
  /** Расширения изображений */
76
92
  private static readonly IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
77
93
 
78
- constructor(b24: B24OAuth) {
94
+ constructor(b24: B24MethodCaller) {
79
95
  if (!b24 || typeof b24.callMethod !== "function") {
80
96
  throw new Error("Event: передали некорректный b24 или не передали вообще");
81
97
  }
@@ -156,19 +172,26 @@ export class Event {
156
172
  data.processId = arrOfflineEvents.process_id;
157
173
  // Событие может принести несколько сообщений. Раньше бралось только
158
174
  // первое, а остальные терялись безвозвратно — событие после обработки
159
- // удаляется из очереди B24
175
+ // удаляется из очереди B24.
176
+ //
177
+ // Разбор намеренно устойчив к форме и к битым элементам: исключение
178
+ // здесь означало бы, что get() вернёт ошибку, потребитель не вызовет
179
+ // clear(), и те же события придут следующим опросом — очередь встала бы
180
+ // навсегда на одном кривом сообщении
160
181
  data.message = allEvents.flatMap((event) => {
161
- const messages = event.EVENT_DATA.MESSAGES ?? [];
162
-
163
- return messages.map((messageData: any) => ({
164
- connectorId: event.EVENT_DATA.CONNECTOR,
165
- lineId: event.EVENT_DATA.LINE,
166
- chatId: messageData.chat.id,
167
- text: messageData.message.text || null,
168
- file: this.convertB24FilesToTelegramFormat(messageData.message.files),
169
- attachments: messageData.message.attachments || null,
170
- im: messageData.im || null,
171
- }));
182
+ const eventData = event.EVENT_DATA ?? {};
183
+
184
+ return normalizeMessages(eventData.MESSAGES)
185
+ .filter((messageData) => messageData?.chat && messageData?.message)
186
+ .map((messageData) => ({
187
+ connectorId: eventData.CONNECTOR,
188
+ lineId: eventData.LINE,
189
+ chatId: messageData.chat.id,
190
+ text: messageData.message.text || null,
191
+ file: this.convertB24FilesToTelegramFormat(messageData.message.files),
192
+ attachments: messageData.message.attachments || null,
193
+ im: messageData.im || null,
194
+ }));
172
195
  });
173
196
  }
174
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -1,5 +1,4 @@
1
- import type { B24OAuth } from "@bitrix24/b24jssdk";
2
- import { getResultData } from "../bitrix24/b24.ts";
1
+ import { getResultData, type B24MethodCaller } from "../bitrix24/b24.ts";
3
2
  import { fetchRetry } from "../utils/fetchRetry.ts";
4
3
 
5
4
  // ==================== Типы ====================
@@ -35,9 +34,11 @@ interface SmsResult {
35
34
  export class Smsgold {
36
35
  private user: string;
37
36
  private pass: string;
38
- private b24: B24OAuth | null;
37
+ // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
38
+ // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
39
+ private b24: B24MethodCaller | null;
39
40
 
40
- constructor(authParam: SmsgoldAuthParam, b24: B24OAuth | null = null) {
41
+ constructor(authParam: SmsgoldAuthParam, b24: B24MethodCaller | null = null) {
41
42
  this.user = authParam.user;
42
43
  this.pass = authParam.pass;
43
44
  this.b24 = b24;
@@ -36,7 +36,7 @@ export function isNetworkError(error: unknown): boolean {
36
36
  * Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
37
37
  * хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
38
38
  */
39
- const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH"];
39
+ const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "EAI_NODATA", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN"];
40
40
 
41
41
  /**
42
42
  * Проверяет, что соединение не состоялось — запрос точно не был выполнен сервером.
@@ -79,8 +79,9 @@ export function maskUrl(url: string | URL | Request): string {
79
79
  raw
80
80
  // Секрет входящего вебхука: /rest/<id пользователя>/<секрет>/
81
81
  .replace(/(\/rest\/\d+\/)[^/?#]+/gi, "$1***")
82
- // Токены в query-параметрах
83
- .replace(/([?&](?:auth|access_token|refresh_token|token)=)[^&#]+/gi, "$1***")
82
+ // Токены и подписи в query-параметрах. signature и sessid нужны отдельно:
83
+ // ссылка на файл B24-диска несёт секрет именно в signature
84
+ .replace(/([?&](?:auth|access_token|refresh_token|token|signature|sessid)=)[^&#]+/gi, "$1***")
84
85
  );
85
86
  }
86
87