@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.
@@ -1,5 +1,6 @@
1
1
  import { getResultData, type B24MethodCaller } from "../bitrix24/b24.ts";
2
- import { fetchRetry } from "../utils/fetchRetry.ts";
2
+ import { fetchRetry, fetchWithTimeout, isTimeoutError, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
3
+ import { logs } from "../logs/logs.ts";
3
4
 
4
5
  // ==================== Типы ====================
5
6
 
@@ -17,6 +18,15 @@ interface SmsMessageData {
17
18
  fileName?: string;
18
19
  }
19
20
 
21
+ /**
22
+ * Папка на диске Bitrix24, куда кладётся вложение перед отправкой SMS.
23
+ * Значение — папка «contract_company» проекта «Документы и договора» портала
24
+ * repacheb.bitrix24.ru. Для опубликованной библиотеки это чужая константа,
25
+ * поэтому она вынесена в параметр конструктора; здесь она осталась дефолтом,
26
+ * иначе смена была бы ломающей для всех текущих потребителей.
27
+ */
28
+ const DEFAULT_UPLOAD_FOLDER_ID = 2792881;
29
+
20
30
  /** Результат отправки SMS */
21
31
  interface SmsResult {
22
32
  error: boolean;
@@ -27,6 +37,12 @@ interface SmsResult {
27
37
  data?: string;
28
38
  errorText?: string;
29
39
  } | null;
40
+ /**
41
+ * Выставляется только при таймауте ожидания ответа SMSGold: запрос ушёл, ответ не пришёл.
42
+ * Означает «сообщение могло уйти» — не «не отправлено». Повторять отправку нельзя,
43
+ * решение принимает человек.
44
+ */
45
+ unknownDelivery?: boolean;
30
46
  }
31
47
 
32
48
  // ==================== Класс ====================
@@ -37,11 +53,14 @@ export class Smsgold {
37
53
  // Структурный тип, а не B24OAuth: объявляет callMethod сам, поэтому переживёт
38
54
  // удаление метода из SDK. Сигнатуру конструктора не сужает — она стала шире
39
55
  private b24: B24MethodCaller | null;
56
+ // ID папки на диске B24 для вложений SMS — параметр, а не константа модуля
57
+ private uploadFolderId: number;
40
58
 
41
- constructor(authParam: SmsgoldAuthParam, b24: B24MethodCaller | null = null) {
59
+ constructor(authParam: SmsgoldAuthParam, b24: B24MethodCaller | null = null, uploadFolderId: number = DEFAULT_UPLOAD_FOLDER_ID) {
42
60
  this.user = authParam.user;
43
61
  this.pass = authParam.pass;
44
62
  this.b24 = b24;
63
+ this.uploadFolderId = uploadFolderId;
45
64
  }
46
65
 
47
66
  async sendSms(messageData: SmsMessageData): Promise<SmsResult> {
@@ -97,13 +116,20 @@ export class Smsgold {
97
116
  sender: "potolkiRepa",
98
117
  }).toString();
99
118
 
100
- const result = await fetch("https://web.smsgold.ru/http2/", {
101
- method: "POST",
102
- headers: {
103
- "Content-Type": "application/x-www-form-urlencoded",
119
+ // Отправка неидемпотентна — повторов нет, единственная защита от зависшего
120
+ // соединения — бюджет времени. 30с взяты с запасом, чтобы «неизвестный статус»
121
+ // случался как можно реже
122
+ const result = await fetchWithTimeout(
123
+ "https://web.smsgold.ru/http2/",
124
+ {
125
+ method: "POST",
126
+ headers: {
127
+ "Content-Type": "application/x-www-form-urlencoded",
128
+ },
129
+ body,
104
130
  },
105
- body,
106
- });
131
+ FETCH_TIMEOUTS.send
132
+ );
107
133
 
108
134
  if (!result.ok) {
109
135
  const errorText = await result.text();
@@ -126,6 +152,23 @@ export class Smsgold {
126
152
  },
127
153
  };
128
154
  } catch (error: unknown) {
155
+ // Ответа не дождались: запрос уже ушёл, сообщение могло быть доставлено.
156
+ // Это не «ошибка отправки» — статус неизвестен, и решить это кодом нельзя.
157
+ // Единственное место в классе, где пишем в лог: уровень error уходит в чат B24,
158
+ // потому что разбираться должен человек
159
+ if (isTimeoutError(error)) {
160
+ logs.add(
161
+ `[${phone}] SMS: таймаут ожидания ответа SMSGold ${FETCH_TIMEOUTS.send}мс — статус неизвестен, повторная отправка запрещена`,
162
+ "error"
163
+ );
164
+ return {
165
+ error: true,
166
+ message: `SMSGold не ответил за ${FETCH_TIMEOUTS.send / 1000}с — статус отправки неизвестен: сообщение могло уйти, повторять нельзя`,
167
+ result: null,
168
+ unknownDelivery: true,
169
+ };
170
+ }
171
+
129
172
  const errMsg = error instanceof Error ? error.message : String(error);
130
173
  return { error: true, message: errMsg, result: null };
131
174
  }
@@ -138,7 +181,7 @@ export class Smsgold {
138
181
 
139
182
  // Используем метод disk.folder.uploadfile
140
183
  const res = await this.b24!.callMethod("disk.folder.uploadfile", {
141
- id: 2792881, // Id папки "contract_company" из проекта "Документы и договора"
184
+ id: this.uploadFolderId,
142
185
  data: { NAME: fileName },
143
186
  fileContent: [fileName, base64File],
144
187
  });
@@ -153,9 +196,11 @@ export class Smsgold {
153
196
  private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
154
197
  try {
155
198
  // Скачивание файла идемпотентно — повтор при сетевом сбое безопасен.
156
- // Отправка SMS (fetch выше по коду) намеренно остаётся без retry:
157
- // при потерянном ответе повтор отправил бы сообщение второй раз
158
- const response = await fetchRetry(fileUrl);
199
+ // Отправка SMS (fetchWithTimeout выше по коду) намеренно остаётся без retry:
200
+ // при потерянном ответе повтор отправил бы сообщение второй раз.
201
+ // Попыток 3, а не 5: при бюджете 60с на попытку пять попыток дали бы до пяти
202
+ // минут ожидания перед самой отправкой SMS
203
+ const response = await fetchRetry(fileUrl, undefined, 3, 500, FETCH_TIMEOUTS.transfer);
159
204
  if (!response.ok) {
160
205
  throw new Error(`HTTP error! status: ${response.status}`);
161
206
  }
@@ -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
  /** Параметры авторизации Wappi-мессенджера */
@@ -27,6 +30,11 @@ interface WappiResult {
27
30
  error: boolean;
28
31
  message: string;
29
32
  data: any;
33
+ /**
34
+ * Ответа не дождались. Отличает «проверка сказала нет» от «проверки не было»:
35
+ * без этого признака вызывающий код принимает таймаут за отрицательный ответ.
36
+ */
37
+ timeout?: boolean;
30
38
  }
31
39
 
32
40
  // ==================== Класс ====================
@@ -85,18 +93,42 @@ export class Wappi {
85
93
  url = `https://wappi.pro/maxapi/sync/message/send?${sendOpenLine}profile_id=${profile_id}`;
86
94
  }
87
95
 
88
- const res = await fetch(url!, {
89
- method: "POST",
90
- headers: {
91
- "Content-Type": "application/json",
92
- Authorization: token!,
93
- },
94
- body: JSON.stringify({
95
- recipient: phone,
96
- body: message,
97
- }),
98
- });
99
- const data: any = await res.json();
96
+ let data: any;
97
+ try {
98
+ const res = await fetchWithTimeout(
99
+ url!,
100
+ {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ Authorization: token!,
105
+ },
106
+ body: JSON.stringify({
107
+ recipient: phone,
108
+ body: message,
109
+ }),
110
+ },
111
+ FETCH_TIMEOUTS.send
112
+ );
113
+ data = await res.json();
114
+ } catch (error: unknown) {
115
+ // Метод исторически без try/catch — перехватываем только таймаут, который сами
116
+ // и вводим, чтобы не появился новый путь исключений там, где его сегодня нет.
117
+ // Остальные ошибки летят наружу как раньше
118
+ if (!isTimeoutError(error)) throw error;
119
+
120
+ // Уровень error — единственный, который уходит в чат B24. До появления
121
+ // таймаута зависание долетало исключением до потребителя и там порождало
122
+ // задачу через errorB24(); перехватив ошибку здесь, мы обязаны сами оставить
123
+ // след, иначе правка сделала бы отказ отправки тише, чем он был
124
+ logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.send}мс при отправке сообщения через ${messangerType} — статус неизвестен, повторять нельзя`, "error");
125
+
126
+ return {
127
+ error: true,
128
+ message: `Wappi: таймаут ${FETCH_TIMEOUTS.send}мс при отправке сообщения через ${messangerType} — статус неизвестен`,
129
+ data: null,
130
+ };
131
+ }
100
132
 
101
133
  if (data.status !== "done") {
102
134
  return { error: true, message: `Ошибка при отправке сообщения в ChatApp через ${messangerType}`, data };
@@ -170,21 +202,40 @@ export class Wappi {
170
202
  url = `https://wappi.pro/maxapi/async/message/file/url/send?${sendOpenLine}profile_id=${profile_id}`;
171
203
  }
172
204
 
173
- const res = await fetch(url!, {
174
- method: "POST",
175
- headers: {
176
- "Content-Type": "application/json",
177
- Authorization: token!,
178
- },
179
- body: JSON.stringify({
180
- recipient: phone,
181
- url: fileUrl,
182
- file_name: fileName,
183
- caption: message,
184
- b64_file: base64,
185
- }),
186
- });
187
- const data: any = await res.json();
205
+ let data: any;
206
+ try {
207
+ // В теле уходит base64 файла — бюджет transfer, а не send
208
+ const res = await fetchWithTimeout(
209
+ url!,
210
+ {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ Authorization: token!,
215
+ },
216
+ body: JSON.stringify({
217
+ recipient: phone,
218
+ url: fileUrl,
219
+ file_name: fileName,
220
+ caption: message,
221
+ b64_file: base64,
222
+ }),
223
+ },
224
+ FETCH_TIMEOUTS.transfer
225
+ );
226
+ data = await res.json();
227
+ } catch (error: unknown) {
228
+ // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
229
+ if (!isTimeoutError(error)) throw error;
230
+
231
+ logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.transfer}мс при отправке файла через ${messangerType} — статус неизвестен, повторять нельзя`, "error");
232
+
233
+ return {
234
+ error: true,
235
+ message: `Wappi: таймаут ${FETCH_TIMEOUTS.transfer}мс при отправке файла через ${messangerType} — статус неизвестен`,
236
+ data: null,
237
+ };
238
+ }
188
239
 
189
240
  if (data.status !== "done") {
190
241
  return { error: true, message: `Ошибка при отправке файла в ChatApp через ${messangerType}`, data };
@@ -219,6 +270,14 @@ export class Wappi {
219
270
  return { error: false, message: `Номер ${phone} проверен для мессенджера ${messangerType}`, data: contactCheck.data };
220
271
  }
221
272
 
273
+ // Ответа не дождались — это не «контакта нет». Создавать контакт вслепую нельзя:
274
+ // он мог уже быть в адресной книге, а результат объявил бы номер проверенным,
275
+ // хотя проверки не было
276
+ if (contactCheck.timeout) {
277
+ logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке контакта в Telegram — контакт не создаём`, "warn");
278
+ return { error: true, message: `Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке контакта ${phone} в Telegram`, data: null };
279
+ }
280
+
222
281
  // 3 Если не существует, то номера в телеге нет, пробуем создать
223
282
  const addContactResult = await this.addContactTelegramWappi(phone);
224
283
 
@@ -236,14 +295,29 @@ export class Wappi {
236
295
  url = `https://wappi.pro/maxapi/sync/contact/check?profile_id=${profile_id}&phone=${phone}`;
237
296
  }
238
297
 
239
- const res = await fetch(url!, {
240
- method: "GET",
241
- headers: {
242
- "Content-Type": "application/json",
243
- Authorization: token!,
244
- },
245
- });
246
- const data: any = await res.json();
298
+ let data: any;
299
+ try {
300
+ const res = await fetchWithTimeout(
301
+ url!,
302
+ {
303
+ method: "GET",
304
+ headers: {
305
+ "Content-Type": "application/json",
306
+ Authorization: token!,
307
+ },
308
+ },
309
+ FETCH_TIMEOUTS.api
310
+ );
311
+ data = await res.json();
312
+ } catch (error: unknown) {
313
+ // Перехватываем только таймаут, остальные ошибки ведут себя как раньше
314
+ if (!isTimeoutError(error)) throw error;
315
+
316
+ // warn, а не error: проверка номера ничего не меняет, повторить её безопасно
317
+ logs.add(`[${phone}] Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке номера в ${messangerType}`, "warn");
318
+
319
+ return { error: true, message: `Wappi: таймаут ${FETCH_TIMEOUTS.api}мс при проверке номера ${phone}`, data: null };
320
+ }
247
321
 
248
322
  if (data.status !== "done") {
249
323
  return { error: true, message: `Ошибка проверки номера ${phone} ${messangerType}`, data };
@@ -267,13 +341,17 @@ export class Wappi {
267
341
  const url = `https://wappi.pro/tapi/sync/contact/get?profile_id=${profile_id}&phone=${phone}`;
268
342
 
269
343
  try {
270
- const res = await fetch(url, {
271
- method: "GET",
272
- headers: {
273
- "Content-Type": "application/json",
274
- Authorization: token,
344
+ const res = await fetchWithTimeout(
345
+ url,
346
+ {
347
+ method: "GET",
348
+ headers: {
349
+ "Content-Type": "application/json",
350
+ Authorization: token,
351
+ },
275
352
  },
276
- });
353
+ FETCH_TIMEOUTS.api
354
+ );
277
355
 
278
356
  const data: any = await res.json();
279
357
 
@@ -286,6 +364,13 @@ export class Wappi {
286
364
  return { error: false, message: `Контакт ${phone} получен из Telegram`, data };
287
365
  } catch (error: unknown) {
288
366
  const errMsg = error instanceof Error ? error.message : String(error);
367
+
368
+ // Отмечаем таймаут отдельно: вызывающий phoneCheckWappi иначе примет
369
+ // «не дождались ответа» за «контакта нет» и полезет создавать контакт
370
+ if (isTimeoutError(error)) {
371
+ return { error: true, message: `Ошибка при запросе контакта: ${errMsg}`, data: null, timeout: true };
372
+ }
373
+
289
374
  return { error: true, message: `Ошибка при запросе контакта: ${errMsg}`, data: null };
290
375
  }
291
376
  }
@@ -304,17 +389,21 @@ export class Wappi {
304
389
  const url = `https://wappi.pro/tapi/sync/contact/add?profile_id=${profile_id}`;
305
390
 
306
391
  try {
307
- const res = await fetch(url, {
308
- method: "POST",
309
- headers: {
310
- "Content-Type": "application/json",
311
- Authorization: token,
392
+ const res = await fetchWithTimeout(
393
+ url,
394
+ {
395
+ method: "POST",
396
+ headers: {
397
+ "Content-Type": "application/json",
398
+ Authorization: token,
399
+ },
400
+ body: JSON.stringify({
401
+ recipient: phone,
402
+ name: contactName,
403
+ }),
312
404
  },
313
- body: JSON.stringify({
314
- recipient: phone,
315
- name: contactName,
316
- }),
317
- });
405
+ FETCH_TIMEOUTS.send
406
+ );
318
407
 
319
408
  const data: any = await res.json();
320
409
  if (data.status !== "done") {
@@ -325,14 +414,16 @@ export class Wappi {
325
414
  return { error: false, message: `Контакт ${phone} успешно создан в Telegram`, data };
326
415
  } catch (error: unknown) {
327
416
  const errMsg = error instanceof Error ? error.message : String(error);
328
- return { error: true, message: `Ошибка при создании контакта: ${errMsg}`, data: null };
417
+ // Создание контакта неидемпотентно: при таймауте ответ потерян, а контакт мог создаться
418
+ const unknownStatus = isTimeoutError(error) ? " — контакт мог быть создан" : "";
419
+ return { error: true, message: `Ошибка при создании контакта: ${errMsg}${unknownStatus}`, data: null };
329
420
  }
330
421
  }
331
422
 
332
423
  /** Скачиваем файл по ссылке и конвертируем в base64 */
333
424
  private async convertToBase64(fileUrl: string): Promise<{ error: boolean; message?: string; data?: string }> {
334
425
  try {
335
- const response = await fetch(fileUrl);
426
+ const response = await fetchWithTimeout(fileUrl, undefined, FETCH_TIMEOUTS.transfer);
336
427
  if (!response.ok) {
337
428
  throw new Error(`HTTP error! status: ${response.status}`);
338
429
  }