@andrey4emk/npm-app-back-b24 3.7.1 → 3.8.1

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.
@@ -3,7 +3,18 @@ import { $b24, getResultData, type B24MethodCaller } from "./b24.ts";
3
3
  // ==================== Типы ====================
4
4
 
5
5
  /** Сообщение из коннектора */
6
- interface ConnectorMessage {
6
+ export interface ConnectorMessage {
7
+ /**
8
+ * `MESSAGE_ID` записи очереди офлайн-событий — ключ для `clear()`.
9
+ * Идентифицирует запись, а не отдельное сообщение: у сообщений одной записи
10
+ * он общий, поэтому для дедупликации сообщений не годится (для этого `im.id`)
11
+ */
12
+ messageId: string;
13
+ /**
14
+ * `ID` записи очереди офлайн-событий. Поле информационное, для сверки
15
+ * с `event.offline.list`. В `clear()` не передавать — метод принимает `MESSAGE_ID`
16
+ */
17
+ eventId: string;
7
18
  connectorId: string;
8
19
  lineId: string;
9
20
  chatId: string;
@@ -14,7 +25,7 @@ interface ConnectorMessage {
14
25
  }
15
26
 
16
27
  /** События коннектора */
17
- interface ConnectorEvents {
28
+ export interface ConnectorEvents {
18
29
  processId: string | null;
19
30
  message: ConnectorMessage[];
20
31
  }
@@ -26,7 +37,7 @@ interface MessageEntityLink {
26
37
  }
27
38
 
28
39
  /** События сущностей (сделки, смарт-процессы) */
29
- interface EntityEvents {
40
+ export interface EntityEvents {
30
41
  processId: string | null;
31
42
  entitysId: string[];
32
43
  arrMessageIdAndEntityId: MessageEntityLink[];
@@ -41,6 +52,7 @@ interface StandardResult {
41
52
 
42
53
  /** Офлайн-событие Bitrix24 */
43
54
  interface OfflineEvent {
55
+ ID: string;
44
56
  MESSAGE_ID: string;
45
57
  EVENT_NAME: string;
46
58
  EVENT_DATA: any;
@@ -148,7 +160,19 @@ export class Event {
148
160
  }
149
161
 
150
162
  /**
151
- * Получает офлайн-события по имени
163
+ * Получает офлайн-события по имени.
164
+ *
165
+ * Гранулярность очистки — запись очереди, а не сообщение. Для коннектора одна
166
+ * запись (один `MESSAGE_ID`) может нести несколько сообщений, и `get()` раскладывает
167
+ * их через `flatMap` — у таких сообщений `messageId` совпадает. Очистка по этому
168
+ * ключу удалит запись целиком: если из двух сообщений одной записи ушло одно,
169
+ * `clear()` заберёт и неотправленное. Поэлементную очистку с точностью до
170
+ * сообщения на этом ключе построить нельзя.
171
+ *
172
+ * Обратная сторона: запись, из которой не разобрано ни одного сообщения
173
+ * (пустой или битый `MESSAGES`), в `data.message` не попадает вовсе — очистка
174
+ * по собранным `messageId` её не заберёт, и она останется зарезервированной
175
+ * под тем же `process_id`. Безопасный режим — `clear(processId)` целиком.
152
176
  */
153
177
  async get(eventName: string): Promise<StandardResult> {
154
178
  try {
@@ -184,6 +208,10 @@ export class Event {
184
208
  return normalizeMessages(eventData.MESSAGES)
185
209
  .filter((messageData) => messageData?.chat && messageData?.message)
186
210
  .map((messageData) => ({
211
+ // Портал отдаёт идентификаторы то строкой, то числом —
212
+ // приводим, чтобы тип `string` не врал
213
+ messageId: String(event.MESSAGE_ID ?? ""),
214
+ eventId: String(event.ID ?? ""),
187
215
  connectorId: eventData.CONNECTOR,
188
216
  lineId: eventData.LINE,
189
217
  chatId: messageData.chat.id,
@@ -210,6 +238,9 @@ export class Event {
210
238
 
211
239
  if (arrEventsToClear.length > 0) {
212
240
  const arrMessageIdToClear = arrEventsToClear.map((event) => event.MESSAGE_ID);
241
+ // Результат намеренно не проверяется: отказ здесь не фатален — те же
242
+ // записи придут следующим опросом, а бросок лишил бы потребителя всей
243
+ // выборки активных событий
213
244
  await this.b24.callMethod("event.offline.clear", {
214
245
  process_id: arrOfflineEvents.process_id,
215
246
  message_id: arrMessageIdToClear,
@@ -243,11 +274,29 @@ export class Event {
243
274
  */
244
275
  async clear(processId: string, messageId?: string | string[]): Promise<StandardResult> {
245
276
  try {
277
+ // Пустой массив в JavaScript истинен и уезжал в теле запроса как `message_id: []` —
278
+ // это не документированный режим «очистить весь пакет» (что портал делает с
279
+ // пустым списком, не проверялось). Считаем пустой список за «очищать нечего»
280
+ // и до API не доходим: молча сносить весь пакет по пустому списку опасно —
281
+ // зарезервированный пакет портал повторно не выдаёт
282
+ if (Array.isArray(messageId) && messageId.length === 0) {
283
+ return { error: false, message: "Список записей пуст, очищать нечего", data: null };
284
+ }
285
+
246
286
  const params: any = { process_id: processId };
247
287
  if (messageId) {
248
288
  params.message_id = messageId;
249
289
  }
250
- await this.b24.callMethod("event.offline.clear", params);
290
+ const response = await this.b24.callMethod("event.offline.clear", params);
291
+ // «Мягкую» ошибку (например, ERROR_ENTITY_NOT_FOUND на протухшем process_id)
292
+ // SDK не бросает, а возвращает AjaxResult с isSuccess: false. Без проверки
293
+ // метод рапортовал бы «События очищены» при неочищенной очереди.
294
+ // Именно isSuccess, а не getResultData(): та бросает ещё и на result == null,
295
+ // а что портал кладёт в result этого метода, не проверено
296
+ if (!response.isSuccess) {
297
+ const messages = response.getErrorMessages().join("; ") || "неизвестная ошибка";
298
+ throw new Error(`event.offline.clear: Bitrix24 вернул ошибку — ${messages}`);
299
+ }
251
300
 
252
301
  return { error: false, message: "События очищены", data: null };
253
302
  } catch (error: any) {
package/logs/logs.ts CHANGED
@@ -30,6 +30,15 @@ const B24_CLIENT_ID = "6rf74zyu842h6op1z186b08k7aavqq7z";
30
30
  /** Лимит длины сообщения для B24 чата (безопасный, с запасом на BB-обёртку) */
31
31
  const B24_TEXT_LIMIT = 4000;
32
32
 
33
+ /**
34
+ * Бюджет времени на отправку error-лога в чат B24, мс.
35
+ *
36
+ * Своя константа, а не `FETCH_TIMEOUTS` из `utils/fetchRetry.ts`: тот модуль импортирует
37
+ * `logs` первой строкой, и обратный импорт дал бы цикл. Дублирование одного числа
38
+ * дешевле цикла зависимостей.
39
+ */
40
+ const B24_CHAT_TIMEOUT_MS = 5_000;
41
+
33
42
  // ==================== Настройки ====================
34
43
 
35
44
  const configDir: string = process.env.CONFIG_DIR || "../config";
@@ -149,7 +158,10 @@ class LogsAPI {
149
158
 
150
159
  /**
151
160
  * Отправляет error-сообщение в чат B24 через webhook (fire-and-forget)
152
- * Использует нативный fetch, а не fetchRetry — во избежание циклической зависимости
161
+ * Использует нативный fetch, а не fetchRetry/fetchWithTimeout — во избежание
162
+ * циклической зависимости, поэтому и таймаут выставляется здесь вручную через
163
+ * `AbortSignal.timeout`: без него зависшее соединение никого бы не разбудило,
164
+ * вызов идёт без await
153
165
  * Ошибки не пробрасываются, только console.error
154
166
  */
155
167
  private sendToB24Chat(message: string, jsonData?: unknown): void {
@@ -195,6 +207,7 @@ class LogsAPI {
195
207
  DIALOG_ID: this.b24ChatId,
196
208
  MESSAGE: text,
197
209
  }),
210
+ signal: AbortSignal.timeout(B24_CHAT_TIMEOUT_MS),
198
211
  })
199
212
  .then((response) => {
200
213
  if (!response.ok) {
@@ -202,6 +215,11 @@ class LogsAPI {
202
215
  }
203
216
  })
204
217
  .catch((error: unknown) => {
218
+ // Проверка локальная, без импорта isTimeoutError — тот модуль импортирует logs
219
+ if ((error as { name?: unknown })?.name === "TimeoutError") {
220
+ console.error(`Logs B24 Chat: не ответил за ${B24_CHAT_TIMEOUT_MS / 1000}с, сообщение не доставлено`);
221
+ return;
222
+ }
205
223
  const errMsg = error instanceof Error ? error.message : String(error);
206
224
  console.error(`Logs B24 Chat: ошибка отправки — ${errMsg}`);
207
225
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.7.1",
3
+ "version": "3.8.1",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -18,6 +18,9 @@
18
18
  "url": "git+ssh://git@github.com/andrey4emk/npm_appBackB24.git"
19
19
  },
20
20
  "keywords": [],
21
+ "engines": {
22
+ "node": ">=20.3.0"
23
+ },
21
24
  "author": "andrey4emk",
22
25
  "license": "MIT",
23
26
  "bugs": {
@@ -37,7 +40,7 @@
37
40
  "utils/fetchRetry.ts"
38
41
  ],
39
42
  "dependencies": {
40
- "@bitrix24/b24jssdk": "2.0.0",
43
+ "@bitrix24/b24jssdk": "2.2.0",
41
44
  "@types/express": "5.0.6",
42
45
  "@types/luxon": "3.7.4",
43
46
  "@types/node": "25.9.5",
@@ -1,3 +1,6 @@
1
+ import { fetchWithTimeout, isTimeoutError, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
2
+ import { logs } from "../logs/logs.ts";
3
+
1
4
  // ==================== Типы ====================
2
5
 
3
6
  /** Параметры для создания токена ChatApp */
@@ -85,6 +88,30 @@ export class ChatApp {
85
88
  this.telegramType = ChatApp.resolveMessengerType(typeParam?.telegram);
86
89
  }
87
90
 
91
+ /**
92
+ * Общая часть URL до раздела запроса: лицензия и разрешённый тип мессенджера.
93
+ * `null` — тип из конфига не разрешился, обращаться некуда. Проверка типа идёт
94
+ * первой: при отсутствующей секции конфига `this.type.whatsApp` тоже undefined,
95
+ * и до чтения licenseId дело не доходит.
96
+ */
97
+ private resolveBaseUrl(messangerType: MessengerType): string | null {
98
+ const resolvedType = messangerType === "whatsApp" ? this.whatsAppType : this.telegramType;
99
+ if (!resolvedType) {
100
+ return null;
101
+ }
102
+ const licenseId = messangerType === "whatsApp" ? this.type.whatsApp.licenseId : this.type.telegram.licenseId;
103
+ return `https://api.chatapp.online/v1/licenses/${licenseId}/messengers/${resolvedType}`;
104
+ }
105
+
106
+ /**
107
+ * Отказ гейта — один текст на все три публичных метода.
108
+ * Не бросаем: экземпляр создаётся на уровне модуля, и бросок уронил бы импорт
109
+ * всего модуля отправки вместе с остальными каналами.
110
+ */
111
+ private static denyMessenger(messangerType: MessengerType): ChatAppResult {
112
+ return { error: true, message: `ChatApp: не разрешён тип мессенджера ${messangerType} (нет конфига или пуст messenger)`, data: null };
113
+ }
114
+
88
115
  async sendMessageChatApp(messangerType: MessengerType, messageData: MessageData): Promise<ChatAppResult> {
89
116
  // Проверяем что верно указан messangerType и в messageData есть phone и message
90
117
  if (!["whatsApp", "telegram"].includes(messangerType)) {
@@ -94,9 +121,9 @@ export class ChatApp {
94
121
  return { error: true, message: "Отсутствует phone или message", data: null };
95
122
  }
96
123
  // Тип мессенджера не разрешился из конфига — отправлять некуда
97
- const resolvedType = messangerType === "whatsApp" ? this.whatsAppType : this.telegramType;
98
- if (!resolvedType) {
99
- return { error: true, message: `ChatApp: не разрешён тип мессенджера ${messangerType} (нет конфига или пуст messenger)`, data: null };
124
+ const baseUrl = this.resolveBaseUrl(messangerType);
125
+ if (!baseUrl) {
126
+ return ChatApp.denyMessenger(messangerType);
100
127
  }
101
128
 
102
129
  const check = await this.checkTokenChatApp();
@@ -107,25 +134,42 @@ export class ChatApp {
107
134
  const phone = messageData.phone;
108
135
  const message = messageData.message;
109
136
 
110
- let url: string | undefined;
111
- if (messangerType === "whatsApp") {
112
- url = `https://api.chatapp.online/v1/licenses/${this.type.whatsApp.licenseId}/messengers/${resolvedType}/chats/${phone}/messages/text`;
113
- }
114
- if (messangerType === "telegram") {
115
- url = `https://api.chatapp.online/v1/licenses/${this.type.telegram.licenseId}/messengers/${resolvedType}/chats/${phone}/messages/text`;
116
- }
137
+ const url = `${baseUrl}/chats/${phone}/messages/text`;
117
138
 
118
- const res = await fetch(url!, {
119
- method: "POST",
120
- headers: {
121
- Lang: "en",
122
- "Content-Type": "application/json",
123
- Accept: "application/json",
124
- Authorization: this.auth.accessToken,
125
- },
126
- body: JSON.stringify({ text: message }),
127
- });
128
- const data: any = await res.json();
139
+ let data: any;
140
+ try {
141
+ const res = await fetchWithTimeout(
142
+ url,
143
+ {
144
+ method: "POST",
145
+ headers: {
146
+ Lang: "en",
147
+ "Content-Type": "application/json",
148
+ Accept: "application/json",
149
+ Authorization: this.auth.accessToken,
150
+ },
151
+ body: JSON.stringify({ text: message }),
152
+ },
153
+ FETCH_TIMEOUTS.send
154
+ );
155
+ data = await res.json();
156
+ } catch (error: unknown) {
157
+ // Метод исторически без try/catch — перехватываем только таймаут, который сами
158
+ // и вводим. Остальные ошибки летят наружу как раньше
159
+ if (!isTimeoutError(error)) throw error;
160
+
161
+ // Уровень error — единственный, который уходит в чат B24. До появления
162
+ // таймаута зависание долетало исключением до потребителя и там порождало
163
+ // задачу через errorB24(); перехватив ошибку здесь, мы обязаны сами оставить
164
+ // след, иначе правка сделала бы отказ отправки тише, чем он был
165
+ logs.add(`[${phone}] ChatApp: таймаут ${FETCH_TIMEOUTS.send}мс при отправке сообщения через ${messangerType} — статус неизвестен, повторять нельзя`, "error");
166
+
167
+ return {
168
+ error: true,
169
+ message: `ChatApp: таймаут ${FETCH_TIMEOUTS.send}мс при отправке сообщения через ${messangerType} — статус неизвестен`,
170
+ data: null,
171
+ };
172
+ }
129
173
 
130
174
  if (!data.success) {
131
175
  return { error: true, message: `Ошибка при отправке сообщения в ChatApp через ${messangerType}`, data };
@@ -149,9 +193,9 @@ export class ChatApp {
149
193
  return { error: true, message: "Отсутствует phone или message", data: null };
150
194
  }
151
195
  // Тип мессенджера не разрешился из конфига — отправлять некуда
152
- const resolvedType = messangerType === "whatsApp" ? this.whatsAppType : this.telegramType;
153
- if (!resolvedType) {
154
- return { error: true, message: `ChatApp: не разрешён тип мессенджера ${messangerType} (нет конфига или пуст messenger)`, data: null };
196
+ const baseUrl = this.resolveBaseUrl(messangerType);
197
+ if (!baseUrl) {
198
+ return ChatApp.denyMessenger(messangerType);
155
199
  }
156
200
 
157
201
  const check = await this.checkTokenChatApp();
@@ -164,25 +208,38 @@ export class ChatApp {
164
208
  const file = messageData.fileUrl;
165
209
  const fileName = messageData.fileName;
166
210
 
167
- let url: string | undefined;
168
- if (messangerType === "whatsApp") {
169
- url = `https://api.chatapp.online/v1/licenses/${this.type.whatsApp.licenseId}/messengers/${resolvedType}/chats/${phone}/messages/file`;
170
- }
171
- if (messangerType === "telegram") {
172
- url = `https://api.chatapp.online/v1/licenses/${this.type.telegram.licenseId}/messengers/${resolvedType}/chats/${phone}/messages/file`;
173
- }
211
+ const url = `${baseUrl}/chats/${phone}/messages/file`;
212
+
213
+ let data: any;
214
+ try {
215
+ // Файл по ссылке забирает сам ChatApp, поэтому ответа можно ждать дольше — бюджет transfer
216
+ const res = await fetchWithTimeout(
217
+ url,
218
+ {
219
+ method: "POST",
220
+ headers: {
221
+ Lang: "en",
222
+ "Content-Type": "application/json",
223
+ Accept: "application/json",
224
+ Authorization: this.auth.accessToken,
225
+ },
226
+ body: JSON.stringify({ file, fileName, caption }),
227
+ },
228
+ FETCH_TIMEOUTS.transfer
229
+ );
230
+ data = await res.json();
231
+ } catch (error: unknown) {
232
+ // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
233
+ if (!isTimeoutError(error)) throw error;
174
234
 
175
- const res = await fetch(url!, {
176
- method: "POST",
177
- headers: {
178
- Lang: "en",
179
- "Content-Type": "application/json",
180
- Accept: "application/json",
181
- Authorization: this.auth.accessToken,
182
- },
183
- body: JSON.stringify({ file, fileName, caption }),
184
- });
185
- const data: any = await res.json();
235
+ logs.add(`[${phone}] ChatApp: таймаут ${FETCH_TIMEOUTS.transfer}мс при отправке файла через ${messangerType} — статус неизвестен, повторять нельзя`, "error");
236
+
237
+ return {
238
+ error: true,
239
+ message: `ChatApp: таймаут ${FETCH_TIMEOUTS.transfer}мс при отправке файла через ${messangerType} — статус неизвестен`,
240
+ data: null,
241
+ };
242
+ }
186
243
 
187
244
  if (!data.success) {
188
245
  return { error: true, message: `Ошибка при отправке сообщения в ChatApp через ${messangerType}`, data };
@@ -197,14 +254,23 @@ export class ChatApp {
197
254
  return { error: false, message: `Сообщение успешно отправлено в ChatApp через ${messangerType}`, data, auth: newAuth };
198
255
  }
199
256
 
257
+ /**
258
+ * Проверка телефона. Результат — поле `check` в `data` (значение `data.data.exist` ответа API).
259
+ *
260
+ * Изменение поведения от 16.08.2026: ответ с `success: true`, но без объекта `data`
261
+ * раньше давал `TypeError` наружу (метод не обёрнут в try/catch). Теперь такой ответ
262
+ * возвращается как обычный для этого класса отказ `{ error: true, ... }` с сырым ответом
263
+ * в `data`. Молча подставлять `check: undefined` нельзя: «телефон не найден» и «ответ
264
+ * непонятной формы» — разные факты, и вызывающий отличил бы их только по исключению.
265
+ */
200
266
  async phoneCheckChatApp(messangerType: MessengerType, phone: string): Promise<ChatAppResult> {
201
267
  if (!["whatsApp", "telegram"].includes(messangerType)) {
202
268
  return { error: true, message: "Неверный тип мессенджера", data: null };
203
269
  }
204
270
  // Тип мессенджера не разрешился из конфига — проверять телефон негде
205
- const resolvedType = messangerType === "whatsApp" ? this.whatsAppType : this.telegramType;
206
- if (!resolvedType) {
207
- return { error: true, message: `ChatApp: не разрешён тип мессенджера ${messangerType} (нет конфига или пуст messenger)`, data: null };
271
+ const baseUrl = this.resolveBaseUrl(messangerType);
272
+ if (!baseUrl) {
273
+ return ChatApp.denyMessenger(messangerType);
208
274
  }
209
275
 
210
276
  const check = await this.checkTokenChatApp();
@@ -212,23 +278,32 @@ export class ChatApp {
212
278
  return { error: true, message: `Ошибка с токеном ChatApp в функции phoneCheckChatApp класса ChatApp\n${check.message}`, data: check };
213
279
  }
214
280
 
215
- let url: string | undefined;
216
- if (messangerType === "whatsApp") {
217
- url = `https://api.chatapp.online/v1/licenses/${this.type.whatsApp.licenseId}/messengers/${resolvedType}/phones/${phone}/check`;
218
- }
219
- if (messangerType === "telegram") {
220
- url = `https://api.chatapp.online/v1/licenses/${this.type.telegram.licenseId}/messengers/${resolvedType}/phones/${phone}/check`;
221
- }
281
+ const url = `${baseUrl}/phones/${phone}/check`;
222
282
 
223
- const res = await fetch(url!, {
224
- method: "GET",
225
- headers: {
226
- Lang: "en",
227
- Accept: "application/json",
228
- Authorization: this.auth.accessToken,
229
- },
230
- });
231
- const data: any = await res.json();
283
+ let data: any;
284
+ try {
285
+ const res = await fetchWithTimeout(
286
+ url,
287
+ {
288
+ method: "GET",
289
+ headers: {
290
+ Lang: "en",
291
+ Accept: "application/json",
292
+ Authorization: this.auth.accessToken,
293
+ },
294
+ },
295
+ FETCH_TIMEOUTS.api
296
+ );
297
+ data = await res.json();
298
+ } catch (error: unknown) {
299
+ // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
300
+ if (!isTimeoutError(error)) throw error;
301
+
302
+ // warn, а не error: проверка номера ничего не меняет, повторить её безопасно
303
+ logs.add(`[${phone}] ChatApp: таймаут ${FETCH_TIMEOUTS.api}мс при проверке телефона через ${messangerType}`, "warn");
304
+
305
+ return { error: true, message: `ChatApp: таймаут ${FETCH_TIMEOUTS.api}мс при проверке телефона через ${messangerType}`, data: null };
306
+ }
232
307
 
233
308
  if (!data.success) {
234
309
  return { error: true, message: `Ошибка при проверке телефона в ChatApp через ${messangerType}`, data };
@@ -240,20 +315,30 @@ export class ChatApp {
240
315
  newAuth = this.auth;
241
316
  this.updateToken = false;
242
317
  }
318
+ // Ответ разбирается как any — noUncheckedIndexedAccess здесь ничего не ловит,
319
+ // а обращение к data.data.exist без проверки давало TypeError мимо всякого try/catch
320
+ if (typeof data.data !== "object" || data.data === null) {
321
+ return { error: true, message: `Ответ ChatApp без поля data при проверке телефона через ${messangerType}`, data, auth: newAuth };
322
+ }
323
+
243
324
  data.check = data.data.exist;
244
325
  return { error: false, message: `Телефон успешно проверен в ChatApp через ${messangerType}`, data, auth: newAuth };
245
326
  }
246
327
 
247
328
  async checkTokenChatApp(): Promise<{ error: boolean; message: string; data?: null }> {
248
329
  try {
249
- const response = await fetch("https://api.chatapp.online/v1/tokens/check", {
250
- method: "GET",
251
- headers: {
252
- Lang: "en",
253
- Accept: "application/json",
254
- Authorization: this.auth.accessToken,
330
+ const response = await fetchWithTimeout(
331
+ "https://api.chatapp.online/v1/tokens/check",
332
+ {
333
+ method: "GET",
334
+ headers: {
335
+ Lang: "en",
336
+ Accept: "application/json",
337
+ Authorization: this.auth.accessToken,
338
+ },
255
339
  },
256
- });
340
+ FETCH_TIMEOUTS.quick
341
+ );
257
342
 
258
343
  const data: any = await response.json();
259
344
  if (!data.success) {
@@ -270,19 +355,23 @@ export class ChatApp {
270
355
  async makeTokenChatApp(): Promise<{ error: boolean; message?: string; data?: unknown }> {
271
356
  console.warn("makeTokenChatApp called");
272
357
  try {
273
- const response = await fetch("https://api.chatapp.online/v1/tokens", {
274
- method: "POST",
275
- headers: {
276
- Lang: "en",
277
- "Content-Type": "application/json",
278
- Accept: "application/json",
358
+ const response = await fetchWithTimeout(
359
+ "https://api.chatapp.online/v1/tokens",
360
+ {
361
+ method: "POST",
362
+ headers: {
363
+ Lang: "en",
364
+ "Content-Type": "application/json",
365
+ Accept: "application/json",
366
+ },
367
+ body: JSON.stringify({
368
+ email: this.make.email,
369
+ password: this.make.pass,
370
+ appId: this.make.appId,
371
+ }),
279
372
  },
280
- body: JSON.stringify({
281
- email: this.make.email,
282
- password: this.make.pass,
283
- appId: this.make.appId,
284
- }),
285
- });
373
+ FETCH_TIMEOUTS.quick
374
+ );
286
375
 
287
376
  const data: any = await response.json();
288
377
  if (!data.success) {
@@ -303,15 +392,19 @@ export class ChatApp {
303
392
 
304
393
  async refreshTokenChatApp(): Promise<{ error: boolean; message?: string; data?: unknown }> {
305
394
  try {
306
- const response = await fetch("https://api.chatapp.online/v1/tokens/refresh", {
307
- method: "POST",
308
- headers: {
309
- Lang: "en",
310
- "Content-Type": "application/json",
311
- Accept: "application/json",
395
+ const response = await fetchWithTimeout(
396
+ "https://api.chatapp.online/v1/tokens/refresh",
397
+ {
398
+ method: "POST",
399
+ headers: {
400
+ Lang: "en",
401
+ "Content-Type": "application/json",
402
+ Accept: "application/json",
403
+ },
404
+ body: JSON.stringify({ refreshToken: this.auth.refreshToken }),
312
405
  },
313
- body: JSON.stringify({ refreshToken: this.auth.refreshToken }),
314
- });
406
+ FETCH_TIMEOUTS.quick
407
+ );
315
408
 
316
409
  const data: any = await response.json();
317
410
  if (!data.success) {
@@ -336,14 +429,18 @@ export class ChatApp {
336
429
 
337
430
  async getLicensesChatApp(): Promise<{ error: boolean; message: string; data: unknown }> {
338
431
  try {
339
- const response = await fetch("https://api.chatapp.online/v1/licenses", {
340
- method: "GET",
341
- headers: {
342
- Lang: "en",
343
- Accept: "application/json",
344
- Authorization: this.auth.accessToken,
432
+ const response = await fetchWithTimeout(
433
+ "https://api.chatapp.online/v1/licenses",
434
+ {
435
+ method: "GET",
436
+ headers: {
437
+ Lang: "en",
438
+ Accept: "application/json",
439
+ Authorization: this.auth.accessToken,
440
+ },
345
441
  },
346
- });
442
+ FETCH_TIMEOUTS.api
443
+ );
347
444
  const data: any = await response.json();
348
445
 
349
446
  if (!data.success) {
@@ -1,4 +1,5 @@
1
1
  import nodemailer from "nodemailer";
2
+ import { fetchWithTimeout, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
2
3
 
3
4
  // ==================== Типы ====================
4
5
 
@@ -42,6 +43,12 @@ export class Email {
42
43
  host: "smtp.yandex.ru",
43
44
  port: 465, // 465 = SMTPS, 587 = STARTTLS
44
45
  secure: true, // true для 465
46
+ // Дефолты nodemailer (соединение 2 мин, приветствие 30с, сокет 10 мин) слишком щедры:
47
+ // письмо с вложением уходит на Яндекс за секунды, а 10 минут молчания сокета
48
+ // блокируют вызывающую очередь
49
+ connectionTimeout: 15_000,
50
+ greetingTimeout: 10_000,
51
+ socketTimeout: 120_000,
45
52
  });
46
53
  }
47
54
 
@@ -51,7 +58,9 @@ export class Email {
51
58
  if (dataMail.fileUrl) {
52
59
  const resDownloadFile = await this.downloadFileToUrl(dataMail.fileUrl);
53
60
  if (resDownloadFile.error) {
54
- return { error: true, message: resDownloadFile.message };
61
+ // Подстановка вместо возможного undefined: при exactOptionalPropertyTypes
62
+ // у потребителя явный undefined не подходит полю message?: string
63
+ return { error: true, message: resDownloadFile.message ?? "Не удалось скачать файл по fileUrl" };
55
64
  }
56
65
  dataMail.attachments = [
57
66
  {
@@ -80,7 +89,7 @@ export class Email {
80
89
 
81
90
  private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
82
91
  try {
83
- const response = await fetch(fileUrl);
92
+ const response = await fetchWithTimeout(fileUrl, undefined, FETCH_TIMEOUTS.transfer);
84
93
  if (!response.ok) {
85
94
  throw new Error(`HTTP error! status: ${response.status}`);
86
95
  }