@andrey4emk/npm-app-back-b24 3.9.0 → 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 });
@@ -704,7 +724,7 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
704
724
  - **`Email`** — класс для отправки email через SMTP Яндекса. Экземпляр нужно создать самостоятельно.
705
725
 
706
726
  ```js
707
- import { Email } from "@andrey4emk/npm-app-back-b24";
727
+ import { Email } from "@andrey4emk/npm-app-back-b24/email";
708
728
 
709
729
  const emailClient = new Email({
710
730
  user: "your-email@yandex.ru",
@@ -743,7 +763,9 @@ const sent = await fetchWithTimeout("https://api.example.com/send", { method: "P
743
763
  - **Отвергнутый получатель — это ошибка.** Ответ транспорта разбирается: адрес ищется в `accepted` и `rejected`, элементы принимаются и строкой, и объектом `{ name, address }`. Текст ошибки несёт адрес и ответ сервера. Сверка идёт по **нормализованному** адресу: снимается регистр, снимаются угловые скобки, домен приводится к punycode. Иначе живой адрес объявлялся бы недоставленным — почтовый сервер возвращает адреса из конверта, а не в том виде, как их передали: `ivan@пример.рф` приходит обратно как `ivan@xn--e1afmkfd.xn--p1ai`, а `<a@b.ru>` — как `a@b.ru`. Если транспорт не сообщил ни `accepted`, ни `rejected`, результат считается успешным и в лог уходит одна строка `warn`: объявить доставленное письмо ошибкой было бы хуже исходного дефекта.
744
764
  - **Входной объект не мутируется.** Прежняя реализация записывала вложения обратно в переданный `dataMail`, затирая `attachments` вызывающего.
745
765
  - **Второй аргумент конструктора — подмена транспорта.** `new Email(auth, transport)` принимает любой объект с методом `sendMail(options)` (тип `MailTransport`). Нужен тестам и потребителю со своим SMTP; без него класс поднимает транспорт Яндекса сам.
746
- - Транспорт — `nodemailer` `^9` (диапазон, а не точная версия: иначе патч безопасности нельзя было бы применить на стороне потребителя).
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 предпочтёт их; отдельный пакет типов там лишний.
747
769
  - Вложения передаются готовым `Buffer` — файл по `fileUrl` пакет скачивает сам. Поля `path` и `href` намеренно не поддерживаются: nodemailer не должен ходить по URL. Скачивание идёт через `fetchWithTimeout` с бюджетом 60 секунд.
748
770
  - Таймауты SMTP-транспорта переопределены: соединение 15 с, приветствие 10 с, сокет 120 с. Дефолты nodemailer (2 мин / 30 с / 10 мин) слишком щедры — письмо на Яндекс уходит за секунды, а 10 минут молчания сокета блокируют вызывающую очередь.
749
771
 
@@ -999,6 +1021,79 @@ CHATAPP_PASS=your-password
999
1021
  CHATAPP_APP_ID=your-app-id
1000
1022
  ```
1001
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
+
1002
1097
  ## Скрипты
1003
1098
 
1004
1099
  - `npm run test` — прогон юнит-тестов из `tests/` через встроенный `node --test`. Дополнительных зависимостей не нужно: `.ts` запускаются на нативном стирании типов Node, поэтому прогону нужен Node 22.6+ (`engines: ">=20.3.0"` — это требование к потребителю пакета, а не к разработке). Сети тесты не касаются, в тарбол папка не попадает.
@@ -31,6 +31,11 @@ export const FETCH_LIST_MAX_PAGES = 20000;
31
31
  * Функция превращает такой ответ в понятную ошибку вместо падения на undefined.
32
32
  * Также отсекает случай, когда запрос успешен, но result пустой (null/undefined).
33
33
  *
34
+ * Дефолт дженерика — `any`, и это осознанно: потребители пишут
35
+ * `getResultData(response, "tasks.task.add").task.id` без параметра типа.
36
+ * Замена дефолта на `unknown` сломала бы каждое такое обращение, ничего не дав
37
+ * взамен: узкий тип задаётся на месте вызова — `getResultData<Deal>(response, method)`.
38
+ *
34
39
  * @param response — результат $b24.callMethod()
35
40
  * @param methodName — имя метода B24, попадёт в текст ошибки
36
41
  */
@@ -82,8 +87,16 @@ export function readV2Envelope(response: AjaxResult, method: string): V2Envelope
82
87
  * идемпотентности (`getRetryBlockReason`) видит те же аргументы, что и раньше,
83
88
  * — retry оборачивает фасад снаружи, а преобразование в объект опций
84
89
  * происходит уже внутри.
90
+ *
91
+ * `any` в сигнатурах ниже (`callMethod<T = any>`, `params?: any`, `AsyncGenerator<any[]>`,
92
+ * `Array<any>`, а также `call.make<any>` внутри `fetchListMethod` — тип страницы
93
+ * задаёт вызывающий) оставлен НАМЕРЕННО и снятию по T-53 не подлежит. Это дефолты дженериков
94
+ * публичной поверхности, дословно повторяющие `AbstractB24`: на них стоит код потребителей
95
+ * (`for await (const chunk of $b24.fetchListMethod(...)) chunk.forEach((x) => x.ID)`).
96
+ * `unknown` в дефолте уронил бы каждое такое место, не дав взамен ничего — узкий тип
97
+ * задаётся параметром типа на месте вызова (`callMethod<Deal>(...)`).
85
98
  */
