@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.
- package/README.md +67 -10
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +358 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +78 -0
- package/bitrix24/b24/retry.ts +478 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +278 -0
- package/bitrix24/b24/types.ts +66 -0
- package/bitrix24/b24.ts +60 -949
- package/bitrix24/eventB24.ts +233 -31
- package/package.json +10 -2
- package/sendMessage/email.ts +233 -32
- package/utils/fetchRetry.ts +70 -9
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
|
|
|
@@ -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
|
-
* Приводит
|
|
98
|
+
* Приводит коллекцию из ответа портала к массиву.
|
|
92
99
|
*
|
|
93
100
|
* B24 отдаёт коллекции то массивом, то словарём с числовыми ключами (так PHP
|
|
94
101
|
* сериализует разреженный массив), поэтому опираться на одну форму нельзя.
|
|
95
102
|
* Неизвестная форма даёт пустой массив, а не исключение.
|
|
103
|
+
*
|
|
104
|
+
* Применяется к обеим коллекциям одного ответа — к списку записей и к сообщениям
|
|
105
|
+
* внутри записи: защита только одной из них читалась бы как решение «здесь
|
|
106
|
+
* нормализация не нужна», а словарь на месте списка ронял бы весь вызов.
|
|
96
107
|
*/
|
|
97
|
-
function
|
|
98
|
-
if (Array.isArray(
|
|
99
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 =
|
|
469
|
+
// Работаем только с рабочими записями: не системными и не битыми
|
|
470
|
+
const activeEvents = verdicts.filter((verdict): verdict is ActiveEntityVerdict => verdict.kind === "active");
|
|
269
471
|
|
|
270
472
|
if (activeEvents.length > 0) {
|
|
271
|
-
data.processId =
|
|
272
|
-
data.entitysId = activeEvents.map((
|
|
273
|
-
data.arrMessageIdAndEntityId = activeEvents.map((
|
|
274
|
-
messageId:
|
|
275
|
-
entityId:
|
|
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.
|
|
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": "
|
|
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",
|