@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.
@@ -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 };
@@ -1,4 +1,5 @@
1
- import nodemailer from "nodemailer";
1
+ import nodemailer, { type SendMailOptions } from "nodemailer";
2
+ import { logs } from "../logs/logs.ts";
2
3
  import { fetchWithTimeout, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
3
4
 
4
5
  // ==================== Типы ====================
@@ -9,6 +10,12 @@ interface EmailAuthParam {
9
10
  pass: string;
10
11
  }
11
12
 
13
+ /** Вложение письма */
14
+ interface EmailAttachment {
15
+ filename: string;
16
+ content: Buffer;
17
+ }
18
+
12
19
  /** Данные для отправки письма */
13
20
  interface EmailData {
14
21
  to: string;
@@ -17,7 +24,7 @@ interface EmailData {
17
24
  html?: string;
18
25
  fileUrl?: string;
19
26
  fileName?: string;
20
- attachments?: { filename: string; content: Buffer }[] | null;
27
+ attachments?: EmailAttachment[] | null;
21
28
  }
22
29
 
23
30
  /** Результат отправки письма */
@@ -27,66 +34,260 @@ interface EmailResult {
27
34
  info?: unknown;
28
35
  }
29
36
 
37
+ /**
38
+ * Ответ транспорта в той части, которую разбирает класс.
39
+ *
40
+ * Собственный узкий тип, а не `SentMessageInfo` из `@types/nodemailer`: там он
41
+ * объявлен как `any`, и в сигнатуру пакета это тянуть нельзя.
42
+ */
43
+ export interface SendMailInfo {
44
+ accepted?: unknown;
45
+ rejected?: unknown;
46
+ response?: unknown;
47
+ }
48
+
49
+ /**
50
+ * Минимальный контракт транспорта — ровно то, что использует класс.
51
+ *
52
+ * Второй аргумент конструктора существует ради тестов и подмены транспорта
53
+ * у потребителя; по умолчанию класс поднимает SMTP Яндекса сам.
54
+ */
55
+ export interface MailTransport {
56
+ sendMail(mail: SendMailOptions): Promise<SendMailInfo>;
57
+ }
58
+
59
+ // ==================== Утилиты ====================
60
+
61
+ /**
62
+ * Грубая проверка адреса: непустая строка без пробелов, одна собака,
63
+ * непустые части слева и справа.
64
+ *
65
+ * Полная валидация адресов регуляркой — известная ловушка, и она здесь не нужна:
66
+ * цель — отсечь пустую строку и явный мусор, отказ сервера всё равно проверяется
67
+ * по ответу. Точку в домене не требуем: внутрикорпоративные адреса без точки существуют.
68
+ */
69
+ function isLikelyEmail(value: unknown): value is string {
70
+ if (typeof value !== "string") return false;
71
+ if (/\s/.test(value)) return false;
72
+
73
+ const parts = value.split("@");
74
+ if (parts.length !== 2) return false;
75
+
76
+ const [local, domain] = parts;
77
+ return Boolean(local) && Boolean(domain);
78
+ }
79
+
80
+ /**
81
+ * Приводит адрес к той форме, в которой его возвращает почтовый сервер.
82
+ *
83
+ * Сравнивать «как передали» нельзя: конверт письма строит nodemailer, и в `accepted`
84
+ * приходит адрес из конверта — домен в punycode, угловые скобки сняты. Живой адрес
85
+ * `ivan@пример.рф` возвращался как `ivan@xn--e1afmkfd.xn--p1ai`, с исходным не совпадал,
86
+ * и доставленное письмо объявлялось недоставленным.
87
+ *
88
+ * Применяется к обеим сторонам сравнения. Локальная часть приводится к нижнему регистру
89
+ * намеренно: формально она регистрозависима, но здесь адрес сверяется сам с собой,
90
+ * а не с чужим.
91
+ */
92
+ function normalizeAddress(value: string): string {
93
+ let address = value.trim();
94
+
95
+ if (address.startsWith("<") && address.endsWith(">")) {
96
+ address = address.slice(1, -1).trim();
97
+ }
98
+
99
+ // Разделяем по последней собаке: в локальной части она допустима в кавычках
100
+ const separator = address.lastIndexOf("@");
101
+ if (separator <= 0) return address.toLowerCase();
102
+
103
+ const local = address.slice(0, separator);
104
+ const domain = address.slice(separator + 1);
105
+
106
+ let host: string;
107
+ try {
108
+ // Доступный в Node способ получить punycode-форму домена: разбор хоста в URL
109
+ // делает то же преобразование, что и nodemailer при построении конверта
110
+ host = new URL(`http://${domain}`).hostname;
111
+ } catch {
112
+ // Домен как хост не разбирается (пустой, с недопустимым символом) — сверяем как есть
113
+ host = domain.toLowerCase();
114
+ }
115
+
116
+ return `${local.toLowerCase()}@${host}`;
117
+ }
118
+
119
+ /**
120
+ * Приводит `accepted`/`rejected` к списку нормализованных адресов.
121
+ *
122
+ * Элементы приходят строкой либо объектом `{ name, address }` — форма зависит
123
+ * от того, как адрес был передан в `sendMail`.
124
+ */
125
+ function toAddressList(value: unknown): string[] {
126
+ if (!Array.isArray(value)) return [];
127
+
128
+ const addresses: string[] = [];
129
+
130
+ for (const item of value as unknown[]) {
131
+ if (typeof item === "string") {
132
+ addresses.push(item);
133
+ continue;
134
+ }
135
+
136
+ if (item && typeof item === "object") {
137
+ const address = (item as { address?: unknown }).address;
138
+ if (typeof address === "string") addresses.push(address);
139
+ }
140
+ }
141
+
142
+ return addresses.map((address) => normalizeAddress(address)).filter((address) => address !== "");
143
+ }
144
+
145
+ /**
146
+ * Имя для скачанного вложения: явно переданное `fileName`, иначе последний
147
+ * сегмент пути из URL (без query), иначе запасное `attachment`.
148
+ *
149
+ * Прежде здесь стояло `dataMail.fileName!`, и письмо с `fileUrl` без имени
150
+ * уезжало с вложением без имени файла.
151
+ */
152
+ function resolveAttachmentName(fileUrl: string, fileName?: string): string {
153
+ const explicit = fileName?.trim();
154
+ if (explicit) return explicit;
155
+
156
+ try {
157
+ const segment = new URL(fileUrl).pathname.split("/").pop();
158
+ const decoded = segment ? decodeURIComponent(segment) : "";
159
+ if (decoded) return decoded;
160
+ } catch {
161
+ // Адрес может быть относительным или битым — тогда берём запасное имя
162
+ }
163
+
164
+ return "attachment";
165
+ }
166
+
30
167
  // ==================== Класс ====================
31
168
 
32
169
  export class Email {
33
170
  private auth: EmailAuthParam;
34
- private transporter: ReturnType<typeof nodemailer.createTransport>;
171
+ private transporter: MailTransport;
35
172
 
36
- constructor(auth: EmailAuthParam) {
173
+ constructor(auth: EmailAuthParam, transporter?: MailTransport) {
37
174
  this.auth = auth;
38
- this.transporter = nodemailer.createTransport({
39
- auth: {
40
- user: this.auth.user,
41
- pass: this.auth.pass,
42
- },
43
- host: "smtp.yandex.ru",
44
- port: 465, // 465 = SMTPS, 587 = STARTTLS
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,
52
- });
175
+ this.transporter =
176
+ transporter ??
177
+ nodemailer.createTransport({
178
+ auth: {
179
+ user: this.auth.user,
180
+ pass: this.auth.pass,
181
+ },
182
+ host: "smtp.yandex.ru",
183
+ port: 465, // 465 = SMTPS, 587 = STARTTLS
184
+ secure: true, // true для 465
185
+ // Дефолты nodemailer (соединение 2 мин, приветствие 30с, сокет 10 мин) слишком щедры:
186
+ // письмо с вложением уходит на Яндекс за секунды, а 10 минут молчания сокета
187
+ // блокируют вызывающую очередь
188
+ connectionTimeout: 15_000,
189
+ greetingTimeout: 10_000,
190
+ socketTimeout: 120_000,
191
+ });
53
192
  }
54
193
 
194
+ /**
195
+ * Отправляет письмо получателю, копию — на адрес отправителя.
196
+ *
197
+ * Адрес получателя проверяется до вызова транспорта: пустой или мусорный `to`
198
+ * раньше давал «успешную» отправку копии себе и ни одной жалобы в логе.
199
+ *
200
+ * Вложения вызывающего (`attachments`) сохраняются и дополняются файлом,
201
+ * скачанным по `fileUrl`. Входной объект не мутируется.
202
+ *
203
+ * @param dataMail — адресат, тема, тело письма, вложения
204
+ * @returns `{ error, message?, info? }`; `error: true` приходит и тогда, когда
205
+ * сервер принял копию отправителю, но отверг получателя
206
+ */
55
207
  async send(dataMail: EmailData): Promise<EmailResult> {
56
208
  try {
57
- // Если прислали ссылку на файл, то скачиваем его и добавляем в attachments
209
+ const to = typeof dataMail.to === "string" ? dataMail.to.trim() : "";
210
+ if (!isLikelyEmail(to)) {
211
+ return { error: true, message: `Email: адрес получателя некорректен ('${String(dataMail.to)}') — письмо не отправлено` };
212
+ }
213
+
214
+ // Вложения вызывающего не затираем и входной объект не мутируем:
215
+ // `attachments` и пара `fileUrl`/`fileName` объявлены независимыми полями,
216
+ // ни одно не документировано как «вместо». Тот, кто заполнил оба, хочет оба;
217
+ // замена молча теряла данные. Порядок: сначала переданные, затем скачанное
218
+ const attachments: EmailAttachment[] = [...(dataMail.attachments ?? [])];
219
+
58
220
  if (dataMail.fileUrl) {
59
221
  const resDownloadFile = await this.downloadFileToUrl(dataMail.fileUrl);
60
- if (resDownloadFile.error) {
222
+ if (resDownloadFile.error || !resDownloadFile.buffer) {
61
223
  // Подстановка вместо возможного undefined: при exactOptionalPropertyTypes
62
224
  // у потребителя явный undefined не подходит полю message?: string
63
225
  return { error: true, message: resDownloadFile.message ?? "Не удалось скачать файл по fileUrl" };
64
226
  }
65
- dataMail.attachments = [
66
- {
67
- filename: dataMail.fileName!,
68
- content: resDownloadFile.buffer!,
69
- },
70
- ];
71
- } else {
72
- dataMail.attachments = null;
227
+
228
+ attachments.push({
229
+ filename: resolveAttachmentName(dataMail.fileUrl, dataMail.fileName),
230
+ content: resDownloadFile.buffer,
231
+ });
73
232
  }
74
233
 
75
234
  const info = await this.transporter.sendMail({
76
235
  from: `Натяжные потолки Репа. <${this.auth.user}>`,
77
- to: [dataMail.to, this.auth.user],
236
+ to: [to, this.auth.user],
78
237
  subject: dataMail.subject,
79
238
  text: dataMail.text,
80
239
  html: dataMail.html,
81
- attachments: dataMail.attachments ?? undefined,
240
+ attachments: attachments.length > 0 ? attachments : undefined,
82
241
  });
83
- return { error: false, info };
242
+
243
+ return this.checkDelivery(to, info);
84
244
  } catch (error: unknown) {
85
245
  const message = error instanceof Error ? error.message : String(error);
86
246
  return { error: true, message };
87
247
  }
88
248
  }
89
249
 
250
+ /**
251
+ * Проверяет по ответу транспорта, принял ли сервер основного адресата.
252
+ *
253
+ * В `to` всегда уходит пара «получатель + отправитель», а `sendMail` резолвится,
254
+ * если принят хотя бы один адрес. Без этой проверки отказ по получателю
255
+ * возвращался вызывающему как `error: false`.
256
+ *
257
+ * Отказ логируется не здесь: результат уходит вызывающему, а уровень `error`
258
+ * дублировался бы в чат B24. От таймаутов отправки в `Smsgold`/`Wappi` случай
259
+ * отличается тем, что статус известен — сервер прямо сказал «не принял».
260
+ */
261
+ private checkDelivery(to: string, info: SendMailInfo): EmailResult {
262
+ const hasReport = Array.isArray(info?.accepted) || Array.isArray(info?.rejected);
263
+
264
+ // Транспорт вообще не сообщил, какие адреса приняты. Отказ здесь не выдумываем:
265
+ // у SMTP-транспорта эти поля есть всегда, а объявить успешно доставленное письмо
266
+ // ошибкой — регресс хуже исходного дефекта. Успех плюс строка в лог
267
+ if (!hasReport) {
268
+ logs.add("Email: транспорт не сообщил, какие адреса приняты — проверить доставку нечем", "warn");
269
+ return { error: false, info };
270
+ }
271
+
272
+ // Обе стороны сравнения нормализуем одинаково: сервер отдаёт адреса из конверта,
273
+ // а не в том виде, в каком их передали
274
+ const target = normalizeAddress(to);
275
+ const accepted = toAddressList(info.accepted);
276
+ const rejected = toAddressList(info.rejected);
277
+
278
+ if (rejected.includes(target) || !accepted.includes(target)) {
279
+ const response = typeof info.response === "string" && info.response.trim() !== "" ? ` Ответ сервера: ${info.response}` : "";
280
+
281
+ return {
282
+ error: true,
283
+ message: `Email: почтовый сервер не принял адрес получателя ${to}, письмо ему не ушло (копия отправителю могла уйти).${response}`,
284
+ info,
285
+ };
286
+ }
287
+
288
+ return { error: false, info };
289
+ }
290
+
90
291
  private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
91
292
  try {
92
293
  const response = await fetchWithTimeout(fileUrl, undefined, FETCH_TIMEOUTS.transfer);
@@ -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);