@andrey4emk/npm-app-back-b24 3.8.2 → 3.9.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 +67 -10
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +358 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +78 -0
- package/bitrix24/b24/retry.ts +478 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +278 -0
- package/bitrix24/b24/types.ts +66 -0
- package/bitrix24/b24.ts +60 -949
- package/bitrix24/eventB24.ts +233 -31
- package/package.json +10 -2
- package/sendMessage/email.ts +233 -32
- package/utils/fetchRetry.ts +70 -9
package/bitrix24/b24.ts
CHANGED
|
@@ -1,411 +1,65 @@
|
|
|
1
|
-
import { B24OAuth
|
|
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
|
-
RestrictionParams,
|
|
15
|
-
} from "@bitrix24/b24jssdk";
|
|
16
|
-
import { logs } from "../logs/logs.ts";
|
|
17
|
-
import { isNetworkError, isPreConnectionError } from "../utils/fetchRetry.ts";
|
|
18
|
-
import Conf from "conf";
|
|
19
|
-
import path from "path";
|
|
1
|
+
import type { B24OAuth } from "@bitrix24/b24jssdk";
|
|
20
2
|
import type { Request, Response } from "express";
|
|
3
|
+
import { logs } from "../logs/logs.ts";
|
|
21
4
|
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/** Результат операций с токенами */
|
|
28
|
-
interface SaveResult {
|
|
29
|
-
error: boolean;
|
|
30
|
-
message: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
|
|
35
|
-
*
|
|
36
|
-
* SDK 2.x помечает `callMethod`/`callListMethod`/`fetchListMethod`/`callBatch`/`callBatchByChunk`
|
|
37
|
-
* к удалению в следующем major. Пакет предоставляет их сам, поэтому сигнатуры для потребителя
|
|
38
|
-
* не меняются, а зависимости от удаляемого API у нас больше нет.
|
|
39
|
-
*
|
|
40
|
-
* Тип расширяет `B24OAuth`, поэтому `$b24` по-прежнему передаётся туда, где ждут `B24OAuth`
|
|
41
|
-
* (`new Smsgold(auth, $b24)`, `new Event($b24)`).
|
|
42
|
-
*/
|
|
43
|
-
export interface B24Client extends B24OAuth {
|
|
44
|
-
callMethod<T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>>;
|
|
45
|
-
callListMethod(method: string, params?: object, progress?: null | ((progress: number) => void), customKeyForResult?: string | null): Promise<Result>;
|
|
46
|
-
fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]>;
|
|
47
|
-
callBatch(calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result>;
|
|
48
|
-
callBatchByChunk(calls: Array<any>, isHaltOnError: boolean): Promise<Result>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Минимальный контракт «умеет вызывать REST-методы B24».
|
|
53
|
-
*
|
|
54
|
-
* Нужен внутренним классам (`Event`, `Smsgold`), которые принимают экземпляр извне.
|
|
55
|
-
* Объявляет `callMethod` сам, поэтому переживёт удаление метода из `B24OAuth`.
|
|
56
|
-
*
|
|
57
|
-
* Объединение `B24OAuth | B24Client` эту задачу не решает: `B24Client` расширяет
|
|
58
|
-
* `B24OAuth`, union схлопывается в супертип, а вызов метода на union требует его
|
|
59
|
-
* наличия в обеих ветках — то есть после удаления из SDK тайпчек сломается всё равно.
|
|
60
|
-
*
|
|
61
|
-
* Структурный тип шире `B24OAuth`, поэтому публичные сигнатуры конструкторов
|
|
62
|
-
* не сужаются: и `B24OAuth`, и `$b24`, и любой свой объект с `callMethod` подходят.
|
|
63
|
-
*/
|
|
64
|
-
export interface B24MethodCaller {
|
|
65
|
-
// Дженерика здесь быть не должно: у SDK метод не generic, и `B24OAuth`
|
|
66
|
-
// потребителя перестал бы подходить под этот тип. Проверено тайпчеком
|
|
67
|
-
callMethod(method: string, params?: object, start?: number): Promise<AjaxResult>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Набор методов фасада — то, что Proxy отдаёт вместо реализаций SDK */
|
|
71
|
-
type FacadeMethods = Pick<B24Client, "callMethod" | "callListMethod" | "fetchListMethod" | "callBatch" | "callBatchByChunk">;
|
|
72
|
-
|
|
73
|
-
/** Формы списка команд batch, которые принимает actions.v2 */
|
|
74
|
-
type BatchCalls = BatchCommandsArrayUniversal | BatchCommandsObjectUniversal | BatchNamedCommandsUniversal;
|
|
75
|
-
|
|
76
|
-
// ==================== Константы ====================
|
|
77
|
-
|
|
78
|
-
const CONFIG_DIR = process.env.CONFIG_DIR || "../config";
|
|
79
|
-
const APP_ENV = process.env.APP_ENV || "PROD";
|
|
80
|
-
const CLIENT_ID = process.env.APP_B24_CLIENT_ID;
|
|
81
|
-
const CLIENT_SECRET = process.env.APP_B24_CLIENT_SECRET;
|
|
82
|
-
|
|
83
|
-
/** 25 минут при TTL токена 30 минут */
|
|
84
|
-
const PROACTIVE_REFRESH_INTERVAL_MS = 25 * 60 * 1000;
|
|
85
|
-
/** 2 минуты — ускоренный интервал при ошибке обновления */
|
|
86
|
-
const PROACTIVE_RETRY_ON_ERROR_MS = 2 * 60 * 1000;
|
|
87
|
-
|
|
88
|
-
/** Количество попыток при сетевых ошибках */
|
|
89
|
-
const RETRY_COUNT = 5;
|
|
90
|
-
/** Задержка между попытками (мс) */
|
|
91
|
-
const RETRY_DELAY_MS = 500;
|
|
92
|
-
|
|
93
|
-
/** Методы фасада, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
|
|
94
|
-
const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
|
|
98
|
-
*
|
|
99
|
-
* Шире, чем RETRYABLE_METHODS: `fetchListMethod` и `callBatchByChunk` мы реализуем,
|
|
100
|
-
* но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
|
|
101
|
-
* SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
|
|
102
|
-
*/
|
|
103
|
-
const FACADE_METHODS = new Set(["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"]);
|
|
104
|
-
|
|
105
|
-
/** Потолок числа страниц в callListMethod — страховка от бесконечной пагинации */
|
|
106
|
-
const LIST_MAX_PAGES = 1000;
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
|
|
110
|
-
* и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
|
|
111
|
-
*/
|
|
112
|
-
const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* REST-методы B24, повтор которых создаёт дубликат сущности или запускает
|
|
116
|
-
* повторное действие: add, create, start, send, uploadfile, import, register.
|
|
117
|
-
*
|
|
118
|
-
* Транспортная ошибка не говорит, дошёл ли запрос до портала: NETWORK_ERROR приходит
|
|
119
|
-
* и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже
|
|
120
|
-
* принял и выполнил запрос. Во втором случае retry создаст вторую задачу, второй файл,
|
|
121
|
-
* второй экземпляр бизнес-процесса. Идемпотентные записи (update, delete, set) повторять
|
|
122
|
-
* безопасно: повторное применение даёт то же состояние.
|
|
123
|
-
*
|
|
124
|
-
* По той же причине у SDK отключены собственные повторы транспортных сбоев
|
|
125
|
-
* (SDK_RESTRICTION_PARAMS) — он этой разницы не знает вовсе.
|
|
126
|
-
*/
|
|
127
|
-
const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.json)?(\?|$)/i;
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Параметры ограничителя SDK. Передаются третьим аргументом конструктора B24OAuth
|
|
131
|
-
* и сливаются там с дефолтами `ParamsFactory.getDefault()`.
|
|
132
|
-
*
|
|
133
|
-
* Единый инвариант: **транспортные сбои SDK не повторяет вообще, повторяет только
|
|
134
|
-
* наш слой**, где работает гейт NON_IDEMPOTENT_METHOD_RE. У SDK нет понятия
|
|
135
|
-
* «создающий вызов»: оборванный crm.deal.add он повторил бы трижды и создал три сделки.
|
|
136
|
-
*
|
|
137
|
-
* Одного `retryOnNetworkError: false` для этого мало. Он добавляет в жёсткий список
|
|
138
|
-
* ровно два кода — NETWORK_ERROR и REQUEST_TIMEOUT, — а SDK конвертирует в них только
|
|
139
|
-
* `ERR_NETWORK` и `ECONNABORTED`. Остальные транспортные ошибки Node доезжают до
|
|
140
|
-
* лимитера под своим кодом (`ECONNRESET` при обрыве сокета, `ERR_BAD_RESPONSE` при
|
|
141
|
-
* 502/504 от шлюза), не находятся ни в жёстком, ни в мягком списке и потому считаются
|
|
142
|
-
* временными — то есть повторяются с backoff. Замерено на локальном сервере, рвущем
|
|
143
|
-
* соединение после приёма запроса: без hardErrorCodes портал выполняет crm.deal.add
|
|
144
|
-
* три раза, с ними — один. На SDK 2.0.0 был один: там транспорт маскировался в
|
|
145
|
-
* JSSDK_UNKNOWN_ERROR, а тот во встроенном жёстком списке.
|
|
146
|
-
*
|
|
147
|
-
* `ERR_NETWORK` и `ECONNABORTED` в списке не нужны: до лимитера они не доживают.
|
|
148
|
-
*
|
|
149
|
-
* Плата за список: `ECONNREFUSED`/`ENOTFOUND`/`EAI_AGAIN` доказывают, что запрос не ушёл,
|
|
150
|
-
* и SDK мог бы повторить их безопасно даже для создающих вызовов. Отказываемся сознательно —
|
|
151
|
-
* ровно так вёл себя 2.0.0, а один понятный инвариант дороже трёх сэкономленных попыток.
|
|
152
|
-
*/
|
|
153
|
-
const SDK_RESTRICTION_PARAMS = {
|
|
154
|
-
retryOnNetworkError: false,
|
|
155
|
-
hardErrorCodes: [
|
|
156
|
-
"ECONNRESET",
|
|
157
|
-
"ECONNREFUSED",
|
|
158
|
-
"ENOTFOUND",
|
|
159
|
-
"ETIMEDOUT",
|
|
160
|
-
"EHOSTUNREACH",
|
|
161
|
-
"ENETUNREACH",
|
|
162
|
-
"EAI_AGAIN",
|
|
163
|
-
"EPIPE",
|
|
164
|
-
"EPROTO",
|
|
165
|
-
"ERR_BAD_RESPONSE",
|
|
166
|
-
],
|
|
167
|
-
} as const satisfies RestrictionParams;
|
|
168
|
-
|
|
169
|
-
const confAuthB24 = new Conf({
|
|
170
|
-
cwd: path.resolve(CONFIG_DIR),
|
|
171
|
-
configName: "authB24",
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// ==================== Утилиты ====================
|
|
175
|
-
|
|
176
|
-
/** Убирает протокол из домена (https://example.bitrix24.ru → example.bitrix24.ru) */
|
|
177
|
-
function cleanDomain(domain: string): string {
|
|
178
|
-
return domain.replace(/^https?:\/\//, "");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Задержка на указанное количество миллисекунд */
|
|
182
|
-
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Достаёт result из ответа SDK.
|
|
186
|
-
*
|
|
187
|
-
* SDK при «мягких» ошибках (ERROR_ENTITY_NOT_FOUND, BITRIX_REST_V3_EXCEPTION_*)
|
|
188
|
-
* не бросает исключение, а возвращает AjaxResult с ошибкой.
|
|
189
|
-
* Функция превращает такой ответ в понятную ошибку вместо падения на undefined.
|
|
190
|
-
* Также отсекает случай, когда запрос успешен, но result пустой (null/undefined).
|
|
191
|
-
*
|
|
192
|
-
* @param response — результат $b24.callMethod()
|
|
193
|
-
* @param methodName — имя метода B24, попадёт в текст ошибки
|
|
194
|
-
*/
|
|
195
|
-
export function getResultData<T = any>(response: AjaxResult, methodName: string): T {
|
|
196
|
-
if (!response.isSuccess) {
|
|
197
|
-
const messages = response.getErrorMessages().join("; ") || "неизвестная ошибка";
|
|
198
|
-
throw new Error(`${methodName}: Bitrix24 вернул ошибку — ${messages}`);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const data = response.getData();
|
|
202
|
-
if (data?.result == null) {
|
|
203
|
-
throw new Error(`${methodName}: Bitrix24 вернул пустой ответ`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return data.result as T;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Конверт ответа restApi:v2 — то, что прислал портал, до того как AjaxResult его урезал */
|
|
210
|
-
interface V2Envelope {
|
|
211
|
-
result?: unknown;
|
|
212
|
-
next?: unknown;
|
|
213
|
-
total?: unknown;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Читает сырой конверт ответа из AjaxResult.
|
|
218
|
-
*
|
|
219
|
-
* `getData()` отдаёт замороженную пару `{ result, time }` — полей `next` и `total`
|
|
220
|
-
* в ней нет намеренно (в restApi:v3 их не существует). С версии 2.2.0 SDK снял
|
|
221
|
-
* `isMore()`/`getTotal()`/`getNext()` с удаления и объявил их постоянными читателями
|
|
222
|
-
* конверта restApi:v2, но числового смещения среди них так и нет: `isMore()` отвечает
|
|
223
|
-
* только «есть ли ещё», а `getNext(http)` сам делает следующий запрос, мимо нашего
|
|
224
|
-
* прогресса и retry. Поэтому конверт читаем напрямую: `_data` объявлен `protected`,
|
|
225
|
-
* а не приватным полем класса, поэтому в рантайме доступен.
|
|
226
|
-
*
|
|
227
|
-
* Это единственная точка связи с внутренностями SDK. Если она перестанет работать,
|
|
228
|
-
* она обязана упасть громко: тихо оборванная на первой странице выборка — потеря данных.
|
|
229
|
-
*/
|
|
230
|
-
function readV2Envelope(response: AjaxResult, method: string): V2Envelope {
|
|
231
|
-
const envelope = (response as unknown as { _data?: unknown })._data;
|
|
232
|
-
|
|
233
|
-
if (!envelope || typeof envelope !== "object") {
|
|
234
|
-
throw new Error(`${method}: не удалось прочитать конверт ответа Bitrix24 (AjaxResult._data недоступен) — изменился внутренний формат SDK`);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return envelope as V2Envelope;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ==================== Логгер SDK ====================
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Мост из логгера SDK в `logs` пакета.
|
|
244
|
-
*
|
|
245
|
-
* Порог WARNING обязателен: SDK пишет `post/send` и `post/response` на уровне `info`
|
|
246
|
-
* на каждый запрос и `http batch request starting/completed` на `debug` — без фильтра
|
|
247
|
-
* это залило бы лог.
|
|
248
|
-
*
|
|
249
|
-
* `AbstractHandler` объявлен в типах SDK, но в рантайме не экспортируется,
|
|
250
|
-
* поэтому реализуем интерфейс `Handler` обычным классом.
|
|
251
|
-
*/
|
|
252
|
-
class B24SdkLogHandler implements Handler {
|
|
253
|
-
private formatter: Formatter | null = null;
|
|
254
|
-
|
|
255
|
-
isHandling(level: LogLevel): boolean {
|
|
256
|
-
return level >= LogLevel.WARNING;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
shouldBubble(): boolean {
|
|
260
|
-
return true;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
setFormatter(formatter: Formatter): void {
|
|
264
|
-
this.formatter = formatter;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
getFormatter(): Formatter | null {
|
|
268
|
-
return this.formatter;
|
|
269
|
-
}
|
|
5
|
+
import { createB24Instance } from "./b24/instance.ts";
|
|
6
|
+
import { wrapB24WithRetry } from "./b24/proxy.ts";
|
|
7
|
+
import { $b24, setB24Instance } from "./b24/state.ts";
|
|
8
|
+
import { refreshAndSaveTokens, resetRefreshMutex, saveTokens, startProactiveRefresh, stopProactiveRefresh } from "./b24/tokens.ts";
|
|
9
|
+
import type { SaveResult } from "./b24/types.ts";
|
|
270
10
|
|
|
271
|
-
|
|
272
|
-
// Бросать отсюда нельзя ни при каких обстоятельствах: Logger.log() делает
|
|
273
|
-
// await handle(), но лимитер зовёт логгер без await — исключение стало бы
|
|
274
|
-
// unhandled rejection и на дефолтных настройках Node убило бы процесс.
|
|
275
|
-
// Путь к броску реален: logs.add() читает conf, а тот перечитывает файл
|
|
276
|
-
// на каждом обращении и падает на битом log.json. В catch пишем через
|
|
277
|
-
// console.error, а не через logs — иначе рискуем зациклиться на той же ошибке.
|
|
278
|
-
try {
|
|
279
|
-
const status = Number(record.context?.status);
|
|
11
|
+
// ==================== Реэкспорт публичного API ====================
|
|
280
12
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// SDK — #logNonRetryableClientError, а он вызывается из-под условия, которое
|
|
290
|
-
// 408 и 429 уже исключает. Сообщения лимитера про 429/503 идут другим путём,
|
|
291
|
-
// без status в контексте, и маппятся в warn независимо от этой строки.
|
|
292
|
-
// Условие оставлено потому, что фейлит в безопасную сторону: начни SDK класть
|
|
293
|
-
// status в такие записи — таймаут и упор в лимит будут видны, а не утонут в debug.
|
|
294
|
-
//
|
|
295
|
-
// Остальное: уровень ERROR у SDK — это диагностика отдельной неуспешной
|
|
296
|
-
// попытки. На транспортном сбое их пять — по числу наших попыток, повторы SDK
|
|
297
|
-
// выключены (см. SDK_RESTRICTION_PARAMS). На 429/503 и неизвестных 5xx SDK
|
|
298
|
-
// повторяет по-прежнему, там до пятнадцати: три внутри каждой из наших пяти.
|
|
299
|
-
// Уровень error пакета уходит в чат B24, поэтому маппим SDK-ERROR в warn:
|
|
300
|
-
// окончательный провал вызова логирует retry-слой, ровно один раз.
|
|
301
|
-
const isQuietClientError = status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
302
|
-
const level = isQuietClientError ? "debug" : record.level >= LogLevel.CRITICAL ? "error" : "warn";
|
|
13
|
+
// Явные списки, а не `export *`: тот вытащил бы наружу внутренние имена
|
|
14
|
+
// (`SaveResult`, `createFacade`, `getRetryBlockReason`, `setB24Instance`)
|
|
15
|
+
// и расширил публичный API пакета
|
|
16
|
+
export { $b24 } from "./b24/state.ts";
|
|
17
|
+
export { getResultData } from "./b24/facade.ts";
|
|
18
|
+
export { callProtected } from "./b24/retry.ts";
|
|
19
|
+
export { saveTokens, refreshAndSaveTokens, stopProactiveRefresh } from "./b24/tokens.ts";
|
|
20
|
+
export type { B24Client, B24MethodCaller } from "./b24/types.ts";
|
|
303
21
|
|
|
304
|
-
|
|
305
|
-
// через redactSensitiveParams — токены в него не попадают
|
|
306
|
-
const context = record.context && Object.keys(record.context).length > 0 ? record.context : undefined;
|
|
22
|
+
// ==================== Инициализация ====================
|
|
307
23
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
24
|
+
/** Настраивает колбэк автосохранения токенов на экземпляре B24OAuth */
|
|
25
|
+
function setupRefreshCallback(raw: B24OAuth): void {
|
|
26
|
+
raw.setCallbackRefreshAuth(async ({ authData }) => {
|
|
27
|
+
const result = saveTokens(authData);
|
|
28
|
+
if (result.error) {
|
|
29
|
+
logs.add(`Ошибка при автосохранении токенов: ${result.message}`, "error");
|
|
30
|
+
} else {
|
|
31
|
+
logs.add("Токены автоматически обновлены и сохранены в authB24.json", "debug");
|
|
312
32
|
}
|
|
313
|
-
|
|
314
|
-
return true;
|
|
315
|
-
}
|
|
33
|
+
});
|
|
316
34
|
}
|
|
317
35
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// Проект вообще не использует OAuth: ходит в B24 по входящему вебхуку либо берёт
|
|
322
|
-
// из пакета только logs/fetchRetry. Отсутствие авторизации для него — норма, а не
|
|
323
|
-
// ошибка, поэтому уровень debug: иначе ложная строка попадает в мониторинг.
|
|
324
|
-
// Признак «OAuth задуман» — наличие credentials приложения в .env.
|
|
325
|
-
if (!CLIENT_ID && !CLIENT_SECRET) {
|
|
326
|
-
logs.add("OAuth Bitrix24 не настроен (APP_B24_CLIENT_ID и APP_B24_CLIENT_SECRET не заданы), $b24 = null", "debug");
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
36
|
+
/** Пересоздаёт $b24 из актуального authB24.json без перезапуска сервера */
|
|
37
|
+
export async function reinitializeB24(): Promise<SaveResult> {
|
|
38
|
+
stopProactiveRefresh();
|
|
329
39
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
40
|
+
// Мьютекс общий на модуль: без сброса новый экземпляр приклеился бы к обмену
|
|
41
|
+
// старого, то есть к запросу со старым refresh-токеном, а при залипании обмена
|
|
42
|
+
// сохранение токенов с фронта висело бы до срабатывания сторожа.
|
|
43
|
+
// Компромисс: зависший старый обмен, завершившись позже, перезапишет authB24.json
|
|
44
|
+
// своими токенами поверх только что сохранённых — оба комплекта валидны для одного
|
|
45
|
+
// портала, побеждает последняя запись; гонка предсуществующая, сбросом не создаётся
|
|
46
|
+
resetRefreshMutex();
|
|
334
47
|
|
|
335
|
-
|
|
336
|
-
const
|
|
48
|
+
// Оба слоя пишутся одним вызовом: держать их согласованными — забота state.ts
|
|
49
|
+
const raw = createB24Instance();
|
|
50
|
+
setB24Instance(raw, raw ? wrapB24WithRetry(raw) : null);
|
|
337
51
|
|
|
338
|
-
if (!
|
|
339
|
-
|
|
340
|
-
return null;
|
|
52
|
+
if (!raw || !$b24) {
|
|
53
|
+
return { error: true, message: "Не удалось пересоздать $b24 — проверь authB24.json и APP_B24_CLIENT_ID/APP_B24_CLIENT_SECRET" };
|
|
341
54
|
}
|
|
342
55
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const authParams: B24OAuthParams = {
|
|
346
|
-
applicationToken: "",
|
|
347
|
-
userId: 0,
|
|
348
|
-
memberId: authConfig.member_id,
|
|
349
|
-
accessToken: authConfig.access_token,
|
|
350
|
-
refreshToken: authConfig.refresh_token,
|
|
351
|
-
expires: authConfig.expires,
|
|
352
|
-
expiresIn: authConfig.expires_in || 1800,
|
|
353
|
-
scope: "",
|
|
354
|
-
domain,
|
|
355
|
-
clientEndpoint: `https://${domain}/rest/`,
|
|
356
|
-
serverEndpoint: "https://oauth.bitrix.info/rest/",
|
|
357
|
-
status: "L",
|
|
358
|
-
issuer: "store",
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
const secret: B24OAuthSecret = { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET };
|
|
362
|
-
|
|
363
|
-
// Параметры ограничителя задаём третьим аргументом конструктора: SDK сливает их
|
|
364
|
-
// с дефолтами (`{ ...ParamsFactory.getDefault(), ...restrictionParams }` в AbstractHttp)
|
|
365
|
-
// и передаёт обоим http-клиентам, v2 и v3, ещё до того как экземпляр можно использовать.
|
|
366
|
-
// Через setRestrictionManagerParams() было бы окно между созданием и настройкой,
|
|
367
|
-
// а результат вызова к тому же непроверяем: он завершается Promise.allSettled и
|
|
368
|
-
// не отклоняется никогда.
|
|
369
|
-
const b24 = new B24OAuth(authParams, secret, { restrictionParams: SDK_RESTRICTION_PARAMS });
|
|
370
|
-
|
|
371
|
-
// Диагностика SDK от WARNING и выше уходит в logs пакета: предупреждения
|
|
372
|
-
// callList/fetchList про игнорируемый order и остановку пагинации, сообщения лимитера.
|
|
373
|
-
// Дефолтный NullLogger не годится — LoggerFactory.forcedLog пишет мимо логгера
|
|
374
|
-
// прямо в console.warn, но после перехода на фасад этот путь у нас недостижим.
|
|
375
|
-
b24.setLogger(Logger.create("npm-app-back-b24").pushHandler(new B24SdkLogHandler()));
|
|
376
|
-
|
|
377
|
-
return b24;
|
|
378
|
-
}
|
|
56
|
+
setupRefreshCallback(raw);
|
|
379
57
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
/** Проверяет, что authData содержит все обязательные непустые поля */
|
|
383
|
-
function isValidAuthData(data: AuthData): boolean {
|
|
384
|
-
return !!(
|
|
385
|
-
data &&
|
|
386
|
-
typeof data.access_token === "string" && data.access_token.length > 0 &&
|
|
387
|
-
typeof data.refresh_token === "string" && data.refresh_token.length > 0 &&
|
|
388
|
-
typeof data.domain === "string" && data.domain.length > 0 &&
|
|
389
|
-
data.expires
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Сохраняет токены в authB24.json, нормализуя домен без протокола */
|
|
394
|
-
export function saveTokens(authData: AuthData): SaveResult {
|
|
395
|
-
try {
|
|
396
|
-
if (!isValidAuthData(authData)) {
|
|
397
|
-
const msg = "Попытка сохранить невалидные токены — операция отклонена";
|
|
398
|
-
logs.add(msg, "error");
|
|
399
|
-
return { error: true, message: msg };
|
|
400
|
-
}
|
|
58
|
+
const refreshResult = await refreshAndSaveTokens();
|
|
59
|
+
startProactiveRefresh();
|
|
401
60
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return { error: false, message: "Токены сохранены." };
|
|
405
|
-
} catch (error: any) {
|
|
406
|
-
logs.add(`Ошибка сохранения токенов: ${error.message}`, "error");
|
|
407
|
-
return { error: true, message: error.message };
|
|
408
|
-
}
|
|
61
|
+
logs.add("$b24 переинициализирован с новыми токенами", "debug");
|
|
62
|
+
return refreshResult;
|
|
409
63
|
}
|
|
410
64
|
|
|
411
65
|
/** HTTP-обработчик для сохранения токенов с фронта */
|
|
@@ -434,7 +88,15 @@ export async function saveAuthB24Handler(req: Request, res: Response): Promise<v
|
|
|
434
88
|
|
|
435
89
|
const reinitResult = await reinitializeB24();
|
|
436
90
|
if (reinitResult.error) {
|
|
437
|
-
|
|
91
|
+
// Два разных исхода под одним флагом ошибки: экземпляр не создан вовсе
|
|
92
|
+
// или создан, а не удалось лишь первое обновление токена. Во втором случае
|
|
93
|
+
// $b24 работает, колбэк навешен, таймер запущен — сообщать «не переинициализирован»
|
|
94
|
+
// значит уводить разбор в сторону
|
|
95
|
+
const message = $b24
|
|
96
|
+
? `Токены сохранены, $b24 пересоздан, первое обновление токена не удалось: ${reinitResult.message}`
|
|
97
|
+
: `Токены сохранены, но $b24 не переинициализирован: ${reinitResult.message}`;
|
|
98
|
+
|
|
99
|
+
res.status(201).json({ status: "ok", message });
|
|
438
100
|
} else {
|
|
439
101
|
res.status(201).json({ status: "ok", message: "Токены сохранены и применены." });
|
|
440
102
|
}
|
|
@@ -444,564 +106,13 @@ export async function saveAuthB24Handler(req: Request, res: Response): Promise<v
|
|
|
444
106
|
}
|
|
445
107
|
}
|
|
446
108
|
|
|
447
|
-
// ==================== Мьютекс для refresh токена ====================
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Дедупликация refresh-запросов.
|
|
451
|
-
* Пока промис не завершён, все новые вызовы ждут его результат
|
|
452
|
-
* вместо параллельных запросов к oauth.bitrix.info.
|
|
453
|
-
*/
|
|
454
|
-
let refreshInProgress: Promise<AuthData> | null = null;
|
|
455
|
-
|
|
456
|
-
async function refreshAuthWithMutex(): Promise<AuthData> {
|
|
457
|
-
if (refreshInProgress) {
|
|
458
|
-
logs.add("refreshAuth: ожидаем завершения текущего refresh", "debug");
|
|
459
|
-
return refreshInProgress;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
refreshInProgress = (async () => {
|
|
463
|
-
try {
|
|
464
|
-
if (!$b24) throw new Error("$b24 не инициализирован");
|
|
465
|
-
|
|
466
|
-
let lastError: unknown;
|
|
467
|
-
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
468
|
-
try {
|
|
469
|
-
return await $b24.auth.refreshAuth();
|
|
470
|
-
} catch (error) {
|
|
471
|
-
lastError = error;
|
|
472
|
-
|
|
473
|
-
// Повторяем только когда соединение заведомо не состоялось.
|
|
474
|
-
// При таймауте или обрыве сервер мог уже провести ротацию: тогда
|
|
475
|
-
// локальный refresh-токен мёртв независимо от наших действий, и повтор
|
|
476
|
-
// не помогает, а лишь маскирует проблему серией одинаковых отказов.
|
|
477
|
-
// Следующая попытка всё равно будет — проактивный таймер повторит
|
|
478
|
-
// через 2 минуты при времени жизни токена около часа
|
|
479
|
-
if (!isPreConnectionError(error) || attempt === RETRY_COUNT) throw error;
|
|
480
|
-
|
|
481
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
482
|
-
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
483
|
-
const delayMs = RETRY_DELAY_MS * attempt;
|
|
484
|
-
logs.add(`refreshAuth${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${delayMs}мс`, "warn");
|
|
485
|
-
await delay(delayMs);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
throw lastError;
|
|
489
|
-
} finally {
|
|
490
|
-
refreshInProgress = null;
|
|
491
|
-
}
|
|
492
|
-
})();
|
|
493
|
-
|
|
494
|
-
return refreshInProgress;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Обновляет токены через мьютекс и сохраняет в файл.
|
|
499
|
-
* Используется проактивным таймером и может вызываться вручную.
|
|
500
|
-
*/
|
|
501
|
-
export async function refreshAndSaveTokens(): Promise<SaveResult> {
|
|
502
|
-
if (!$b24) {
|
|
503
|
-
return { error: true, message: "$b24 не инициализирован." };
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
const authData = await refreshAuthWithMutex();
|
|
508
|
-
return saveTokens(authData);
|
|
509
|
-
} catch (error: any) {
|
|
510
|
-
logs.add(`Ошибка обновления токенов: ${error.message}`, "error");
|
|
511
|
-
return { error: true, message: error.message };
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ==================== Проактивное обновление токена ====================
|
|
516
|
-
|
|
517
|
-
let proactiveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
518
|
-
|
|
519
|
-
/** Запускает проактивное обновление токена. При ошибке — повтор через 2 мин, при успехе — через 25 мин */
|
|
520
|
-
function startProactiveRefresh(): void {
|
|
521
|
-
if (proactiveRefreshTimer) {
|
|
522
|
-
clearTimeout(proactiveRefreshTimer);
|
|
523
|
-
proactiveRefreshTimer = null;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const scheduleNext = (delayMs: number) => {
|
|
527
|
-
proactiveRefreshTimer = setTimeout(async () => {
|
|
528
|
-
logs.add("Проактивное обновление токена Bitrix24", "debug");
|
|
529
|
-
const result = await refreshAndSaveTokens();
|
|
530
|
-
|
|
531
|
-
if (result.error) {
|
|
532
|
-
logs.add(`Ошибка проактивного обновления токена, повтор через ${PROACTIVE_RETRY_ON_ERROR_MS / 60000} мин`, "error");
|
|
533
|
-
scheduleNext(PROACTIVE_RETRY_ON_ERROR_MS);
|
|
534
|
-
} else {
|
|
535
|
-
scheduleNext(PROACTIVE_REFRESH_INTERVAL_MS);
|
|
536
|
-
}
|
|
537
|
-
}, delayMs);
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
scheduleNext(PROACTIVE_REFRESH_INTERVAL_MS);
|
|
541
|
-
logs.add(`Проактивное обновление токена запущено (каждые ${PROACTIVE_REFRESH_INTERVAL_MS / 60000} мин)`, "debug");
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/** Останавливает проактивное обновление токена */
|
|
545
|
-
export function stopProactiveRefresh(): void {
|
|
546
|
-
if (!proactiveRefreshTimer) return;
|
|
547
|
-
clearTimeout(proactiveRefreshTimer);
|
|
548
|
-
proactiveRefreshTimer = null;
|
|
549
|
-
logs.add("Проактивное обновление токена остановлено", "debug");
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// ==================== Retry-обёртка ====================
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* Проверяет, является ли ошибка из SDK сетевой.
|
|
556
|
-
*
|
|
557
|
-
* С версии 2.2.0 SDK отдаёт транспортный сбой честным кодом: NETWORK_ERROR (status 0)
|
|
558
|
-
* или REQUEST_TIMEOUT (status 408). До 2.2.0 он их маскировал — в логирующей ветке падал
|
|
559
|
-
* собственный TypeError, и наружу приходил AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0.
|
|
560
|
-
* Проверку по status 0 оставляем: она ловит всё, что SDK отдаёт без внятного кода.
|
|
561
|
-
* Сюда же попадает провал обновления токена внутри callMethod: SDK перезаворачивает
|
|
562
|
-
* SdkError в AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0, и исходный код теряется.
|
|
563
|
-
* RefreshTokenError приходит как SdkError с кодом вроде ENOTFOUND и без originalError.
|
|
564
|
-
*/
|
|
565
|
-
function isB24NetworkError(error: unknown): boolean {
|
|
566
|
-
if (error instanceof AjaxError) {
|
|
567
|
-
// Практически недостижимо (SDK бросает реальную ошибку раньше),
|
|
568
|
-
// но если код всё же пришёл — SDK уже исчерпал свои попытки
|
|
569
|
-
if (error.code === "JSSDK_CALL_ALL_ATTEMPTS_EXHAUSTED") return false;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if (error instanceof SdkError) {
|
|
573
|
-
const { code, originalError, status } = error;
|
|
574
|
-
const originalCode = (originalError as { code?: string } | undefined)?.code;
|
|
575
|
-
|
|
576
|
-
if (NETWORK_ERROR_CODES.has(code)) return true;
|
|
577
|
-
if (originalError && isNetworkError(originalError)) return true;
|
|
578
|
-
if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
|
|
579
|
-
if (status === 0) return true;
|
|
580
|
-
|
|
581
|
-
// 502 Bad Gateway: шлюз перед порталом не смог получить ответ от upstream —
|
|
582
|
-
// запрос почти наверняка не выполнялся, повтор осмыслен. Мы держим этот код
|
|
583
|
-
// в SDK_RESTRICTION_PARAMS.hardErrorCodes, чтобы SDK его не повторял, но
|
|
584
|
-
// повторить его должен наш слой: там создающие вызовы отсекает гейт.
|
|
585
|
-
//
|
|
586
|
-
// 504 сюда намеренно не входит, хотя приходит тем же кодом ERR_BAD_RESPONSE:
|
|
587
|
-
// это «портал взял запрос и считает прямо сейчас, шлюз устал ждать». Повтор
|
|
588
|
-
// создающего вызова дал бы дубликат, а читающего — ничего: за таймаут шлюза
|
|
589
|
-
// запрос не уложился один раз, не уложится и на второй, зато пять тяжёлых
|
|
590
|
-
// повторов добавят нагрузки порталу ровно тогда, когда ему и так плохо.
|
|
591
|
-
if (status === 502) return true;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
return isNetworkError(error);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Достаёт имя REST-метода из одной команды batch.
|
|
599
|
-
* Возвращает null, если форма команды не распознана.
|
|
600
|
-
*/
|
|
601
|
-
function extractBatchCommandMethod(cmd: unknown): string | null {
|
|
602
|
-
// Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
|
|
603
|
-
if (Array.isArray(cmd)) {
|
|
604
|
-
return typeof cmd[0] === "string" ? cmd[0] : null;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Объект { method, params }
|
|
608
|
-
if (cmd && typeof cmd === "object") {
|
|
609
|
-
const method = (cmd as { method?: unknown }).method;
|
|
610
|
-
return typeof method === "string" ? method : null;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
|
|
614
|
-
// (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
|
|
615
|
-
// если потребитель остался на ней со старой версии
|
|
616
|
-
if (typeof cmd === "string") {
|
|
617
|
-
return cmd;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Возвращает причину, по которой вызов запрещено повторять, либо null.
|
|
625
|
-
*
|
|
626
|
-
* Для callBatch действует правило fail-closed: если хоть одну команду разобрать
|
|
627
|
-
* не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
|
|
628
|
-
* дешевле — потребитель получит ошибку вместо тихого дубликата.
|
|
629
|
-
*/
|
|
630
|
-
function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
|
|
631
|
-
const first = args[0];
|
|
632
|
-
|
|
633
|
-
// callMethod(method, params) и callListMethod(method, params, ...)
|
|
634
|
-
if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
|
|
635
|
-
if (typeof first !== "string") return null;
|
|
636
|
-
const method = first.trim();
|
|
637
|
-
return NON_IDEMPOTENT_METHOD_RE.test(method) ? method : null;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (sdkMethod !== "callBatch") return null;
|
|
641
|
-
|
|
642
|
-
// callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
|
|
643
|
-
if (!first || typeof first !== "object") {
|
|
644
|
-
return "аргументы batch не разобраны";
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
|
|
648
|
-
const blocking: string[] = [];
|
|
649
|
-
|
|
650
|
-
for (const cmd of commands) {
|
|
651
|
-
const method = extractBatchCommandMethod(cmd);
|
|
652
|
-
|
|
653
|
-
if (method === null) return "команда batch не разобрана";
|
|
654
|
-
if (NON_IDEMPOTENT_METHOD_RE.test(method.trim())) blocking.push(method.trim());
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return blocking.length > 0 ? blocking.join(", ") : null;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Общий цикл повторов при сетевых ошибках.
|
|
662
|
-
*
|
|
663
|
-
* @param fn — вызов без аргументов, уже замкнутый на нужные параметры
|
|
664
|
-
* @param label — префикс лог-строк, по нему в логе видно источник повтора
|
|
665
|
-
* @param blockReason — причина, по которой повтор запрещён (создающий вызов), либо null
|
|
666
|
-
*/
|
|
667
|
-
async function runWithRetry<T>(fn: () => Promise<T>, label: string, blockReason: string | null): Promise<T> {
|
|
668
|
-
let lastError: unknown;
|
|
669
|
-
|
|
670
|
-
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
671
|
-
try {
|
|
672
|
-
return await fn();
|
|
673
|
-
} catch (error: unknown) {
|
|
674
|
-
lastError = error;
|
|
675
|
-
|
|
676
|
-
// Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
|
|
677
|
-
// вызывающему коду как есть, он решает, что с ней делать
|
|
678
|
-
if (!isB24NetworkError(error)) throw error;
|
|
679
|
-
|
|
680
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
681
|
-
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
682
|
-
|
|
683
|
-
// Окончательные отказы логируем на error: собственные модули пакета
|
|
684
|
-
// (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
|
|
685
|
-
// но не пишут в лог — без этих строк сбой $b24 не виден нигде
|
|
686
|
-
|
|
687
|
-
if (attempt === RETRY_COUNT) {
|
|
688
|
-
logs.add(`${label}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
|
|
689
|
-
throw error;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Ошибка со status 0 может означать и «запрос не ушёл», и «запрос выполнен,
|
|
693
|
-
// а ответ не разобрался»: различить их нельзя, поэтому создающие вызовы
|
|
694
|
-
// не повторяем — лучше вернуть ошибку, чем создать дубликат
|
|
695
|
-
if (blockReason) {
|
|
696
|
-
logs.add(`${label}${code}: повтор отменён, вызов создаёт сущности (${blockReason}) — ${msg}`, "error");
|
|
697
|
-
throw error;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Промежуточные попытки — warn: в чат B24 уходит только уровень error,
|
|
701
|
-
// иначе один упавший вызов дал бы пять сообщений
|
|
702
|
-
logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс`, "warn");
|
|
703
|
-
|
|
704
|
-
await delay(RETRY_DELAY_MS);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
|
|
709
|
-
throw lastError;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/** Оборачивает async-функцию retry-логикой при сетевых ошибках */
|
|
713
|
-
function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
|
|
714
|
-
return (async (...args: any[]) => {
|
|
715
|
-
// Состав вызова между попытками не меняется — разбираем один раз
|
|
716
|
-
const blockReason = getRetryBlockReason(methodName, args);
|
|
717
|
-
return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
|
|
718
|
-
}) as T;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Прогоняет произвольный вызов `actions.*` через тот же retry и гейт идемпотентности,
|
|
723
|
-
* что и методы фасада. Прямой `$b24.actions.v3.call.make()` идёт мимо защиты — если она
|
|
724
|
-
* нужна (например, ради `FilterV3` или keyset-пагинации), вызов оборачивают этой функцией.
|
|
725
|
-
*
|
|
726
|
-
* @param run — сам вызов, например `() => $b24.actions.v3.call.make({ method, params })`
|
|
727
|
-
* @param methodName — имя REST-метода B24 (не метода SDK) либо список имён, если внутри
|
|
728
|
-
* batch: по ним гейт решает, создаёт ли вызов сущность
|
|
729
|
-
*
|
|
730
|
-
* @example
|
|
731
|
-
* const response = await callProtected(
|
|
732
|
-
* () => $b24!.actions.v3.call.make({ method: "crm.item.list", params }),
|
|
733
|
-
* "crm.item.list"
|
|
734
|
-
* );
|
|
735
|
-
*
|
|
736
|
-
* @example
|
|
737
|
-
* const response = await callProtected(
|
|
738
|
-
* () => $b24!.actions.v3.batch.make({ calls }),
|
|
739
|
-
* ["crm.item.get", "crm.item.add"]
|
|
740
|
-
* );
|
|
741
|
-
*/
|
|
742
|
-
export async function callProtected<T>(run: () => Promise<T>, methodName: string | string[]): Promise<T> {
|
|
743
|
-
const names = (Array.isArray(methodName) ? methodName : [methodName]).map((name) => String(name).trim()).filter((name) => name.length > 0);
|
|
744
|
-
|
|
745
|
-
// Fail-closed, как в гейте callBatch: список пуст или имя не похоже на REST-метод
|
|
746
|
-
// (легальные имена всегда с точкой — crm.deal.add, disk.folder.uploadfile) — считаем
|
|
747
|
-
// вызов создающим. Иначе callProtected(() => batch.make({ calls }), "batch") прошёл бы
|
|
748
|
-
// мимо гейта, и батч с crm.deal.add внутри повторился бы до пяти раз при status 0.
|
|
749
|
-
const isUnparsed = names.length === 0 || names.some((name) => !name.includes("."));
|
|
750
|
-
const blocking = names.filter((name) => NON_IDEMPOTENT_METHOD_RE.test(name));
|
|
751
|
-
|
|
752
|
-
const blockReason = isUnparsed ? "имя метода не разобрано" : blocking.length > 0 ? blocking.join(", ") : null;
|
|
753
|
-
|
|
754
|
-
return runWithRetry(run, `actions:${names.join(", ") || "имя не указано"}`, blockReason);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// ==================== Фасад поверх actions.v2.* ====================
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* Собственные реализации пяти методов, которые SDK 2.x помечает к удалению.
|
|
761
|
-
* Повторяют `AbstractB24` дословно, включая дефолты и порядок аргументов.
|
|
762
|
-
*
|
|
763
|
-
* Позиционные сигнатуры сохранены один в один: на этом держится то, что гейт
|
|
764
|
-
* идемпотентности (`getRetryBlockReason`) видит те же аргументы, что и раньше,
|
|
765
|
-
* — retry оборачивает фасад снаружи, а преобразование в объект опций
|
|
766
|
-
* происходит уже внутри.
|
|
767
|
-
*/
|
|
768
|
-
function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any> {
|
|
769
|
-
// Геттер actions при каждом обращении проверяет инициализацию экземпляра —
|
|
770
|
-
// читаем его в момент вызова, а не один раз при создании фасада
|
|
771
|
-
const v2 = () => target.actions.v2;
|
|
772
|
-
|
|
773
|
-
const callMethod = async <T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>> => {
|
|
774
|
-
const merged: TypeCallParams = { ...params };
|
|
775
|
-
|
|
776
|
-
// Явный params.start приоритетнее аргумента start — как в AbstractB24.callMethod.
|
|
777
|
-
// typeof, а не только Number.isInteger: последний ничего не сужает, и при
|
|
778
|
-
// exactOptionalPropertyTypes у потребителя присваивание number | undefined не проходит
|
|
779
|
-
if (!("start" in merged && Number.isInteger(merged.start)) && typeof start === "number" && Number.isInteger(start)) {
|
|
780
|
-
merged.start = start;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
return v2().call.make<T>({ method, params: merged });
|
|
784
|
-
};
|
|
785
|
-
|
|
786
|
-
/**
|
|
787
|
-
* Собственный офсетный цикл, а не `actions.v2.callList.make`.
|
|
788
|
-
*
|
|
789
|
-
* У `callList.make` keyset-пагинация: он шлёт `start: -1`, навязывает
|
|
790
|
-
* `order: { ID: 'ASC' }` и добавляет в фильтр `>ID`. Пользовательский `order`
|
|
791
|
-
* при этом молча игнорируется, а колбэка `progress` там нет вовсе. Делегирование
|
|
792
|
-
* туда изменило бы поведение постраничных выборок у потребителей.
|
|
793
|
-
*/
|
|
794
|
-
const callListMethod = async (
|
|
795
|
-
method: string,
|
|
796
|
-
params?: object,
|
|
797
|
-
progress?: null | ((progress: number) => void),
|
|
798
|
-
customKeyForResult?: string | null
|
|
799
|
-
): Promise<Result> => {
|
|
800
|
-
const result = new Result();
|
|
801
|
-
const onProgress = typeof progress === "function" ? progress : null;
|
|
802
|
-
|
|
803
|
-
onProgress?.(0);
|
|
804
|
-
|
|
805
|
-
const list: unknown[] = [];
|
|
806
|
-
let start = 0;
|
|
807
|
-
let completed = false;
|
|
808
|
-
|
|
809
|
-
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
|
|
810
|
-
const response = await v2().call.make({ method, params: { ...params, start } });
|
|
811
|
-
|
|
812
|
-
// SDK в своём callListMethod этой проверки не делает и падает TypeError
|
|
813
|
-
// на getData().result, когда портал вернул «мягкую» ошибку
|
|
814
|
-
if (!response.isSuccess) {
|
|
815
|
-
throw new Error(`${method}: Bitrix24 вернул ошибку — ${response.getErrorMessages().join("; ")}`);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// Читаем конверт целиком: getData() режет ответ до { result, time },
|
|
819
|
-
// а пагинация держится на next, которого там нет
|
|
820
|
-
const envelope = readV2Envelope(response, method);
|
|
821
|
-
const payload = envelope.result;
|
|
822
|
-
const chunk = customKeyForResult ? (payload as Record<string, unknown> | undefined)?.[customKeyForResult] : payload;
|
|
823
|
-
|
|
824
|
-
if (!Array.isArray(chunk)) {
|
|
825
|
-
throw new Error(`${method}: ответ не является списком`);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
for (const item of chunk) {
|
|
829
|
-
list.push(item);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Единственное правило остановки — отсутствие next в конверте. Считать
|
|
833
|
-
// смещение самим (start += chunk.length) нельзя: портал ставит next = start + 50
|
|
834
|
-
// независимо от того, сколько строк реально отдал, и на странице, укороченной
|
|
835
|
-
// правами доступа или фильтром, собственный счётчик отстаёт — перекрытие
|
|
836
|
-
// читается второй раз и записи дублируются. Метод, который игнорирует start
|
|
837
|
-
// и отдаёт весь список одной страницей, next не присылает и останавливается здесь же.
|
|
838
|
-
if (!Number.isInteger(envelope.next)) {
|
|
839
|
-
completed = true;
|
|
840
|
-
break;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
const nextStart = Number(envelope.next);
|
|
844
|
-
|
|
845
|
-
// next обязан расти. Портал, вернувший прежнее или меньшее смещение,
|
|
846
|
-
// иначе гонял бы одну и ту же страницу до потолка, раздувая список
|
|
847
|
-
if (nextStart <= start) {
|
|
848
|
-
throw new Error(`${method}: портал вернул непродвигающийся next (${nextStart}) при start ${start} — пагинация зациклилась`);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
start = nextStart;
|
|
852
|
-
|
|
853
|
-
if (onProgress) {
|
|
854
|
-
const total = Number(envelope.total) || 0;
|
|
855
|
-
onProgress(total > 0 ? Math.round((100 * list.length) / total) : 100);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// Обрезанный список, отданный как успешный, — худший исход: потребитель
|
|
860
|
-
// примет неполную выборку за полную. Поэтому бросаем, а не логируем:
|
|
861
|
-
// addError() на базовом Result бесполезен — getData() отдаёт данные
|
|
862
|
-
// независимо от наличия ошибок, и существующие потребители её не увидят
|
|
863
|
-
if (!completed) {
|
|
864
|
-
throw new Error(`${method}: достигнут потолок в ${LIST_MAX_PAGES} страниц, портал продолжает отдавать next — выборка прервана как неполная`);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
onProgress?.(100);
|
|
868
|
-
result.setData(list);
|
|
869
|
-
return result;
|
|
870
|
-
};
|
|
871
|
-
|
|
872
|
-
async function* fetchListMethod(method: string, params?: any, idKey?: string, customKeyForResult?: string | null): AsyncGenerator<any[]> {
|
|
873
|
-
// Ключи со значением undefined не подставляем вовсе: при exactOptionalPropertyTypes
|
|
874
|
-
// у потребителя явный undefined не подходит опциональному полю опций SDK.
|
|
875
|
-
// Поведение прежнее — null и undefined одинаково означают «ключ не передан»
|
|
876
|
-
yield* v2().fetchList.make<any>({
|
|
877
|
-
method,
|
|
878
|
-
params,
|
|
879
|
-
...(idKey === undefined ? {} : { idKey }),
|
|
880
|
-
...(customKeyForResult === null || customKeyForResult === undefined ? {} : { customKeyForResult }),
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const callBatch = async (calls: Array<any> | object, isHaltOnError?: boolean, returnAjaxResult?: boolean): Promise<Result> => {
|
|
885
|
-
// Дефолты подставляем мы: batch.make своих не имеет, он просто
|
|
886
|
-
// расширяет переданный объект опций версией API
|
|
887
|
-
return v2().batch.make({
|
|
888
|
-
calls: calls as BatchCalls,
|
|
889
|
-
options: {
|
|
890
|
-
isHaltOnError: isHaltOnError ?? true,
|
|
891
|
-
returnAjaxResult: returnAjaxResult ?? false,
|
|
892
|
-
},
|
|
893
|
-
});
|
|
894
|
-
};
|
|
895
|
-
|
|
896
|
-
const callBatchByChunk = async (calls: Array<any>, isHaltOnError: boolean): Promise<Result> => {
|
|
897
|
-
// isHaltOnError передаём как есть, без ?? true — дословно по AbstractB24.
|
|
898
|
-
// returnAjaxResult не передаём: batchByChunk.make жёстко ставит false сам,
|
|
899
|
-
// а его тип опций этот ключ не принимает
|
|
900
|
-
return v2().batchByChunk.make({ calls, options: { isHaltOnError } });
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
return { callMethod, callListMethod, fetchListMethod, callBatch, callBatchByChunk } satisfies FacadeMethods;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* Proxy-обёртка вокруг B24OAuth.
|
|
908
|
-
*
|
|
909
|
-
* Имена из FACADE_METHODS резолвятся в собственные реализации пакета поверх
|
|
910
|
-
* `actions.v2.*`; из них методы RETRYABLE_METHODS дополнительно оборачиваются
|
|
911
|
-
* retry-логикой. Все остальные методы привязываются к оригинальному объекту
|
|
912
|
-
* через bind: класс B24OAuth использует приватные поля (#authOAuthManager),
|
|
913
|
-
* и при вызове метода с this === Proxy движок бросает "Cannot read private member".
|
|
914
|
-
*
|
|
915
|
-
* Обёртки кешируются — по одной на метод, чтобы не ломать сравнение по ссылке.
|
|
916
|
-
*/
|
|
917
|
-
function wrapB24WithRetry(b24: B24OAuth): B24Client {
|
|
918
|
-
const methodCache = new Map<string, Function>();
|
|
919
|
-
// Фасад создаётся один раз на экземпляр: он замкнут на конкретный target,
|
|
920
|
-
// а пересоздание на каждом обращении ломало бы кеш и сравнение по ссылке
|
|
921
|
-
const facade = createFacade(b24);
|
|
922
|
-
|
|
923
|
-
return new Proxy(b24, {
|
|
924
|
-
get(target, prop) {
|
|
925
|
-
// Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
|
|
926
|
-
// deprecated-методы, иначе мы отдавали бы их, а не свои
|
|
927
|
-
if (typeof prop === "string" && FACADE_METHODS.has(prop)) {
|
|
928
|
-
if (!methodCache.has(prop)) {
|
|
929
|
-
const impl = facade[prop]!;
|
|
930
|
-
methodCache.set(prop, RETRYABLE_METHODS.has(prop) ? withRetry(impl, target, prop) : impl);
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
return methodCache.get(prop);
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// Передаём target третьим аргументом: геттеры (например, auth)
|
|
937
|
-
// тоже должны исполняться с this === target
|
|
938
|
-
const value = Reflect.get(target, prop, target);
|
|
939
|
-
|
|
940
|
-
if (typeof prop !== "string" || typeof value !== "function") {
|
|
941
|
-
return value;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
if (!methodCache.has(prop)) {
|
|
945
|
-
const wrapped = RETRYABLE_METHODS.has(prop)
|
|
946
|
-
? withRetry(value as (...args: any[]) => Promise<any>, target, prop)
|
|
947
|
-
: value.bind(target);
|
|
948
|
-
methodCache.set(prop, wrapped);
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
return methodCache.get(prop);
|
|
952
|
-
},
|
|
953
|
-
|
|
954
|
-
// Страховка на будущее: когда SDK уберёт методы из прототипа,
|
|
955
|
-
// "callMethod" in $b24 обязано остаться истинным (eventB24.ts проверяет
|
|
956
|
-
// наличие метода перед работой). Цель расширяема, лишние true законны
|
|
957
|
-
has(target, prop) {
|
|
958
|
-
if (typeof prop === "string" && FACADE_METHODS.has(prop)) return true;
|
|
959
|
-
return Reflect.has(target, prop);
|
|
960
|
-
},
|
|
961
|
-
}) as B24Client;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// ==================== Инициализация ====================
|
|
965
|
-
|
|
966
|
-
let _b24Raw = createB24Instance();
|
|
967
|
-
export let $b24: B24Client | null = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
|
|
968
|
-
|
|
969
|
-
/** Настраивает колбэк автосохранения токенов на экземпляре B24OAuth */
|
|
970
|
-
function setupRefreshCallback(raw: B24OAuth): void {
|
|
971
|
-
raw.setCallbackRefreshAuth(async ({ authData }) => {
|
|
972
|
-
const result = saveTokens(authData);
|
|
973
|
-
if (result.error) {
|
|
974
|
-
logs.add(`Ошибка при автосохранении токенов: ${result.message}`, "error");
|
|
975
|
-
} else {
|
|
976
|
-
logs.add("Токены автоматически обновлены и сохранены в authB24.json", "debug");
|
|
977
|
-
}
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
/** Пересоздаёт $b24 из актуального authB24.json без перезапуска сервера */
|
|
982
|
-
export async function reinitializeB24(): Promise<SaveResult> {
|
|
983
|
-
stopProactiveRefresh();
|
|
984
|
-
|
|
985
|
-
_b24Raw = createB24Instance();
|
|
986
|
-
$b24 = _b24Raw ? wrapB24WithRetry(_b24Raw) : null;
|
|
987
|
-
|
|
988
|
-
if (!_b24Raw || !$b24) {
|
|
989
|
-
return { error: true, message: "Не удалось пересоздать $b24 — проверь authB24.json и APP_B24_CLIENT_ID/APP_B24_CLIENT_SECRET" };
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
setupRefreshCallback(_b24Raw);
|
|
993
|
-
|
|
994
|
-
const refreshResult = await refreshAndSaveTokens();
|
|
995
|
-
startProactiveRefresh();
|
|
996
|
-
|
|
997
|
-
logs.add("$b24 переинициализирован с новыми токенами", "debug");
|
|
998
|
-
return refreshResult;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
109
|
// ==================== Начальная инициализация ====================
|
|
1002
110
|
|
|
1003
|
-
|
|
1004
|
-
|
|
111
|
+
const initialRaw = createB24Instance();
|
|
112
|
+
setB24Instance(initialRaw, initialRaw ? wrapB24WithRetry(initialRaw) : null);
|
|
113
|
+
|
|
114
|
+
if (initialRaw) {
|
|
115
|
+
setupRefreshCallback(initialRaw);
|
|
1005
116
|
|
|
1006
117
|
// Намеренно не ждём результат: импорт модуля не должен блокироваться сетевым
|
|
1007
118
|
// запросом к oauth.bitrix.info. try/finally обязателен — если обновление всё же
|