@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.
- package/README.md +165 -13
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +371 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +120 -0
- package/bitrix24/b24/retry.ts +490 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +280 -0
- package/bitrix24/b24/types.ts +93 -0
- package/bitrix24/b24.ts +97 -955
- package/bitrix24/errTaskB24.ts +8 -5
- package/bitrix24/eventB24.ts +301 -63
- package/index.ts +4 -1
- package/package.json +26 -7
- package/sendMessage/chatApp.ts +101 -24
- package/sendMessage/email.ts +233 -32
- package/sendMessage/smsgold.ts +11 -2
- package/sendMessage/wappi.ts +41 -15
- package/utils/fetchRetry.ts +70 -9
package/bitrix24/errTaskB24.ts
CHANGED
|
@@ -27,7 +27,8 @@ export interface ErrorTaskData {
|
|
|
27
27
|
interface ErrorB24Result {
|
|
28
28
|
error: boolean;
|
|
29
29
|
message: string;
|
|
30
|
-
|
|
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:
|
|
69
|
-
|
|
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:
|
|
138
|
+
} catch (error: unknown) {
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
140
|
return {
|
|
138
141
|
error: true,
|
|
139
|
-
message: `Не удалось создать задачу в Битрикс24: ${
|
|
142
|
+
message: `Не удалось создать задачу в Битрикс24: ${message}`,
|
|
140
143
|
};
|
|
141
144
|
}
|
|
142
145
|
}
|
package/bitrix24/eventB24.ts
CHANGED
|
@@ -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:
|
|
23
|
-
attachments:
|
|
24
|
-
im:
|
|
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
|
-
|
|
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:
|
|
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
|
-
* Приводит
|
|
115
|
+
* Приводит коллекцию из ответа портала к массиву.
|
|
92
116
|
*
|
|
93
117
|
* B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
|
|
94
118
|
* сериализует разреженный массив), поэтому опираться на одну форму нельзя.
|
|
95
119
|
* Неизвестная форма даёт пустой массив, а не исключение.
|
|
120
|
+
*
|
|
121
|
+
* Применяется к обеим коллекциям одного ответа — к списку записей и к сообщениям
|
|
122
|
+
* внутри записи: защита только одной из них читалась бы как решение «здесь
|
|
123
|
+
* нормализация не нужна», а словарь на месте списка ронял бы весь вызов.
|
|
96
124
|
*/
|
|
97
|
-
function
|
|
98
|
-
if (Array.isArray(
|
|
99
|
-
if (
|
|
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:
|
|
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((
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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((
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 =
|
|
503
|
+
// Работаем только с рабочими записями: не системными и не битыми
|
|
504
|
+
const activeEvents = verdicts.filter((verdict): verdict is ActiveEntityVerdict => verdict.kind === "active");
|
|
269
505
|
|
|
270
506
|
if (activeEvents.length > 0) {
|
|
271
|
-
data.processId =
|
|
272
|
-
data.entitysId = activeEvents.map((
|
|
273
|
-
data.arrMessageIdAndEntityId = activeEvents.map((
|
|
274
|
-
messageId:
|
|
275
|
-
entityId:
|
|
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:
|
|
516
|
+
} catch (error: unknown) {
|
|
517
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
281
518
|
return {
|
|
282
519
|
error: true,
|
|
283
|
-
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:
|
|
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:
|
|
556
|
+
} catch (error: unknown) {
|
|
557
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
320
558
|
return {
|
|
321
559
|
error: true,
|
|
322
|
-
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
|
+
"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": "
|
|
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
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
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
|
}
|