@ihazz/bitrix24 1.1.13 → 1.1.14
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/dist/src/channel.d.ts +2 -0
- package/dist/src/channel.d.ts.map +1 -1
- package/dist/src/channel.js +1154 -143
- package/dist/src/channel.js.map +1 -1
- package/dist/src/i18n.d.ts +1 -0
- package/dist/src/i18n.d.ts.map +1 -1
- package/dist/src/i18n.js +11 -0
- package/dist/src/i18n.js.map +1 -1
- package/dist/src/inbound-handler.d.ts +10 -0
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/inbound-handler.js +196 -9
- package/dist/src/inbound-handler.js.map +1 -1
- package/dist/src/media-service.d.ts +2 -0
- package/dist/src/media-service.d.ts.map +1 -1
- package/dist/src/media-service.js +30 -0
- package/dist/src/media-service.js.map +1 -1
- package/dist/src/message-utils.d.ts.map +1 -1
- package/dist/src/message-utils.js +40 -1
- package/dist/src/message-utils.js.map +1 -1
- package/dist/src/send-service.d.ts +1 -1
- package/dist/src/send-service.d.ts.map +1 -1
- package/dist/src/send-service.js +8 -2
- package/dist/src/send-service.js.map +1 -1
- package/package.json +1 -1
- package/src/channel.ts +1447 -202
- package/src/i18n.ts +13 -0
- package/src/inbound-handler.ts +247 -10
- package/src/media-service.ts +39 -0
- package/src/message-utils.ts +54 -1
- package/src/send-service.ts +12 -2
package/src/i18n.ts
CHANGED
|
@@ -32,6 +32,19 @@ export function mediaDownloadFailed(lang: string | undefined, fileNames: string)
|
|
|
32
32
|
return resolve(I18N_MEDIA_DOWNLOAD_FAILED, lang)(fileNames);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const I18N_EMPTY_REPLY_FALLBACK: Record<string, string> = {
|
|
36
|
+
en: 'I could not send the reply. Please try again.',
|
|
37
|
+
ru: 'Не удалось отправить ответ. Попробуйте еще раз.',
|
|
38
|
+
de: 'Die Antwort konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
|
|
39
|
+
es: 'No pude enviar la respuesta. Intentalo de nuevo.',
|
|
40
|
+
fr: 'Je n ai pas pu envoyer la reponse. Veuillez reessayer.',
|
|
41
|
+
pt: 'Nao foi possivel enviar a resposta. Tente novamente.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function emptyReplyFallback(lang: string | undefined): string {
|
|
45
|
+
return resolve(I18N_EMPTY_REPLY_FALLBACK, lang);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
// ─── Group chat unsupported ──────────────────────────────────────────────────
|
|
36
49
|
|
|
37
50
|
const I18N_GROUP_CHAT_UNSUPPORTED: Record<string, string> = {
|
package/src/inbound-handler.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type {
|
|
|
19
19
|
import { Dedup } from './dedup.js';
|
|
20
20
|
import { createVerboseLogger, defaultLogger } from './utils.js';
|
|
21
21
|
|
|
22
|
+
const USER_SCOPE_GROUP_MIRROR_DEBOUNCE_MS = 1200;
|
|
23
|
+
|
|
22
24
|
/** Normalized fetch command context passed to onFetchCommand callback. */
|
|
23
25
|
export interface FetchCommandContext {
|
|
24
26
|
commandId: number;
|
|
@@ -77,6 +79,11 @@ export class InboundHandler {
|
|
|
77
79
|
private config: Bitrix24AccountConfig;
|
|
78
80
|
private logger: Logger;
|
|
79
81
|
private verboseLog: boolean;
|
|
82
|
+
private knownBotConversations = new Map<string, number>();
|
|
83
|
+
private pendingUserScopeGroupMessages = new Map<string, {
|
|
84
|
+
timer: ReturnType<typeof setTimeout>;
|
|
85
|
+
resolve: (value: boolean) => void;
|
|
86
|
+
}>();
|
|
80
87
|
private onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
|
|
81
88
|
private onJoinChat?: (ctx: FetchJoinChatContext) => void | Promise<void>;
|
|
82
89
|
private onReactionChange?: (ctx: FetchReactionContext) => void | Promise<void>;
|
|
@@ -95,6 +102,112 @@ export class InboundHandler {
|
|
|
95
102
|
this.onBotDelete = opts.onBotDelete;
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
private pruneKnownBotConversations(now = Date.now()): void {
|
|
106
|
+
for (const [key, expiresAt] of this.knownBotConversations.entries()) {
|
|
107
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
108
|
+
this.knownBotConversations.delete(key);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private buildConversationMirrorKeys(dialogId: string, chatId: string): string[] {
|
|
114
|
+
const keys: string[] = [];
|
|
115
|
+
const normalizedDialogId = String(dialogId ?? '').trim();
|
|
116
|
+
const normalizedChatId = String(chatId ?? '').trim();
|
|
117
|
+
|
|
118
|
+
if (normalizedDialogId) {
|
|
119
|
+
keys.push(`dialog:${normalizedDialogId}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (normalizedChatId) {
|
|
123
|
+
keys.push(`chat:${normalizedChatId}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return keys;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private rememberBotConversation(dialogId: string, chatId: string): void {
|
|
130
|
+
const keys = this.buildConversationMirrorKeys(dialogId, chatId);
|
|
131
|
+
if (keys.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.pruneKnownBotConversations();
|
|
136
|
+
const expiresAt = Date.now() + (5 * 60 * 1000);
|
|
137
|
+
keys.forEach((key) => {
|
|
138
|
+
this.knownBotConversations.set(key, expiresAt);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private isKnownBotConversation(dialogId: string, chatId: string): boolean {
|
|
143
|
+
const keys = this.buildConversationMirrorKeys(dialogId, chatId);
|
|
144
|
+
if (keys.length === 0) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.pruneKnownBotConversations();
|
|
149
|
+
return keys.some((key) => this.knownBotConversations.has(key));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private shouldSkipMirroredUserEvent(params: {
|
|
153
|
+
dialogId: string;
|
|
154
|
+
chatId: string;
|
|
155
|
+
isP2P: boolean;
|
|
156
|
+
botId: string;
|
|
157
|
+
}): boolean {
|
|
158
|
+
if (!this.config.agentMode) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (params.isP2P) {
|
|
163
|
+
return params.dialogId === params.botId;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return this.isKnownBotConversation(params.dialogId, params.chatId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private buildPendingUserScopeGroupMessageKey(messageId: string, chatId: string): string {
|
|
170
|
+
return `${chatId}:${messageId}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private consumePendingUserScopeGroupMessage(messageId: string, chatId: string): boolean {
|
|
174
|
+
const key = this.buildPendingUserScopeGroupMessageKey(messageId, chatId);
|
|
175
|
+
const pendingEntry = this.pendingUserScopeGroupMessages.get(key);
|
|
176
|
+
if (!pendingEntry) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
clearTimeout(pendingEntry.timer);
|
|
181
|
+
this.pendingUserScopeGroupMessages.delete(key);
|
|
182
|
+
pendingEntry.resolve(true);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private deferPotentialMirroredUserScopeGroupMessage(params: {
|
|
187
|
+
messageId: string;
|
|
188
|
+
chatId: string;
|
|
189
|
+
run: () => Promise<void>;
|
|
190
|
+
}): Promise<boolean> {
|
|
191
|
+
const key = this.buildPendingUserScopeGroupMessageKey(params.messageId, params.chatId);
|
|
192
|
+
const existingEntry = this.pendingUserScopeGroupMessages.get(key);
|
|
193
|
+
if (existingEntry) {
|
|
194
|
+
return Promise.resolve(true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
const timer = setTimeout(async () => {
|
|
199
|
+
this.pendingUserScopeGroupMessages.delete(key);
|
|
200
|
+
try {
|
|
201
|
+
await params.run();
|
|
202
|
+
} finally {
|
|
203
|
+
resolve(true);
|
|
204
|
+
}
|
|
205
|
+
}, USER_SCOPE_GROUP_MIRROR_DEBOUNCE_MS);
|
|
206
|
+
|
|
207
|
+
this.pendingUserScopeGroupMessages.set(key, { timer, resolve });
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
98
211
|
// ─── V2 FETCH mode event handling ───────────────────────────────────────
|
|
99
212
|
|
|
100
213
|
/**
|
|
@@ -167,18 +280,49 @@ export class InboundHandler {
|
|
|
167
280
|
}
|
|
168
281
|
const messageId = String(rawMessageId);
|
|
169
282
|
|
|
170
|
-
const dedupKey = `${eventScope}:${messageId}`;
|
|
171
|
-
if (this.dedup.isDuplicate(dedupKey)) {
|
|
172
|
-
this.logger.debug(`Fetch: duplicate message ${messageId} for scope ${eventScope}, skipping`);
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
283
|
const dialogId = String(data.chat?.dialogId ?? data.message.chatId ?? data.user.id ?? '');
|
|
177
284
|
if (!dialogId) {
|
|
178
285
|
this.logger.warn('Fetch: message event has no dialogId, skipping');
|
|
179
286
|
return true;
|
|
180
287
|
}
|
|
288
|
+
const chatInternalId = String(data.message?.chatId ?? data.chat?.id ?? dialogId);
|
|
181
289
|
const isP2P = !dialogId.startsWith('chat');
|
|
290
|
+
const normalizedBotId = String(fetchCtx.botId);
|
|
291
|
+
const normalizedSenderId = String(data.user?.id ?? '');
|
|
292
|
+
const normalizedAuthorId = String(data.message?.authorId ?? '');
|
|
293
|
+
|
|
294
|
+
if (eventScope === 'bot' || normalizedSenderId === normalizedBotId || normalizedAuthorId === normalizedBotId) {
|
|
295
|
+
this.rememberBotConversation(dialogId, chatInternalId);
|
|
296
|
+
if (eventScope === 'bot' && this.consumePendingUserScopeGroupMessage(messageId, chatInternalId)) {
|
|
297
|
+
this.logger.debug(`Fetch: canceled pending mirrored user-scope group message ${messageId}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (isBitrixBackendServiceMessage(data.message?.text ?? '', data.message?.params)) {
|
|
302
|
+
this.logger.debug(`Fetch: skipping Bitrix backend service message ${messageId}`);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (normalizedSenderId === normalizedBotId || normalizedAuthorId === normalizedBotId) {
|
|
307
|
+
this.logger.debug(`Fetch: skipping self-authored message ${messageId} for scope ${eventScope}`);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (eventScope === 'user' && this.shouldSkipMirroredUserEvent({
|
|
312
|
+
dialogId,
|
|
313
|
+
chatId: chatInternalId,
|
|
314
|
+
isP2P,
|
|
315
|
+
botId: normalizedBotId,
|
|
316
|
+
})) {
|
|
317
|
+
this.logger.debug(`Fetch: skipping mirrored user-scope message ${messageId}`);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const dedupKey = `${eventScope}:${messageId}`;
|
|
322
|
+
if (this.dedup.isDuplicate(dedupKey)) {
|
|
323
|
+
this.logger.debug(`Fetch: duplicate message ${messageId} for scope ${eventScope}, skipping`);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
182
326
|
|
|
183
327
|
// Extract file attachments from message params
|
|
184
328
|
const media = extractFilesFromParams(data.message.params);
|
|
@@ -208,7 +352,7 @@ export class InboundHandler {
|
|
|
208
352
|
senderName: data.user?.name ?? dialogId,
|
|
209
353
|
senderFirstName: data.user?.firstName,
|
|
210
354
|
chatId: dialogId,
|
|
211
|
-
chatInternalId
|
|
355
|
+
chatInternalId,
|
|
212
356
|
chatName: data.chat?.name,
|
|
213
357
|
chatType: data.chat?.type,
|
|
214
358
|
messageId,
|
|
@@ -231,6 +375,16 @@ export class InboundHandler {
|
|
|
231
375
|
memberId: '',
|
|
232
376
|
};
|
|
233
377
|
|
|
378
|
+
if (eventScope === 'user' && !isP2P && this.config.agentMode && !this.isKnownBotConversation(dialogId, chatInternalId)) {
|
|
379
|
+
return this.deferPotentialMirroredUserScopeGroupMessage({
|
|
380
|
+
messageId,
|
|
381
|
+
chatId: chatInternalId,
|
|
382
|
+
run: async () => {
|
|
383
|
+
await this.onMessage?.(ctx);
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
234
388
|
await this.onMessage?.(ctx);
|
|
235
389
|
return true;
|
|
236
390
|
}
|
|
@@ -269,14 +423,44 @@ export class InboundHandler {
|
|
|
269
423
|
this.logger.warn('Fetch: reaction event has no dialogId, skipping', { eventId: item.eventId });
|
|
270
424
|
return true;
|
|
271
425
|
}
|
|
426
|
+
const chatId = String(data.chat?.id ?? data.message.chatId ?? dialogId);
|
|
427
|
+
const isP2P = !dialogId.startsWith('chat');
|
|
428
|
+
const normalizedBotId = String(fetchCtx.botId);
|
|
429
|
+
const normalizedSenderId = String(data.user.id ?? '');
|
|
430
|
+
const normalizedMessageAuthorId = String(data.message.authorId ?? '');
|
|
431
|
+
|
|
432
|
+
if (eventScope === 'bot' || normalizedSenderId === normalizedBotId || normalizedMessageAuthorId === normalizedBotId) {
|
|
433
|
+
this.rememberBotConversation(dialogId, chatId);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (normalizedSenderId === normalizedBotId) {
|
|
437
|
+
this.logger.debug(`Fetch: skipping self-authored reaction ${messageId} for scope ${eventScope}`);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (eventScope === 'user' && this.shouldSkipMirroredUserEvent({
|
|
442
|
+
dialogId,
|
|
443
|
+
chatId,
|
|
444
|
+
isP2P,
|
|
445
|
+
botId: normalizedBotId,
|
|
446
|
+
})) {
|
|
447
|
+
this.logger.debug(`Fetch: skipping mirrored user-scope reaction ${messageId}`);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const dedupKey = `${eventScope}:reaction:${messageId}:${normalizedSenderId}:${action}:${String(data.reaction ?? '')}`;
|
|
452
|
+
if (this.dedup.isDuplicate(dedupKey)) {
|
|
453
|
+
this.logger.debug(`Fetch: duplicate reaction ${messageId} for scope ${eventScope}, skipping`);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
272
456
|
|
|
273
457
|
await this.onReactionChange?.({
|
|
274
458
|
eventScope,
|
|
275
|
-
senderId:
|
|
459
|
+
senderId: normalizedSenderId,
|
|
276
460
|
dialogId,
|
|
277
|
-
chatId
|
|
461
|
+
chatId,
|
|
278
462
|
messageId,
|
|
279
|
-
messageAuthorId:
|
|
463
|
+
messageAuthorId: normalizedMessageAuthorId,
|
|
280
464
|
reaction: String(data.reaction ?? ''),
|
|
281
465
|
action,
|
|
282
466
|
language: data.language,
|
|
@@ -300,6 +484,8 @@ export class InboundHandler {
|
|
|
300
484
|
return true;
|
|
301
485
|
}
|
|
302
486
|
|
|
487
|
+
this.rememberBotConversation(data.dialogId, String(data.chat.id ?? data.dialogId));
|
|
488
|
+
|
|
303
489
|
const joinCtx: FetchJoinChatContext = {
|
|
304
490
|
senderId: String(data.user?.id ?? data.dialogId),
|
|
305
491
|
dialogId: data.dialogId,
|
|
@@ -348,6 +534,7 @@ export class InboundHandler {
|
|
|
348
534
|
return true;
|
|
349
535
|
}
|
|
350
536
|
const isP2P = !dialogId.startsWith('chat');
|
|
537
|
+
this.rememberBotConversation(dialogId, String(data.chat?.id ?? data.message?.chatId ?? dialogId));
|
|
351
538
|
|
|
352
539
|
const cmdCtx: FetchCommandContext = {
|
|
353
540
|
commandId,
|
|
@@ -448,6 +635,12 @@ export class InboundHandler {
|
|
|
448
635
|
|
|
449
636
|
destroy(): void {
|
|
450
637
|
this.dedup.destroy();
|
|
638
|
+
this.knownBotConversations.clear();
|
|
639
|
+
for (const pendingEntry of this.pendingUserScopeGroupMessages.values()) {
|
|
640
|
+
clearTimeout(pendingEntry.timer);
|
|
641
|
+
pendingEntry.resolve(true);
|
|
642
|
+
}
|
|
643
|
+
this.pendingUserScopeGroupMessages.clear();
|
|
451
644
|
}
|
|
452
645
|
}
|
|
453
646
|
|
|
@@ -466,6 +659,50 @@ function normalizeMessageParams(params: B24V2MessageParams | undefined): Record<
|
|
|
466
659
|
return params;
|
|
467
660
|
}
|
|
468
661
|
|
|
662
|
+
function extractAttachBlockMessages(params: B24V2MessageParams | undefined): string[] {
|
|
663
|
+
const normalizedParams = normalizeMessageParams(params);
|
|
664
|
+
const rawAttach = normalizedParams.ATTACH;
|
|
665
|
+
if (!Array.isArray(rawAttach)) {
|
|
666
|
+
return [];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return rawAttach.flatMap((attachItem) => {
|
|
670
|
+
if (!attachItem || typeof attachItem !== 'object') {
|
|
671
|
+
return [];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const rawBlocks = (attachItem as Record<string, unknown>).BLOCKS;
|
|
675
|
+
if (!Array.isArray(rawBlocks)) {
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return rawBlocks
|
|
680
|
+
.map((block) => (
|
|
681
|
+
block && typeof block === 'object'
|
|
682
|
+
? String((block as Record<string, unknown>).MESSAGE ?? '').trim().toLowerCase()
|
|
683
|
+
: ''
|
|
684
|
+
))
|
|
685
|
+
.filter(Boolean);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function isBitrixBackendServiceMessage(text: string, params: B24V2MessageParams | undefined): boolean {
|
|
690
|
+
if (text.trim().toLowerCase() !== 'backend') {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const blockMessages = extractAttachBlockMessages(params);
|
|
695
|
+
if (blockMessages.length === 0) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const hasEvent = blockMessages.some((message) => message.startsWith('event:'));
|
|
700
|
+
const hasTool = blockMessages.some((message) => message === 'tool: im');
|
|
701
|
+
const hasCategory = blockMessages.some((message) => message === 'category: chat');
|
|
702
|
+
|
|
703
|
+
return hasEvent && hasTool && hasCategory;
|
|
704
|
+
}
|
|
705
|
+
|
|
469
706
|
function normalizeFileRecords(rawFiles: unknown): Record<string, Record<string, unknown>> {
|
|
470
707
|
if (!rawFiles || typeof rawFiles !== 'object') {
|
|
471
708
|
return {};
|
package/src/media-service.ts
CHANGED
|
@@ -166,6 +166,7 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
|
166
166
|
|
|
167
167
|
/** Timeout for media download requests (30 seconds). */
|
|
168
168
|
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
169
|
+
const DOWNLOADED_MEDIA_CLEANUP_DELAY_MS = 30 * 60 * 1000;
|
|
169
170
|
|
|
170
171
|
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
171
172
|
const MANAGED_FILE_NAME_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}_(.+)$/i;
|
|
@@ -186,6 +187,7 @@ export class MediaService {
|
|
|
186
187
|
private api: Bitrix24Api;
|
|
187
188
|
private logger: Logger;
|
|
188
189
|
private dirReady = false;
|
|
190
|
+
private pendingDownloadedMediaCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
189
191
|
|
|
190
192
|
constructor(api: Bitrix24Api, logger?: Logger) {
|
|
191
193
|
this.api = api;
|
|
@@ -607,6 +609,12 @@ export class MediaService {
|
|
|
607
609
|
const uniquePaths = [...new Set(paths)];
|
|
608
610
|
|
|
609
611
|
for (const filePath of uniquePaths) {
|
|
612
|
+
const scheduledCleanup = this.pendingDownloadedMediaCleanupTimers.get(filePath);
|
|
613
|
+
if (scheduledCleanup) {
|
|
614
|
+
clearTimeout(scheduledCleanup);
|
|
615
|
+
this.pendingDownloadedMediaCleanupTimers.delete(filePath);
|
|
616
|
+
}
|
|
617
|
+
|
|
610
618
|
if (!(await this.isManagedMediaPath(filePath))) {
|
|
611
619
|
this.logger.debug('Skipping cleanup for unmanaged media path', { path: filePath });
|
|
612
620
|
continue;
|
|
@@ -630,6 +638,37 @@ export class MediaService {
|
|
|
630
638
|
}
|
|
631
639
|
}
|
|
632
640
|
|
|
641
|
+
scheduleDownloadedMediaCleanup(
|
|
642
|
+
paths: string[],
|
|
643
|
+
delayMs = DOWNLOADED_MEDIA_CLEANUP_DELAY_MS,
|
|
644
|
+
): void {
|
|
645
|
+
const normalizedDelayMs = Math.max(0, Number(delayMs) || 0);
|
|
646
|
+
const uniquePaths = [...new Set(paths.map((path) => path.trim()).filter(Boolean))];
|
|
647
|
+
|
|
648
|
+
for (const filePath of uniquePaths) {
|
|
649
|
+
const scheduledCleanup = this.pendingDownloadedMediaCleanupTimers.get(filePath);
|
|
650
|
+
if (scheduledCleanup) {
|
|
651
|
+
clearTimeout(scheduledCleanup);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const timer = setTimeout(() => {
|
|
655
|
+
this.pendingDownloadedMediaCleanupTimers.delete(filePath);
|
|
656
|
+
this.cleanupDownloadedMedia([filePath]).catch((error) => {
|
|
657
|
+
this.logger.warn('Failed to cleanup downloaded media after delay', {
|
|
658
|
+
path: filePath,
|
|
659
|
+
error: serializeError(error),
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
}, normalizedDelayMs);
|
|
663
|
+
|
|
664
|
+
if (timer && typeof timer === 'object' && 'unref' in timer) {
|
|
665
|
+
timer.unref();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
this.pendingDownloadedMediaCleanupTimers.set(filePath, timer);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
633
672
|
private async isManagedMediaPath(filePath: string): Promise<boolean> {
|
|
634
673
|
try {
|
|
635
674
|
const resolvedPath = await realpath(filePath);
|
package/src/message-utils.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { KeyboardButton, B24Keyboard } from './types.js';
|
|
|
4
4
|
|
|
5
5
|
const PH_ESC = '\x00ESC'; // escape sequences
|
|
6
6
|
const PH_BBCODE = '\x00BB'; // existing BBCode blocks
|
|
7
|
+
const PH_ENTITY = '\x00BE'; // existing Bitrix entity mentions
|
|
7
8
|
const PH_FCODE = '\x00FC'; // fenced code blocks
|
|
8
9
|
const PH_ICODE = '\x00IC'; // inline code
|
|
9
10
|
const PH_HR = '\x00HR'; // horizontal rules
|
|
@@ -62,6 +63,7 @@ export function markdownToBbCode(md: string): string {
|
|
|
62
63
|
return `${PH_ICODE}${inlineCodes.length - 1}\x00`;
|
|
63
64
|
});
|
|
64
65
|
const tableSeparators: string[] = [];
|
|
66
|
+
const bitrixEntityBlocks: string[] = [];
|
|
65
67
|
|
|
66
68
|
// ── Phase 2: Block rules (line-level, order matters) ──────────────────────
|
|
67
69
|
|
|
@@ -173,6 +175,13 @@ export function markdownToBbCode(md: string): string {
|
|
|
173
175
|
// 3j. Autolink email: <user@example.com> → [URL]mailto:user@example.com[/URL]
|
|
174
176
|
text = text.replace(/<([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>/g, '[URL]mailto:$1[/URL]');
|
|
175
177
|
|
|
178
|
+
text = text.replace(/\[(CHAT|USER)=[^\]]+\][\s\S]*?\[\/\1\]/giu, (block: string) => {
|
|
179
|
+
bitrixEntityBlocks.push(block);
|
|
180
|
+
return `${PH_ENTITY}${bitrixEntityBlocks.length - 1}\x00`;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
text = repairMalformedBitrixEntityMentions(text);
|
|
184
|
+
|
|
176
185
|
// ── Phase 4: Restore placeholders ─────────────────────────────────────────
|
|
177
186
|
|
|
178
187
|
// 4a. Horizontal rules → visual separator
|
|
@@ -205,7 +214,13 @@ export function markdownToBbCode(md: string): string {
|
|
|
205
214
|
return value != null ? restoreBbCodeCodeBlock(value) : _m;
|
|
206
215
|
});
|
|
207
216
|
|
|
208
|
-
// 4d.
|
|
217
|
+
// 4d. Existing Bitrix entity mentions → original source, untouched
|
|
218
|
+
text = text.replace(new RegExp(`${PH_ENTITY.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
|
|
219
|
+
const value = bitrixEntityBlocks[Number(idx)];
|
|
220
|
+
return value != null ? value : _m;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// 4e. Escape sequences → literal characters
|
|
209
224
|
text = text.replace(new RegExp(`${PH_ESC.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
|
|
210
225
|
const value = escapes[Number(idx)];
|
|
211
226
|
return value != null ? value : _m;
|
|
@@ -214,6 +229,44 @@ export function markdownToBbCode(md: string): string {
|
|
|
214
229
|
return text;
|
|
215
230
|
}
|
|
216
231
|
|
|
232
|
+
function repairMalformedBitrixEntityMentions(text: string): string {
|
|
233
|
+
let repaired = text;
|
|
234
|
+
|
|
235
|
+
const quotedPatterns: RegExp[] = [
|
|
236
|
+
/\[(CHAT|USER)=([^\]]+)\]\s*«([^»\r\n]{1,80})»(?!\s*\[\/\1\])/giu,
|
|
237
|
+
/\[(CHAT|USER)=([^\]]+)\]\s*“([^”\r\n]{1,80})”(?!\s*\[\/\1\])/giu,
|
|
238
|
+
/\[(CHAT|USER)=([^\]]+)\]\s*"([^"\r\n]{1,80})"(?!\s*\[\/\1\])/giu,
|
|
239
|
+
/\[(CHAT|USER)=([^\]]+)\]\s*'([^'\r\n]{1,80})'(?!\s*\[\/\1\])/giu,
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
for (const pattern of quotedPatterns) {
|
|
243
|
+
repaired = repaired.replace(pattern, (_match, rawTag: string, rawId: string, rawLabel: string) => {
|
|
244
|
+
return buildBitrixEntityMention(rawTag, rawId, rawLabel);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
repaired = repaired.replace(
|
|
249
|
+
/\[(CHAT|USER)=([^\]]+)\]\s*([\p{Lu}\d][\p{L}\p{N}._+-]*(?:\s+[\p{Lu}\d][\p{L}\p{N}._+-]*){0,5})(?=$|[\s.,!?;:)\]]|\[)(?!\s*\[\/\1\])/gu,
|
|
250
|
+
(_match, rawTag: string, rawId: string, rawLabel: string) => {
|
|
251
|
+
return buildBitrixEntityMention(rawTag, rawId, rawLabel);
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return repaired;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildBitrixEntityMention(rawTag: string, rawId: string, rawLabel: string): string {
|
|
259
|
+
const tag = rawTag.toUpperCase();
|
|
260
|
+
const id = rawId.trim();
|
|
261
|
+
const label = rawLabel.trim();
|
|
262
|
+
|
|
263
|
+
if (!id || !label) {
|
|
264
|
+
return `[${tag}=${rawId}]${rawLabel}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return `[${tag}=${id}]${label}[/${tag}]`;
|
|
268
|
+
}
|
|
269
|
+
|
|
217
270
|
function isInlineAccentToken(value: string): boolean {
|
|
218
271
|
const trimmed = value.trim();
|
|
219
272
|
if (!trimmed || /[\r\n\t]/.test(trimmed) || trimmed.length > 64) {
|
package/src/send-service.ts
CHANGED
|
@@ -209,12 +209,22 @@ export class SendService {
|
|
|
209
209
|
/**
|
|
210
210
|
* Send the default generic typing indicator.
|
|
211
211
|
*/
|
|
212
|
-
async sendTyping(ctx: SendContext): Promise<void> {
|
|
212
|
+
async sendTyping(ctx: SendContext, duration?: number): Promise<void> {
|
|
213
213
|
try {
|
|
214
|
-
|
|
214
|
+
if (duration === undefined) {
|
|
215
|
+
await this.api.notifyInputAction(ctx.webhookUrl, ctx.bot, ctx.dialogId);
|
|
216
|
+
} else {
|
|
217
|
+
await this.api.notifyInputAction(
|
|
218
|
+
ctx.webhookUrl,
|
|
219
|
+
ctx.bot,
|
|
220
|
+
ctx.dialogId,
|
|
221
|
+
{ duration },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
215
224
|
} catch (error) {
|
|
216
225
|
this.logger.debug('Failed to send typing indicator', {
|
|
217
226
|
dialogId: ctx.dialogId,
|
|
227
|
+
duration,
|
|
218
228
|
error: serializeError(error),
|
|
219
229
|
});
|
|
220
230
|
}
|