86
- export function createFacade(target: B24OAuth): Record<string, (...args: any[]) => any> {
99
+ export function createFacade(target: B24OAuth): FacadeMethods {
87
100
  // Геттер actions при каждом обращении проверяет инициализацию экземпляра —
88
101
  // читаем его в момент вызова, а не один раз при создании фасада
89
102
  const v2 = () => target.actions.v2;
@@ -1,21 +1,61 @@
1
1
  import type { B24OAuth } from "@bitrix24/b24jssdk";
2
2
  import { createFacade } from "./facade.ts";
3
3
  import { withRetry } from "./retry.ts";
4
- import type { B24Client } from "./types.ts";
4
+ import type { AsyncFn } from "./retry.ts";
5
+ import type { B24Client, FacadeMethods } from "./types.ts";
5
6
 
6
- // ==================== Константы ====================
7
+ // ==================== Константы и типы ====================
8
+
9
+ /** Имя метода, который Proxy резолвит в реализацию пакета */
10
+ type FacadeMethodName = keyof FacadeMethods;
7
11
 
8
12
  /** Методы фасада, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
9
- export const RETRYABLE_METHODS = new Set(["callMethod", "callListMethod", "callBatch"]);
13
+ const RETRYABLE_METHOD_NAMES = ["callMethod", "callListMethod", "callBatch"] as const;
10
14
 
11
15
  /**
12
16
  * Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
13
17
  *
14
- * Шире, чем RETRYABLE_METHODS: `fetchListMethod` и `callBatchByChunk` мы реализуем,
18
+ * Шире, чем RETRYABLE_METHOD_NAMES: `fetchListMethod` и `callBatchByChunk` мы реализуем,
15
19
  * но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
16
20
  * SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
21
+ *
22
+ * `satisfies` ловит опечатку и переименование: имя, которого нет среди методов фасада,
23
+ * уронит тайпчек здесь, а не молча уедет мимо Proxy в SDK.
24
+ */
25
+ const FACADE_METHOD_NAMES = ["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"] as const satisfies readonly FacadeMethodName[];
26
+
27
+ /**
28
+ * Обратная страховка к `satisfies` выше: ловит НЕдостачу.
29
+ * Забыли имя — `Exclude` перестанет быть `never`, и тип станет `never`,
30
+ * а `true` в него не присвоится. Значение нигде не используется
17
31
  */
18
- export const FACADE_METHODS = new Set(["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"]);
32
+ const _facadeNamesAreExhaustive: Exclude<FacadeMethodName, (typeof FACADE_METHOD_NAMES)[number]> extends never ? true : never = true;
33
+ void _facadeNamesAreExhaustive;
34
+
35
+ /**
36
+ * Имя метода фасада, который дополнительно оборачивается retry.
37
+ *
38
+ * Уже, чем `FacadeMethodName`: `fetchListMethod` — async-генератор, он возвращает
39
+ * не `Promise`, под `AsyncFn` не подходит и в `withRetry` не попадает даже по типу.
40
+ * Тип выведен из самого списка, поэтому список и тип разойтись не могут
41
+ */
42
+ type RetryableMethodName = (typeof RETRYABLE_METHOD_NAMES)[number];
43
+
44
+ /** Тип `ReadonlySet<string>`, а не `Set<RetryableMethodName>`: `has()` вызывается с произвольным ключом Proxy */
45
+ export const RETRYABLE_METHODS: ReadonlySet<string> = new Set<string>(RETRYABLE_METHOD_NAMES);
46
+
47
+ /** См. FACADE_METHOD_NAMES */
48
+ export const FACADE_METHODS: ReadonlySet<string> = new Set<string>(FACADE_METHOD_NAMES);
49
+
50
+ /** Метод резолвится в реализацию пакета, а не SDK */
51
+ function isFacadeMethod(prop: string): prop is FacadeMethodName {
52
+ return FACADE_METHODS.has(prop);
53
+ }
54
+
55
+ /** Метод фасада оборачивается retry-логикой */
56
+ function isRetryableMethod(prop: string): prop is RetryableMethodName {
57
+ return RETRYABLE_METHODS.has(prop);
58
+ }
19
59
 
20
60
  // ==================== Proxy-обёртка ====================
21
61
 
@@ -40,10 +80,12 @@ export function wrapB24WithRetry(b24: B24OAuth): B24Client {
40
80
  get(target, prop) {
41
81
  // Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
42
82
  // deprecated-методы, иначе мы отдавали бы их, а не свои
43
- if (typeof prop === "string" && FACADE_METHODS.has(prop)) {
83
+ if (typeof prop === "string" && isFacadeMethod(prop)) {
44
84
  if (!methodCache.has(prop)) {
45
- const impl = facade[prop]!;
46
- methodCache.set(prop, RETRYABLE_METHODS.has(prop) ? withRetry(impl, target, prop) : impl);
85
+ // Индексируем внутри ветки: после сужения prop до RetryableMethodName
86
+ // из типа facade[prop] уходит fetchListMethod, и withRetry принимает
87
+ // остаток без приведения
88
+ methodCache.set(prop, isRetryableMethod(prop) ? withRetry(facade[prop], target, prop) : facade[prop]);
47
89
  }
48
90
 
49
91
  return methodCache.get(prop);
@@ -59,7 +101,7 @@ export function wrapB24WithRetry(b24: B24OAuth): B24Client {
59
101
 
60
102
  if (!methodCache.has(prop)) {
61
103
  const wrapped = RETRYABLE_METHODS.has(prop)
62
- ? withRetry(value as (...args: any[]) => Promise<any>, target, prop)
104
+ ? withRetry(value as AsyncFn, target, prop)
63
105
  : value.bind(target);
64
106
  methodCache.set(prop, wrapped);
65
107
  }
@@ -2,6 +2,18 @@ import { AjaxError, RefreshTokenError, SdkError } from "@bitrix24/b24jssdk";
2
2
  import { logs } from "../../logs/logs.ts";
3
3
  import { isNetworkError, isPreConnectionError } from "../../utils/fetchRetry.ts";
4
4
 
5
+ // ==================== Типы ====================
6
+
7
+ /**
8
+ * Любая асинхронная функция.
9
+ *
10
+ * `never[]` в параметрах делает тип надмножеством всех сигнатур: `never`
11
+ * присваивается чему угодно, поэтому под ограничение подходит и `callMethod`,
12
+ * и `callBatch`. Возврат `Promise<unknown>` отсекает `fetchListMethod` —
13
+ * это async-генератор, и оборачивать его retry-обёрткой нельзя (`for await` ломается).
14
+ */
15
+ export type AsyncFn = (...args: never[]) => Promise<unknown>;
16
+
5
17
  // ==================== Константы ====================
6
18
 
7
19
  /** Количество попыток при сетевых ошибках */
@@ -302,7 +314,7 @@ function joinReasons(creating: string[], reserving: string[]): string | null {
302
314
  * не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
303
315
  * дешевле — потребитель получит ошибку вместо тихого дубликата.
304
316
  */
305
- export function getRetryBlockReason(sdkMethod: string, args: any[]): string | null {
317
+ export function getRetryBlockReason(sdkMethod: string, args: readonly unknown[]): string | null {
306
318
  const first = args[0];
307
319
 
308
320
  // callMethod(method, params) и callListMethod(method, params, ...)
@@ -430,10 +442,10 @@ export async function runWithRetry<T>(fn: () => Promise<T>, label: string, block
430
442
  }
431
443
 
432
444
  /** Оборачивает async-функцию retry-логикой при сетевых ошибках */
433
- export function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, context: any, methodName: string): T {
434
- return (async (...args: any[]) => {
445
+ export function withRetry<T extends AsyncFn>(fn: T, context: object, methodName: string): T {
446
+ return (async (...args: Parameters<T>) => {
435
447
  // Состав вызова между попытками не меняется — разбираем один раз
436
- const blockReason = getRetryBlockReason(methodName, args);
448
+ const blockReason = getRetryBlockReason(methodName, args as readonly unknown[]);
437
449
  return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
438
450
  }) as T;
439
451
  }
@@ -64,9 +64,10 @@ export function saveTokens(authData: AuthData): SaveResult {
64
64
  confAuthB24.set(APP_ENV, { ...authData, domain: cleanDomain(authData.domain) });
65
65
  logs.add("Токены Bitrix24 сохранены", "debug");
66
66
  return { error: false, message: "Токены сохранены." };
67
- } catch (error: any) {
68
- logs.add(`Ошибка сохранения токенов: ${error.message}`, "error");
69
- return { error: true, message: error.message };
67
+ } catch (error: unknown) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ logs.add(`Ошибка сохранения токенов: ${message}`, "error");
70
+ return { error: true, message };
70
71
  }
71
72
  }
72
73
 
@@ -234,9 +235,10 @@ export async function refreshAndSaveTokens(): Promise<SaveResult> {
234
235
  try {
235
236
  const authData = await refreshAuthWithMutex();
236
237
  return saveTokens(authData);
237
- } catch (error: any) {
238
- logs.add(`Ошибка обновления токенов: ${error.message}`, "error");
239
- return { error: true, message: error.message };
238
+ } catch (error: unknown) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ logs.add(`Ошибка обновления токенов: ${message}`, "error");
241
+ return { error: true, message };
240
242
  }
241
243
  }
242
244
 
@@ -8,6 +8,26 @@ export interface SaveResult {
8
8
  message: string;
9
9
  }
10
10
 
11
+ /**
12
+ * Минимальный контракт HTTP-запроса, который читает `saveAuthB24Handler`.
13
+ *
14
+ * Своя структура вместо `Request` из `@types/express`: три потребителя
15
+ * (`b24_rnp`, `b24_ai`, `marketing_vk_ads`) `express` не ставят вовсе и жили
16
+ * на нашей копии типов, приезжавшей транзитивно. С 4.0.0 `@types/*` в
17
+ * `devDependencies`, поэтому такой копии больше нет.
18
+ *
19
+ * Express подставляет сюда свой `Request` без правок у потребителя:
20
+ * параметры контравариантны, и `Request.body: any` подходит под `body: unknown`.
21
+ */
22
+ export interface AuthSaveRequest {
23
+ body: unknown;
24
+ }
25
+
26
+ /** Минимальный контракт HTTP-ответа, который пишет `saveAuthB24Handler` */
27
+ export interface AuthSaveResponse {
28
+ status(code: number): { json(body: unknown): unknown };
29
+ }
30
+
11
31
  /**
12
32
  * `B24OAuth` плюс пять методов, которые пакет реализует сам поверх `actions.v2.*`.
13
33
  *
@@ -24,6 +44,13 @@ export interface SaveResult {
24
44
  * SDK 2.2.0 останется зелёным, то есть пропажа обнаружится только у потребителя.
25
45
  * Страховка от «уборки дублей» — `tests/client-type.test.ts`: он читает текст этого
26
46
  * файла и падает, если хоть одно объявление отсюда исчезло.
47
+ *
48
+ * `any` в сигнатурах оставлен НАМЕРЕННО и снятию по T-53 не подлежит: это дефолты
49
+ * дженериков публичной поверхности, дословно повторяющие `AbstractB24`. На них стоит
50
+ * код потребителей — `for await (const chunk of $b24.fetchListMethod(...))
51
+ * chunk.forEach((x) => x.ID)` и `callBatch([["crm.deal.get", { id }]])`. `unknown`
52
+ * в дефолте уронил бы каждое такое место, ничего не дав взамен: узкий тип задаётся
53
+ * параметром типа на месте вызова (`callMethod<Deal>(...)`).
27
54
  */
28
55
  export interface B24Client extends B24OAuth {
29
56
  callMethod<T = any>(method: string, params?: object, start?: number): Promise<AjaxResult<T>>;
package/bitrix24/b24.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import type { B24OAuth } from "@bitrix24/b24jssdk";
2
- import type { Request, Response } from "express";
3
2
  import { logs } from "../logs/logs.ts";
4
3
 
5
4
  import { createB24Instance } from "./b24/instance.ts";
6
5
  import { wrapB24WithRetry } from "./b24/proxy.ts";
7
6
  import { $b24, setB24Instance } from "./b24/state.ts";
8
7
  import { refreshAndSaveTokens, resetRefreshMutex, saveTokens, startProactiveRefresh, stopProactiveRefresh } from "./b24/tokens.ts";
9
- import type { SaveResult } from "./b24/types.ts";
8
+ import type { AuthSaveRequest, AuthSaveResponse, SaveResult } from "./b24/types.ts";
10
9
 
11
10
  // ==================== Реэкспорт публичного API ====================
12
11
 
@@ -62,12 +61,43 @@ export async function reinitializeB24(): Promise<SaveResult> {
62
61
  return refreshResult;
63
62
  }
64
63
 
65
- /** HTTP-обработчик для сохранения токенов с фронта */
66
- export async function saveAuthB24Handler(req: Request, res: Response): Promise<void> {
67
- try {
68
- const { access_token, refresh_token, domain, expires_in, member_id } = req.body;
64
+ /** Непустая строка — единственная форма, пригодная для поля токена */
65
+ function isFilledString(value: unknown): value is string {
66
+ return typeof value === "string" && value.length > 0;
67
+ }
69
68
 
70
- if (!access_token || !refresh_token || !domain || !expires_in || !member_id) {
69
+ /**
70
+ * HTTP-обработчик для сохранения токенов с фронта.
71
+ *
72
+ * Типы запроса и ответа — собственные структурные (`AuthSaveRequest`/`AuthSaveResponse`),
73
+ * а не `Request`/`Response` из `@types/express`: пакет больше не тянет типы express
74
+ * потребителю. Express подставляет свои объекты сюда без правок на его стороне
75
+ */
76
+ export async function saveAuthB24Handler(req: AuthSaveRequest, res: AuthSaveResponse): Promise<void> {
77
+ try {
78
+ // req.body теперь unknown — разбираем явно, деструктуризация по нему не пройдёт
79
+ const body = (req.body ?? {}) as Record<string, unknown>;
80
+ const access_token = body["access_token"];
81
+ const refresh_token = body["refresh_token"];
82
+ const domain = body["domain"];
83
+ const expires_in = body["expires_in"];
84
+ const member_id = body["member_id"];
85
+
86
+ // expires_in участвует и в самом объекте токенов, и в расчёте expires —
87
+ // приводим один раз в отдельную переменную
88
+ const expiresIn = Number(expires_in);
89
+
90
+ // Проверяем именно тип, а не «истинность» после String(): приведение объекта
91
+ // дало бы непустую строку "[object Object]", она прошла бы валидацию
92
+ // в saveTokens и затёрла рабочие токены в authB24.json
93
+ if (
94
+ !isFilledString(access_token) ||
95
+ !isFilledString(refresh_token) ||
96
+ !isFilledString(domain) ||
97
+ !isFilledString(member_id) ||
98
+ !Number.isFinite(expiresIn) ||
99
+ expiresIn <= 0
100
+ ) {
71
101
  res.status(400).json({ status: "error", message: "Не заполнены обязательные поля." });
72
102
  return;
73
103
  }
@@ -76,9 +106,9 @@ export async function saveAuthB24Handler(req: Request, res: Response): Promise<v
76
106
  access_token,
77
107
  refresh_token,
78
108
  domain,
79
- expires_in,
109
+ expires_in: expiresIn,
80
110
  member_id,
81
- expires: Math.floor(Date.now() / 1000) + expires_in,
111
+ expires: Math.floor(Date.now() / 1000) + expiresIn,
82
112
  });
83
113
 
84
114
  if (result.error) {
@@ -100,9 +130,10 @@ export async function saveAuthB24Handler(req: Request, res: Response): Promise<v
100
130
  } else {
101
131
  res.status(201).json({ status: "ok", message: "Токены сохранены и применены." });
102
132
  }
103
- } catch (error: any) {
104
- logs.add(`Ошибка в saveAuthB24Handler: ${error.message}`, "error");
105
- res.status(500).json({ status: "error", message: error.message });
133
+ } catch (error: unknown) {
134
+ const message = error instanceof Error ? error.message : String(error);
135
+ logs.add(`Ошибка в saveAuthB24Handler: ${message}`, "error");
136
+ res.status(500).json({ status: "error", message });
106
137
  }
107
138
  }
108
139
 
@@ -27,7 +27,8 @@ export interface ErrorTaskData {
27
27
  interface ErrorB24Result {
28
28
  error: boolean;
29
29
  message: string;
30
- data?: any;
30
+ /** Ответ портала на tasks.task.add как есть — форму задаёт сам портал, поэтому unknown */
31
+ data?: unknown;
31
32
  }
32
33
 
33
34
  /** Задача из Bitrix24 */
@@ -65,8 +66,9 @@ export async function checkB24Scope(): Promise<void> {
65
66
  if (missingScopes.length > 0) {
66
67
  logs.add(`Отсутствуют скоупы: ${missingScopes.join(", ")}`, "error");
67
68
  }
68
- } catch (error: any) {
69
- logs.add(`Ошибка при получении скоупов: ${error.message}`, "error");
69
+ } catch (error: unknown) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ logs.add(`Ошибка при получении скоупов: ${message}`, "error");
70
72
  }
71
73
  }
72
74
 
@@ -133,10 +135,11 @@ export async function errorB24(dataTask: ErrorTaskData): Promise<ErrorB24Result>
133
135
  message: "Задача создана в Битрикс24.",
134
136
  data: createdTask,
135
137
  };
136
- } catch (error: any) {
138
+ } catch (error: unknown) {
139
+ const message = error instanceof Error ? error.message : String(error);
137
140
  return {
138
141
  error: true,
139
- message: `Не удалось создать задачу в Битрикс24: ${error.message}`,
142
+ message: `Не удалось создать задачу в Битрикс24: ${message}`,
140
143
  };
141
144
  }
142
145
  }
@@ -20,9 +20,21 @@ export interface ConnectorMessage {
20
20
  lineId: string;
21
21
  chatId: string;
22
22
  text: string | null;
23
- file: any[] | null;
24
- attachments: any[] | null;
25
- im: any | null;
23
+ file: TelegramFile[] | null;
24
+ attachments: unknown[] | null;
25
+ im: Record<string, unknown> | null;
26
+ }
27
+
28
+ /**
29
+ * Файл сообщения, приведённый к формату Telegram.
30
+ *
31
+ * Раньше метод отдавал `any[]`, и единственный потребитель (`b24_conector`)
32
+ * объявлял такую же структуру у себя. Тип экспортируется, чтобы объявление было одно
33
+ */
34
+ export interface TelegramFile {
35
+ type: "image" | "video" | "file" | "document";
36
+ downloadLink: string;
37
+ name: string;
26
38
  }
27
39
 
28
40
  /** События коннектора */
@@ -48,7 +60,12 @@ export interface EntityEvents {
48
60
  interface StandardResult {
49
61
  error: boolean;
50
62
  message: string;
51
- data: any;
63
+ /**
64
+ * `ConnectorEvents`, `EntityEvents` либо `null` — какой именно, зависит от имени
65
+ * события, переданного в `get()`. Разобрать это типом нельзя, поэтому `unknown`:
66
+ * потребитель приводит результат сам, как и делал до 4.0.0
67
+ */
68
+ data: unknown;
52
69
  }
53
70
 
54
71
  /** Офлайн-событие Bitrix24 */
@@ -56,7 +73,7 @@ interface OfflineEvent {
56
73
  ID: string;
57
74
  MESSAGE_ID: string;
58
75
  EVENT_NAME: string;
59
- EVENT_DATA: any;
76
+ EVENT_DATA: unknown;
60
77
  EVENT_ADDITIONAL: {
61
78
  user_id: string;
62
79
  };
@@ -105,7 +122,7 @@ const SYSTEM_USER_ID = "138";
105
122
  * внутри записи: защита только одной из них читалась бы как решение «здесь
106
123
  * нормализация не нужна», а словарь на месте списка ронял бы весь вызов.
107
124
  */
108
- function normalizeCollection(value: unknown): any[] {
125
+ function normalizeCollection(value: unknown): unknown[] {
109
126
  if (Array.isArray(value)) return value;
110
127
  if (value && typeof value === "object") return Object.values(value);
111
128
  return [];
@@ -234,15 +251,23 @@ export class Event {
234
251
  /**
235
252
  * Преобразует файлы из формата Bitrix24 в формат TelegramFile
236
253
  */
237
- private convertB24FilesToTelegramFormat(b24Files: any[] | null | undefined): any[] | null {
254
+ private convertB24FilesToTelegramFormat(b24Files: unknown[] | null | undefined): TelegramFile[] | null {
238
255
  if (!b24Files || !Array.isArray(b24Files) || b24Files.length === 0) {
239
256
  return null;
240
257
  }
241
258
 
242
- return b24Files.map((f: any) => {
243
- const fileName = f.name || "";
244
- const link = f.link || f.url || "";
245
- const fileType = this.detectFileType(fileName, f.type);
259
+ return b24Files.map((rawFile) => {
260
+ // Элемент приходит как unknown: сужаем через asRecord, как и запись очереди.
261
+ // Битый элемент (null, строка) даёт пустые имя и ссылку — по той же причине,
262
+ // по которой устойчив разбор записи: исключение здесь остановило бы очередь
263
+ const file = asRecord(rawFile) ?? {};
264
+
265
+ // Проверка типа, а не приведение: нестроковое `name` уронило бы
266
+ // detectFileType на toLowerCase(), get() вернул бы ошибку — и очередь встала
267
+ const fileName = typeof file["name"] === "string" ? file["name"] : "";
268
+ const link = typeof file["link"] === "string" ? file["link"] : typeof file["url"] === "string" ? file["url"] : "";
269
+ const rawType = file["type"];
270
+ const fileType = this.detectFileType(fileName, typeof rawType === "string" ? rawType : undefined);
246
271
 
247
272
  return {
248
273
  type: fileType,
@@ -348,24 +373,33 @@ export class Event {
348
373
  // здесь означало бы, что get() вернёт ошибку, потребитель не вызовет
349
374
  // clear(), и те же события придут следующим опросом — очередь встала бы
350
375
  // навсегда на одном кривом сообщении
351
- data.message = allEvents.flatMap((event) => {
352
- const eventData = event.EVENT_DATA ?? {};
353
-
354
- return normalizeCollection(eventData.MESSAGES)
355
- .filter((messageData) => messageData?.chat && messageData?.message)
356
- .map((messageData) => ({
357
- // Портал отдаёт идентификаторы то строкой, то числом —
358
- // приводим, чтобы тип `string` не врал
359
- messageId: String(event.MESSAGE_ID ?? ""),
360
- eventId: String(event.ID ?? ""),
361
- connectorId: eventData.CONNECTOR,
362
- lineId: eventData.LINE,
363
- chatId: messageData.chat.id,
364
- text: messageData.message.text || null,
365
- file: this.convertB24FilesToTelegramFormat(messageData.message.files),
366
- attachments: messageData.message.attachments || null,
367
- im: messageData.im || null,
368
- }));
376
+ data.message = allEvents.flatMap((rawEvent) => {
377
+ // Запись очереди приходит из normalizeCollection как unknown —
378
+ // сужаем через asRecord, тем же способом, что и classifyEntityEvent
379
+ const record = asRecord(rawEvent) ?? {};
380
+ const eventData = asRecord(record["EVENT_DATA"]) ?? {};
381
+
382
+ return normalizeCollection(eventData["MESSAGES"])
383
+ .map((rawMessage) => asRecord(rawMessage))
384
+ .filter((messageData): messageData is Record<string, unknown> => Boolean(messageData?.["chat"] && messageData?.["message"]))
385
+ .map((messageData) => {
386
+ const chat = asRecord(messageData["chat"]) ?? {};
387
+ const message = asRecord(messageData["message"]) ?? {};
388
+
389
+ return {
390
+ // Портал отдаёт идентификаторы то строкой, то числом —
391
+ // приводим, чтобы тип `string` не врал
392
+ messageId: String(record["MESSAGE_ID"] ?? ""),
393
+ eventId: String(record["ID"] ?? ""),
394
+ connectorId: readId(eventData["CONNECTOR"]) ?? "",
395
+ lineId: readId(eventData["LINE"]) ?? "",
396
+ chatId: readId(chat["id"]) ?? "",
397
+ text: (message["text"] as string | undefined) || null,
398
+ file: this.convertB24FilesToTelegramFormat(message["files"] as unknown[] | undefined),
399
+ attachments: (message["attachments"] as unknown[] | undefined) || null,
400
+ im: (messageData["im"] as Record<string, unknown> | undefined) || null,
401
+ };
402
+ });
369
403
  });
370
404
  }
371
405
 
@@ -382,7 +416,7 @@ export class Event {
382
416
  // Тип OfflineEvent описывает то, что портал обещает, а не то, что присылает:
383
417
  // EVENT_ADDITIONAL и EVENT_DATA приходили и без обязательных полей (T-50).
384
418
  // Параметр колбэка объявлен unknown намеренно — так классификация видит запись
385
- // такой, какая она есть. Глобальная зачистка `any` в этом файле — отдельная задача T-53
419
+ // такой, какая она есть
386
420
  const verdicts = allEvents.map((raw: unknown) => classifyEntityEvent(raw, eventName));
387
421
 
388
422
  // Битую запись обработать нечем, и о её пропаже иначе не узнает никто:
@@ -479,10 +513,11 @@ export class Event {
479
513
  }
480
514
 
481
515
  return { error: false, message: "События получены", data };
482
- } catch (error: any) {
516
+ } catch (error: unknown) {
517
+ const message = error instanceof Error ? error.message : String(error);
483
518
  return {
484
519
  error: true,
485
- message: `Не удалось получить события. ${error.message}`,
520
+ message: `Не удалось получить события. ${message}`,
486
521
  data: null,
487
522
  };
488
523
  }
@@ -502,7 +537,7 @@ export class Event {
502
537
  return { error: false, message: "Список записей пуст, очищать нечего", data: null };
503
538
  }
504
539
 
505
- const params: any = { process_id: processId };
540
+ const params: Record<string, unknown> = { process_id: processId };
506
541
  if (messageId) {
507
542
  params.message_id = messageId;
508
543
  }
@@ -518,10 +553,11 @@ export class Event {
518
553
  }
519
554
 
520
555
  return { error: false, message: "События очищены", data: null };
521
- } catch (error: any) {
556
+ } catch (error: unknown) {
557
+ const message = error instanceof Error ? error.message : String(error);
522
558
  return {
523
559
  error: true,
524
- message: `Не удалось очистить события. ${error.message}`,
560
+ message: `Не удалось очистить события. ${message}`,
525
561
  data: null,
526
562
  };
527
563
  }
package/index.ts CHANGED
@@ -6,9 +6,12 @@ export * from "./bitrix24/errTaskB24.ts";
6
6
  export * from "./bitrix24/eventB24.ts";
7
7
 
8
8
  // Мессенджеры и уведомления
9
+ // `Email` здесь намеренно отсутствует: с 4.0.0 он живёт в подпуте
10
+ // `@andrey4emk/npm-app-back-b24/email`, чтобы optional peer `nodemailer`
11
+ // не требовался проектам, которые почту не шлют. Возврат `export *` сюда
12
+ // вернёт зависимость в баррель — это ловит tests/public-api.test.ts
9
13
  export * from "./sendMessage/chatApp.ts";
10
14
  export * from "./sendMessage/smsgold.ts";
11
- export * from "./sendMessage/email.ts";
12
15
  export * from "./sendMessage/wappi.ts";
13
16
 
14
17
  // Логирование
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.9.0",
3
+ "version": "4.0.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./index.ts",
9
9
  "./logs": "./logs/logs.ts",
10
- "./fetchRetry": "./utils/fetchRetry.ts"
10
+ "./fetchRetry": "./utils/fetchRetry.ts",
11
+ "./email": "./sendMessage/email.ts"
11
12
  },
12
13
  "scripts": {
13
14
  "test": "DOTENV_CONFIG_QUIET=true node --test --test-timeout=30000 \"tests/**/*.test.ts\"",
@@ -49,13 +50,23 @@
49
50
  ],
50
51
  "dependencies": {
51
52
  "@bitrix24/b24jssdk": "2.2.0",
53
+ "conf": "15.1.0",
54
+ "dotenv": "17.4.2",
55
+ "luxon": "3.7.2"
56
+ },
57
+ "devDependencies": {
52
58
  "@types/express": "5.0.6",
53
59
  "@types/luxon": "3.7.4",
54
60
  "@types/node": "25.9.5",
55
61
  "@types/nodemailer": "8.0.1",
56
- "conf": "15.1.0",
57
- "dotenv": "17.4.2",
58
- "luxon": "3.7.2",
59
- "nodemailer": "^9.0.5"
62
+ "nodemailer": "9.0.5"
63
+ },
64
+ "peerDependencies": {
65
+ "nodemailer": "^9.0.5 || ^10.0.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "nodemailer": {
69
+ "optional": true
70
+ }
60
71
  }
61
72
  }
@@ -45,6 +45,63 @@ interface FileMessageData extends MessageData {
45
45
  fileName: string;
46
46
  }
47
47
 
48
+ /**
49
+ * Ответ ChatApp в той части, которую разбирает класс.
50
+ *
51
+ * Описан по фактически читаемым полям, а не по всему API: остальное уезжает
52
+ * потребителю в `data` как есть и типом не сужается
53
+ */
54
+ interface ChatAppResponse {
55
+ success?: boolean;
56
+ /** Полезная нагрузка ответа. Форму задаёт сам ChatApp, разбираем на месте */
57
+ data?: unknown;
58
+ /** Дописывается классом в phoneCheckChatApp. В ответе ChatApp этого поля нет */
59
+ check?: unknown;
60
+ }
61
+
62
+ /**
63
+ * Комплект токенов ChatApp — то, что сервис обещает в ответе на выдачу и обновление.
64
+ *
65
+ * Поля необязательные намеренно: обязательность в чужом JSON ничем не подкреплена,
66
+ * а усечённый ответ клал бы `undefined` под типом `string`. Комплект проверяется
67
+ * на месте разбора
68
+ */
69
+ interface ChatAppTokens {
70
+ accessToken?: string;
71
+ accessTokenEndTime?: string;
72
+ refreshToken?: string;
73
+ refreshTokenEndTime?: string;
74
+ }
75
+
76
+ /** Ответ на выдачу или обновление токена */
77
+ interface ChatAppTokenResponse {
78
+ success?: boolean;
79
+ data?: ChatAppTokens;
80
+ }
81
+
82
+ /**
83
+ * Проверяет, что в ответе пришёл весь комплект токенов.
84
+ *
85
+ * Частичный ответ до этой проверки клал `undefined` в `this.auth` под типом `string`:
86
+ * метод отчитывался успехом, а следующее обновление уходило с пустым refresh-токеном
87
+ * и не восстанавливалось до перезапуска процесса
88
+ */
89
+ function isFullTokenSet(tokens: ChatAppTokens | undefined): tokens is ChatAppAuthParam {
90
+ return !!(
91
+ tokens &&
92
+ typeof tokens.accessToken === "string" && tokens.accessToken.length > 0 &&
93
+ typeof tokens.accessTokenEndTime === "string" &&
94
+ typeof tokens.refreshToken === "string" && tokens.refreshToken.length > 0 &&
95
+ typeof tokens.refreshTokenEndTime === "string"
96
+ );
97
+ }
98
+
99
+ /** Ответ на запрос списка лицензий */
100
+ interface ChatAppLicensesResponse {
101
+ success?: boolean;
102
+ licenses?: unknown;
103
+ }
104
+
48
105
  /** Стандартный результат операции */
49
106
  interface ChatAppResult {
50
107
  error: boolean;
@@ -136,7 +193,7 @@ export class ChatApp {
136
193
 
137
194
  const url = `${baseUrl}/chats/${phone}/messages/text`;
138
195
 
139
- let data: any;
196
+ let data: ChatAppResponse;
140
197
  try {
141
198
  const res = await fetchWithTimeout(
142
199
  url,
@@ -152,7 +209,7 @@ export class ChatApp {
152
209
  },
153
210
  FETCH_TIMEOUTS.send
154
211
  );
155
- data = await res.json();
212
+ data = (await res.json()) as ChatAppResponse;
156
213
  } catch (error: unknown) {
157
214
  // Метод исторически без try/catch — перехватываем только таймаут, который сами
158
215
  // и вводим. Остальные ошибки летят наружу как раньше
@@ -210,7 +267,7 @@ export class ChatApp {
210
267
 
211
268
  const url = `${baseUrl}/chats/${phone}/messages/file`;
212
269
 
213
- let data: any;
270
+ let data: ChatAppResponse;
214
271
  try {
215
272
  // Файл по ссылке забирает сам ChatApp, поэтому ответа можно ждать дольше — бюджет transfer
216
273
  const res = await fetchWithTimeout(
@@ -227,7 +284,7 @@ export class ChatApp {
227
284
  },
228
285
  FETCH_TIMEOUTS.transfer
229
286
  );
230
- data = await res.json();
287
+ data = (await res.json()) as ChatAppResponse;
231
288
  } catch (error: unknown) {
232
289
  // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
233
290
  if (!isTimeoutError(error)) throw error;
@@ -280,7 +337,7 @@ export class ChatApp {
280
337
 
281
338
  const url = `${baseUrl}/phones/${phone}/check`;
282
339
 
283
- let data: any;
340
+ let data: ChatAppResponse;
284
341
  try {
285
342
  const res = await fetchWithTimeout(
286
343
  url,
@@ -294,7 +351,7 @@ export class ChatApp {
294
351
  },
295
352
  FETCH_TIMEOUTS.api
296
353
  );
297
- data = await res.json();
354
+ data = (await res.json()) as ChatAppResponse;
298
355
  } catch (error: unknown) {
299
356
  // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
300
357
  if (!isTimeoutError(error)) throw error;
@@ -315,13 +372,14 @@ export class ChatApp {
315
372
  newAuth = this.auth;
316
373
  this.updateToken = false;
317
374
  }
318
- // Ответ разбирается как any — noUncheckedIndexedAccess здесь ничего не ловит,
319
- // а обращение к data.data.exist без проверки давало TypeError мимо всякого try/catch
320
- if (typeof data.data !== "object" || data.data === null) {
375
+ // Полезная нагрузка ответа объявлена unknown: обращение к data.data.exist
376
+ // без проверки давало TypeError мимо всякого try/catch
377
+ const payload = data.data;
378
+ if (typeof payload !== "object" || payload === null) {
321
379
  return { error: true, message: `Ответ ChatApp без поля data при проверке телефона через ${messangerType}`, data, auth: newAuth };
322
380
  }
323
381
 
324
- data.check = data.data.exist;
382
+ data.check = (payload as Record<string, unknown>)["exist"];
325
383
  return { error: false, message: `Телефон успешно проверен в ChatApp через ${messangerType}`, data, auth: newAuth };
326
384
  }
327
385
 
@@ -340,9 +398,14 @@ export class ChatApp {
340
398
  FETCH_TIMEOUTS.quick
341
399
  );
342
400
 
343
- const data: any = await response.json();
401
+ const data = (await response.json()) as ChatAppResponse;
344
402
  if (!data.success) {
345
- await this.refreshTokenChatApp();
403
+ // Результат обновления не проглатываем: раньше «Токен обновлен» уходило
404
+ // и когда обновление провалилось, а мёртвый токен доезжал до отправки
405
+ const refreshResult = await this.refreshTokenChatApp();
406
+ if (refreshResult.error) {
407
+ return { error: true, message: `Не удалось обновить токен ChatApp: ${refreshResult.message ?? ""}`, data: null };
408
+ }
346
409
  return { error: false, message: "Токен обновлен" };
347
410
  }
348
411
  return { error: false, message: "Токен действителен" };
@@ -353,7 +416,6 @@ export class ChatApp {
353
416
  }
354
417
 
355
418
  async makeTokenChatApp(): Promise<{ error: boolean; message?: string; data?: unknown }> {
356
- console.warn("makeTokenChatApp called");
357
419
  try {
358
420
  const response = await fetchWithTimeout(
359
421
  "https://api.chatapp.online/v1/tokens",
@@ -373,15 +435,24 @@ export class ChatApp {
373
435
  FETCH_TIMEOUTS.quick
374
436
  );
375
437
 
376
- const data: any = await response.json();
438
+ const data = (await response.json()) as ChatAppTokenResponse;
377
439
  if (!data.success) {
378
440
  return { error: true, message: "Не удалось получить токен ChatApp", data };
379
441
  }
380
442
 
381
- this.auth.accessToken = data.data.accessToken;
382
- this.auth.accessTokenEndTime = data.data.accessTokenEndTime;
383
- this.auth.refreshToken = data.data.refreshToken;
384
- this.auth.refreshTokenEndTime = data.data.refreshTokenEndTime;
443
+ // Успех без комплекта токенов раньше давал TypeError на undefined и уходил
444
+ // в catch общим сообщением. Отказ явный, флаг ошибки тот же.
445
+ // Проверяем весь комплект, а не наличие контейнера: усечённый ответ клал бы
446
+ // undefined в this.auth, и обновление токена не восстановилось бы до перезапуска
447
+ const tokens = data.data;
448
+ if (!isFullTokenSet(tokens)) {
449
+ return { error: true, message: "ChatApp: в ответе нет данных токена", data };
450
+ }
451
+
452
+ this.auth.accessToken = tokens.accessToken;
453
+ this.auth.accessTokenEndTime = tokens.accessTokenEndTime;
454
+ this.auth.refreshToken = tokens.refreshToken;
455
+ this.auth.refreshTokenEndTime = tokens.refreshTokenEndTime;
385
456
  this.updateToken = true;
386
457
  return { error: false };
387
458
  } catch (error: unknown) {
@@ -406,7 +477,7 @@ export class ChatApp {
406
477
  FETCH_TIMEOUTS.quick
407
478
  );
408
479
 
409
- const data: any = await response.json();
480
+ const data = (await response.json()) as ChatAppTokenResponse;
410
481
  if (!data.success) {
411
482
  const resMakeToken = await this.makeTokenChatApp();
412
483
  if (resMakeToken.error) {
@@ -415,10 +486,16 @@ export class ChatApp {
415
486
  return { error: false };
416
487
  }
417
488
 
418
- this.auth.accessToken = data.data.accessToken;
419
- this.auth.accessTokenEndTime = data.data.accessTokenEndTime;
420
- this.auth.refreshToken = data.data.refreshToken;
421
- this.auth.refreshTokenEndTime = data.data.refreshTokenEndTime;
489
+ // Тот же явный отказ, что и в makeTokenChatApp: успех без комплекта токенов
490
+ const tokens = data.data;
491
+ if (!isFullTokenSet(tokens)) {
492
+ return { error: true, message: "ChatApp: в ответе нет данных токена", data };
493
+ }
494
+
495
+ this.auth.accessToken = tokens.accessToken;
496
+ this.auth.accessTokenEndTime = tokens.accessTokenEndTime;
497
+ this.auth.refreshToken = tokens.refreshToken;
498
+ this.auth.refreshTokenEndTime = tokens.refreshTokenEndTime;
422
499
  this.updateToken = true;
423
500
  return { error: false };
424
501
  } catch (error: unknown) {
@@ -441,7 +518,7 @@ export class ChatApp {
441
518
  },
442
519
  FETCH_TIMEOUTS.api
443
520
  );
444
- const data: any = await response.json();
521
+ const data = (await response.json()) as ChatAppLicensesResponse;
445
522
 
446
523
  if (!data.success) {
447
524
  return { error: true, message: "Не удалось получить лицензии ChatApp", data: null };
@@ -27,6 +27,15 @@ interface SmsMessageData {
27
27
  */
28
28
  const DEFAULT_UPLOAD_FOLDER_ID = 2792881;
29
29
 
30
+ /**
31
+ * Файл, загруженный на диск Bitrix24 — то, что классу нужно от ответа
32
+ * `disk.folder.uploadfile`. Идентификатор портал отдаёт то строкой, то числом,
33
+ * и класс лишь передаёт его дальше в `disk.file.getExternalLink`
34
+ */
35
+ interface UploadedFile {
36
+ ID: string | number;
37
+ }
38
+
30
39
  /** Результат отправки SMS */
31
40
  interface SmsResult {
32
41
  error: boolean;
@@ -175,7 +184,7 @@ export class Smsgold {
175
184
  }
176
185
 
177
186
  /** Загружает файл в папку Битрикс24 через метод disk.folder.uploadfile */
178
- private async uploadFileInFolderB24(fileBuffer: Buffer, fileName: string): Promise<{ error: boolean; message?: string; data?: any }> {
187
+ private async uploadFileInFolderB24(fileBuffer: Buffer, fileName: string): Promise<{ error: boolean; message?: string; data?: UploadedFile }> {
179
188
  try {
180
189
  const base64File = fileBuffer.toString("base64");
181
190
 
@@ -185,7 +194,7 @@ export class Smsgold {
185
194
  data: { NAME: fileName },
186
195
  fileContent: [fileName, base64File],
187
196
  });
188
- const data = getResultData(res, "disk.folder.uploadfile");
197
+ const data = getResultData<UploadedFile>(res, "disk.folder.uploadfile");
189
198
  return { error: false, data };
190
199
  } catch (error: unknown) {
191
200
  const errMsg = error instanceof Error ? error.message : String(error);
@@ -25,11 +25,31 @@ interface WappiFileMessageData extends WappiMessageData {
25
25
  fileName: string;
26
26
  }
27
27
 
28
+ /**
29
+ * Ответ Wappi в той части, которую разбирает класс.
30
+ *
31
+ * Описан по фактически читаемым полям, а не по всему API. Поля `check` и `haveContact`
32
+ * в ответе Wappi отсутствуют — их дописывает сам класс в объект ответа, поэтому
33
+ * в интерфейсе они обязаны быть, иначе присваивание не пройдёт тайпчек
34
+ */
35
+ interface WappiResponse {
36
+ status?: string;
37
+ detail?: string;
38
+ on_max?: boolean;
39
+ on_whatsapp?: boolean;
40
+ contact?: unknown;
41
+ /** Дописывается классом: номер пригоден для отправки */
42
+ check?: boolean;
43
+ /** Дописывается классом: контакт найден или создан */
44
+ haveContact?: boolean;
45
+ }
46
+
28
47
  /** Стандартный результат операции */
29
48
  interface WappiResult {
30
49
  error: boolean;
31
50
  message: string;
32
- data: any;
51
+ /** Ответ Wappi как есть — форму задаёт сервис, потребитель приводит сам */
52
+ data: unknown;
33
53
  /**
34
54
  * Ответа не дождались. Отличает «проверка сказала нет» от «проверки не было»:
35
55
  * без этого признака вызывающий код принимает таймаут за отрицательный ответ.
@@ -93,7 +113,7 @@ export class Wappi {
93
113
  url = `https://wappi.pro/maxapi/sync/message/send?${sendOpenLine}profile_id=${profile_id}`;
94
114
  }
95
115
 
96
- let data: any;
116
+ let data: WappiResponse;
97
117
  try {
98
118
  const res = await fetchWithTimeout(
99
119
  url!,
@@ -110,7 +130,7 @@ export class Wappi {
110
130
  },
111
131
  FETCH_TIMEOUTS.send
112
132
  );
113
- data = await res.json();
133
+ data = (await res.json()) as WappiResponse;
114
134
  } catch (error: unknown) {
115
135
  // Метод исторически без try/catch — перехватываем только таймаут, который сами
116
136
  // и вводим, чтобы не появился новый путь исключений там, где его сегодня нет.
@@ -202,7 +222,7 @@ export class Wappi {
202
222
  url = `https://wappi.pro/maxapi/async/message/file/url/send?${sendOpenLine}profile_id=${profile_id}`;
203
223
  }
204
224
 
205
- let data: any;
225
+ let data: WappiResponse;
206
226
  try {
207
227
  // В теле уходит base64 файла — бюджет transfer, а не send
208
228
  const res = await fetchWithTimeout(
@@ -223,7 +243,7 @@ export class Wappi {
223
243
  },
224
244
  FETCH_TIMEOUTS.transfer
225
245
  );
226
- data = await res.json();
246
+ data = (await res.json()) as WappiResponse;
227
247
  } catch (error: unknown) {
228
248
  // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
229
249
  if (!isTimeoutError(error)) throw error;
@@ -265,9 +285,14 @@ export class Wappi {
265
285
  const contactCheck = await this.getContactTelegramWappi(phone);
266
286
 
267
287
  // 2 Если существует, то номер в телеге есть, можем отправлять сообщение
268
- if (!contactCheck.error && contactCheck.data.haveContact) {
269
- contactCheck.data.check = true;
270
- return { error: false, message: `Номер ${phone} проверен для мессенджера ${messangerType}`, data: contactCheck.data };
288
+ // Приведение, а не проверка: `data` объявлен unknown только в публичном типе,
289
+ // внутри класса это всегда разобранный ответ Wappi. На ошибке там null,
290
+ // но до чтения поля не доходит — первым проверяется `!contactCheck.error`,
291
+ // порядок вычисления тот же, что и до 4.0.0
292
+ const contactData = contactCheck.data as WappiResponse;
293
+ if (!contactCheck.error && contactData.haveContact) {
294
+ contactData.check = true;
295
+ return { error: false, message: `Номер ${phone} проверен для мессенджера ${messangerType}`, data: contactData };
271
296
  }
272
297
 
273
298
  // Ответа не дождались — это не «контакта нет». Создавать контакт вслепую нельзя:
@@ -280,14 +305,15 @@ export class Wappi {
280
305
 
281
306
  // 3 Если не существует, то номера в телеге нет, пробуем создать
282
307
  const addContactResult = await this.addContactTelegramWappi(phone);
308
+ const addedData = addContactResult.data as WappiResponse;
283
309
 
284
310
  if (!addContactResult.error) {
285
311
  // Если получается создать, отправляем информацию
286
- addContactResult.data.check = true;
287
- return { error: false, message: `Номер ${phone} успешно добавлен и проверен для мессенджера ${messangerType}`, data: addContactResult.data };
312
+ addedData.check = true;
313
+ return { error: false, message: `Номер ${phone} успешно добавлен и проверен для мессенджера ${messangerType}`, data: addedData };
288
314
  }
289
315
  // Если не получается, то отправляем информацию об ошибке
290
- return { error: true, message: `Не удалось проверить и добавить номер ${phone} в Telegram: ${addContactResult.message}`, data: addContactResult.data };
316
+ return { error: true, message: `Не удалось проверить и добавить номер ${phone} в Telegram: ${addContactResult.message}`, data: addedData };
291
317
  }
292
318
  if (messangerType === "max") {
293
319
  token = this.maxAuth.token;
@@ -295,7 +321,7 @@ export class Wappi {
295
321
  url = `https://wappi.pro/maxapi/sync/contact/check?profile_id=${profile_id}&phone=${phone}`;
296
322
  }
297
323
 
298
- let data: any;
324
+ let data: WappiResponse;
299
325
  try {
300
326
  const res = await fetchWithTimeout(
301
327
  url!,
@@ -308,7 +334,7 @@ export class Wappi {
308
334
  },
309
335
  FETCH_TIMEOUTS.api
310
336
  );
311
- data = await res.json();
337
+ data = (await res.json()) as WappiResponse;
312
338
  } catch (error: unknown) {
313
339
  // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
314
340
  if (!isTimeoutError(error)) throw error;
@@ -353,7 +379,7 @@ export class Wappi {
353
379
  FETCH_TIMEOUTS.api
354
380
  );
355
381
 
356
- const data: any = await res.json();
382
+ const data = (await res.json()) as WappiResponse;
357
383
 
358
384
  if (data.status !== "done") {
359
385
  return { error: true, message: `Ошибка получения контакта ${phone} из Telegram`, data };
@@ -405,7 +431,7 @@ export class Wappi {
405
431
  FETCH_TIMEOUTS.send
406
432
  );
407
433
 
408
- const data: any = await res.json();
434
+ const data = (await res.json()) as WappiResponse;
409
435
  if (data.status !== "done") {
410
436
  return { error: true, message: `Ошибка создания контакта ${phone} в Telegram`, data };
411
437
  }