@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 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`). Метод `fetchListMethod` из ретраев исключён — это async-генератор, оборачивать его в async-функцию нельзя. `callBatchByChunk` пакет реализует, но не ретраит.
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 отменяется, а в лог идёт `повтор отменён`. Причина: транспортная ошибка не говорит, дошёл ли запрос до портала. `NETWORK_ERROR` приходит и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже принял и выполнил запрос — а повтор во втором случае создаёт вторую задачу, второй файл, второй экземпляр бизнес-процесса. Идемпотентные записи (`*.update`, `*.delete`, `*.set`) повторяются как прежде: повторное применение даёт то же состояние.
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-токенов приложений. Это защита от петли: всё, что приложения сами пишут в B24, приходит событием с `user_id: '138'`, и без фильтра код обрабатывал бы собственные записи повторно. Оборотная сторона: ручные правки под этой же учёткой (администратор портала) тоже не возвращаются. **Для полной выгрузки всех изменений класс не подходит** — там читать `event.offline.get` напрямую.
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
- - **Возвращает:** промис с объектом `{ error, info }` при успехе или `{ error, message }` при ошибке.
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
- - Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
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, скачивание файлов) передают свои значения из `FETCH_TIMEOUTS` и переменную игнорируют. Значение должно быть целым от 0 до 2 147 483 647 — непригодное отбрасывается с записью в лог, а не выключает защиту молча.
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`** — именованные бюджеты, которыми пакет пользуется сам: `quick` 10 000 (токены), `api` 20 000 (короткое чтение), `send` 30 000 (отправка), `transfer` 60 000 (файлы). **`DEFAULT_FETCH_TIMEOUT_MS`** — действующий дефолт `fetchRetry` (60 000 либо значение `FETCH_TIMEOUT_MS`).
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
+ }