@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 +60 -5
- package/bitrix24/b24.ts +499 -68
- package/bitrix24/eventB24.ts +39 -16
- package/package.json +1 -1
- package/sendMessage/smsgold.ts +5 -4
- package/utils/fetchRetry.ts +4 -3
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
|
|
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-функцию
|
|
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 **не** вызывают повторных попыток.
|
|
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
|
-
|
|
166
|
+
Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
|
|
152
167
|
|
|
153
|
-
|
|
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 {
|
|
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
|
-
/** Методы
|
|
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
|
-
* задачу, второй
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
353
|
-
|
|
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
|
-
|
|
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 (
|
|
363
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
383
|
-
|
|
638
|
+
const blockReason = getRetryBlockReason(methodName, args);
|
|
639
|
+
return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
|
|
640
|
+
}) as T;
|
|
641
|
+
}
|
|
384
642
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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):
|
|
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.
|
|
501
|
-
//
|
|
924
|
+
// запросом к oauth.bitrix.info. try/finally обязателен — если обновление всё же
|
|
925
|
+
// бросит (например, битый log.json уронит логгер), без него таймер не стартует
|
|
926
|
+
// никогда и процесс проживёт без обновления токена до перезапуска
|
|
502
927
|
void (async () => {
|
|
503
|
-
|
|
504
|
-
|
|
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
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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:
|
|
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
|
|
162
|
-
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
package/sendMessage/smsgold.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
37
|
+
// Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
|
|
38
|
+
// удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
|
|
39
|
+
private b24: B24MethodCaller | null;
|
|
39
40
|
|
|
40
|
-
constructor(authParam: SmsgoldAuthParam, b24:
|
|
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;
|
package/utils/fetchRetry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|