@andrey4emk/npm-app-back-b24 3.8.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -13
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +371 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +120 -0
- package/bitrix24/b24/retry.ts +490 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +280 -0
- package/bitrix24/b24/types.ts +93 -0
- package/bitrix24/b24.ts +97 -955
- package/bitrix24/errTaskB24.ts +8 -5
- package/bitrix24/eventB24.ts +301 -63
- package/index.ts +4 -1
- package/package.json +26 -7
- package/sendMessage/chatApp.ts +101 -24
- package/sendMessage/email.ts +233 -32
- package/sendMessage/smsgold.ts +11 -2
- package/sendMessage/wappi.ts +41 -15
- package/utils/fetchRetry.ts +70 -9
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- [Логирование](#логирование)
|
|
18
18
|
- [fetchRetry и таймауты](#fetchretry-и-таймауты)
|
|
19
19
|
- [Переменные окружения](#переменные-окружения)
|
|
20
|
+
- [Миграция на 4.0.0](#миграция-на-400)
|
|
20
21
|
- [Скрипты](#скрипты)
|
|
21
22
|
- [Лицензия](#лицензия)
|
|
22
23
|
|
|
@@ -26,6 +27,15 @@
|
|
|
26
27
|
npm install @andrey4emk/npm-app-back-b24
|
|
27
28
|
```
|
|
28
29
|
|
|
30
|
+
`nodemailer` — **опциональная peer-зависимость**: её ставит потребитель, и только если пользуется классом `Email`. Всем остальным она не нужна и не приезжает.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Только тем, кто шлёт почту
|
|
34
|
+
npm install nodemailer@9.0.5
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Типы (`@types/node`, `@types/luxon` и прочие) пакет **не транслирует** — с 4.0.0 они лежат в его `devDependencies`. Потребителю, который гоняет `tsc`, нужны свои: подробности в разделе [Миграция на 4.0.0](#миграция-на-400).
|
|
38
|
+
|
|
29
39
|
### Точки входа
|
|
30
40
|
|
|
31
41
|
| Импорт | Что отдаёт | Поднимает OAuth-модуль |
|
|
@@ -33,6 +43,7 @@ npm install @andrey4emk/npm-app-back-b24
|
|
|
33
43
|
| `@andrey4emk/npm-app-back-b24` | всё | да |
|
|
34
44
|
| `@andrey4emk/npm-app-back-b24/logs` | `logs` | нет |
|
|
35
45
|
| `@andrey4emk/npm-app-back-b24/fetchRetry` | `fetchRetry`, `fetchWithTimeout`, `isNetworkError`, `isPreConnectionError`, `isTimeoutError`, `isAbortError`, `maskUrl`, `FETCH_TIMEOUTS`, `DEFAULT_FETCH_TIMEOUT_MS` | нет |
|
|
46
|
+
| `@andrey4emk/npm-app-back-b24/email` | `Email` | нет |
|
|
36
47
|
|
|
37
48
|
Корневой импорт — barrel: он тянет модуль OAuth, который при загрузке читает `authB24.json` и, если приложение авторизовано, запускает таймер проактивного обновления токена. Проектам, которые ходят в B24 по **входящему вебхуку** или берут из пакета только логгер и `fetchRetry`, удобнее импортировать по подпутям — тогда OAuth-модуль не загружается вообще.
|
|
38
49
|
|
|
@@ -42,6 +53,13 @@ import { logs } from "@andrey4emk/npm-app-back-b24/logs";
|
|
|
42
53
|
import { fetchRetry, maskUrl } from "@andrey4emk/npm-app-back-b24/fetchRetry";
|
|
43
54
|
```
|
|
44
55
|
|
|
56
|
+
Подпуть `./email` стоит особняком: `Email` вынесен из barrel'а не ради экономии импорта, а ради зависимости. Он единственный тянет `nodemailer`, и пока класс лежал в barrel'е, почтовая библиотека приезжала во все восемь проектов — включая те шесть, что почту не шлют.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
// Почту шлём — ставим nodemailer и берём Email из подпути
|
|
60
|
+
import { Email } from "@andrey4emk/npm-app-back-b24/email";
|
|
61
|
+
```
|
|
62
|
+
|
|
45
63
|
Корневой импорт при этом остаётся безопасным: если `APP_B24_CLIENT_ID` и `APP_B24_CLIENT_SECRET` не заданы, пакет считает, что OAuth в проекте не используется, молча выставляет `$b24 = null` и пишет об этом только в `debug`.
|
|
46
64
|
|
|
47
65
|
### Строгость типов
|
|
@@ -101,7 +119,6 @@ import {
|
|
|
101
119
|
Event,
|
|
102
120
|
ChatApp,
|
|
103
121
|
Smsgold,
|
|
104
|
-
Email,
|
|
105
122
|
Wappi,
|
|
106
123
|
logs,
|
|
107
124
|
fetchRetry,
|
|
@@ -112,6 +129,9 @@ import {
|
|
|
112
129
|
FETCH_TIMEOUTS,
|
|
113
130
|
} from "@andrey4emk/npm-app-back-b24";
|
|
114
131
|
|
|
132
|
+
// Email живёт в подпути: он единственный тянет optional peer nodemailer
|
|
133
|
+
import { Email } from "@andrey4emk/npm-app-back-b24/email";
|
|
134
|
+
|
|
115
135
|
// $b24 — готовый экземпляр B24OAuth (или null, если токены не настроены)
|
|
116
136
|
if ($b24) {
|
|
117
137
|
const result = await $b24.callMethod("crm.contact.list", { limit: 5 });
|
|
@@ -191,11 +211,21 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
191
211
|
|
|
192
212
|
Поведение прежнее у четырёх методов из пяти. **`callListMethod` отличается в двух местах:** он бросает внятную ошибку с именем метода там, где SDK падал `TypeError` из своих недр (портал вернул «мягкую» ошибку, ответ не список, конверт нечитаем, `next` не растёт), и у него есть потолок в 1000 страниц — при упоре в него вызов тоже бросает, а не отдаёт молча обрезанный список. Если у вас есть `try`/`catch` вокруг постраничных выборок, текст ошибки изменится; если его нет — падение станет заметнее, но не появится там, где раньше всё работало.
|
|
193
213
|
|
|
214
|
+
**`fetchListMethod` теперь тоже свой:** keyset-цикл пакета вместо `actions.v2.fetchList.make`. Форма запроса прежняя — тот же курсор по `idKey`, тот же навязанный порядок сортировки. Появился постраничный повтор (см. ниже), стали внятными ошибки (имя метода B24 в тексте вместо `TypeError` из недр SDK и `SdkError` с внутренним кодом библиотеки; если вы разбирали `error.code` у этого метода, разбор придётся заменить на чтение сообщения) и изменились правила остановки:
|
|
215
|
+
|
|
216
|
+
- **выборку завершает только пустая страница.** Раньше, как в SDK, цикл останавливался ещё и на короткой — меньше 50 строк. У портала страница, укороченная правами доступа, штатна, поэтому старое правило могло молча отдать полсотни записей вместо нескольких тысяч. Цена нового — один лишний запрос на всю выборку, всегда пустой;
|
|
217
|
+
- **нечитаемый курсор бросает.** Если в строках ответа нет числового идентификатора по ключу курсора, продолжить пагинацию нечем — вместо тихой остановки (SDK пишет предупреждение и выходит) выборка прерывается ошибкой «выборка прервана как неполная». Неполный список, отданный как полный, хуже громкого отказа;
|
|
218
|
+
- **есть потолок в 20 000 страниц** и **проверка роста курсора** — обе бросают. Портал, отбросивший неизвестный ключ фильтра, отдаёт одну и ту же страницу бесконечно, без ошибки и без строки в логе; у SDK от этого нет никакой страховки.
|
|
219
|
+
|
|
220
|
+
**Ограничение по ключу курсора.** Один и тот же ключ идёт и на чтение идентификатора из строки ответа, и в `order`/`filter`. Методы, у которых имя поля в ответе отличается от сортируемого — например `tasks.task.list`, где в ответе `id`, а в фильтре `ID`, — этим методом не выбираются: передача `idKey: "id"` уберёт ошибку, но запрос уйдёт с параметрами, которых метод не понимает. Для таких выборок берите `callListMethod` или `actions.v2.fetchList.make` с опцией `cursorIdKey`. Ключ `>ID` в вашем фильтре пагинация перетирает своим курсором — об этом теперь пишется предупреждение уровня `warn`, как и про игнорируемый `order`.
|
|
221
|
+
|
|
194
222
|
Прямой доступ к `$b24.actions.*` остаётся: это сырой SDK **без** retry и гейта идемпотентности. Если защита нужна, оборачивайте вызов в `callProtected()`.
|
|
195
223
|
|
|
196
224
|
**Retry при сетевых ошибках:**
|
|
197
225
|
|
|
198
|
-
Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`).
|
|
226
|
+
Экземпляр `$b24` обёрнут Proxy, который автоматически повторяет запросы при сетевых ошибках. Повторные попытки применяются к методам `callMethod`, `callListMethod`, `callBatch`, а также к обновлению токена (`refreshAuth`). `callBatchByChunk` пакет реализует, но не ретраит.
|
|
227
|
+
|
|
228
|
+
Метод `fetchListMethod` Proxy снаружи не оборачивает — это async-генератор, обёртка в async-функцию сломала бы `for await`. Повтор у него внутренний и **постраничный**: обрыв на двенадцатой странице повторяет двенадцатую страницу, а не всю выборку. Уже отданные страницы не теряются и не дублируются, потому что курсор двигается только после успешной страницы. Раньше одиночный сбой обрывал выборку целиком, и прочитанное потребитель либо терял, либо принимал за полный результат.
|
|
199
229
|
|
|
200
230
|
`callListMethod` повторяется целиком: при сетевом сбое пагинация начинается с нулевой страницы заново. Постраничный retry был бы сменой семантики, поэтому поведение оставлено прежним.
|
|
201
231
|
|
|
@@ -208,7 +238,19 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
208
238
|
|
|
209
239
|
**Уровни логов.** Промежуточные попытки пишутся на `warn`, окончательный отказ — на `error`: исчерпание всех попыток и отмена повтора гейтом. Так один упавший вызов даёт одно сообщение в чат B24 вместо пяти, но при этом не теряется совсем. Логировать провал обязан сам Proxy: `errorB24()`, `Event` и `Smsgold` ошибку только возвращают вызывающему коду и в лог не пишут.
|
|
210
240
|
|
|
211
|
-
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала.
|
|
241
|
+
**Создающие вызовы не повторяются Proxy-слоем.** Если в вызове есть метод, создающий сущность или запускающий действие (`*.add`, `*.create`, `*.start`, `*.send`, `*.uploadfile`, `*.import`, `*.register` — в том числе среди команд `callBatch`), retry отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. Обрыв приходит и когда соединение не состоялось, и когда оно случилось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
|
|
242
|
+
|
|
243
|
+
Ключевое слово ищется **в любой позиции** имени, а не только в конце: `imconnector.send.messages` и `imconnector.send.status.delivery` тоже под гейтом. После ключевого слова обязателен разделитель (`.`, `?` или конец строки), поэтому `crm.deal.addcustom` и `landing.landing.addbytemplate` повторяются как прежде.
|
|
244
|
+
|
|
245
|
+
Попадание `imconnector.send.status.delivery` под гейт — осознанная плата за правило без списка исключений. Метод лишь фиксирует доставку и отмечает сообщение прочитанным, повтор его безвреден, а отказ от повтора стоит только потерянной отметки о доставке: переписку это не ломает. Ручной же список исключений молча устаревал бы при появлении нового метода портала — ровно тот дефект, которым и был пропуск `imconnector.send.messages` мимо гейта.
|
|
246
|
+
|
|
247
|
+
**Отдельный список методов, теряющих данные при повторе.** Пока в нём один `event.offline.get`: вызов резервирует пакет очереди офлайн-событий и прячет его от следующих запросов, поэтому потерянный ответ уносит `process_id` с собой, а повтор зарезервировал бы ещё один пакет поверх первого. Сущностей метод не создаёт, и в логе это видно по фразе: `повтор отменён, вызов резервирует данные на портале (event.offline.get)`.
|
|
248
|
+
|
|
249
|
+
**Исключение для обоих ограничений — доказанный pre-connection.** Коды `ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETDOWN` означают, что соединение с целевым хостом не состоялось: дубликата повтор дать не может. Такие вызовы повторяются все пять раз, а в warn-строке появляется хвост `— соединение не состоялось, ограничение (...) снято`.
|
|
250
|
+
|
|
251
|
+
Следствие для вашего кода: после снятия гейта отказ вызова больше не означает, что на портале ничего не произошло — первая попытка могла упасть на `ECONNREFUSED`, а вторая дойти и оборваться уже на ответе. Дубликата пакет при этом не создаёт, но потребитель с собственным повтором поверх пакета создаст.
|
|
252
|
+
|
|
253
|
+
**Провал обмена refresh-токена повторов не получает.** Когда обмен падает внутри вызова, попытка ровно одна, а в лог идёт строка уровня `error` с исходным кодом и статусом ответа oauth-хоста: `обновление токена не удалось (invalid_grant/400), повтор небезопасен: сервер мог провести ротацию`. Под правило попадает **любой** отказ обмена, кроме доказанного pre-connection, — и мёртвый токен после ротации, и транзиентные 429/503 от oauth-хоста: различить их по ответу нельзя. Если ротация прошла, локальный `refresh_token` мёртв независимо от повтора и нужна переавторизация приложения на портале. DNS-сбой на oauth-хосте под это правило не попадает: соединение не состоялось, значит повторяем.
|
|
212
254
|
|
|
213
255
|
Команды `callBatch` разбираются в обеих формах — кортеж `["crm.deal.add", {...}]` (основная в SDK 2.x) и объект `{ method, params }`. Если форму команды разобрать не удалось, вызов считается создающим и не повторяется: для защиты от дубликатов безопаснее ошибиться в сторону осторожности.
|
|
214
256
|
|
|
@@ -220,6 +262,8 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
220
262
|
|
|
221
263
|
**Обмен refresh-токена** повторяется по более строгому правилу — только если соединение заведомо не состоялось (см. `isPreConnectionError()`). При таймауте или обрыве сервер мог уже провести ротацию; тогда локальный `refresh_token` мёртв независимо от наших действий, и повтор не помогает, а лишь маскирует проблему серией одинаковых отказов. Следующая попытка всё равно будет: проактивный таймер повторяет через 2 минуты при времени жизни токена около часа.
|
|
222
264
|
|
|
265
|
+
Над обменом стоит **сторожевой таймер** с бюджетом 2 минуты. Он нужен потому, что сам обмен идёт внутри SDK отдельным HTTP-клиентом без таймаута: зависшее соединение раньше занимало мьютекс навсегда, все следующие обновления приклеивались к мёртвому запросу, и через час процесс начинал получать 401 на каждый вызов. Сторож освобождает **только** мьютекс — сам запрос не отменяется, и если ответ всё же придёт, токены сохранятся: колбэк сохранения SDK зовёт сам. Ждущий получает ошибку «обмен refresh-токена завис», повтор делает проактивный таймер. `reinitializeB24()` (а значит и сохранение токенов с фронта через `saveAuthB24Handler`) сбрасывает мьютекс принудительно и не ждёт зависший обмен.
|
|
266
|
+
|
|
223
267
|
У самого SDK есть собственный слой ретраев (`maxRetries: 3`), но на транспортных ошибках он выключен — там одна попытка, повторяет только Proxy. При 429, 503 и неизвестных 5xx SDK повторяет по-прежнему.
|
|
224
268
|
|
|
225
269
|
Отдельно про ответы шлюза перед порталом. **502** повторяет наш слой: шлюз не смог получить ответ от upstream, запрос почти наверняка не выполнялся. **504** не повторяет никто — это «портал взял запрос и считает прямо сейчас»; повтор создающего вызова дал бы дубликат, а читающего — ничего, кроме нагрузки на портал в худший для него момент.
|
|
@@ -316,7 +360,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
316
360
|
|
|
317
361
|
- **Параметры:**
|
|
318
362
|
- `run` (`() => Promise<T>`) — сам вызов.
|
|
319
|
-
- `methodName` (`string | string[]`) — имя REST-метода B24 (**не** метода SDK) либо список имён, если внутри batch
|
|
363
|
+
- `methodName` (`string | string[]`) — имя REST-метода B24 (**не** метода SDK) либо список имён, если внутри batch. По ним гейт решает не только «создаёт ли вызов сущность», но и «не входит ли он в список методов, повтор которых теряет данные» (`event.offline.get`).
|
|
320
364
|
- **Возвращает:** промис с результатом `run`.
|
|
321
365
|
|
|
322
366
|
```js
|
|
@@ -338,7 +382,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
338
382
|
|
|
339
383
|
Без обёртки вызов `$b24.actions.*` идёт мимо защиты: ни повторов при сетевых сбоях, ни блокировки повтора для создающих методов.
|
|
340
384
|
|
|
341
|
-
- **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`.
|
|
385
|
+
- **`B24Client`** — экспортируемый тип: `B24OAuth` плюс пять методов, которые пакет реализует сам. Объявлен как `interface B24Client extends B24OAuth`, поэтому `$b24` по-прежнему принимается везде, где ожидается `B24OAuth` — например в `new Smsgold(auth, $b24)`. Все пять сигнатур объявлены в интерфейсе явно, а не взяты по наследству: в SDK 3.x этих методов у базового класса не будет, и без явных объявлений тип потерял бы весь фасад. Держит их тест `tests/client-type.test.ts`: он читает текст `types.ts` (пропажу объявления система типов не видит — метод пока наследуется) и сверяет кортежи параметров строгим равенством типов, поэтому потеря необязательного аргумента или расширение параметра до `any` роняют `npx tsc --noEmit`.
|
|
342
386
|
|
|
343
387
|
```ts
|
|
344
388
|
import type { B24Client } from "@andrey4emk/npm-app-back-b24";
|
|
@@ -500,7 +544,12 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
500
544
|
```
|
|
501
545
|
|
|
502
546
|
- **Особенности:**
|
|
503
|
-
- **Автоматически отбрасывает и очищает события пользователя 138** — владельца OAuth-токенов приложений. Это защита от петли: всё, что приложения
|
|
547
|
+
- **Автоматически отбрасывает и очищает события пользователя 138** — владельца OAuth-токенов приложений. Это защита от петли: всё, что приложения и роботы пишут в B24 под этой учёткой, приходит событием с `user_id: '138'`. Собственные правки платформа приложению не отдаёт, а правки соседнего приложения под тем же владельцем — отдаёт, и без фильтра они обрабатывались бы как внешние изменения по кругу. Оборотная сторона: ручные правки под этой же учёткой (администратор портала) тоже не возвращаются. **Для полной выгрузки всех изменений класс не подходит** — там читать `event.offline.get` напрямую.
|
|
548
|
+
- **Битая запись в ветке сущностей не роняет весь вызов.** Битой считается запись, которую обработать нечем: не читается идентификатор сущности `EVENT_DATA.FIELDS.ID` (в том числе когда на его месте пришёл пустой массив — так PHP сериализует пустую коллекцию) или не читается ключ очистки `MESSAGE_ID`. Такая запись отбрасывается, её `MESSAGE_ID` уходит в очистку вместе с системными, а в лог пишется одна строка `warn` с причиной. Раньше такая запись валила `get()` исключением: пакет к тому моменту уже зарезервирован порталом, `processId` наружу не ушёл, и очистить его было нечем. Запись, у которой не читается сам `MESSAGE_ID`, только логируется — передавать в `clear()` нечего, снять её с резерва может лишь `clear(processId)` целиком.
|
|
549
|
+
- **Отсутствие `EVENT_ADDITIONAL.user_id` битой записью не является.** Идентификатор сущности на месте, значит запись обработать можно: она уходит потребителю как рабочая, а в лог пишется строка `warn` о том, что системной её признать нечем. Удалять такую запись нельзя — `event.offline.clear` снимает её с резерва безвозвратно.
|
|
550
|
+
- **Если битой оказалась вся выборка целиком** (больше одной записи и ни одной рабочей), в лог уходит строка уровня `error` с числом записей и причиной первой: так выглядит не одна кривая запись, а смена формы ответа портала. Очистку это не меняет — записи по-прежнему снимаются с резерва.
|
|
551
|
+
- **Сбой `event.offline.clear` не уносит выборку.** Исключение перехватывается, пишется строка `warn`, активные записи возвращаются потребителю. Иначе он остался бы и без `processId`, и без событий, а пакет уже зарезервирован порталом. Если в ответе нет `process_id`, очистка пропускается, а в лог уходит одна строка `warn` на весь вызов: записи придут следующим опросом и будут обработаны повторно.
|
|
552
|
+
- Значение `user_id` сравнивается после нормализации, поэтому системным считается и строка `'138'`, и число `138`. Портал сегодня отдаёт это поле строкой; форма поля на защиту от петли влиять не должна.
|
|
504
553
|
- Для коннекторных событий конвертирует файлы в формат с типами `image`, `video`, `document`.
|
|
505
554
|
- Если событий нет, возвращает `data` с пустыми массивами.
|
|
506
555
|
- **Гранулярность очистки — запись очереди, а не сообщение.** Одна запись коннектора (один `MESSAGE_ID`) может нести несколько сообщений, и они приходят отдельными элементами `message` с одинаковым `messageId`. Очистка по этому ключу удаляет запись целиком: если из двух сообщений одной записи ушло одно, `clear()` заберёт и неотправленное. Строить поэлементную очистку с точностью до сообщения на `messageId` нельзя. Для дедупликации отдельных сообщений он не годится по той же причине — у сообщений одной записи он общий, для этого есть `im.id`. Обратная сторона: запись, из которой не разобрано ни одного сообщения, в `data.message` не попадёт вовсе, и очистка по собранным `messageId` её не заберёт. Безопасный режим — `clear(processId)` целиком.
|
|
@@ -675,7 +724,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
675
724
|
- **`Email`** — класс для отправки email через SMTP Яндекса. Экземпляр нужно создать самостоятельно.
|
|
676
725
|
|
|
677
726
|
```js
|
|
678
|
-
import { Email } from "@andrey4emk/npm-app-back-b24";
|
|
727
|
+
import { Email } from "@andrey4emk/npm-app-back-b24/email";
|
|
679
728
|
|
|
680
729
|
const emailClient = new Email({
|
|
681
730
|
user: "your-email@yandex.ru",
|
|
@@ -694,8 +743,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
694
743
|
- `text` (string) — текстовая версия.
|
|
695
744
|
- `html` (string) — опционально, HTML-версия.
|
|
696
745
|
- `fileUrl` (string) — опционально, ссылка на файл (будет скачан и прикреплён).
|
|
697
|
-
- `fileName` (string) — опционально, имя файла для вложения.
|
|
698
|
-
|
|
746
|
+
- `fileName` (string) — опционально, имя файла для вложения. Не задано — имя выводится из последнего сегмента пути в `fileUrl`, а если и оттуда не выходит, вложение называется `attachment`.
|
|
747
|
+
- `attachments` (array) — опционально, готовые вложения вида `{ filename, content }`, где `content` — `Buffer`. Скачанное по `fileUrl` **добавляется** к ним, а не заменяет: оба поля независимы, и заполнивший оба получает оба вложения. Порядок — сначала переданные, затем скачанное.
|
|
748
|
+
- **Возвращает:** промис с объектом `{ error, info }` при успехе или `{ error, message }` при ошибке. `error: true` приходит и тогда, когда SMTP принял копию отправителю, но **отверг получателя**: в `to` всегда уходит пара «получатель + отправитель», а `sendMail` резолвится, если принят хотя бы один адрес.
|
|
699
749
|
|
|
700
750
|
```js
|
|
701
751
|
const result = await emailClient.send({
|
|
@@ -709,7 +759,13 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
709
759
|
- **Особенности:**
|
|
710
760
|
- SMTP-сервер: `smtp.yandex.ru:465` (SMTPS).
|
|
711
761
|
- Копия письма автоматически отправляется на адрес отправителя.
|
|
712
|
-
-
|
|
762
|
+
- **Адрес получателя проверяется до вызова транспорта.** Пустая строка, строка с пробелом, адрес без собаки или с пустой частью слева либо справа дают `{ error: true }`, и письмо не отправляется вовсе — раньше такой `to` давал «успешную» отправку копии себе. Проверка намеренно грубая: точка в домене не требуется, полную валидацию адреса делает почтовый сервер. Поле `to` принимает **ровно один адрес**: формы `Имя <адрес>` и перечисление через запятую не поддерживаются и дают `{ error: true }`.
|
|
763
|
+
- **Отвергнутый получатель — это ошибка.** Ответ транспорта разбирается: адрес ищется в `accepted` и `rejected`, элементы принимаются и строкой, и объектом `{ name, address }`. Текст ошибки несёт адрес и ответ сервера. Сверка идёт по **нормализованному** адресу: снимается регистр, снимаются угловые скобки, домен приводится к punycode. Иначе живой адрес объявлялся бы недоставленным — почтовый сервер возвращает адреса из конверта, а не в том виде, как их передали: `ivan@пример.рф` приходит обратно как `ivan@xn--e1afmkfd.xn--p1ai`, а `<a@b.ru>` — как `a@b.ru`. Если транспорт не сообщил ни `accepted`, ни `rejected`, результат считается успешным и в лог уходит одна строка `warn`: объявить доставленное письмо ошибкой было бы хуже исходного дефекта.
|
|
764
|
+
- **Входной объект не мутируется.** Прежняя реализация записывала вложения обратно в переданный `dataMail`, затирая `attachments` вызывающего.
|
|
765
|
+
- **Второй аргумент конструктора — подмена транспорта.** `new Email(auth, transport)` принимает любой объект с методом `sendMail(options)` (тип `MailTransport`). Нужен тестам и потребителю со своим SMTP; без него класс поднимает транспорт Яндекса сам.
|
|
766
|
+
- **Класс живёт в подпути `@andrey4emk/npm-app-back-b24/email`, а не в корне.** Из barrel'а он убран в 4.0.0.
|
|
767
|
+
- Транспорт — `nodemailer`, **опциональная peer-зависимость** с диапазоном `^9.0.5 || ^10.0.0`. Ставит его потребитель. Нижняя граница — CVE (GHSA-p6gq-j5cr-w38f, уязвимы версии `<= 9.0.0`), верхняя — проверенная совместимость с десяткой.
|
|
768
|
+
- `@types/nodemailer` нужен **только на девятке**. У `nodemailer` 10 типы свои, и TypeScript предпочтёт их; отдельный пакет типов там лишний.
|
|
713
769
|
- Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
|
|
714
770
|
- Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
|
|
715
771
|
|
|
@@ -880,7 +936,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
880
936
|
- **Свой `options.signal` отключает наш дефолт.** Если вызывающий передал сигнал именно в `options`, а `timeoutMs` не задан, пакет своего таймаута не добавляет — чужой осознанный бюджет не обрезается. Явно переданный `timeoutMs` применяется всегда, тогда побеждает тот сигнал, который сработал раньше. Отмена по чужому сигналу **никогда не повторяется**; различаем её по объекту сигнала, а не по типу ошибки: причиной отмены может быть любой объект, включая `TypeError`.
|
|
881
937
|
- **Сигнал у объекта `Request` на дефолт не влияет.** Он участвует в отмене — сигналы склеиваются через `AbortSignal.any`, иначе `fetch(request, init)` затёр бы его, — но признаком «вызывающий сам управляет временем» не считается: у любого `Request` сигнал есть всегда, даже когда его никто не задавал. Учитывать его значило бы молча снять таймаут со всех вызовов такой формы. Нужен свой бюджет при вызове с `Request` — передавайте `timeoutMs` пятым аргументом.
|
|
882
938
|
- **Таймаут при чтении тела не повторяется и не логируется.** Бюджет действует до конца запроса, поэтому `res.text()` / `res.arrayBuffer()` после возврата из `fetchRetry` могут упасть с `TimeoutError` уже у вас. Функция об этом не знает: повторов там нет, записи в лог тоже. Читаете большое тело — задавайте `timeoutMs` с запасом на скачивание.
|
|
883
|
-
- **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов)
|
|
939
|
+
- **Переменная `FETCH_TIMEOUT_MS`** меняет дефолт без правки кода (читается один раз при загрузке модуля), `FETCH_TIMEOUT_MS=0` выключает таймаут. Это аварийный рычаг на случай, когда дефолт обрывает живой долгий запрос — отчёт Seatable, Google Sheets, МойСклад. Действует только на вызовы **без явного** `timeoutMs`: внутренние вызовы пакета (ChatApp, Wappi, SMS, скачивание файлов) берут свои значения из `FETCH_TIMEOUTS`, и каждое из них переопределяется отдельной переменной (см. ниже). Значение должно быть целым от 0 до 2 147 483 647 — непригодное отбрасывается с записью в лог, а не выключает защиту молча. Значение от 1 до 999 применяется **как есть**, но с записью `warn`: столько миллисекунд не хватит ни одному живому запросу, и это типовая путаница секунд с миллисекундами. Ноль под правило не попадает — это задокументированное выключение таймаута.
|
|
884
940
|
- **Ограничение при вызове с объектом `Request`:** тело такого запроса читается первой попыткой, и повтор упадёт с `body used already`. Существовало и до таймаутов; для повторяемых запросов передавайте строку URL и `options`.
|
|
885
941
|
|
|
886
942
|
- **`fetchWithTimeout(url, options?, timeoutMs?)`** — один запрос с бюджетом времени, **без повторов и без логирования**. Для неидемпотентных операций: отправка сообщения, создание сущности — там, где повтор мог бы продублировать действие, но защита от зависшего соединения нужна. Правила про чужой сигнал те же, что у `fetchRetry`. Внутри пакета через неё идут все отправки ChatApp, Wappi и SMSGold.
|
|
@@ -895,7 +951,20 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
895
951
|
);
|
|
896
952
|
```
|
|
897
953
|
|
|
898
|
-
- **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется
|
|
954
|
+
- **`FETCH_TIMEOUTS`** — именованные бюджеты, которыми пакет пользуется сам. Каждый переопределяется своей переменной окружения:
|
|
955
|
+
|
|
956
|
+
| Ключ | Переменная | Дефолт, мс | Где применяется |
|
|
957
|
+
| ---------- | -------------------------- | ---------- | ------------------------ |
|
|
958
|
+
| `quick` | `FETCH_TIMEOUT_QUICK_MS` | 10 000 | токены |
|
|
959
|
+
| `api` | `FETCH_TIMEOUT_API_MS` | 20 000 | короткое чтение |
|
|
960
|
+
| `send` | `FETCH_TIMEOUT_SEND_MS` | 30 000 | отправка |
|
|
961
|
+
| `transfer` | `FETCH_TIMEOUT_TRANSFER_MS`| 60 000 | файлы и вложения |
|
|
962
|
+
|
|
963
|
+
Значения читаются **один раз при загрузке модуля**, поэтому менять их нужно до старта процесса. Проверка та же, что у `FETCH_TIMEOUT_MS`: целое от 0 до 2 147 483 647, непригодное значение даёт строку `warn` и дефолт.
|
|
964
|
+
|
|
965
|
+
**`0` выключает таймаут для этого бюджета целиком.** У `send` и `transfer` это снимает заодно защиту «неизвестный статус доставки»: зависшая отправка снова держится до таймаутов undici, а `unknownDelivery` в `Smsgold` не выставится никогда. Пользоваться нулём только как аварийным рычагом.
|
|
966
|
+
|
|
967
|
+
**`DEFAULT_FETCH_TIMEOUT_MS`** — действующий дефолт `fetchRetry` (60 000 либо значение `FETCH_TIMEOUT_MS`).
|
|
899
968
|
|
|
900
969
|
- **`isTimeoutError(error)` / `isAbortError(error)`** — различают «не дождались» и «отменили». Ошибка таймаута не оборачивается в свою: наружу идёт исходный `DOMException` с `name: "TimeoutError"` (отмена без причины — `"AbortError"`), проверки читают именно `name`.
|
|
901
970
|
|
|
@@ -922,6 +991,10 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
|
|
|
922
991
|
| `APP_NAME` | Название приложения (используется в описании задач) |
|
|
923
992
|
| `CONFIG_DIR` | Путь к директории с конфигами (по умолчанию `../config`) |
|
|
924
993
|
| `FETCH_TIMEOUT_MS` | Бюджет одной попытки `fetchRetry`/`fetchWithTimeout`, мс (по умолчанию `60000`; `0` — без таймаута) |
|
|
994
|
+
| `FETCH_TIMEOUT_QUICK_MS` | Бюджет `FETCH_TIMEOUTS.quick` — токены, мс (по умолчанию `10000`) |
|
|
995
|
+
| `FETCH_TIMEOUT_API_MS` | Бюджет `FETCH_TIMEOUTS.api` — короткое чтение, мс (по умолчанию `20000`) |
|
|
996
|
+
| `FETCH_TIMEOUT_SEND_MS` | Бюджет `FETCH_TIMEOUTS.send` — отправка, мс (по умолчанию `30000`) |
|
|
997
|
+
| `FETCH_TIMEOUT_TRANSFER_MS` | Бюджет `FETCH_TIMEOUTS.transfer` — файлы и вложения, мс (по умолчанию `60000`) |
|
|
925
998
|
| `CHATAPP_EMAIL` | Email аккаунта ChatApp |
|
|
926
999
|
| `CHATAPP_PASS` | Пароль аккаунта ChatApp |
|
|
927
1000
|
| `CHATAPP_APP_ID` | ID приложения в ChatApp |
|
|
@@ -937,14 +1010,93 @@ APP_NAME=MyApp
|
|
|
937
1010
|
CONFIG_DIR=../config
|
|
938
1011
|
FETCH_TIMEOUT_MS=60000
|
|
939
1012
|
|
|
1013
|
+
# Именованные бюджеты пакета — задавать только при необходимости, иначе действуют дефолты
|
|
1014
|
+
# FETCH_TIMEOUT_QUICK_MS=10000
|
|
1015
|
+
# FETCH_TIMEOUT_API_MS=20000
|
|
1016
|
+
# FETCH_TIMEOUT_SEND_MS=30000
|
|
1017
|
+
# FETCH_TIMEOUT_TRANSFER_MS=60000
|
|
1018
|
+
|
|
940
1019
|
CHATAPP_EMAIL=your-email@example.com
|
|
941
1020
|
CHATAPP_PASS=your-password
|
|
942
1021
|
CHATAPP_APP_ID=your-app-id
|
|
943
1022
|
```
|
|
944
1023
|
|
|
1024
|
+
## Миграция на 4.0.0
|
|
1025
|
+
|
|
1026
|
+
Выпуск ломающий. Правок у потребителя четыре, все механические.
|
|
1027
|
+
|
|
1028
|
+
### 1. `Email` переехал в подпуть
|
|
1029
|
+
|
|
1030
|
+
Из общего импорта его убрать, добавить отдельной строкой:
|
|
1031
|
+
|
|
1032
|
+
```diff
|
|
1033
|
+
-import { $b24, logs, Email } from "@andrey4emk/npm-app-back-b24";
|
|
1034
|
+
+import { $b24, logs } from "@andrey4emk/npm-app-back-b24";
|
|
1035
|
+
+import { Email } from "@andrey4emk/npm-app-back-b24/email";
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
Затрагивает два проекта: `b24_conector/server/api/sendMessage.ts` и `b24_desktop/server/api/sendMessage.js` (первая строка в обоих). Больше `Email` из пакета никто не импортирует.
|
|
1039
|
+
|
|
1040
|
+
### 2. `nodemailer` — теперь зависимость потребителя
|
|
1041
|
+
|
|
1042
|
+
Кто пользуется `Email`, ставит его себе: `pnpm add nodemailer@9.0.5` (оба проекта на pnpm). Диапазон peer — `^9.0.5 || ^10.0.0`. Тем же двум проектам `@types/nodemailer` в `devDependencies`, если остаются на девятке; на десятке типы приходят с самим пакетом.
|
|
1043
|
+
|
|
1044
|
+
Шаг обязателен обоим, но по разным причинам:
|
|
1045
|
+
|
|
1046
|
+
- **`b24_conector`** — `nodemailer` в его `package.json` отсутствует вовсе, библиотека приезжала транзитивно из наших `dependencies`. После 4.0.0 не приедет, и первый же импорт `server/api/sendMessage.ts` упадёт с `ERR_MODULE_NOT_FOUND`. Вместе с почтой встанут `ChatApp`, `Wappi` и SemySMS — они живут в том же модуле. Пропустить шаг нельзя;
|
|
1047
|
+
- **`b24_desktop`** — зависимость уже прописана в его `package.json`, поставленная версия `9.0.5` подходит под диапазон. Здесь достаточно сверки.
|
|
1048
|
+
|
|
1049
|
+
Под pnpm несовпадение опционального peer не блокирует установку: менеджер пишет `WARN` и завершает работу с нулевым кодом. Кому нужен жёсткий блок — `strict-peer-dependencies=true` в своём `.npmrc`.
|
|
1050
|
+
|
|
1051
|
+
Остальным шести проектам делать нечего — почтовая библиотека к ним больше не приезжает.
|
|
1052
|
+
|
|
1053
|
+
### 3. `@types/*` больше не приезжают из пакета
|
|
1054
|
+
|
|
1055
|
+
Они переехали в `devDependencies` и потребителю не транслируются. Кто гоняет `tsc`, держит свои:
|
|
1056
|
+
|
|
1057
|
+
- `@types/node` — всем;
|
|
1058
|
+
- `@types/luxon` — всем, кто импортирует корень (`errTaskB24.ts` тянет `luxon` в рантайме);
|
|
1059
|
+
- `@types/express` — **больше не нужен из-за нас никому**: пакет типы `express` не импортирует вовсе.
|
|
1060
|
+
|
|
1061
|
+
Без `@types/luxon` тайпчек упадёт с `TS7016` на `bitrix24/errTaskB24.ts`. Семь из восьми живых потребителей его уже держат.
|
|
1062
|
+
|
|
1063
|
+
### 4. Изменённые публичные типы
|
|
1064
|
+
|
|
1065
|
+
`any` в поверхности пакета заменён на `unknown` — читающий поле напрямую добавляет приведение.
|
|
1066
|
+
|
|
1067
|
+
| Что | Было | Стало |
|
|
1068
|
+
| ---------------------------------------------- | ------- | --------------------------- |
|
|
1069
|
+
| `data` у `event.get()` и `event.clear()` | `any` | `unknown` |
|
|
1070
|
+
| `ConnectorMessage.file` | `any[] \| null` | `TelegramFile[] \| null` |
|
|
1071
|
+
| `ConnectorMessage.attachments` | `any[] \| null` | `unknown[] \| null` |
|
|
1072
|
+
| `ConnectorMessage.im` | `any \| null` | `Record<string, unknown> \| null` |
|
|
1073
|
+
| `data` у методов `Wappi` | `any` | `unknown` |
|
|
1074
|
+
| `data` у `errorB24()` | `any` | `unknown` |
|
|
1075
|
+
|
|
1076
|
+
```diff
|
|
1077
|
+
-const { data } = await event.get("ONCRMDEALUPDATE");
|
|
1078
|
+
-for (const id of data.entitysId) { /* ... */ }
|
|
1079
|
+
+const { data } = await event.get("ONCRMDEALUPDATE");
|
|
1080
|
+
+const events = data as EntityEvents;
|
|
1081
|
+
+for (const id of events.entitysId) { /* ... */ }
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
Тип `TelegramFile` экспортируется из пакета — своё объявление в `b24_conector` можно убрать, но замена не drop-in: у них поле объявлено `name?: string`, в пакете оно `name: string`. Разбор пакета всегда отдаёт строку (на битом элементе — пустую), поэтому чтение вида `fileInfo.name || fallback` продолжит работать. Сверки потребуют их собственные места, где `TelegramFile` создаётся, — в том числе очередь ретраев `confB24Outbox.ts`: объект без `name` в наш тип не ляжет и упадёт тайпчеком.
|
|
1085
|
+
|
|
1086
|
+
`saveAuthB24Handler` больше не типизирован типами `express`, но по-прежнему принимает `Request`/`Response` без правок: `app.post("/saveAuthB24", saveAuthB24Handler)` работает как раньше.
|
|
1087
|
+
|
|
1088
|
+
Дефолты дженериков `callMethod`, `callBatch`, `fetchListMethod` и `getResultData` остались `any` намеренно — код вида `chunk.forEach((x) => x.ID)` продолжает компилироваться.
|
|
1089
|
+
|
|
1090
|
+
### Что изменилось само, правок не требует
|
|
1091
|
+
|
|
1092
|
+
- Вместе с `Email` из барреля уехали и его типы — `MailTransport` и `SendMailInfo`. Теперь они в подпути `@andrey4emk/npm-app-back-b24/email`. Из потребителей их никто не импортирует.
|
|
1093
|
+
- `saveAuthB24Handler` на запрос **без тела** отвечает `400 Не заполнены обязательные поля.` вместо прежних `500`. Раньше разбор пустого тела падал и уходил в общий `catch`.
|
|
1094
|
+
- Он же отвечает `400` на **нестроковые поля** тела: объект в `access_token`, число в `member_id`, нечисловой `expires_in`. Раньше такой запрос давал `500` — а в 4.0.0 до этой правки успевал затереть рабочий `config/authB24.json` строкой `"[object Object]"` и ответить `201`. Файл токенов в этом случае не трогается.
|
|
1095
|
+
- `expires_in`, пришедший строкой (`"3600"`), считается правильно. Раньше склейка строк в расчёте `expires` давала абсурдную дату.
|
|
1096
|
+
|
|
945
1097
|
## Скрипты
|
|
946
1098
|
|
|
947
|
-
- `npm run test` —
|
|
1099
|
+
- `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=20.3.0"` — это требование к потребителю пакета, а не к разработке). Сети тесты не касаются, в тарбол папка не попадает.
|
|
948
1100
|
- `npm run pack:dry` — предпросмотр содержимого пакета перед публикацией.
|
|
949
1101
|
|
|
950
1102
|
## Лицензия
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import dotEnv from "dotenv";
|
|
5
|
+
dotEnv.config();
|
|
6
|
+
|
|
7
|
+
// ==================== Константы ====================
|
|
8
|
+
|
|
9
|
+
export const CONFIG_DIR = process.env.CONFIG_DIR || "../config";
|
|
10
|
+
export const APP_ENV = process.env.APP_ENV || "PROD";
|
|
11
|
+
export const CLIENT_ID = process.env.APP_B24_CLIENT_ID;
|
|
12
|
+
export const CLIENT_SECRET = process.env.APP_B24_CLIENT_SECRET;
|
|
13
|
+
|
|
14
|
+
export const confAuthB24 = new Conf({
|
|
15
|
+
cwd: path.resolve(CONFIG_DIR),
|
|
16
|
+
configName: "authB24",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ==================== Утилиты ====================
|
|
20
|
+
|
|
21
|
+
/** Убирает протокол из домена (https://example.bitrix24.ru → example.bitrix24.ru) */
|
|
22
|
+
export function cleanDomain(domain: string): string {
|
|
23
|
+
return domain.replace(/^https?:\/\//, "");
|
|
24
|
+
}
|