@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/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> = {
@@ -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: String(data.message?.chatId ?? dialogId),
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: String(data.user.id ?? ''),
459
+ senderId: normalizedSenderId,
276
460
  dialogId,
277
- chatId: String(data.chat?.id ?? data.message.chatId ?? dialogId),
461
+ chatId,
278
462
  messageId,
279
- messageAuthorId: String(data.message.authorId ?? ''),
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 {};
@@ -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);
@@ -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. Escape sequencesliteral characters
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) {
@@ -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
- await this.api.notifyInputAction(ctx.webhookUrl, ctx.bot, ctx.dialogId);
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
  }