@andrey4emk/npm-app-back-b24 3.8.2 → 3.9.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.
@@ -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
 
@@ -74,32 +75,140 @@ interface OfflineEventsResponse {
74
75
  /**
75
76
  * Пользователь, чьи события класс отбрасывает и сразу очищает из очереди.
76
77
  *
77
- * Это защита от петли, а не случайность: 138 — владелец OAuth-токенов приложений
78
- * на портале, и всё, что приложения пишут в B24 (обновление сделки, комментарий,
79
- * задача), приходит офлайн-событием с `user_id: "138"`. Без фильтра правка сделки
80
- * кодом возвращалась бы событием в тот же код и обрабатывалась бы второй раз.
78
+ * Это защита от петли, а не случайность: 138 — владелец OAuth-токенов всех приложений
79
+ * на портале, и всё, что приложения и роботы пишут в B24 (обновление сделки,
80
+ * комментарий, задача), приходит офлайн-событием с `user_id: "138"`. Собственные
81
+ * правки платформа приложению не отдаёт, но правки соседнего приложения под тем же
82
+ * владельцем — отдаёт; без фильтра правка сделки одним приложением обрабатывалась бы
83
+ * другим как внешнее изменение, и дальше по кругу.
81
84
  *
82
85
  * Оборотная сторона: ручные правки под этой же учёткой (это администратор портала)
83
86
  * тоже пропадают. Поэтому класс не годится для задач полной выгрузки или витрин,
84
87
  * где нужны все изменения без исключения — там читать `event.offline.get` напрямую
88
+ *
89
+ * Сравнение идёт по нормализованному значению (`readId`), поэтому системным считается
90
+ * и строка `"138"`, и число `138`. Портал сегодня отдаёт `user_id` строкой, то есть
91
+ * в бою это ничего не меняет; форма поля на защиту от петли влиять не должна
85
92
  */
86
93
  const SYSTEM_USER_ID = "138";
87
94
 
88
95
  // ==================== Утилиты ====================
89
96
 
90
97
  /**
91
- * Приводит MESSAGES из события к массиву.
98
+ * Приводит коллекцию из ответа портала к массиву.
92
99
  *
93
100
  * B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
94
101
  * сериализует разреженный массив), поэтому опираться на одну форму нельзя.
95
102
  * Неизвестная форма даёт пустой массив, а не исключение.
103
+ *
104
+ * Применяется к обеим коллекциям одного ответа — к списку записей и к сообщениям
105
+ * внутри записи: защита только одной из них читалась бы как решение «здесь
106
+ * нормализация не нужна», а словарь на месте списка ронял бы весь вызов.
96
107
  */
97
- function normalizeMessages(messages: unknown): any[] {
98
- if (Array.isArray(messages)) return messages;
99
- if (messages && typeof messages === "object") return Object.values(messages);
108
+ function normalizeCollection(value: unknown): any[] {
109
+ if (Array.isArray(value)) return value;
110
+ if (value && typeof value === "object") return Object.values(value);
100
111
  return [];
101
112
  }
102
113
 
114
+ /**
115
+ * Возвращает значение как словарь или null.
116
+ *
117
+ * Массив словарём не считается намеренно: PHP сериализует пустую коллекцию в `[]`,
118
+ * и такая форма на месте `EVENT_DATA` или `EVENT_ADDITIONAL` — признак битой записи,
119
+ * а не словаря с полями.
120
+ */
121
+ function asRecord(value: unknown): Record<string, unknown> | null {
122
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
123
+ return value as Record<string, unknown>;
124
+ }
125
+
126
+ /**
127
+ * Приводит идентификатор B24 к непустой строке.
128
+ *
129
+ * Портал отдаёт идентификаторы то строкой, то числом. Всё остальное (null, undefined,
130
+ * объект, пустая строка, нечисловое число) считается отсутствием значения.
131
+ */
132
+ function readId(value: unknown): string | null {
133
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
134
+ if (typeof value !== "string") return null;
135
+
136
+ const trimmed = value.trim();
137
+ return trimmed === "" ? null : trimmed;
138
+ }
139
+
140
+ /** Что делать с записью очереди в ветке сущностей */
141
+ type EntityVerdict =
142
+ | { kind: "system"; messageId: string | null }
143
+ | { kind: "broken"; messageId: string | null; eventName: string; reason: string }
144
+ | { kind: "active"; messageId: string; entityId: string; note?: string };
145
+
146
+ /** Вердикт `active` — вынесен отдельно, чтобы фильтр давал строгие `string` без утверждений типа */
147
+ type ActiveEntityVerdict = Extract<EntityVerdict, { kind: "active" }>;
148
+
149
+ /** Вердикт `broken` — вынесен отдельно ради типизированного фильтра при логировании */
150
+ type BrokenEntityVerdict = Extract<EntityVerdict, { kind: "broken" }>;
151
+
152
+ /**
153
+ * Классифицирует запись очереди офлайн-событий в ветке сущностей.
154
+ *
155
+ * Чистая функция без побочных эффектов: решает только «системная / битая / рабочая»,
156
+ * а логирование и очистку делает вызывающий.
157
+ *
158
+ * Битой считается только та запись, которую обработать нечем: нет идентификатора
159
+ * сущности (`EVENT_DATA.FIELDS.ID`) или нет ключа очистки (`MESSAGE_ID`). Отсутствие
160
+ * `EVENT_ADDITIONAL.user_id` битой запись не делает — идентификатор сущности на месте,
161
+ * значит потребитель её обработает. Признать такую запись битой значило бы поменять
162
+ * громкое падение на тихое безвозвратное удаление: `event.offline.clear` снимает
163
+ * запись с резерва навсегда, и восстановить её нечем.
164
+ *
165
+ * Обратная сторона учтена: если у записи не читается `user_id`, а на самом деле она
166
+ * от системного пользователя, она пройдёт как рабочая и теоретически даст петлю между
167
+ * приложениями. Это приемлемо — петля шумная и видимая, а тихая потеря нет; вдобавок
168
+ * на такую запись вызывающий пишет строку `warn` (поле `note`).
169
+ *
170
+ * Порядок проверок важен. Системная запись определяется до разбора `EVENT_DATA`:
171
+ * её данные не нужны, а битая системная запись должна уходить в очистку как системная —
172
+ * иначе лог шумел бы про записи, которые мы и так выбрасываем по замыслу.
173
+ *
174
+ * @param raw — запись, как её прислал портал
175
+ * @param requestedEventName — имя события из запроса, подставляется, если в записи его нет
176
+ */
177
+ function classifyEntityEvent(raw: unknown, requestedEventName: string): EntityVerdict {
178
+ const record = asRecord(raw);
179
+ if (!record) {
180
+ return { kind: "broken", messageId: null, eventName: requestedEventName, reason: "запись не является объектом" };
181
+ }
182
+
183
+ const messageId = readId(record.MESSAGE_ID);
184
+ const eventName = readId(record.EVENT_NAME) ?? requestedEventName;
185
+
186
+ const additional = asRecord(record.EVENT_ADDITIONAL);
187
+ const userId = readId(additional?.user_id);
188
+
189
+ if (userId === SYSTEM_USER_ID) {
190
+ return { kind: "system", messageId };
191
+ }
192
+
193
+ const fields = asRecord(asRecord(record.EVENT_DATA)?.FIELDS);
194
+ const entityId = readId(fields?.ID);
195
+ if (entityId === null) {
196
+ return { kind: "broken", messageId, eventName, reason: "нет EVENT_DATA.FIELDS.ID" };
197
+ }
198
+
199
+ // Без ключа очистки запись обработать нельзя: потребитель обработал бы сущность
200
+ // и не смог закрыть запись, и та пришла бы снова следующим опросом
201
+ if (messageId === null) {
202
+ return { kind: "broken", messageId, eventName, reason: "нет MESSAGE_ID" };
203
+ }
204
+
205
+ if (userId === null) {
206
+ return { kind: "active", messageId, entityId, note: "нет EVENT_ADDITIONAL.user_id, запись считается не системной" };
207
+ }
208
+
209
+ return { kind: "active", messageId, entityId };
210
+ }
211
+
103
212
  // ==================== Класс ====================
104
213
 
105
214
  /**
@@ -187,9 +296,27 @@ export class Event {
187
296
  * под тем же `process_id`. Безопасный режим — `clear(processId)` целиком.
188
297
  *
189
298
  * События пользователя `SYSTEM_USER_ID` (138, владелец токенов приложений)
190
- * отбрасываются и очищаются здесь же — защита от петли «приложение записало →
191
- * событие → приложение обработало свою же запись». Для полной выгрузки всех
192
- * изменений класс не подходит, см. комментарий у константы.
299
+ * отбрасываются и очищаются здесь же — защита от петли между приложениями
300
+ * и роботами под одной учёткой. Для полной выгрузки всех изменений класс
301
+ * не подходит, см. комментарий у константы.
302
+ *
303
+ * В ветке сущностей битая запись больше не роняет весь вызов. Битой считается
304
+ * та, у которой не читается идентификатор сущности (`EVENT_DATA.FIELDS.ID`)
305
+ * или ключ очистки (`MESSAGE_ID`): она отбрасывается, её `MESSAGE_ID` уходит
306
+ * в очистку вместе с системными, а в лог пишется одна строка `warn`. Запись,
307
+ * у которой не читается сам `MESSAGE_ID`, только логируется — снять её с резерва
308
+ * может лишь `clear(processId)` целиком.
309
+ *
310
+ * Отсутствие `EVENT_ADDITIONAL.user_id` битой записью не является: обработать её
311
+ * можно, поэтому она уходит потребителю как рабочая, а в лог пишется строка `warn`
312
+ * о том, что системной её признать нечем.
313
+ *
314
+ * Retry-слой этот вызов **не повторяет**: `event.offline.get` входит в
315
+ * `NON_RETRYABLE_METHODS` (`bitrix24/b24/retry.ts`). Повтор при сетевом сбое
316
+ * зарезервировал бы второй пакет поверх первого, ответ которого уже потерян.
317
+ * Исключение — доказанный pre-connection: соединение не состоялось, резервировать
318
+ * порталу было нечего. На практике это значит, что сбой сети даёт одну попытку
319
+ * и метод возвращает `{ error: true }` — очередь заберёт следующий опрос по таймеру.
193
320
  */
194
321
  async get(eventName: string): Promise<StandardResult> {
195
322
  try {
@@ -199,8 +326,10 @@ export class Event {
199
326
  });
200
327
 
201
328
  const arrOfflineEvents = getResultData<OfflineEventsResponse["result"]>(response, "event.offline.get");
202
- // B24 для пустой коллекции может вернуть result: [] — тогда events отсутствует
203
- const allEvents = arrOfflineEvents?.events ?? [];
329
+ // B24 для пустой коллекции может вернуть result: [] — тогда events отсутствует.
330
+ // Форму самой коллекции тоже не угадываем: словарь с числовыми ключами
331
+ // не должен ронять разбор, как не роняет он MESSAGES внутри записи
332
+ const allEvents = normalizeCollection(arrOfflineEvents?.events);
204
333
 
205
334
  // Обработка событий коннектора
206
335
  if (eventName === "ONIMCONNECTORMESSAGEADD") {
@@ -222,7 +351,7 @@ export class Event {
222
351
  data.message = allEvents.flatMap((event) => {
223
352
  const eventData = event.EVENT_DATA ?? {};
224
353
 
225
- return normalizeMessages(eventData.MESSAGES)
354
+ return normalizeCollection(eventData.MESSAGES)
226
355
  .filter((messageData) => messageData?.chat && messageData?.message)
227
356
  .map((messageData) => ({
228
357
  // Портал отдаёт идентификаторы то строкой, то числом —
@@ -250,29 +379,102 @@ export class Event {
250
379
  arrMessageIdAndEntityId: [],
251
380
  };
252
381
 
253
- // Очищаем события от системного пользователя
254
- const arrEventsToClear = allEvents.filter((event) => event.EVENT_ADDITIONAL.user_id === SYSTEM_USER_ID);
382
+ // Тип OfflineEvent описывает то, что портал обещает, а не то, что присылает:
383
+ // EVENT_ADDITIONAL и EVENT_DATA приходили и без обязательных полей (T-50).
384
+ // Параметр колбэка объявлен unknown намеренно — так классификация видит запись
385
+ // такой, какая она есть. Глобальная зачистка `any` в этом файле — отдельная задача T-53
386
+ const verdicts = allEvents.map((raw: unknown) => classifyEntityEvent(raw, eventName));
387
+
388
+ // Битую запись обработать нечем, и о её пропаже иначе не узнает никто:
389
+ // ошибку вызова логирует retry-слой, а отброшенные данные — только мы
390
+ const brokenVerdicts = verdicts.filter((verdict): verdict is BrokenEntityVerdict => verdict.kind === "broken");
391
+
392
+ for (const verdict of brokenVerdicts) {
393
+ logs.add(
394
+ `[${verdict.messageId ?? "без MESSAGE_ID"}] event.offline.get: запись пропущена, ${verdict.reason} (событие ${verdict.eventName})`,
395
+ "warn"
396
+ );
397
+ }
398
+
399
+ // Запись без user_id уходит потребителю как рабочая — предупреждаем:
400
+ // системной её признать нечем, и петля между приложениями на такой записи
401
+ // иначе прошла бы незамеченной
402
+ for (const verdict of verdicts) {
403
+ if (verdict.kind !== "active" || !verdict.note) continue;
404
+
405
+ logs.add(`[${verdict.messageId}] event.offline.get: ${verdict.note}`, "warn");
406
+ }
407
+
408
+ // Битой оказалась вся выборка целиком — это уже не одна кривая запись,
409
+ // а похоже на смену формы ответа портала (переименованное поле). Уровень
410
+ // error, потому что строка обязана дойти до чата B24: очередь при этом
411
+ // продолжает вычищаться пакет за пакетом, и локальный лог никто не увидит
412
+ if (verdicts.length > 1 && brokenVerdicts.length === verdicts.length) {
413
+ const firstReason = brokenVerdicts.at(0)?.reason ?? "причина не определена";
414
+
415
+ logs.add(
416
+ `event.offline.get: битыми оказались все записи выборки, их ${verdicts.length}, причина первой — ${firstReason}. Похоже на смену формы ответа портала`,
417
+ "error"
418
+ );
419
+ }
420
+
421
+ // process_id читаем защищённо: при пустой коллекции портал отдаёт result: []
422
+ const processId = readId(arrOfflineEvents?.process_id);
423
+
424
+ // Без process_id снять с резерва нельзя ни битые записи, ни обработанные:
425
+ // потребитель не получит ключ для clear(). Предупреждаем один раз на вызов
426
+ // и независимо от корзины — последствие у всей выборки одно: записи придут
427
+ // следующим опросом и будут обработаны повторно
428
+ if (processId === null && verdicts.length > 0) {
429
+ logs.add(
430
+ "event.offline.get: в ответе нет process_id — снять записи с резерва нечем, они придут следующим опросом и будут обработаны повторно",
431
+ "warn"
432
+ );
433
+ }
255
434
 
256
- if (arrEventsToClear.length > 0) {
257
- const arrMessageIdToClear = arrEventsToClear.map((event) => event.MESSAGE_ID);
435
+ // Снимаем с резерва и системные записи (выброшены по замыслу), и битые:
436
+ // иначе они провисят зарезервированными до автоудаления через 30 дней.
437
+ // Битая запись без читаемого MESSAGE_ID сюда попасть не может — передавать нечего,
438
+ // забрать её может только clear(processId) целиком
439
+ const messageIdsToClear = verdicts
440
+ .filter((verdict) => verdict.kind !== "active")
441
+ .map((verdict) => verdict.messageId)
442
+ .filter((messageId): messageId is string => messageId !== null);
443
+
444
+ // Без process_id вызов пропускаем молча: строка warn о его отсутствии
445
+ // уже написана выше, на всю выборку разом
446
+ if (messageIdsToClear.length > 0 && processId !== null) {
258
447
  // Результат намеренно не проверяется: отказ здесь не фатален — те же
259
- // записи придут следующим опросом, а бросок лишил бы потребителя всей
260
- // выборки активных событий
261
- await this.b24.callMethod("event.offline.clear", {
262
- process_id: arrOfflineEvents.process_id,
263
- message_id: arrMessageIdToClear,
264
- });
448
+ // записи придут следующим опросом. Исключение ловим здесь же и наружу
449
+ // не пускаем: бросок после исчерпания повторов Proxy унёс бы всю выборку
450
+ // активных записей, а пакет к этому моменту уже зарезервирован под
451
+ // process_id — снять его с резерва потребителю было бы нечем
452
+ try {
453
+ await this.b24.callMethod("event.offline.clear", {
454
+ process_id: processId,
455
+ message_id: messageIdsToClear,
456
+ });
457
+ } catch (error: unknown) {
458
+ const message = error instanceof Error ? error.message : String(error);
459
+
460
+ // Уровень warn, а не error: окончательный отказ вызова уже написал
461
+ // строку error retry-слой Proxy, дубль ушёл бы в чат B24 второй раз
462
+ logs.add(
463
+ `event.offline.clear: не удалось снять с резерва записи, их ${messageIdsToClear.length} — ${message}. Записи придут следующим опросом`,
464
+ "warn"
465
+ );
466
+ }
265
467
  }
266
468
 
267
- // Работаем только с событиями, не относящимися к системному пользователю
268
- const activeEvents = allEvents.filter((event) => event.EVENT_ADDITIONAL.user_id !== SYSTEM_USER_ID);
469
+ // Работаем только с рабочими записями: не системными и не битыми
470
+ const activeEvents = verdicts.filter((verdict): verdict is ActiveEntityVerdict => verdict.kind === "active");
269
471
 
270
472
  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,
473
+ data.processId = processId;
474
+ data.entitysId = activeEvents.map((verdict) => verdict.entityId);
475
+ data.arrMessageIdAndEntityId = activeEvents.map((verdict) => ({
476
+ messageId: verdict.messageId,
477
+ entityId: verdict.entityId,
276
478
  }));
277
479
  }
278
480
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrey4emk/npm-app-back-b24",
3
- "version": "3.8.2",
3
+ "version": "3.9.0",
4
4
  "description": "Bitrix24 OAuth helpers for Node.js projects",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  "./fetchRetry": "./utils/fetchRetry.ts"
11
11
  },
12
12
  "scripts": {
13
- "test": "echo \"No tests\" && exit 0",
13
+ "test": "DOTENV_CONFIG_QUIET=true node --test --test-timeout=30000 \"tests/**/*.test.ts\"",
14
14
  "pack:dry": "npm pack --dry-run"
15
15
  },
16
16
  "repository": {
@@ -30,6 +30,14 @@
30
30
  "files": [
31
31
  "index.ts",
32
32
  "bitrix24/b24.ts",
33
+ "bitrix24/b24/types.ts",
34
+ "bitrix24/b24/config.ts",
35
+ "bitrix24/b24/state.ts",
36
+ "bitrix24/b24/retry.ts",
37
+ "bitrix24/b24/facade.ts",
38
+ "bitrix24/b24/proxy.ts",
39
+ "bitrix24/b24/instance.ts",
40
+ "bitrix24/b24/tokens.ts",
33
41
  "bitrix24/errTaskB24.ts",
34
42
  "bitrix24/eventB24.ts",
35
43
  "sendMessage/chatApp.ts",