@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.
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { $b24, getResultData, type B24MethodCaller } from "./b24.ts";
2
+ import { logs } from "../logs/logs.ts";
2
3
 
3
4
  // ==================== Типы ====================
4
5
 
@@ -19,9 +20,21 @@ export interface ConnectorMessage {
19
20
  lineId: string;
20
21
  chatId: string;
21
22
  text: string | null;
22
- file: any[] | null;
23
- attachments: any[] | null;
24
- 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;
25
38
  }
26
39
 
27
40
  /** События коннектора */
@@ -47,7 +60,12 @@ export interface EntityEvents {
47
60
  interface StandardResult {
48
61
  error: boolean;
49
62
  message: string;
50
- data: any;
63
+ /**
64
+ * `ConnectorEvents`, `EntityEvents` либо `null` — какой именно, зависит от имени
65
+ * события, переданного в `get()`. Разобрать это типом нельзя, поэтому `unknown`:
66
+ * потребитель приводит результат сам, как и делал до 4.0.0
67
+ */
68
+ data: unknown;
51
69
  }
52
70
 
53
71
  /** Офлайн-событие Bitrix24 */
@@ -55,7 +73,7 @@ interface OfflineEvent {
55
73
  ID: string;
56
74
  MESSAGE_ID: string;
57
75
  EVENT_NAME: string;
58
- EVENT_DATA: any;
76
+ EVENT_DATA: unknown;
59
77
  EVENT_ADDITIONAL: {
60
78
  user_id: string;
61
79
  };
@@ -74,32 +92,140 @@ interface OfflineEventsResponse {
74
92
  /**
75
93
  * Пользователь, чьи события класс отбрасывает и сразу очищает из очереди.
76
94
  *
77
- * Это защита от петли, а не случайность: 138 — владелец OAuth-токенов приложений
78
- * на портале, и всё, что приложения пишут в B24 (обновление сделки, комментарий,
79
- * задача), приходит офлайн-событием с `user_id: "138"`. Без фильтра правка сделки
80
- * кодом возвращалась бы событием в тот же код и обрабатывалась бы второй раз.
95
+ * Это защита от петли, а не случайность: 138 — владелец OAuth-токенов всех приложений
96
+ * на портале, и всё, что приложения и роботы пишут в B24 (обновление сделки,
97
+ * комментарий, задача), приходит офлайн-событием с `user_id: "138"`. Собственные
98
+ * правки платформа приложению не отдаёт, но правки соседнего приложения под тем же
99
+ * владельцем — отдаёт; без фильтра правка сделки одним приложением обрабатывалась бы
100
+ * другим как внешнее изменение, и дальше по кругу.
81
101
  *
82
102
  * Оборотная сторона: ручные правки под этой же учёткой (это администратор портала)
83
103
  * тоже пропадают. Поэтому класс не годится для задач полной выгрузки или витрин,
84
104
  * где нужны все изменения без исключения — там читать `event.offline.get` напрямую
105
+ *
106
+ * Сравнение идёт по нормализованному значению (`readId`), поэтому системным считается
107
+ * и строка `"138"`, и число `138`. Портал сегодня отдаёт `user_id` строкой, то есть
108
+ * в бою это ничего не меняет; форма поля на защиту от петли влиять не должна
85
109
  */
86
110
  const SYSTEM_USER_ID = "138";
87
111
 
88
112
  // ==================== Утилиты ====================
89
113
 
90
114
  /**
91
- * Приводит MESSAGES из события к массиву.
115
+ * Приводит коллекцию из ответа портала к массиву.
92
116
  *
93
117
  * B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
94
118
  * сериализует разреженный массив), поэтому опираться на одну форму нельзя.
95
119
  * Неизвестная форма даёт пустой массив, а не исключение.
120
+ *
121
+ * Применяется к обеим коллекциям одного ответа — к списку записей и к сообщениям
122
+ * внутри записи: защита только одной из них читалась бы как решение «здесь
123
+ * нормализация не нужна», а словарь на месте списка ронял бы весь вызов.
96
124
  */
97
- function normalizeMessages(messages: unknown): any[] {
98
- if (Array.isArray(messages)) return messages;
99
- if (messages && typeof messages === "object") return Object.values(messages);
125
+ function normalizeCollection(value: unknown): unknown[] {
126
+ if (Array.isArray(value)) return value;
127
+ if (value && typeof value === "object") return Object.values(value);
100
128
  return [];
101
129
  }
102
130
 
131
+ /**
132
+ * Возвращает значение как словарь или null.
133
+ *
134
+ * Массив словарём не считается намеренно: PHP сериализует пустую коллекцию в `[]`,
135
+ * и такая форма на месте `EVENT_DATA` или `EVENT_ADDITIONAL` — признак битой записи,
136
+ * а не словаря с полями.
137
+ */
138
+ function asRecord(value: unknown): Record<string, unknown> | null {
139
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
140
+ return value as Record<string, unknown>;
141
+ }
142
+
143
+ /**
144
+ * Приводит идентификатор B24 к непустой строке.
145
+ *
146
+ * Портал отдаёт идентификаторы то строкой, то числом. Всё остальное (null, undefined,
147
+ * объект, пустая строка, нечисловое число) считается отсутствием значения.
148
+ */
149
+ function readId(value: unknown): string | null {
150
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
151
+ if (typeof value !== "string") return null;
152
+
153
+ const trimmed = value.trim();
154
+ return trimmed === "" ? null : trimmed;
155
+ }
156
+
157
+ /** Что делать с записью очереди в ветке сущностей */
158
+ type EntityVerdict =
159
+ | { kind: "system"; messageId: string | null }
160
+ | { kind: "broken"; messageId: string | null; eventName: string; reason: string }
161
+ | { kind: "active"; messageId: string; entityId: string; note?: string };
162
+
163
+ /** Вердикт `active` — вынесен отдельно, чтобы фильтр давал строгие `string` без утверждений типа */
164
+ type ActiveEntityVerdict = Extract<EntityVerdict, { kind: "active" }>;
165
+
166
+ /** Вердикт `broken` — вынесен отдельно ради типизированного фильтра при логировании */
167
+ type BrokenEntityVerdict = Extract<EntityVerdict, { kind: "broken" }>;
168
+
169
+ /**
170
+ * Классифицирует запись очереди офлайн-событий в ветке сущностей.
171
+ *
172
+ * Чистая функция без побочных эффектов: решает только «системная / битая / рабочая»,
173
+ * а логирование и очистку делает вызывающий.
174
+ *
175
+ * Битой считается только та запись, которую обработать нечем: нет идентификатора
176
+ * сущности (`EVENT_DATA.FIELDS.ID`) или нет ключа очистки (`MESSAGE_ID`). Отсутствие
177
+ * `EVENT_ADDITIONAL.user_id` битой запись не делает — идентификатор сущности на месте,
178
+ * значит потребитель её обработает. Признать такую запись битой значило бы поменять
179
+ * громкое падение на тихое безвозвратное удаление: `event.offline.clear` снимает
180
+ * запись с резерва навсегда, и восстановить её нечем.
181
+ *
182
+ * Обратная сторона учтена: если у записи не читается `user_id`, а на самом деле она
183
+ * от системного пользователя, она пройдёт как рабочая и теоретически даст петлю между
184
+ * приложениями. Это приемлемо — петля шумная и видимая, а тихая потеря нет; вдобавок
185
+ * на такую запись вызывающий пишет строку `warn` (поле `note`).
186
+ *
187
+ * Порядок проверок важен. Системная запись определяется до разбора `EVENT_DATA`:
188
+ * её данные не нужны, а битая системная запись должна уходить в очистку как системная —
189
+ * иначе лог шумел бы про записи, которые мы и так выбрасываем по замыслу.
190
+ *
191
+ * @param raw — запись, как её прислал портал
192
+ * @param requestedEventName — имя события из запроса, подставляется, если в записи его нет
193
+ */
194
+ function classifyEntityEvent(raw: unknown, requestedEventName: string): EntityVerdict {
195
+ const record = asRecord(raw);
196
+ if (!record) {
197
+ return { kind: "broken", messageId: null, eventName: requestedEventName, reason: "запись не является объектом" };
198
+ }
199
+
200
+ const messageId = readId(record.MESSAGE_ID);
201
+ const eventName = readId(record.EVENT_NAME) ?? requestedEventName;
202
+
203
+ const additional = asRecord(record.EVENT_ADDITIONAL);
204
+ const userId = readId(additional?.user_id);
205
+
206
+ if (userId === SYSTEM_USER_ID) {
207
+ return { kind: "system", messageId };
208
+ }
209
+
210
+ const fields = asRecord(asRecord(record.EVENT_DATA)?.FIELDS);
211
+ const entityId = readId(fields?.ID);
212
+ if (entityId === null) {
213
+ return { kind: "broken", messageId, eventName, reason: "нет EVENT_DATA.FIELDS.ID" };
214
+ }
215
+
216
+ // Без ключа очистки запись обработать нельзя: потребитель обработал бы сущность
217
+ // и не смог закрыть запись, и та пришла бы снова следующим опросом
218
+ if (messageId === null) {
219
+ return { kind: "broken", messageId, eventName, reason: "нет MESSAGE_ID" };
220
+ }
221
+
222
+ if (userId === null) {
223
+ return { kind: "active", messageId, entityId, note: "нет EVENT_ADDITIONAL.user_id, запись считается не системной" };
224
+ }
225
+
226
+ return { kind: "active", messageId, entityId };
227
+ }
228
+
103
229
  // ==================== Класс ====================
104
230
 
105
231
  /**
@@ -125,15 +251,23 @@ export class Event {
125
251
  /**
126
252
  * Преобразует файлы из формата Bitrix24 в формат TelegramFile
127
253
  */
128
- private convertB24FilesToTelegramFormat(b24Files: any[] | null | undefined): any[] | null {
254
+ private convertB24FilesToTelegramFormat(b24Files: unknown[] | null | undefined): TelegramFile[] | null {
129
255
  if (!b24Files || !Array.isArray(b24Files) || b24Files.length === 0) {
130
256
  return null;
131
257
  }
132
258
 
133
- return b24Files.map((f: any) => {
134
- const fileName = f.name || "";
135
- const link = f.link || f.url || "";
136
- 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);
137
271
 
138
272
  return {
139
273
  type: fileType,
@@ -187,9 +321,27 @@ export class Event {
187
321
  * под тем же `process_id`. Безопасный режим — `clear(processId)` целиком.
188
322
  *
189
323
  * События пользователя `SYSTEM_USER_ID` (138, владелец токенов приложений)
190
- * отбрасываются и очищаются здесь же — защита от петли «приложение записало →
191
- * событие → приложение обработало свою же запись». Для полной выгрузки всех
192
- * изменений класс не подходит, см. комментарий у константы.
324
+ * отбрасываются и очищаются здесь же — защита от петли между приложениями
325
+ * и роботами под одной учёткой. Для полной выгрузки всех изменений класс
326
+ * не подходит, см. комментарий у константы.
327
+ *
328
+ * В ветке сущностей битая запись больше не роняет весь вызов. Битой считается
329
+ * та, у которой не читается идентификатор сущности (`EVENT_DATA.FIELDS.ID`)
330
+ * или ключ очистки (`MESSAGE_ID`): она отбрасывается, её `MESSAGE_ID` уходит
331
+ * в очистку вместе с системными, а в лог пишется одна строка `warn`. Запись,
332
+ * у которой не читается сам `MESSAGE_ID`, только логируется — снять её с резерва
333
+ * может лишь `clear(processId)` целиком.
334
+ *
335
+ * Отсутствие `EVENT_ADDITIONAL.user_id` битой записью не является: обработать её
336
+ * можно, поэтому она уходит потребителю как рабочая, а в лог пишется строка `warn`
337
+ * о том, что системной её признать нечем.
338
+ *
339
+ * Retry-слой этот вызов **не повторяет**: `event.offline.get` входит в
340
+ * `NON_RETRYABLE_METHODS` (`bitrix24/b24/retry.ts`). Повтор при сетевом сбое
341
+ * зарезервировал бы второй пакет поверх первого, ответ которого уже потерян.
342
+ * Исключение — доказанный pre-connection: соединение не состоялось, резервировать
343
+ * порталу было нечего. На практике это значит, что сбой сети даёт одну попытку
344
+ * и метод возвращает `{ error: true }` — очередь заберёт следующий опрос по таймеру.
193
345
  */
194
346
  async get(eventName: string): Promise<StandardResult> {
195
347
  try {
@@ -199,8 +351,10 @@ export class Event {
199
351
  });
200
352
 
201
353
  const arrOfflineEvents = getResultData<OfflineEventsResponse["result"]>(response, "event.offline.get");
202
- // B24 для пустой коллекции может вернуть result: [] — тогда events отсутствует
203
- const allEvents = arrOfflineEvents?.events ?? [];
354
+ // B24 для пустой коллекции может вернуть result: [] — тогда events отсутствует.
355
+ // Форму самой коллекции тоже не угадываем: словарь с числовыми ключами
356
+ // не должен ронять разбор, как не роняет он MESSAGES внутри записи
357
+ const allEvents = normalizeCollection(arrOfflineEvents?.events);
204
358
 
205
359
  // Обработка событий коннектора
206
360
  if (eventName === "ONIMCONNECTORMESSAGEADD") {
@@ -219,24 +373,33 @@ export class Event {
219
373
  // здесь означало бы, что get() вернёт ошибку, потребитель не вызовет
220
374
  // clear(), и те же события придут следующим опросом — очередь встала бы
221
375
  // навсегда на одном кривом сообщении
222
- data.message = allEvents.flatMap((event) => {
223
- const eventData = event.EVENT_DATA ?? {};
224
-
225
- return normalizeMessages(eventData.MESSAGES)
226
- .filter((messageData) => messageData?.chat && messageData?.message)
227
- .map((messageData) => ({
228
- // Портал отдаёт идентификаторы то строкой, то числом —
229
- // приводим, чтобы тип `string` не врал
230
- messageId: String(event.MESSAGE_ID ?? ""),
231
- eventId: String(event.ID ?? ""),
232
- connectorId: eventData.CONNECTOR,
233
- lineId: eventData.LINE,
234
- chatId: messageData.chat.id,
235
- text: messageData.message.text || null,
236
- file: this.convertB24FilesToTelegramFormat(messageData.message.files),
237
- attachments: messageData.message.attachments || null,
238
- im: messageData.im || null,
239
- }));
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
+ });
240
403
  });
241
404
  }
242
405
 
@@ -250,37 +413,111 @@ export class Event {
250
413
  arrMessageIdAndEntityId: [],
251
414
  };
252
415
 
253
- // Очищаем события от системного пользователя
254
- const arrEventsToClear = allEvents.filter((event) => event.EVENT_ADDITIONAL.user_id === SYSTEM_USER_ID);
416
+ // Тип OfflineEvent описывает то, что портал обещает, а не то, что присылает:
417
+ // EVENT_ADDITIONAL и EVENT_DATA приходили и без обязательных полей (T-50).
418
+ // Параметр колбэка объявлен unknown намеренно — так классификация видит запись
419
+ // такой, какая она есть
420
+ const verdicts = allEvents.map((raw: unknown) => classifyEntityEvent(raw, eventName));
421
+
422
+ // Битую запись обработать нечем, и о её пропаже иначе не узнает никто:
423
+ // ошибку вызова логирует retry-слой, а отброшенные данные — только мы
424
+ const brokenVerdicts = verdicts.filter((verdict): verdict is BrokenEntityVerdict => verdict.kind === "broken");
425
+
426
+ for (const verdict of brokenVerdicts) {
427
+ logs.add(
428
+ `[${verdict.messageId ?? "без MESSAGE_ID"}] event.offline.get: запись пропущена, ${verdict.reason} (событие ${verdict.eventName})`,
429
+ "warn"
430
+ );
431
+ }
432
+
433
+ // Запись без user_id уходит потребителю как рабочая — предупреждаем:
434
+ // системной её признать нечем, и петля между приложениями на такой записи
435
+ // иначе прошла бы незамеченной
436
+ for (const verdict of verdicts) {
437
+ if (verdict.kind !== "active" || !verdict.note) continue;
438
+
439
+ logs.add(`[${verdict.messageId}] event.offline.get: ${verdict.note}`, "warn");
440
+ }
441
+
442
+ // Битой оказалась вся выборка целиком — это уже не одна кривая запись,
443
+ // а похоже на смену формы ответа портала (переименованное поле). Уровень
444
+ // error, потому что строка обязана дойти до чата B24: очередь при этом
445
+ // продолжает вычищаться пакет за пакетом, и локальный лог никто не увидит
446
+ if (verdicts.length > 1 && brokenVerdicts.length === verdicts.length) {
447
+ const firstReason = brokenVerdicts.at(0)?.reason ?? "причина не определена";
448
+
449
+ logs.add(
450
+ `event.offline.get: битыми оказались все записи выборки, их ${verdicts.length}, причина первой — ${firstReason}. Похоже на смену формы ответа портала`,
451
+ "error"
452
+ );
453
+ }
255
454
 
256
- if (arrEventsToClear.length > 0) {
257
- const arrMessageIdToClear = arrEventsToClear.map((event) => event.MESSAGE_ID);
455
+ // process_id читаем защищённо: при пустой коллекции портал отдаёт result: []
456
+ const processId = readId(arrOfflineEvents?.process_id);
457
+
458
+ // Без process_id снять с резерва нельзя ни битые записи, ни обработанные:
459
+ // потребитель не получит ключ для clear(). Предупреждаем один раз на вызов
460
+ // и независимо от корзины — последствие у всей выборки одно: записи придут
461
+ // следующим опросом и будут обработаны повторно
462
+ if (processId === null && verdicts.length > 0) {
463
+ logs.add(
464
+ "event.offline.get: в ответе нет process_id — снять записи с резерва нечем, они придут следующим опросом и будут обработаны повторно",
465
+ "warn"
466
+ );
467
+ }
468
+
469
+ // Снимаем с резерва и системные записи (выброшены по замыслу), и битые:
470
+ // иначе они провисят зарезервированными до автоудаления через 30 дней.
471
+ // Битая запись без читаемого MESSAGE_ID сюда попасть не может — передавать нечего,
472
+ // забрать её может только clear(processId) целиком
473
+ const messageIdsToClear = verdicts
474
+ .filter((verdict) => verdict.kind !== "active")
475
+ .map((verdict) => verdict.messageId)
476
+ .filter((messageId): messageId is string => messageId !== null);
477
+
478
+ // Без process_id вызов пропускаем молча: строка warn о его отсутствии
479
+ // уже написана выше, на всю выборку разом
480
+ if (messageIdsToClear.length > 0 && processId !== null) {
258
481
  // Результат намеренно не проверяется: отказ здесь не фатален — те же
259
- // записи придут следующим опросом, а бросок лишил бы потребителя всей
260
- // выборки активных событий
261
- await this.b24.callMethod("event.offline.clear", {
262
- process_id: arrOfflineEvents.process_id,
263
- message_id: arrMessageIdToClear,
264
- });
482
+ // записи придут следующим опросом. Исключение ловим здесь же и наружу
483
+ // не пускаем: бросок после исчерпания повторов Proxy унёс бы всю выборку
484
+ // активных записей, а пакет к этому моменту уже зарезервирован под
485
+ // process_id — снять его с резерва потребителю было бы нечем
486
+ try {
487
+ await this.b24.callMethod("event.offline.clear", {
488
+ process_id: processId,
489
+ message_id: messageIdsToClear,
490
+ });
491
+ } catch (error: unknown) {
492
+ const message = error instanceof Error ? error.message : String(error);
493
+
494
+ // Уровень warn, а не error: окончательный отказ вызова уже написал
495
+ // строку error retry-слой Proxy, дубль ушёл бы в чат B24 второй раз
496
+ logs.add(
497
+ `event.offline.clear: не удалось снять с резерва записи, их ${messageIdsToClear.length} — ${message}. Записи придут следующим опросом`,
498
+ "warn"
499
+ );
500
+ }
265
501
  }
266
502
 
267
- // Работаем только с событиями, не относящимися к системному пользователю
268
- const activeEvents = allEvents.filter((event) => event.EVENT_ADDITIONAL.user_id !== SYSTEM_USER_ID);
503
+ // Работаем только с рабочими записями: не системными и не битыми
504
+ const activeEvents = verdicts.filter((verdict): verdict is ActiveEntityVerdict => verdict.kind === "active");
269
505
 
270
506
  if (activeEvents.length > 0) {
271
- data.processId = arrOfflineEvents.process_id;
272
- data.entitysId = activeEvents.map((event) => event.EVENT_DATA.FIELDS.ID);
273
- data.arrMessageIdAndEntityId = activeEvents.map((event) => ({
274
- messageId: event.MESSAGE_ID,
275
- entityId: event.EVENT_DATA.FIELDS.ID,
507
+ data.processId = processId;
508
+ data.entitysId = activeEvents.map((verdict) => verdict.entityId);
509
+ data.arrMessageIdAndEntityId = activeEvents.map((verdict) => ({
510
+ messageId: verdict.messageId,
511
+ entityId: verdict.entityId,
276
512
  }));
277
513
  }
278
514
 
279
515
  return { error: false, message: "События получены", data };
280
- } catch (error: any) {
516
+ } catch (error: unknown) {
517
+ const message = error instanceof Error ? error.message : String(error);
281
518
  return {
282
519
  error: true,
283
- message: `Не удалось получить события. ${error.message}`,
520
+ message: `Не удалось получить события. ${message}`,
284
521
  data: null,
285
522
  };
286
523
  }
@@ -300,7 +537,7 @@ export class Event {
300
537
  return { error: false, message: "Список записей пуст, очищать нечего", data: null };
301
538
  }
302
539
 
303
- const params: any = { process_id: processId };
540
+ const params: Record<string, unknown> = { process_id: processId };
304
541
  if (messageId) {
305
542
  params.message_id = messageId;
306
543
  }
@@ -316,10 +553,11 @@ export class Event {
316
553
  }
317
554
 
318
555
  return { error: false, message: "События очищены", data: null };
319
- } catch (error: any) {
556
+ } catch (error: unknown) {
557
+ const message = error instanceof Error ? error.message : String(error);
320
558
  return {
321
559
  error: true,
322
- message: `Не удалось очистить события. ${error.message}`,
560
+ message: `Не удалось очистить события. ${message}`,
323
561
  data: null,
324
562
  };
325
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,16 +1,17 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.8.2",
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
- "test": "echo \"No tests\" && exit 0",
14
+ "test": "DOTENV_CONFIG_QUIET=true node --test --test-timeout=30000 \"tests/**/*.test.ts\"",
14
15
  "pack:dry": "npm pack --dry-run"
15
16
  },
16
17
  "repository": {
@@ -30,6 +31,14 @@
30
31
  "files": [
31
32
  "index.ts",
32
33
  "bitrix24/b24.ts",
34
+ "bitrix24/b24/types.ts",
35
+ "bitrix24/b24/config.ts",
36
+ "bitrix24/b24/state.ts",
37
+ "bitrix24/b24/retry.ts",
38
+ "bitrix24/b24/facade.ts",
39
+ "bitrix24/b24/proxy.ts",
40
+ "bitrix24/b24/instance.ts",
41
+ "bitrix24/b24/tokens.ts",
33
42
  "bitrix24/errTaskB24.ts",
34
43
  "bitrix24/eventB24.ts",
35
44
  "sendMessage/chatApp.ts",
@@ -41,13 +50,23 @@
41
50
  ],
42
51
  "dependencies": {
43
52
  "@bitrix24/b24jssdk": "2.2.0",
53
+ "conf": "15.1.0",
54
+ "dotenv": "17.4.2",
55
+ "luxon": "3.7.2"
56
+ },
57
+ "devDependencies": {
44
58
  "@types/express": "5.0.6",
45
59
  "@types/luxon": "3.7.4",
46
60
  "@types/node": "25.9.5",
47
61
  "@types/nodemailer": "8.0.1",
48
- "conf": "15.1.0",
49
- "dotenv": "17.4.2",
50
- "luxon": "3.7.2",
51
- "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
+ }
52
71
  }
53
72
  }