@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/channel.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  accessApproved,
40
40
  accessDenied,
41
41
  commandKeyboardLabels,
42
+ emptyReplyFallback,
42
43
  groupPairingPending,
43
44
  mediaDownloadFailed,
44
45
  groupChatUnsupported,
@@ -57,6 +58,12 @@ import type {
57
58
  B24Attach,
58
59
  B24AttachBlock,
59
60
  B24AttachColorToken,
61
+ B24AttachFileItem,
62
+ B24AttachGridDisplay,
63
+ B24AttachGridItem,
64
+ B24AttachImageItem,
65
+ B24AttachLinkValue,
66
+ B24AttachUserValue,
60
67
  B24MsgContext,
61
68
  B24InputActionStatusCode,
62
69
  B24V2FetchEventItem,
@@ -85,6 +92,7 @@ const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
85
92
  const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
86
93
  const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
87
94
  const REPLY_FALLBACK_GRACE_MS = 750;
95
+ const EMPTY_REPLY_FALLBACK_GRACE_MS = 15 * 1000;
88
96
  const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
89
97
  const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
90
98
  const MEDIA_DOWNLOAD_CONCURRENCY = 2;
@@ -101,11 +109,26 @@ const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
101
109
  const ACTIVE_SESSION_NAMESPACE_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;
102
110
  const NATIVE_FORWARD_ACK_TTL_MS = 60 * 1000;
103
111
  const NATIVE_REACTION_ACK_TTL_MS = 60 * 1000;
112
+ const NATIVE_FORWARD_SUCCESS_REACTION = 'whiteHeavyCheckMark';
113
+ const REACTION_TYPING_RESET_DURATION_SECONDS = 1;
114
+ const GENERIC_OUTBOUND_FILE_NAME_RE = /^(translated?|translation|output|result|file|document)(?:[_-][a-z]{2,8})?$/i;
115
+ const GENERIC_INBOUND_FILE_NAME_RE = /^(?:file[_-]\d+|[0-9a-f]{8,}(?:-[0-9a-f]{4,})*(?:_file[_-]?\d+)?)$/i;
116
+ const RECENT_MEDIA_DELIVERY_TTL_MS = 60 * 1000;
117
+ const RECENT_SUCCESSFUL_ACTION_TTL_MS = 60 * 1000;
118
+ const RECENT_OUTBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
119
+ const RECENT_WATCH_NOTIFICATION_TTL_MS = 30 * 60 * 1000;
104
120
  const RECENT_INBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
105
121
  const RECENT_INBOUND_MESSAGE_LIMIT = 20;
106
122
  const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
107
123
  const pendingNativeForwardAcks = new Map<string, number>();
108
124
  const pendingNativeReactionAcks = new Map<string, { emoji: string; expiresAt: number }>();
125
+ const recentSuccessfulMediaDeliveries = new Map<string, {
126
+ expiresAt: number;
127
+ mediaByName: Map<string, string>;
128
+ }>();
129
+ const recentSuccessfulActionsByMessage = new Map<string, number>();
130
+ const recentOutboundMessagesById = new Map<string, number>();
131
+ const recentWatchNotificationsByMessage = new Map<string, number>();
109
132
  const recentInboundMessagesByDialog = new Map<string, Array<{
110
133
  messageId: string;
111
134
  body: string;
@@ -115,6 +138,7 @@ const inboundMessageContextById = new Map<string, {
115
138
  dialogId: string;
116
139
  dialogKey: string;
117
140
  body: string;
141
+ mediaNames: string[];
118
142
  timestamp: number;
119
143
  }>();
120
144
 
@@ -294,6 +318,245 @@ function resolvePendingNativeForwardAckDialogId(
294
318
  return inboundMessageContextById.get(messageKey)?.dialogId ?? fallbackDialogId;
295
319
  }
296
320
 
321
+ function resolveRecentInboundDialogId(
322
+ currentMessageId: string | number | undefined,
323
+ accountId?: string,
324
+ ): string | undefined {
325
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
326
+ if (!messageKey) {
327
+ return undefined;
328
+ }
329
+
330
+ pruneRecentInboundMessages();
331
+ return inboundMessageContextById.get(messageKey)?.dialogId;
332
+ }
333
+
334
+ function splitFileNameParts(fileName: string): { stem: string; extension: string } {
335
+ const normalizedFileName = basename(fileName).trim();
336
+ const lastDotIndex = normalizedFileName.lastIndexOf('.');
337
+ if (lastDotIndex <= 0) {
338
+ return { stem: normalizedFileName, extension: '' };
339
+ }
340
+
341
+ return {
342
+ stem: normalizedFileName.slice(0, lastDotIndex).trim(),
343
+ extension: normalizedFileName.slice(lastDotIndex).trim(),
344
+ };
345
+ }
346
+
347
+ function normalizeSourceFileStem(stem: string): string {
348
+ return stem
349
+ .trim()
350
+ .replace(/\s+\(\d+\)$/u, '')
351
+ .trim();
352
+ }
353
+
354
+ function isGenericOutboundFileName(fileName: string): boolean {
355
+ const { stem } = splitFileNameParts(fileName);
356
+ return GENERIC_OUTBOUND_FILE_NAME_RE.test(stem);
357
+ }
358
+
359
+ function isSemanticInboundFileName(fileName: string): boolean {
360
+ const { stem } = splitFileNameParts(fileName);
361
+ return Boolean(stem) && !GENERIC_INBOUND_FILE_NAME_RE.test(stem);
362
+ }
363
+
364
+ function inferTranslationSuffixFromBody(body: string): string | undefined {
365
+ const normalizedBody = body.toLowerCase();
366
+ if (!/translate|translation|перевед|перевод/u.test(normalizedBody)) {
367
+ return undefined;
368
+ }
369
+
370
+ const languagePatterns: Array<[string, RegExp]> = [
371
+ ['en', /english|английск/u],
372
+ ['ru', /russian|русск/u],
373
+ ['de', /german|немец/u],
374
+ ['fr', /french|француз/u],
375
+ ['es', /spanish|испан/u],
376
+ ['it', /italian|итальян/u],
377
+ ['pt', /portuguese|португал/u],
378
+ ['zh', /chinese|китай/u],
379
+ ['ja', /japanese|япон/u],
380
+ ['ko', /korean|корей/u],
381
+ ];
382
+
383
+ for (const [code, pattern] of languagePatterns) {
384
+ if (pattern.test(normalizedBody)) {
385
+ return `_${code}`;
386
+ }
387
+ }
388
+
389
+ return '_translated';
390
+ }
391
+
392
+ function inferTranslationSuffix(requestedFileName: string, body: string): string | undefined {
393
+ const { stem } = splitFileNameParts(requestedFileName);
394
+ const suffixMatch = stem.match(/(?:^|[_-])(en|ru|de|fr|es|it|pt|zh|ja|ko)$/i);
395
+ if (suffixMatch?.[1]) {
396
+ return `_${suffixMatch[1].toLowerCase()}`;
397
+ }
398
+
399
+ return inferTranslationSuffixFromBody(body);
400
+ }
401
+
402
+ function resolveRecentInboundContext(params: {
403
+ dialogId: string;
404
+ currentMessageId?: string | number;
405
+ accountId?: string;
406
+ }): {
407
+ body: string;
408
+ mediaNames: string[];
409
+ } | undefined {
410
+ pruneRecentInboundMessages();
411
+
412
+ const explicitKey = buildAccountMessageScopeKey(params.accountId, params.currentMessageId);
413
+ const explicitEntry = explicitKey ? inboundMessageContextById.get(explicitKey) : undefined;
414
+ if (explicitEntry) {
415
+ return explicitEntry;
416
+ }
417
+
418
+ const dialogKey = buildAccountDialogScopeKey(params.accountId, params.dialogId);
419
+ if (!dialogKey) {
420
+ return undefined;
421
+ }
422
+
423
+ const recentEntries = recentInboundMessagesByDialog.get(dialogKey);
424
+ if (!recentEntries?.length) {
425
+ return undefined;
426
+ }
427
+
428
+ for (let index = recentEntries.length - 1; index >= 0; index -= 1) {
429
+ const entry = recentEntries[index];
430
+ const messageKey = buildAccountMessageScopeKey(params.accountId, entry.messageId);
431
+ if (!messageKey) {
432
+ continue;
433
+ }
434
+
435
+ const inboundEntry = inboundMessageContextById.get(messageKey);
436
+ if (inboundEntry) {
437
+ return inboundEntry;
438
+ }
439
+ }
440
+
441
+ return undefined;
442
+ }
443
+
444
+ function resolveSemanticOutboundFileName(params: {
445
+ requestedFileName: string;
446
+ dialogId: string;
447
+ currentMessageId?: string | number;
448
+ accountId?: string;
449
+ }): string {
450
+ const requestedBaseName = basename(params.requestedFileName).trim();
451
+ if (!requestedBaseName || !isGenericOutboundFileName(requestedBaseName)) {
452
+ return requestedBaseName;
453
+ }
454
+
455
+ const inboundContext = resolveRecentInboundContext({
456
+ dialogId: params.dialogId,
457
+ currentMessageId: params.currentMessageId,
458
+ accountId: params.accountId,
459
+ });
460
+ if (!inboundContext || inboundContext.mediaNames.length !== 1) {
461
+ return requestedBaseName;
462
+ }
463
+
464
+ const sourceFileName = inboundContext.mediaNames[0];
465
+ if (!isSemanticInboundFileName(sourceFileName)) {
466
+ return requestedBaseName;
467
+ }
468
+
469
+ const { stem: sourceStemRaw, extension: sourceExtension } = splitFileNameParts(sourceFileName);
470
+ const { extension: requestedExtension } = splitFileNameParts(requestedBaseName);
471
+ const normalizedSourceStem = normalizeSourceFileStem(sourceStemRaw);
472
+ if (!normalizedSourceStem) {
473
+ return requestedBaseName;
474
+ }
475
+
476
+ const suffix = inferTranslationSuffix(requestedBaseName, inboundContext.body) ?? '';
477
+ const nextStem = suffix && !normalizedSourceStem.endsWith(suffix)
478
+ ? `${normalizedSourceStem}${suffix}`
479
+ : normalizedSourceStem;
480
+ const nextExtension = requestedExtension || sourceExtension;
481
+
482
+ return nextExtension ? `${nextStem}${nextExtension}` : nextStem;
483
+ }
484
+
485
+ async function maybeSendReactionTypingReset(params: {
486
+ sendService: unknown;
487
+ webhookUrl: string;
488
+ bot: BotContext;
489
+ dialogId: string | undefined;
490
+ }): Promise<void> {
491
+ const dialogId = typeof params.dialogId === 'string' ? params.dialogId.trim() : '';
492
+ if (!dialogId) {
493
+ return;
494
+ }
495
+
496
+ const sendTyping = (params.sendService as { sendTyping?: unknown }).sendTyping;
497
+ if (typeof sendTyping !== 'function') {
498
+ return;
499
+ }
500
+
501
+ try {
502
+ await sendTyping.call(
503
+ params.sendService,
504
+ { webhookUrl: params.webhookUrl, bot: params.bot, dialogId },
505
+ REACTION_TYPING_RESET_DURATION_SECONDS,
506
+ );
507
+ } catch (error) {
508
+ defaultLogger.debug('Failed to send Bitrix24 reaction typing reset', {
509
+ dialogId,
510
+ error: error instanceof Error ? error.message : String(error),
511
+ });
512
+ }
513
+ }
514
+
515
+ async function maybeAddNativeForwardSuccessReaction(params: {
516
+ api: unknown;
517
+ sendService: unknown;
518
+ webhookUrl: string;
519
+ bot: BotContext;
520
+ dialogId: string | undefined;
521
+ currentMessageId: string | number | undefined;
522
+ }): Promise<void> {
523
+ const messageId = toMessageId(params.currentMessageId);
524
+ if (!messageId) {
525
+ return;
526
+ }
527
+
528
+ const addReaction = (params.api as { addReaction?: unknown }).addReaction;
529
+ if (typeof addReaction !== 'function') {
530
+ return;
531
+ }
532
+
533
+ let shouldResetTyping = false;
534
+ try {
535
+ await addReaction.call(params.api, params.webhookUrl, params.bot, messageId, NATIVE_FORWARD_SUCCESS_REACTION);
536
+ shouldResetTyping = true;
537
+ } catch (error) {
538
+ const errorMessage = error instanceof Error ? error.message : String(error);
539
+ if (errorMessage.includes('REACTION_ALREADY_SET')) {
540
+ defaultLogger.debug('Native forward success reaction already set', { messageId });
541
+ shouldResetTyping = true;
542
+ } else {
543
+ defaultLogger.warn('Failed to add native forward success reaction', {
544
+ messageId,
545
+ error: errorMessage,
546
+ });
547
+ }
548
+ }
549
+
550
+ if (shouldResetTyping) {
551
+ await maybeSendReactionTypingReset({
552
+ sendService: params.sendService,
553
+ webhookUrl: params.webhookUrl,
554
+ bot: params.bot,
555
+ dialogId: params.dialogId,
556
+ });
557
+ }
558
+ }
559
+
297
560
  function buildPendingNativeReactionAckKey(
298
561
  currentMessageId: string | number | undefined,
299
562
  accountId?: string,
@@ -301,6 +564,211 @@ function buildPendingNativeReactionAckKey(
301
564
  return buildAccountMessageScopeKey(accountId, currentMessageId);
302
565
  }
303
566
 
567
+ function pruneRecentSuccessfulActions(now = Date.now()): void {
568
+ for (const [key, expiresAt] of recentSuccessfulActionsByMessage.entries()) {
569
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
570
+ recentSuccessfulActionsByMessage.delete(key);
571
+ }
572
+ }
573
+ }
574
+
575
+ function markRecentSuccessfulAction(
576
+ currentMessageId: string | number | undefined,
577
+ accountId?: string,
578
+ ): void {
579
+ const key = buildAccountMessageScopeKey(accountId, currentMessageId);
580
+ if (!key) {
581
+ return;
582
+ }
583
+
584
+ pruneRecentSuccessfulActions();
585
+ recentSuccessfulActionsByMessage.set(key, Date.now() + RECENT_SUCCESSFUL_ACTION_TTL_MS);
586
+ }
587
+
588
+ function hasRecentSuccessfulAction(
589
+ currentMessageId: string | number | undefined,
590
+ accountId?: string,
591
+ ): boolean {
592
+ const key = buildAccountMessageScopeKey(accountId, currentMessageId);
593
+ if (!key) {
594
+ return false;
595
+ }
596
+
597
+ pruneRecentSuccessfulActions();
598
+ return recentSuccessfulActionsByMessage.has(key);
599
+ }
600
+
601
+ function pruneRecentOutboundMessages(now = Date.now()): void {
602
+ for (const [key, expiresAt] of recentOutboundMessagesById.entries()) {
603
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
604
+ recentOutboundMessagesById.delete(key);
605
+ }
606
+ }
607
+ }
608
+
609
+ function markRecentOutboundMessage(
610
+ messageId: string | number | undefined,
611
+ accountId?: string,
612
+ ): void {
613
+ const key = buildAccountMessageScopeKey(accountId, messageId);
614
+ if (!key) {
615
+ return;
616
+ }
617
+
618
+ pruneRecentOutboundMessages();
619
+ recentOutboundMessagesById.set(key, Date.now() + RECENT_OUTBOUND_MESSAGE_TTL_MS);
620
+ }
621
+
622
+ function hasRecentOutboundMessage(
623
+ messageId: string | number | undefined,
624
+ accountId?: string,
625
+ ): boolean {
626
+ const key = buildAccountMessageScopeKey(accountId, messageId);
627
+ if (!key) {
628
+ return false;
629
+ }
630
+
631
+ pruneRecentOutboundMessages();
632
+ return recentOutboundMessagesById.has(key);
633
+ }
634
+
635
+ function pruneRecentWatchNotifications(now = Date.now()): void {
636
+ for (const [key, expiresAt] of recentWatchNotificationsByMessage.entries()) {
637
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
638
+ recentWatchNotificationsByMessage.delete(key);
639
+ }
640
+ }
641
+ }
642
+
643
+ function markRecentWatchNotification(
644
+ messageId: string | number | undefined,
645
+ accountId?: string,
646
+ ): void {
647
+ const key = buildAccountMessageScopeKey(accountId, messageId);
648
+ if (!key) {
649
+ return;
650
+ }
651
+
652
+ pruneRecentWatchNotifications();
653
+ recentWatchNotificationsByMessage.set(key, Date.now() + RECENT_WATCH_NOTIFICATION_TTL_MS);
654
+ }
655
+
656
+ function hasRecentWatchNotification(
657
+ messageId: string | number | undefined,
658
+ accountId?: string,
659
+ ): boolean {
660
+ const key = buildAccountMessageScopeKey(accountId, messageId);
661
+ if (!key) {
662
+ return false;
663
+ }
664
+
665
+ pruneRecentWatchNotifications();
666
+ return recentWatchNotificationsByMessage.has(key);
667
+ }
668
+
669
+ function buildRecentMediaDeliveryKey(
670
+ dialogId: string,
671
+ currentMessageId: string | number | undefined,
672
+ accountId?: string,
673
+ ): string | undefined {
674
+ return buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
675
+ }
676
+
677
+ function normalizeMediaDeliveryName(mediaUrl: string): string {
678
+ return basename(mediaUrl.trim()).toLowerCase();
679
+ }
680
+
681
+ function pruneRecentMediaDeliveries(now = Date.now()): void {
682
+ for (const [key, entry] of recentSuccessfulMediaDeliveries.entries()) {
683
+ if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
684
+ recentSuccessfulMediaDeliveries.delete(key);
685
+ }
686
+ }
687
+ }
688
+
689
+ function markRecentMediaDelivery(params: {
690
+ dialogId: string;
691
+ currentMessageId: string | number | undefined;
692
+ mediaUrls: string[];
693
+ accountId?: string;
694
+ messageId?: string;
695
+ }): void {
696
+ const key = buildRecentMediaDeliveryKey(params.dialogId, params.currentMessageId, params.accountId);
697
+ if (!key || params.mediaUrls.length === 0) {
698
+ return;
699
+ }
700
+
701
+ pruneRecentMediaDeliveries();
702
+ const mediaByName = recentSuccessfulMediaDeliveries.get(key)?.mediaByName ?? new Map<string, string>();
703
+ const normalizedMessageId = String(params.messageId ?? '').trim();
704
+
705
+ for (const mediaUrl of params.mediaUrls) {
706
+ const mediaName = normalizeMediaDeliveryName(mediaUrl);
707
+ if (!mediaName) {
708
+ continue;
709
+ }
710
+
711
+ mediaByName.set(mediaName, normalizedMessageId);
712
+ }
713
+
714
+ recentSuccessfulMediaDeliveries.set(key, {
715
+ expiresAt: Date.now() + RECENT_MEDIA_DELIVERY_TTL_MS,
716
+ mediaByName,
717
+ });
718
+ }
719
+
720
+ function findRecentMediaDelivery(params: {
721
+ dialogId: string;
722
+ currentMessageId: string | number | undefined;
723
+ mediaUrls: string[];
724
+ accountId?: string;
725
+ }): { messageId?: string } | null {
726
+ const key = buildRecentMediaDeliveryKey(params.dialogId, params.currentMessageId, params.accountId);
727
+ if (!key || params.mediaUrls.length === 0) {
728
+ return null;
729
+ }
730
+
731
+ pruneRecentMediaDeliveries();
732
+ const entry = recentSuccessfulMediaDeliveries.get(key);
733
+ if (!entry) {
734
+ return null;
735
+ }
736
+
737
+ const normalizedNames = params.mediaUrls
738
+ .map((mediaUrl) => normalizeMediaDeliveryName(mediaUrl))
739
+ .filter(Boolean);
740
+ if (normalizedNames.length === 0) {
741
+ return null;
742
+ }
743
+
744
+ for (const mediaName of normalizedNames) {
745
+ if (!entry.mediaByName.has(mediaName)) {
746
+ return null;
747
+ }
748
+ }
749
+
750
+ const lastMessageId = normalizedNames
751
+ .map((mediaName) => entry.mediaByName.get(mediaName) ?? '')
752
+ .find(Boolean);
753
+
754
+ return lastMessageId ? { messageId: lastMessageId } : {};
755
+ }
756
+
757
+ function hasRecentMediaDelivery(
758
+ dialogId: string,
759
+ currentMessageId: string | number | undefined,
760
+ accountId?: string,
761
+ ): boolean {
762
+ const key = buildRecentMediaDeliveryKey(dialogId, currentMessageId, accountId);
763
+ if (!key) {
764
+ return false;
765
+ }
766
+
767
+ pruneRecentMediaDeliveries();
768
+ const entry = recentSuccessfulMediaDeliveries.get(key);
769
+ return Boolean(entry && entry.mediaByName.size > 0);
770
+ }
771
+
304
772
  function prunePendingNativeReactionAcks(now = Date.now()): void {
305
773
  for (const [key, entry] of pendingNativeReactionAcks.entries()) {
306
774
  if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
@@ -372,6 +840,7 @@ function rememberRecentInboundMessage(
372
840
  dialogId: string,
373
841
  messageId: string | number | undefined,
374
842
  body: string,
843
+ mediaNames: string[] = [],
375
844
  timestamp = Date.now(),
376
845
  accountId?: string,
377
846
  ): void {
@@ -397,6 +866,10 @@ function rememberRecentInboundMessage(
397
866
  dialogId,
398
867
  dialogKey,
399
868
  body: normalizedBody,
869
+ mediaNames: mediaNames
870
+ .filter((name) => typeof name === 'string')
871
+ .map((name) => name.trim())
872
+ .filter(Boolean),
400
873
  timestamp,
401
874
  });
402
875
  }
@@ -558,6 +1031,26 @@ function extractReplyDirective(params: {
558
1031
  return { cleanText };
559
1032
  }
560
1033
 
1034
+ function isBitrix24DirectDialogId(dialogId: string | undefined): boolean {
1035
+ const normalizedDialogId = String(dialogId ?? '').trim();
1036
+ return normalizedDialogId.length > 0 && !normalizedDialogId.startsWith('chat');
1037
+ }
1038
+
1039
+ function shouldUseBitrix24NativeReply(params: {
1040
+ isDm?: boolean;
1041
+ dialogId?: string;
1042
+ }): boolean {
1043
+ if (typeof params.isDm === 'boolean') {
1044
+ return !params.isDm;
1045
+ }
1046
+
1047
+ if (!params.dialogId) {
1048
+ return false;
1049
+ }
1050
+
1051
+ return !isBitrix24DirectDialogId(params.dialogId);
1052
+ }
1053
+
561
1054
  function findNativeForwardTarget(params: {
562
1055
  accountId?: string;
563
1056
  requestText: string;
@@ -728,8 +1221,8 @@ function buildBitrix24NativeReplyAgentHint(currentMessageId: string | number | u
728
1221
  return [
729
1222
  '[Bitrix24 native reply instruction]',
730
1223
  `Current Bitrix24 message id: ${normalizedMessageId}.`,
731
- 'If you intentionally want a native Bitrix24 reply to the current inbound message, set "replyToMessageId" to that id.',
732
- 'For text-only payloads you can also prepend [[reply_to_current]] to the reply text.',
1224
+ 'Only in group chats, if you intentionally want a native Bitrix24 reply to the current inbound message, set "replyToMessageId" to that id.',
1225
+ 'For text-only payloads in group chats, you can also prepend [[reply_to_current]] to the reply text.',
733
1226
  'Do not use a native reply unless you actually want reply threading.',
734
1227
  '[/Bitrix24 native reply instruction]',
735
1228
  ].join('\n');
@@ -738,14 +1231,54 @@ function buildBitrix24NativeReplyAgentHint(currentMessageId: string | number | u
738
1231
  function buildBitrix24FileDeliveryAgentHint(): string {
739
1232
  return [
740
1233
  '[Bitrix24 file delivery instruction]',
741
- 'When you need to deliver a real file or document to the user, use structured reply payload field "mediaUrl" or "mediaUrls".',
1234
+ 'When you need to deliver a real file or document to the user, prefer a structured reply payload with field "mediaUrl" or "mediaUrls".',
742
1235
  'Set "mediaUrl" to the local path of the generated file in the OpenClaw workspace or managed media directory.',
1236
+ 'If the current prompt already includes a <file ...>...</file> block with the file contents, use that inline content directly and do not call read on the attached Bitrix24 media path unless you truly need to.',
1237
+ 'If read on an attached Bitrix24 media path fails, fall back to the inline <file> content from the current prompt instead of giving up.',
1238
+ 'If you use the generic message tool instead of a structured reply payload, attach the local file with "media", "filePath", or "path". Do not use "mediaUrl" with the message tool.',
743
1239
  'Use "text" only for an optional short caption.',
1240
+ 'For file requests, follow this order exactly: read/prepare content, write the output file, send that file once, then stop.',
1241
+ 'Never call the file-delivery tool before the write tool has succeeded for that file path.',
1242
+ 'If the upload tool says the media upload failed, fix the file path or create the file first. Do not blindly retry the same send.',
1243
+ 'After one successful file upload in a turn, do not send the same file again and do not send an extra text-only confirmation.',
1244
+ 'Do not output "NO_REPLY" unless you already called the message tool or emitted a structured reply payload with mediaUrl/mediaUrls in this same turn.',
1245
+ 'Do not output "NO_REPLY" after only read/write tools. NO_REPLY is allowed only after the actual file-delivery tool result succeeded in this same turn.',
1246
+ 'If the user asked for a file and you have not emitted the actual file delivery payload yet, do not answer with "NO_REPLY".',
744
1247
  'Do not place local file paths inside markdown links or plain text, because that only sends text to the user and does not upload the file.',
745
1248
  '[/Bitrix24 file delivery instruction]',
746
1249
  ].join('\n');
747
1250
  }
748
1251
 
1252
+ export function buildBitrix24InlineButtonsAgentHint(): string {
1253
+ return [
1254
+ '[Bitrix24 buttons instruction]',
1255
+ 'For an ordinary reply in the current Bitrix24 dialog that needs buttons or multiple choices, prefer normal assistant text with inline button markup instead of calling the message tool.',
1256
+ 'Append the buttons to the end of the visible reply text using the exact format [[{"text":"Option 1","callback_data":"one"},{"text":"Option 2","callback_data":"two"}]].',
1257
+ 'The channel will strip that markup from the visible text and render native Bitrix24 buttons automatically.',
1258
+ 'Use the message tool for Bitrix24 only when you truly need an explicit action such as send to another dialog, native forward/reply, reaction, edit, delete, or file upload.',
1259
+ '[/Bitrix24 buttons instruction]',
1260
+ ].join('\n');
1261
+ }
1262
+
1263
+ function getDispatchCount(counts: Record<string, number> | undefined, key: 'tool' | 'block' | 'final'): number {
1264
+ const value = counts?.[key];
1265
+ return Number.isFinite(value) ? Number(value) : 0;
1266
+ }
1267
+
1268
+ function isEmptyDispatchResult(
1269
+ dispatchResult: { queuedFinal: boolean; counts: Record<string, number> } | undefined,
1270
+ ): boolean {
1271
+ if (!dispatchResult || dispatchResult.queuedFinal !== false) {
1272
+ return false;
1273
+ }
1274
+
1275
+ const toolCount = getDispatchCount(dispatchResult.counts, 'tool');
1276
+ const blockCount = getDispatchCount(dispatchResult.counts, 'block');
1277
+ const finalCount = getDispatchCount(dispatchResult.counts, 'final');
1278
+
1279
+ return toolCount + blockCount + finalCount === 0;
1280
+ }
1281
+
749
1282
  function readExplicitForwardMessageIds(rawParams: Record<string, unknown>): number[] {
750
1283
  return parseActionMessageIds(
751
1284
  rawParams.forwardMessageIds
@@ -991,6 +1524,10 @@ async function waitReplyFallbackGraceWindow(): Promise<void> {
991
1524
  await new Promise((resolve) => setTimeout(resolve, REPLY_FALLBACK_GRACE_MS));
992
1525
  }
993
1526
 
1527
+ async function waitEmptyReplyFallbackGraceWindow(): Promise<void> {
1528
+ await new Promise((resolve) => setTimeout(resolve, EMPTY_REPLY_FALLBACK_GRACE_MS));
1529
+ }
1530
+
994
1531
  export function canCoalesceDirectMessage(
995
1532
  msgCtx: B24MsgContext,
996
1533
  config: Bitrix24AccountConfig,
@@ -1680,6 +2217,10 @@ export function __setGatewayStateForTests(state: GatewayState | null): void {
1680
2217
  if (state === null) {
1681
2218
  pendingNativeForwardAcks.clear();
1682
2219
  pendingNativeReactionAcks.clear();
2220
+ recentSuccessfulMediaDeliveries.clear();
2221
+ recentSuccessfulActionsByMessage.clear();
2222
+ recentOutboundMessagesById.clear();
2223
+ recentWatchNotificationsByMessage.clear();
1683
2224
  recentInboundMessagesByDialog.clear();
1684
2225
  inboundMessageContextById.clear();
1685
2226
  }
@@ -1690,12 +2231,14 @@ export function __rememberRecentInboundMessageForTests(params: {
1690
2231
  dialogId: string;
1691
2232
  messageId: string | number;
1692
2233
  body: string;
2234
+ mediaNames?: string[];
1693
2235
  timestamp?: number;
1694
2236
  }): void {
1695
2237
  rememberRecentInboundMessage(
1696
2238
  params.dialogId,
1697
2239
  params.messageId,
1698
2240
  params.body,
2241
+ params.mediaNames,
1699
2242
  params.timestamp,
1700
2243
  params.accountId,
1701
2244
  );
@@ -1748,86 +2291,26 @@ const BITRIX24_DISCOVERY_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'dele
1748
2291
  function buildMessageToolButtonsSchema(): Record<string, unknown> {
1749
2292
  return {
1750
2293
  type: 'array',
1751
- description: 'Optional Bitrix24 message buttons as rows of button objects.',
2294
+ description: 'Optional Bitrix24 message buttons as rows of button objects. Each button object can include text, optional callback_data and optional style.',
1752
2295
  items: {
1753
2296
  type: 'array',
1754
2297
  items: {
1755
2298
  type: 'object',
1756
- additionalProperties: false,
1757
- properties: {
1758
- text: {
1759
- type: 'string',
1760
- description: 'Visible button label.',
1761
- },
1762
- callback_data: {
1763
- type: 'string',
1764
- description: 'Registered command like /help, or any plain text payload to send when tapped.',
1765
- },
1766
- style: {
1767
- type: 'string',
1768
- enum: ['primary', 'attention', 'danger'],
1769
- description: 'Optional Bitrix24 button accent.',
1770
- },
1771
- },
1772
- required: ['text'],
2299
+ description: 'Single button object. Use text, optional callback_data, and optional style=primary|attention|danger.',
1773
2300
  },
1774
2301
  },
1775
2302
  };
1776
2303
  }
1777
2304
 
1778
2305
  function buildMessageToolAttachSchema(): Record<string, unknown> {
1779
- const blockSchema = {
1780
- type: 'object',
1781
- description: 'Single Bitrix24 ATTACH rich block. Supported top-level keys are MESSAGE, LINK, IMAGE, FILE, DELIMITER, GRID or USER.',
1782
- };
1783
-
1784
2306
  return {
1785
- description: 'Bitrix24 ATTACH rich layout blocks. Use this for rich cards, not for binary file uploads. For a status-owner-link card, prefer blocks like USER, LINK and GRID. Example: [{"USER":{"NAME":"Alice"}},{"LINK":{"NAME":"Open","LINK":"https://example.com"}},{"GRID":[{"NAME":"Status","VALUE":"In progress","DISPLAY":"LINE"},{"NAME":"Owner","VALUE":"Alice","DISPLAY":"LINE"}]}]. Use mediaUrl/mediaUrls for actual files and media.',
1786
- anyOf: [
1787
- {
1788
- type: 'object',
1789
- additionalProperties: false,
1790
- properties: {
1791
- ID: { type: 'integer' },
1792
- COLOR_TOKEN: {
1793
- type: 'string',
1794
- enum: ['primary', 'secondary', 'alert', 'base'],
1795
- },
1796
- COLOR: { type: 'string' },
1797
- BLOCKS: {
1798
- type: 'array',
1799
- items: blockSchema,
1800
- },
1801
- },
1802
- required: ['BLOCKS'],
1803
- },
1804
- {
1805
- type: 'array',
1806
- items: blockSchema,
1807
- },
1808
- ],
2307
+ description: 'Bitrix24 ATTACH rich layout blocks. Use this for rich cards, not for binary file uploads. Accepts either an array of block objects or an object with BLOCKS array. Prefer direct Bitrix block keys such as [{USER:{USER_ID:1,NAME:"Eugene"}},{GRID:[{DISPLAY:"LINE",NAME:"Status",VALUE:"In progress"},{DISPLAY:"LINE",NAME:"Owner",VALUE:"Eugene"}]},{LINK:{NAME:"Open",LINK:"https://example.com"}}]. Supported top-level block keys include MESSAGE, LINK, IMAGE, FILE, DELIMITER, GRID and USER. For actual file uploads, use the generic message tool fields media/filePath/path or a structured reply payload with mediaUrl/mediaUrls.',
1809
2308
  };
1810
2309
  }
1811
2310
 
1812
2311
  function buildMessageToolIdListSchema(description: string): Record<string, unknown> {
1813
2312
  return {
1814
- anyOf: [
1815
- {
1816
- type: 'integer',
1817
- description,
1818
- },
1819
- {
1820
- type: 'string',
1821
- description: `${description} Can also be a comma-separated list or JSON array string.`,
1822
- },
1823
- {
1824
- type: 'array',
1825
- description,
1826
- items: {
1827
- type: 'integer',
1828
- },
1829
- },
1830
- ],
2313
+ description: `${description} Can be an integer, a string, or an array of integers.`,
1831
2314
  };
1832
2315
  }
1833
2316
 
@@ -1866,93 +2349,507 @@ function describeBitrix24MessageTool(params: {
1866
2349
  return {
1867
2350
  actions: [...BITRIX24_DISCOVERY_ACTION_NAMES],
1868
2351
  capabilities: ['interactive', 'buttons', 'cards'],
1869
- schema: [
1870
- {
1871
- properties: {
1872
- buttons: buildMessageToolButtonsSchema(),
1873
- },
2352
+ schema: {
2353
+ properties: {
2354
+ buttons: buildMessageToolButtonsSchema(),
2355
+ attach: buildMessageToolAttachSchema(),
2356
+ forwardMessageIds: buildMessageToolIdListSchema(
2357
+ 'Bitrix24 source message id(s) to forward natively. Use with action="send" plus to/target. Prefer the referenced message id, not the current instruction message id. The message field must be non-empty — use "↩️" (emoji) for a pure forward without extra text.',
2358
+ ),
2359
+ forwardIds: buildMessageToolIdListSchema(
2360
+ 'Alias for forwardMessageIds. Use with action="send".',
2361
+ ),
2362
+ replyToId: buildMessageToolIdListSchema(
2363
+ 'Bitrix24 message id to reply to natively inside the current dialog.',
2364
+ ),
2365
+ replyToMessageId: buildMessageToolIdListSchema(
2366
+ 'Alias for replyToId.',
2367
+ ),
1874
2368
  },
1875
- {
1876
- properties: {
1877
- attach: buildMessageToolAttachSchema(),
1878
- forwardMessageIds: buildMessageToolIdListSchema(
1879
- 'Bitrix24 source message id(s) to forward natively. Use with action="send" plus to/target. Prefer the referenced message id, not the current instruction message id. The message field must be non-empty — use "↩️" (emoji) for a pure forward without extra text.',
1880
- ),
1881
- forwardIds: buildMessageToolIdListSchema(
1882
- 'Alias for forwardMessageIds. Use with action="send".',
1883
- ),
1884
- replyToId: buildMessageToolIdListSchema(
1885
- 'Bitrix24 message id to reply to natively inside the current dialog.',
1886
- ),
1887
- replyToMessageId: buildMessageToolIdListSchema(
1888
- 'Alias for replyToId.',
1889
- ),
1890
- },
1891
- },
1892
- ],
2369
+ },
2370
+ };
2371
+ }
2372
+
2373
+ function extractBitrix24ToolSend(args: Record<string, unknown>): ExtractedToolSendTarget | null {
2374
+ if ((typeof args.action === 'string' ? args.action.trim() : '') !== 'sendMessage') {
2375
+ return null;
2376
+ }
2377
+
2378
+ const to = typeof args.to === 'string' ? normalizeBitrix24DialogTarget(args.to) : '';
2379
+ if (!to) {
2380
+ return null;
2381
+ }
2382
+
2383
+ const accountId = typeof args.accountId === 'string'
2384
+ ? args.accountId.trim()
2385
+ : undefined;
2386
+ const threadId = typeof args.threadId === 'number'
2387
+ ? String(args.threadId)
2388
+ : typeof args.threadId === 'string'
2389
+ ? args.threadId.trim()
2390
+ : '';
2391
+
2392
+ return {
2393
+ to,
2394
+ ...(accountId ? { accountId } : {}),
2395
+ ...(threadId ? { threadId } : {}),
2396
+ };
2397
+ }
2398
+
2399
+ function readActionTextParam(params: Record<string, unknown>): string | null {
2400
+ const rawText = params.message ?? params.text ?? params.content;
2401
+ return typeof rawText === 'string' ? rawText : null;
2402
+ }
2403
+
2404
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
2405
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
2406
+ }
2407
+
2408
+ function isAttachColorToken(value: unknown): value is B24AttachColorToken {
2409
+ return value === 'primary'
2410
+ || value === 'secondary'
2411
+ || value === 'alert'
2412
+ || value === 'base';
2413
+ }
2414
+
2415
+ function isPresent<T>(value: T | undefined | null): value is T {
2416
+ return value != null;
2417
+ }
2418
+
2419
+ function isAttachBlock(value: unknown): value is B24AttachBlock {
2420
+ return isPlainObject(value) && Object.keys(value).length > 0;
2421
+ }
2422
+
2423
+ function isBitrix24Attach(value: unknown): value is B24Attach {
2424
+ if (Array.isArray(value)) {
2425
+ return value.length > 0 && value.every(isAttachBlock);
2426
+ }
2427
+
2428
+ if (!isPlainObject(value) || !Array.isArray(value.BLOCKS) || value.BLOCKS.length === 0) {
2429
+ return false;
2430
+ }
2431
+
2432
+ if (value.COLOR_TOKEN !== undefined && !isAttachColorToken(value.COLOR_TOKEN)) {
2433
+ return false;
2434
+ }
2435
+
2436
+ return value.BLOCKS.every(isAttachBlock);
2437
+ }
2438
+
2439
+ function readAttachString(value: unknown): string | undefined {
2440
+ if (typeof value !== 'string') {
2441
+ return undefined;
2442
+ }
2443
+
2444
+ const trimmed = value.trim();
2445
+ return trimmed ? trimmed : undefined;
2446
+ }
2447
+
2448
+ function readAttachNumber(value: unknown): number | undefined {
2449
+ if (typeof value === 'number' && Number.isFinite(value)) {
2450
+ return Math.trunc(value);
2451
+ }
2452
+
2453
+ if (typeof value === 'string') {
2454
+ const trimmed = value.trim();
2455
+ if (!trimmed) {
2456
+ return undefined;
2457
+ }
2458
+
2459
+ const normalized = Number(trimmed);
2460
+ if (Number.isFinite(normalized)) {
2461
+ return Math.trunc(normalized);
2462
+ }
2463
+ }
2464
+
2465
+ return undefined;
2466
+ }
2467
+
2468
+ function normalizeAttachColorTokenValue(value: unknown): B24AttachColorToken | undefined {
2469
+ if (value === undefined) {
2470
+ return undefined;
2471
+ }
2472
+
2473
+ if (typeof value !== 'string') {
2474
+ return undefined;
2475
+ }
2476
+
2477
+ const normalized = value.trim().toLowerCase();
2478
+ return isAttachColorToken(normalized) ? normalized : undefined;
2479
+ }
2480
+
2481
+ function normalizeAttachGridDisplayValue(value: unknown): B24AttachGridDisplay | undefined {
2482
+ if (typeof value !== 'string') {
2483
+ return undefined;
2484
+ }
2485
+
2486
+ const normalized = value.trim().toUpperCase();
2487
+ return normalized === 'BLOCK'
2488
+ || normalized === 'LINE'
2489
+ || normalized === 'ROW'
2490
+ || normalized === 'TABLE'
2491
+ ? normalized
2492
+ : undefined;
2493
+ }
2494
+
2495
+ function normalizeAttachGridItem(rawValue: unknown): B24AttachGridItem | undefined {
2496
+ if (!isPlainObject(rawValue)) {
2497
+ return undefined;
2498
+ }
2499
+
2500
+ const display = normalizeAttachGridDisplayValue(rawValue.DISPLAY) ?? 'LINE';
2501
+ const name = readAttachString(rawValue.NAME);
2502
+ const value = readAttachString(rawValue.VALUE);
2503
+ const width = readAttachNumber(rawValue.WIDTH);
2504
+ const height = readAttachNumber(rawValue.HEIGHT);
2505
+ const colorToken = normalizeAttachColorTokenValue(rawValue.COLOR_TOKEN);
2506
+ const color = readAttachString(rawValue.COLOR);
2507
+ const link = readAttachString(rawValue.LINK);
2508
+ const userId = readAttachNumber(rawValue.USER_ID);
2509
+ const chatId = readAttachNumber(rawValue.CHAT_ID);
2510
+
2511
+ if (!name && !value && !link && userId === undefined && chatId === undefined) {
2512
+ return undefined;
2513
+ }
2514
+
2515
+ return {
2516
+ DISPLAY: display,
2517
+ ...(name ? { NAME: name } : {}),
2518
+ ...(value ? { VALUE: value } : {}),
2519
+ ...(width !== undefined ? { WIDTH: width } : {}),
2520
+ ...(height !== undefined ? { HEIGHT: height } : {}),
2521
+ ...(colorToken ? { COLOR_TOKEN: colorToken } : {}),
2522
+ ...(color ? { COLOR: color } : {}),
2523
+ ...(link ? { LINK: link } : {}),
2524
+ ...(userId !== undefined ? { USER_ID: userId } : {}),
2525
+ ...(chatId !== undefined ? { CHAT_ID: chatId } : {}),
2526
+ };
2527
+ }
2528
+
2529
+ function normalizeAttachLinkValue(rawValue: unknown, aliasSource?: Record<string, unknown>): B24AttachLinkValue | undefined {
2530
+ if (typeof rawValue === 'string') {
2531
+ const link = readAttachString(rawValue);
2532
+ if (!link) {
2533
+ return undefined;
2534
+ }
2535
+
2536
+ const aliasName = aliasSource
2537
+ ? readAttachString(aliasSource.NAME) ?? readAttachString(aliasSource.TITLE)
2538
+ : undefined;
2539
+ const aliasDesc = aliasSource ? readAttachString(aliasSource.DESC) : undefined;
2540
+ const aliasHtml = aliasSource ? readAttachString(aliasSource.HTML) : undefined;
2541
+ const aliasPreview = aliasSource ? readAttachString(aliasSource.PREVIEW) : undefined;
2542
+ const aliasWidth = aliasSource ? readAttachNumber(aliasSource.WIDTH) : undefined;
2543
+ const aliasHeight = aliasSource ? readAttachNumber(aliasSource.HEIGHT) : undefined;
2544
+ const aliasUserId = aliasSource ? readAttachNumber(aliasSource.USER_ID) : undefined;
2545
+ const aliasChatId = aliasSource ? readAttachNumber(aliasSource.CHAT_ID) : undefined;
2546
+ const aliasNetworkId = aliasSource ? readAttachString(aliasSource.NETWORK_ID) : undefined;
2547
+
2548
+ return {
2549
+ LINK: link,
2550
+ ...(aliasName ? { NAME: aliasName } : {}),
2551
+ ...(aliasDesc ? { DESC: aliasDesc } : {}),
2552
+ ...(aliasHtml ? { HTML: aliasHtml } : {}),
2553
+ ...(aliasPreview ? { PREVIEW: aliasPreview } : {}),
2554
+ ...(aliasWidth !== undefined ? { WIDTH: aliasWidth } : {}),
2555
+ ...(aliasHeight !== undefined ? { HEIGHT: aliasHeight } : {}),
2556
+ ...(aliasUserId !== undefined ? { USER_ID: aliasUserId } : {}),
2557
+ ...(aliasChatId !== undefined ? { CHAT_ID: aliasChatId } : {}),
2558
+ ...(aliasNetworkId ? { NETWORK_ID: aliasNetworkId } : {}),
2559
+ };
2560
+ }
2561
+
2562
+ if (!isPlainObject(rawValue)) {
2563
+ return undefined;
2564
+ }
2565
+
2566
+ const link = readAttachString(rawValue.LINK);
2567
+ if (!link) {
2568
+ return undefined;
2569
+ }
2570
+
2571
+ const name = readAttachString(rawValue.NAME) ?? readAttachString(rawValue.TITLE);
2572
+ const desc = readAttachString(rawValue.DESC);
2573
+ const html = readAttachString(rawValue.HTML);
2574
+ const preview = readAttachString(rawValue.PREVIEW);
2575
+ const width = readAttachNumber(rawValue.WIDTH);
2576
+ const height = readAttachNumber(rawValue.HEIGHT);
2577
+ const userId = readAttachNumber(rawValue.USER_ID);
2578
+ const chatId = readAttachNumber(rawValue.CHAT_ID);
2579
+ const networkId = readAttachString(rawValue.NETWORK_ID);
2580
+
2581
+ return {
2582
+ LINK: link,
2583
+ ...(name ? { NAME: name } : {}),
2584
+ ...(desc ? { DESC: desc } : {}),
2585
+ ...(html ? { HTML: html } : {}),
2586
+ ...(preview ? { PREVIEW: preview } : {}),
2587
+ ...(width !== undefined ? { WIDTH: width } : {}),
2588
+ ...(height !== undefined ? { HEIGHT: height } : {}),
2589
+ ...(userId !== undefined ? { USER_ID: userId } : {}),
2590
+ ...(chatId !== undefined ? { CHAT_ID: chatId } : {}),
2591
+ ...(networkId ? { NETWORK_ID: networkId } : {}),
2592
+ };
2593
+ }
2594
+
2595
+ function normalizeAttachImageItem(rawValue: unknown): B24AttachImageItem | undefined {
2596
+ const normalized = normalizeAttachLinkValue(rawValue);
2597
+ if (!normalized) {
2598
+ return undefined;
2599
+ }
2600
+
2601
+ return {
2602
+ LINK: normalized.LINK,
2603
+ ...(normalized.NAME ? { NAME: normalized.NAME } : {}),
2604
+ ...(normalized.PREVIEW ? { PREVIEW: normalized.PREVIEW } : {}),
2605
+ ...(normalized.WIDTH !== undefined ? { WIDTH: normalized.WIDTH } : {}),
2606
+ ...(normalized.HEIGHT !== undefined ? { HEIGHT: normalized.HEIGHT } : {}),
2607
+ };
2608
+ }
2609
+
2610
+ function normalizeAttachFileItem(rawValue: unknown): B24AttachFileItem | undefined {
2611
+ if (typeof rawValue === 'string') {
2612
+ const link = readAttachString(rawValue);
2613
+ return link ? { LINK: link } : undefined;
2614
+ }
2615
+
2616
+ if (!isPlainObject(rawValue)) {
2617
+ return undefined;
2618
+ }
2619
+
2620
+ const link = readAttachString(rawValue.LINK);
2621
+ if (!link) {
2622
+ return undefined;
2623
+ }
2624
+
2625
+ const name = readAttachString(rawValue.NAME);
2626
+ const size = readAttachNumber(rawValue.SIZE);
2627
+
2628
+ return {
2629
+ LINK: link,
2630
+ ...(name ? { NAME: name } : {}),
2631
+ ...(size !== undefined ? { SIZE: size } : {}),
2632
+ };
2633
+ }
2634
+
2635
+ function normalizeAttachUserValue(rawValue: unknown, aliasSource?: Record<string, unknown>): B24AttachUserValue | undefined {
2636
+ if (typeof rawValue === 'number' || typeof rawValue === 'string') {
2637
+ const userId = readAttachNumber(rawValue);
2638
+ if (userId === undefined) {
2639
+ return undefined;
2640
+ }
2641
+
2642
+ const aliasName = aliasSource
2643
+ ? readAttachString(aliasSource.NAME) ?? readAttachString(aliasSource.USER_NAME)
2644
+ : undefined;
2645
+ const aliasAvatar = aliasSource ? readAttachString(aliasSource.AVATAR) : undefined;
2646
+ const aliasLink = aliasSource ? readAttachString(aliasSource.LINK) : undefined;
2647
+ const aliasNetworkId = aliasSource ? readAttachString(aliasSource.NETWORK_ID) : undefined;
2648
+
2649
+ return {
2650
+ USER_ID: userId,
2651
+ ...(aliasName ? { NAME: aliasName } : {}),
2652
+ ...(aliasAvatar ? { AVATAR: aliasAvatar } : {}),
2653
+ ...(aliasLink ? { LINK: aliasLink } : {}),
2654
+ ...(aliasNetworkId ? { NETWORK_ID: aliasNetworkId } : {}),
2655
+ };
2656
+ }
2657
+
2658
+ if (!isPlainObject(rawValue)) {
2659
+ return undefined;
2660
+ }
2661
+
2662
+ const userId = readAttachNumber(rawValue.USER_ID);
2663
+ const name = readAttachString(rawValue.NAME) ?? readAttachString(rawValue.USER_NAME);
2664
+ const avatar = readAttachString(rawValue.AVATAR);
2665
+ const link = readAttachString(rawValue.LINK);
2666
+ const networkId = readAttachString(rawValue.NETWORK_ID);
2667
+
2668
+ if (userId === undefined && !name && !avatar && !link && !networkId) {
2669
+ return undefined;
2670
+ }
2671
+
2672
+ return {
2673
+ ...(name ? { NAME: name } : {}),
2674
+ ...(avatar ? { AVATAR: avatar } : {}),
2675
+ ...(link ? { LINK: link } : {}),
2676
+ ...(userId !== undefined ? { USER_ID: userId } : {}),
2677
+ ...(networkId ? { NETWORK_ID: networkId } : {}),
1893
2678
  };
1894
2679
  }
1895
2680
 
1896
- function extractBitrix24ToolSend(args: Record<string, unknown>): ExtractedToolSendTarget | null {
1897
- if ((typeof args.action === 'string' ? args.action.trim() : '') !== 'sendMessage') {
1898
- return null;
2681
+ function normalizeAttachBlock(rawValue: unknown): B24AttachBlock | undefined {
2682
+ if (!isPlainObject(rawValue)) {
2683
+ return undefined;
2684
+ }
2685
+
2686
+ const message = readAttachString(rawValue.MESSAGE);
2687
+ if (message && Object.keys(rawValue).every((key) => key === 'MESSAGE' || key === 'TYPE')) {
2688
+ return { MESSAGE: message };
2689
+ }
2690
+
2691
+ if ('LINK' in rawValue && rawValue.LINK !== undefined) {
2692
+ const linkValue = normalizeAttachLinkValue(rawValue.LINK, rawValue);
2693
+ if (linkValue) {
2694
+ return { LINK: linkValue };
2695
+ }
2696
+ }
2697
+
2698
+ if ('IMAGE' in rawValue && rawValue.IMAGE !== undefined) {
2699
+ const rawImage = rawValue.IMAGE;
2700
+ const imageItems = Array.isArray(rawImage)
2701
+ ? rawImage.map(normalizeAttachImageItem).filter(isPresent)
2702
+ : [normalizeAttachImageItem(rawImage)].filter(isPresent);
2703
+ if (imageItems.length > 0) {
2704
+ return { IMAGE: imageItems.length === 1 ? imageItems[0] : imageItems };
2705
+ }
2706
+ }
2707
+
2708
+ if ('FILE' in rawValue && rawValue.FILE !== undefined) {
2709
+ const rawFile = rawValue.FILE;
2710
+ const fileItems = Array.isArray(rawFile)
2711
+ ? rawFile.map(normalizeAttachFileItem).filter(isPresent)
2712
+ : [normalizeAttachFileItem(rawFile)].filter(isPresent);
2713
+ if (fileItems.length > 0) {
2714
+ return { FILE: fileItems.length === 1 ? fileItems[0] : fileItems };
2715
+ }
2716
+ }
2717
+
2718
+ if ('DELIMITER' in rawValue && isPlainObject(rawValue.DELIMITER)) {
2719
+ const delimiter = rawValue.DELIMITER as Record<string, unknown>;
2720
+ const size = readAttachNumber(delimiter.SIZE);
2721
+ const color = readAttachString(delimiter.COLOR);
2722
+ if (size !== undefined || color) {
2723
+ return {
2724
+ DELIMITER: {
2725
+ ...(size !== undefined ? { SIZE: size } : {}),
2726
+ ...(color ? { COLOR: color } : {}),
2727
+ },
2728
+ };
2729
+ }
2730
+ }
2731
+
2732
+ if ('GRID' in rawValue && Array.isArray(rawValue.GRID)) {
2733
+ const gridItems = rawValue.GRID.map(normalizeAttachGridItem).filter(isPresent);
2734
+ if (gridItems.length > 0) {
2735
+ return { GRID: gridItems };
2736
+ }
2737
+ }
2738
+
2739
+ if ('USER' in rawValue && rawValue.USER !== undefined) {
2740
+ const userValue = normalizeAttachUserValue(rawValue.USER, rawValue);
2741
+ if (userValue) {
2742
+ return { USER: userValue };
2743
+ }
2744
+ }
2745
+
2746
+ const rawType = readAttachString(rawValue.TYPE)?.toUpperCase();
2747
+ if (!rawType) {
2748
+ return undefined;
2749
+ }
2750
+
2751
+ if (rawType === 'MESSAGE') {
2752
+ const title = readAttachString(rawValue.TITLE);
2753
+ return message
2754
+ ? { MESSAGE: message }
2755
+ : title
2756
+ ? { MESSAGE: `[B]${title}[/B]` }
2757
+ : undefined;
2758
+ }
2759
+
2760
+ if (rawType === 'TITLE') {
2761
+ const title = readAttachString(rawValue.TITLE) ?? message;
2762
+ return title ? { MESSAGE: `[B]${title}[/B]` } : undefined;
1899
2763
  }
1900
2764
 
1901
- const to = typeof args.to === 'string' ? normalizeBitrix24DialogTarget(args.to) : '';
1902
- if (!to) {
1903
- return null;
2765
+ if (rawType === 'LINK') {
2766
+ const linkValue = normalizeAttachLinkValue(rawValue.LINK ?? rawValue, rawValue);
2767
+ return linkValue ? { LINK: linkValue } : undefined;
1904
2768
  }
1905
2769
 
1906
- const accountId = typeof args.accountId === 'string'
1907
- ? args.accountId.trim()
1908
- : undefined;
1909
- const threadId = typeof args.threadId === 'number'
1910
- ? String(args.threadId)
1911
- : typeof args.threadId === 'string'
1912
- ? args.threadId.trim()
1913
- : '';
2770
+ if (rawType === 'USER') {
2771
+ const userValue = normalizeAttachUserValue(rawValue.USER ?? rawValue.USER_ID ?? rawValue, rawValue);
2772
+ return userValue ? { USER: userValue } : undefined;
2773
+ }
1914
2774
 
1915
- return {
1916
- to,
1917
- ...(accountId ? { accountId } : {}),
1918
- ...(threadId ? { threadId } : {}),
1919
- };
1920
- }
2775
+ if (rawType === 'GRID') {
2776
+ const rawGridItems = Array.isArray(rawValue.GRID)
2777
+ ? rawValue.GRID
2778
+ : Array.isArray(rawValue.ITEMS)
2779
+ ? rawValue.ITEMS
2780
+ : Array.isArray(rawValue.ROWS)
2781
+ ? rawValue.ROWS
2782
+ : [];
2783
+ const gridItems = rawGridItems.map(normalizeAttachGridItem).filter(isPresent);
2784
+ return gridItems.length > 0 ? { GRID: gridItems } : undefined;
2785
+ }
1921
2786
 
1922
- function readActionTextParam(params: Record<string, unknown>): string | null {
1923
- const rawText = params.message ?? params.text ?? params.content;
1924
- return typeof rawText === 'string' ? rawText : null;
1925
- }
2787
+ if (rawType === 'DELIMITER') {
2788
+ const size = readAttachNumber(rawValue.SIZE);
2789
+ const color = readAttachString(rawValue.COLOR);
2790
+ return size !== undefined || color
2791
+ ? {
2792
+ DELIMITER: {
2793
+ ...(size !== undefined ? { SIZE: size } : {}),
2794
+ ...(color ? { COLOR: color } : {}),
2795
+ },
2796
+ }
2797
+ : undefined;
2798
+ }
1926
2799
 
1927
- function isPlainObject(value: unknown): value is Record<string, unknown> {
1928
- return value !== null && typeof value === 'object' && !Array.isArray(value);
1929
- }
2800
+ if (rawType === 'IMAGE') {
2801
+ const rawImage = rawValue.IMAGE ?? rawValue.LINK ?? rawValue;
2802
+ const imageItems = Array.isArray(rawImage)
2803
+ ? rawImage.map(normalizeAttachImageItem).filter(isPresent)
2804
+ : [normalizeAttachImageItem(rawImage)].filter(isPresent);
2805
+ return imageItems.length > 0
2806
+ ? { IMAGE: imageItems.length === 1 ? imageItems[0] : imageItems }
2807
+ : undefined;
2808
+ }
1930
2809
 
1931
- function isAttachColorToken(value: unknown): value is B24AttachColorToken {
1932
- return value === 'primary'
1933
- || value === 'secondary'
1934
- || value === 'alert'
1935
- || value === 'base';
1936
- }
2810
+ if (rawType === 'FILE') {
2811
+ const rawFile = rawValue.FILE ?? rawValue.LINK ?? rawValue;
2812
+ const fileItems = Array.isArray(rawFile)
2813
+ ? rawFile.map(normalizeAttachFileItem).filter(isPresent)
2814
+ : [normalizeAttachFileItem(rawFile)].filter(isPresent);
2815
+ return fileItems.length > 0
2816
+ ? { FILE: fileItems.length === 1 ? fileItems[0] : fileItems }
2817
+ : undefined;
2818
+ }
1937
2819
 
1938
- function isAttachBlock(value: unknown): value is B24AttachBlock {
1939
- return isPlainObject(value) && Object.keys(value).length > 0;
2820
+ return undefined;
1940
2821
  }
1941
2822
 
1942
- function isBitrix24Attach(value: unknown): value is B24Attach {
1943
- if (Array.isArray(value)) {
1944
- return value.length > 0 && value.every(isAttachBlock);
2823
+ function normalizeBitrix24Attach(rawValue: unknown): B24Attach | undefined {
2824
+ if (Array.isArray(rawValue)) {
2825
+ const blocks = rawValue.map(normalizeAttachBlock).filter(isPresent);
2826
+ return blocks.length > 0 ? blocks : undefined;
1945
2827
  }
1946
2828
 
1947
- if (!isPlainObject(value) || !Array.isArray(value.BLOCKS) || value.BLOCKS.length === 0) {
1948
- return false;
2829
+ if (!isPlainObject(rawValue)) {
2830
+ return undefined;
1949
2831
  }
1950
2832
 
1951
- if (value.COLOR_TOKEN !== undefined && !isAttachColorToken(value.COLOR_TOKEN)) {
1952
- return false;
2833
+ if (Array.isArray(rawValue.BLOCKS)) {
2834
+ const blocks = rawValue.BLOCKS.map(normalizeAttachBlock).filter(isPresent);
2835
+ if (blocks.length === 0) {
2836
+ return undefined;
2837
+ }
2838
+
2839
+ const id = readAttachNumber(rawValue.ID);
2840
+ const colorToken = normalizeAttachColorTokenValue(rawValue.COLOR_TOKEN);
2841
+ const color = readAttachString(rawValue.COLOR);
2842
+
2843
+ return {
2844
+ ...(id !== undefined ? { ID: id } : {}),
2845
+ ...(colorToken ? { COLOR_TOKEN: colorToken } : {}),
2846
+ ...(color ? { COLOR: color } : {}),
2847
+ BLOCKS: blocks,
2848
+ };
1953
2849
  }
1954
2850
 
1955
- return value.BLOCKS.every(isAttachBlock);
2851
+ const block = normalizeAttachBlock(rawValue);
2852
+ return block ? [block] : undefined;
1956
2853
  }
1957
2854
 
1958
2855
  function parseActionAttach(rawValue: unknown): B24Attach | undefined {
@@ -1974,7 +2871,7 @@ function parseActionAttach(rawValue: unknown): B24Attach | undefined {
1974
2871
  }
1975
2872
  }
1976
2873
 
1977
- return isBitrix24Attach(parsed) ? parsed : undefined;
2874
+ return normalizeBitrix24Attach(parsed);
1978
2875
  }
1979
2876
 
1980
2877
  function hasMeaningfulAttachInput(rawValue: unknown): boolean {
@@ -2127,8 +3024,11 @@ function collectActionMediaUrls(params: Record<string, unknown>): string[] {
2127
3024
 
2128
3025
  append(params.mediaUrl);
2129
3026
  append(params.mediaUrls);
3027
+ append(params.media);
2130
3028
  append(params.filePath);
2131
3029
  append(params.filePaths);
3030
+ append(params.path);
3031
+ append(params.paths);
2132
3032
 
2133
3033
  return mediaUrls;
2134
3034
  }
@@ -2685,15 +3585,23 @@ async function uploadOutboundMedia(params: {
2685
3585
  mediaService: MediaService;
2686
3586
  mediaUrls: string[];
2687
3587
  sendCtx: SendContext;
3588
+ currentMessageId?: string | number;
3589
+ accountId?: string;
2688
3590
  initialMessage?: string;
2689
3591
  }): Promise<string> {
2690
3592
  let lastMessageId = '';
2691
3593
  let message = params.initialMessage;
2692
3594
 
2693
3595
  for (const mediaUrl of params.mediaUrls) {
3596
+ const outboundFileName = resolveSemanticOutboundFileName({
3597
+ requestedFileName: basename(mediaUrl),
3598
+ dialogId: params.sendCtx.dialogId,
3599
+ currentMessageId: params.currentMessageId,
3600
+ accountId: params.accountId,
3601
+ });
2694
3602
  const result = await params.mediaService.uploadMediaToChat({
2695
3603
  localPath: mediaUrl,
2696
- fileName: basename(mediaUrl),
3604
+ fileName: outboundFileName,
2697
3605
  webhookUrl: params.sendCtx.webhookUrl,
2698
3606
  bot: params.sendCtx.bot,
2699
3607
  dialogId: params.sendCtx.dialogId,
@@ -2701,7 +3609,7 @@ async function uploadOutboundMedia(params: {
2701
3609
  });
2702
3610
 
2703
3611
  if (!result.ok) {
2704
- throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
3612
+ throw new Error(`Failed to upload media: ${outboundFileName}`);
2705
3613
  }
2706
3614
 
2707
3615
  if (result.messageId) {
@@ -2834,6 +3742,8 @@ export const bitrix24Plugin = {
2834
3742
  mediaService: gatewayState.mediaService,
2835
3743
  sendCtx,
2836
3744
  mediaUrls,
3745
+ currentMessageId: ctx.currentMessageId as string | number | undefined,
3746
+ accountId: ctx.accountId,
2837
3747
  initialMessage: ctx.text,
2838
3748
  });
2839
3749
  return { messageId };
@@ -2877,6 +3787,8 @@ export const bitrix24Plugin = {
2877
3787
  mediaService: gatewayState.mediaService,
2878
3788
  sendCtx,
2879
3789
  mediaUrls,
3790
+ currentMessageId: ctx.currentMessageId as string | number | undefined,
3791
+ accountId: ctx.accountId,
2880
3792
  initialMessage,
2881
3793
  });
2882
3794
 
@@ -2967,6 +3879,9 @@ export const bitrix24Plugin = {
2967
3879
  content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
2968
3880
  details: payload,
2969
3881
  });
3882
+ const markSuccessfulAction = (currentMessageId: string | number | undefined): void => {
3883
+ markRecentSuccessfulAction(currentMessageId, ctx.accountId);
3884
+ };
2970
3885
 
2971
3886
  // ─── Send with buttons ──────────────────────────────────────────────
2972
3887
  if (ctx.action === 'send') {
@@ -2984,6 +3899,7 @@ export const bitrix24Plugin = {
2984
3899
  }
2985
3900
 
2986
3901
  const sendCtx: SendContext = { webhookUrl: config.webhookUrl, bot, dialogId: to };
3902
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ dialogId: to });
2987
3903
  const messageText = readActionTextParam(ctx.params);
2988
3904
  const message = typeof messageText === 'string' ? messageText.trim() : '';
2989
3905
  const keyboard = parseActionKeyboard(rawButtons);
@@ -3026,6 +3942,26 @@ export const bitrix24Plugin = {
3026
3942
  });
3027
3943
  }
3028
3944
 
3945
+ const duplicateMediaDelivery = mediaUrls.length > 0
3946
+ ? findRecentMediaDelivery({
3947
+ dialogId: to,
3948
+ currentMessageId: currentToolMessageId,
3949
+ mediaUrls,
3950
+ accountId: ctx.accountId,
3951
+ })
3952
+ : null;
3953
+ if (duplicateMediaDelivery) {
3954
+ markSuccessfulAction(currentToolMessageId);
3955
+ return toolResult({
3956
+ channel: 'bitrix24',
3957
+ to,
3958
+ via: 'direct',
3959
+ mediaUrl: mediaUrls[0] ?? null,
3960
+ duplicateSuppressed: true,
3961
+ result: { messageId: duplicateMediaDelivery.messageId ?? '' },
3962
+ });
3963
+ }
3964
+
3029
3965
  try {
3030
3966
  if (explicitForwardMessages.length > 0) {
3031
3967
  const normalizedForwardText = normalizeForwardActionText(messageText);
@@ -3042,7 +3978,16 @@ export const bitrix24Plugin = {
3042
3978
  forwardMessages: explicitForwardMessages,
3043
3979
  },
3044
3980
  );
3981
+ await maybeAddNativeForwardSuccessReaction({
3982
+ api: gatewayState.api,
3983
+ sendService: gatewayState.sendService,
3984
+ webhookUrl: config.webhookUrl,
3985
+ bot,
3986
+ dialogId: forwardAckDialogId,
3987
+ currentMessageId: currentToolMessageId,
3988
+ });
3045
3989
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3990
+ markSuccessfulAction(currentToolMessageId);
3046
3991
  return toolResult({
3047
3992
  channel: 'bitrix24',
3048
3993
  to,
@@ -3062,7 +4007,16 @@ export const bitrix24Plugin = {
3062
4007
  forwardMessages: explicitForwardMessages,
3063
4008
  },
3064
4009
  );
4010
+ await maybeAddNativeForwardSuccessReaction({
4011
+ api: gatewayState.api,
4012
+ sendService: gatewayState.sendService,
4013
+ webhookUrl: config.webhookUrl,
4014
+ bot,
4015
+ dialogId: forwardAckDialogId,
4016
+ currentMessageId: currentToolMessageId,
4017
+ });
3065
4018
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
4019
+ markSuccessfulAction(currentToolMessageId);
3066
4020
  return toolResult({
3067
4021
  channel: 'bitrix24',
3068
4022
  to,
@@ -3074,7 +4028,7 @@ export const bitrix24Plugin = {
3074
4028
  });
3075
4029
  }
3076
4030
 
3077
- if (referencedReplyMessageId && !attach && mediaUrls.length === 0) {
4031
+ if (canUseNativeReply && referencedReplyMessageId && !attach && mediaUrls.length === 0) {
3078
4032
  let referencedMessageText = '';
3079
4033
 
3080
4034
  try {
@@ -3092,13 +4046,22 @@ export const bitrix24Plugin = {
3092
4046
  const normalizedMessage = normalizeComparableMessageText(message);
3093
4047
 
3094
4048
  if (normalizedReferencedMessage && normalizedReferencedMessage === normalizedMessage) {
3095
- const result = await gatewayState.sendService.sendText(
3096
- sendCtx,
3097
- '',
3098
- { forwardMessages: [referencedReplyMessageId] },
3099
- );
3100
- markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3101
- return toolResult({
4049
+ const result = await gatewayState.sendService.sendText(
4050
+ sendCtx,
4051
+ '',
4052
+ { forwardMessages: [referencedReplyMessageId] },
4053
+ );
4054
+ await maybeAddNativeForwardSuccessReaction({
4055
+ api: gatewayState.api,
4056
+ sendService: gatewayState.sendService,
4057
+ webhookUrl: config.webhookUrl,
4058
+ bot,
4059
+ dialogId: forwardAckDialogId,
4060
+ currentMessageId: currentToolMessageId,
4061
+ });
4062
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
4063
+ markSuccessfulAction(currentToolMessageId);
4064
+ return toolResult({
3102
4065
  channel: 'bitrix24',
3103
4066
  to,
3104
4067
  via: 'direct',
@@ -3109,21 +4072,25 @@ export const bitrix24Plugin = {
3109
4072
  });
3110
4073
  }
3111
4074
 
4075
+ const sendOptions = {
4076
+ ...(keyboard ? { keyboard } : {}),
4077
+ ...(canUseNativeReply ? { replyToMessageId: referencedReplyMessageId } : {}),
4078
+ };
3112
4079
  const result = await gatewayState.sendService.sendText(
3113
4080
  sendCtx,
3114
4081
  message || ' ',
3115
- {
3116
- ...(keyboard ? { keyboard } : {}),
3117
- replyToMessageId: referencedReplyMessageId,
3118
- },
4082
+ Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
3119
4083
  );
4084
+ markSuccessfulAction(currentToolMessageId);
3120
4085
  return toolResult({
3121
4086
  channel: 'bitrix24',
3122
4087
  to,
3123
4088
  via: 'direct',
3124
4089
  mediaUrl: null,
3125
- replied: true,
3126
- replyToMessageId: referencedReplyMessageId,
4090
+ ...(canUseNativeReply ? {
4091
+ replied: true,
4092
+ replyToMessageId: referencedReplyMessageId,
4093
+ } : {}),
3127
4094
  result: { messageId: String(result.messageId ?? '') },
3128
4095
  });
3129
4096
  }
@@ -3149,7 +4116,16 @@ export const bitrix24Plugin = {
3149
4116
  '',
3150
4117
  { forwardMessages: [Number(previousInboundMessage.messageId)] },
3151
4118
  );
4119
+ await maybeAddNativeForwardSuccessReaction({
4120
+ api: gatewayState.api,
4121
+ sendService: gatewayState.sendService,
4122
+ webhookUrl: config.webhookUrl,
4123
+ bot,
4124
+ dialogId: forwardAckDialogId,
4125
+ currentMessageId: currentToolMessageId,
4126
+ });
3152
4127
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
4128
+ markSuccessfulAction(currentToolMessageId);
3153
4129
  return toolResult({
3154
4130
  channel: 'bitrix24',
3155
4131
  to,
@@ -3167,8 +4143,17 @@ export const bitrix24Plugin = {
3167
4143
  mediaService: gatewayState.mediaService,
3168
4144
  sendCtx,
3169
4145
  mediaUrls,
4146
+ currentMessageId: currentToolMessageId,
4147
+ accountId: ctx.accountId,
3170
4148
  initialMessage,
3171
4149
  });
4150
+ markRecentMediaDelivery({
4151
+ dialogId: to,
4152
+ currentMessageId: currentToolMessageId,
4153
+ mediaUrls,
4154
+ accountId: ctx.accountId,
4155
+ messageId: uploadedMessageId,
4156
+ });
3172
4157
 
3173
4158
  if ((message && !initialMessage) || keyboard || attach) {
3174
4159
  if (attach) {
@@ -3182,6 +4167,7 @@ export const bitrix24Plugin = {
3182
4167
  attach,
3183
4168
  },
3184
4169
  );
4170
+ markSuccessfulAction(currentToolMessageId);
3185
4171
  return toolResult({
3186
4172
  channel: 'bitrix24',
3187
4173
  to,
@@ -3196,6 +4182,7 @@ export const bitrix24Plugin = {
3196
4182
  message || '',
3197
4183
  keyboard ? { keyboard } : undefined,
3198
4184
  );
4185
+ markSuccessfulAction(currentToolMessageId);
3199
4186
  return toolResult({
3200
4187
  channel: 'bitrix24',
3201
4188
  to,
@@ -3205,6 +4192,7 @@ export const bitrix24Plugin = {
3205
4192
  });
3206
4193
  }
3207
4194
 
4195
+ markSuccessfulAction(currentToolMessageId);
3208
4196
  return toolResult({
3209
4197
  channel: 'bitrix24',
3210
4198
  to,
@@ -3225,6 +4213,7 @@ export const bitrix24Plugin = {
3225
4213
  attach,
3226
4214
  },
3227
4215
  );
4216
+ markSuccessfulAction(currentToolMessageId);
3228
4217
  return toolResult({
3229
4218
  channel: 'bitrix24',
3230
4219
  to,
@@ -3237,6 +4226,7 @@ export const bitrix24Plugin = {
3237
4226
  const result = await gatewayState.sendService.sendText(
3238
4227
  sendCtx, message || ' ', keyboard ? { keyboard } : undefined,
3239
4228
  );
4229
+ markSuccessfulAction(currentToolMessageId);
3240
4230
  return toolResult({
3241
4231
  channel: 'bitrix24',
3242
4232
  to,
@@ -3246,6 +4236,14 @@ export const bitrix24Plugin = {
3246
4236
  });
3247
4237
  } catch (err) {
3248
4238
  const errMsg = err instanceof Error ? err.message : String(err);
4239
+ if (errMsg.startsWith('Failed to upload media: ')) {
4240
+ return toolResult({
4241
+ ok: false,
4242
+ reason: 'media_upload_failed',
4243
+ error: errMsg,
4244
+ hint: 'The local file could not be uploaded. First create/write the file in the OpenClaw workspace or managed media directory, then send it once with media/filePath/path. Do not retry the same send blindly, and do not send an extra confirmation after a successful file upload.',
4245
+ });
4246
+ }
3249
4247
  return toolResult({ ok: false, error: errMsg });
3250
4248
  }
3251
4249
  }
@@ -3265,6 +4263,7 @@ export const bitrix24Plugin = {
3265
4263
 
3266
4264
  const toolContext = (ctx as Record<string, unknown>).toolContext as
3267
4265
  | { currentMessageId?: string | number } | undefined;
4266
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ dialogId: to });
3268
4267
  const replyToMessageId = parseActionMessageIds(
3269
4268
  params.replyToMessageId
3270
4269
  ?? params.replyToId
@@ -3300,31 +4299,38 @@ export const bitrix24Plugin = {
3300
4299
  {
3301
4300
  ...(keyboard ? { keyboard } : {}),
3302
4301
  attach,
3303
- replyToMessageId,
4302
+ ...(canUseNativeReply ? { replyToMessageId } : {}),
3304
4303
  },
3305
4304
  );
4305
+ markSuccessfulAction(toolContext?.currentMessageId);
3306
4306
  return toolResult({
3307
4307
  ok: true,
3308
- replied: true,
3309
4308
  to,
3310
- replyToMessageId,
4309
+ ...(canUseNativeReply ? {
4310
+ replied: true,
4311
+ replyToMessageId,
4312
+ } : {}),
3311
4313
  messageId: String(messageId ?? ''),
3312
4314
  });
3313
4315
  }
3314
4316
 
4317
+ const sendOptions = {
4318
+ ...(keyboard ? { keyboard } : {}),
4319
+ ...(canUseNativeReply ? { replyToMessageId } : {}),
4320
+ };
3315
4321
  const result = await gatewayState.sendService.sendText(
3316
4322
  { webhookUrl: config.webhookUrl, bot, dialogId: to },
3317
4323
  normalizedText || ' ',
3318
- {
3319
- ...(keyboard ? { keyboard } : {}),
3320
- replyToMessageId,
3321
- },
4324
+ Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
3322
4325
  );
4326
+ markSuccessfulAction(toolContext?.currentMessageId);
3323
4327
  return toolResult({
3324
4328
  ok: true,
3325
- replied: true,
3326
4329
  to,
3327
- replyToMessageId,
4330
+ ...(canUseNativeReply ? {
4331
+ replied: true,
4332
+ replyToMessageId,
4333
+ } : {}),
3328
4334
  messageId: String(result.messageId ?? ''),
3329
4335
  });
3330
4336
  } catch (err) {
@@ -3394,6 +4400,7 @@ export const bitrix24Plugin = {
3394
4400
  ...(attach ? { attach } : {}),
3395
4401
  },
3396
4402
  );
4403
+ markSuccessfulAction(toolContext?.currentMessageId);
3397
4404
  return toolResult({ ok: true, edited: true, messageId });
3398
4405
  } catch (err) {
3399
4406
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -3429,6 +4436,7 @@ export const bitrix24Plugin = {
3429
4436
 
3430
4437
  try {
3431
4438
  await api.deleteMessage(config.webhookUrl, bot, messageId, complete);
4439
+ markSuccessfulAction(toolContext?.currentMessageId);
3432
4440
  return toolResult({ ok: true, deleted: true, messageId });
3433
4441
  } catch (err) {
3434
4442
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -3453,6 +4461,13 @@ export const bitrix24Plugin = {
3453
4461
  | { currentMessageId?: string | number } | undefined;
3454
4462
  const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
3455
4463
  const messageId = Number(rawMessageId);
4464
+ const reactionAckSourceMessageId = typeof rawMessageId === 'string' || typeof rawMessageId === 'number'
4465
+ ? rawMessageId
4466
+ : toolContext?.currentMessageId;
4467
+ const reactionAckDialogId = resolveRecentInboundDialogId(
4468
+ reactionAckSourceMessageId,
4469
+ ctx.accountId,
4470
+ );
3456
4471
 
3457
4472
  if (!Number.isFinite(messageId) || messageId <= 0) {
3458
4473
  return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
@@ -3469,6 +4484,7 @@ export const bitrix24Plugin = {
3469
4484
  }
3470
4485
  try {
3471
4486
  await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
4487
+ markSuccessfulAction(toolContext?.currentMessageId);
3472
4488
  return toolResult({ ok: true, removed: true });
3473
4489
  } catch (err) {
3474
4490
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -3493,13 +4509,27 @@ export const bitrix24Plugin = {
3493
4509
 
3494
4510
  try {
3495
4511
  await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
4512
+ await maybeSendReactionTypingReset({
4513
+ sendService: gatewayState.sendService,
4514
+ webhookUrl: config.webhookUrl,
4515
+ bot,
4516
+ dialogId: reactionAckDialogId,
4517
+ });
3496
4518
  markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
4519
+ markSuccessfulAction(toolContext?.currentMessageId);
3497
4520
  return toolResult({ ok: true, added: emoji });
3498
4521
  } catch (err) {
3499
4522
  const errMsg = err instanceof Error ? err.message : String(err);
3500
4523
  const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
3501
4524
  if (isAlreadySet) {
4525
+ await maybeSendReactionTypingReset({
4526
+ sendService: gatewayState.sendService,
4527
+ webhookUrl: config.webhookUrl,
4528
+ bot,
4529
+ dialogId: reactionAckDialogId,
4530
+ });
3502
4531
  markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
4532
+ markSuccessfulAction(toolContext?.currentMessageId);
3503
4533
  return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
3504
4534
  }
3505
4535
  return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
@@ -3675,6 +4705,30 @@ export const bitrix24Plugin = {
3675
4705
 
3676
4706
  const sendService = new SendService(api, logger);
3677
4707
  const mediaService = new MediaService(api, logger);
4708
+ const originalSendText = sendService.sendText.bind(sendService);
4709
+ sendService.sendText = async (...args) => {
4710
+ const result = await originalSendText(...args);
4711
+ markRecentOutboundMessage(result?.messageId, ctx.accountId);
4712
+ return result;
4713
+ };
4714
+ const originalAnswerCommandText = sendService.answerCommandText.bind(sendService);
4715
+ sendService.answerCommandText = async (...args) => {
4716
+ const result = await originalAnswerCommandText(...args);
4717
+ markRecentOutboundMessage(result?.messageId, ctx.accountId);
4718
+ return result;
4719
+ };
4720
+ const originalApiSendMessage = api.sendMessage.bind(api);
4721
+ api.sendMessage = async (...args) => {
4722
+ const messageId = await originalApiSendMessage(...args);
4723
+ markRecentOutboundMessage(messageId, ctx.accountId);
4724
+ return messageId;
4725
+ };
4726
+ const originalUploadMediaToChat = mediaService.uploadMediaToChat.bind(mediaService);
4727
+ mediaService.uploadMediaToChat = async (...args) => {
4728
+ const result = await originalUploadMediaToChat(...args);
4729
+ markRecentOutboundMessage(result?.messageId, ctx.accountId);
4730
+ return result;
4731
+ };
3678
4732
  const hydrateReplyEntry = async (
3679
4733
  replyToMessageId: string | undefined,
3680
4734
  ): Promise<{ sender: string; body: string; messageId: string } | undefined> => {
@@ -3867,8 +4921,12 @@ export const bitrix24Plugin = {
3867
4921
  query: bodyWithReply,
3868
4922
  historyCache,
3869
4923
  });
4924
+ const inlineButtonsAgentHint = buildBitrix24InlineButtonsAgentHint();
3870
4925
  const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
3871
- const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(msgCtx.messageId);
4926
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ isDm: msgCtx.isDm });
4927
+ const nativeReplyAgentHint = canUseNativeReply
4928
+ ? buildBitrix24NativeReplyAgentHint(msgCtx.messageId)
4929
+ : undefined;
3872
4930
  const nativeForwardAgentHint = buildBitrix24NativeForwardAgentHint({
3873
4931
  accountId: ctx.accountId,
3874
4932
  currentBody: body,
@@ -3877,6 +4935,7 @@ export const bitrix24Plugin = {
3877
4935
  historyEntries: previousEntries,
3878
4936
  });
3879
4937
  const bodyForAgent = [
4938
+ inlineButtonsAgentHint,
3880
4939
  fileDeliveryAgentHint,
3881
4940
  nativeReplyAgentHint,
3882
4941
  nativeForwardAgentHint,
@@ -3895,6 +4954,7 @@ export const bitrix24Plugin = {
3895
4954
  conversation.dialogId,
3896
4955
  msgCtx.messageId,
3897
4956
  body,
4957
+ msgCtx.media.map((mediaItem) => mediaItem.name),
3898
4958
  msgCtx.timestamp ?? Date.now(),
3899
4959
  ctx.accountId,
3900
4960
  );
@@ -3954,14 +5014,33 @@ export const bitrix24Plugin = {
3954
5014
  deliver: async (payload) => {
3955
5015
  await replyStatusHeartbeat.stopAndWait();
3956
5016
  const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
5017
+ const hadRecentMediaDelivery = mediaUrls.length === 0
5018
+ && hasRecentMediaDelivery(sendCtx.dialogId, msgCtx.messageId, ctx.accountId);
3957
5019
  if (mediaUrls.length > 0) {
3958
- await uploadOutboundMedia({
5020
+ const uploadedMessageId = await uploadOutboundMedia({
3959
5021
  mediaService,
3960
5022
  sendCtx,
3961
5023
  mediaUrls,
3962
5024
  });
5025
+ markRecentMediaDelivery({
5026
+ dialogId: sendCtx.dialogId,
5027
+ currentMessageId: msgCtx.messageId,
5028
+ mediaUrls,
5029
+ accountId: ctx.accountId,
5030
+ messageId: uploadedMessageId,
5031
+ });
3963
5032
  }
3964
5033
  if (payload.text) {
5034
+ if (hadRecentMediaDelivery) {
5035
+ replyDelivered = true;
5036
+ logger.debug('Suppressing trailing text after successful Bitrix24 file upload', {
5037
+ senderId: msgCtx.senderId,
5038
+ chatId: msgCtx.chatId,
5039
+ messageId: msgCtx.messageId,
5040
+ });
5041
+ return;
5042
+ }
5043
+
3965
5044
  if (consumePendingNativeForwardAck(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
3966
5045
  replyDelivered = true;
3967
5046
  logger.debug('Suppressing trailing acknowledgement after native Bitrix24 forward', {
@@ -4016,7 +5095,7 @@ export const bitrix24Plugin = {
4016
5095
  : [];
4017
5096
  const nativeReplyToMessageId = nativeForwardMessageId
4018
5097
  ? undefined
4019
- : replyDirective.replyToMessageId;
5098
+ : (canUseNativeReply ? replyDirective.replyToMessageId : undefined);
4020
5099
  const sendOptions = {
4021
5100
  ...(keyboard ? { keyboard } : {}),
4022
5101
  ...(nativeReplyToMessageId ? { replyToMessageId: nativeReplyToMessageId } : {}),
@@ -4071,25 +5150,64 @@ export const bitrix24Plugin = {
4071
5150
  },
4072
5151
  });
4073
5152
 
5153
+ if (!replyDelivered && hasRecentMediaDelivery(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
5154
+ replyDelivered = true;
5155
+ logger.debug('Suppressing fallback after successful Bitrix24 file upload in the same turn', {
5156
+ senderId: msgCtx.senderId,
5157
+ chatId: msgCtx.chatId,
5158
+ messageId: msgCtx.messageId,
5159
+ });
5160
+ }
5161
+
5162
+ if (!replyDelivered && hasRecentSuccessfulAction(msgCtx.messageId, ctx.accountId)) {
5163
+ replyDelivered = true;
5164
+ logger.debug('Suppressing fallback after successful Bitrix24 action in the same turn', {
5165
+ senderId: msgCtx.senderId,
5166
+ chatId: msgCtx.chatId,
5167
+ messageId: msgCtx.messageId,
5168
+ });
5169
+ }
5170
+
5171
+ const shouldWaitForEmptyReplyFallback = !replyDelivered
5172
+ && isEmptyDispatchResult(dispatchResult);
5173
+
4074
5174
  if (!replyDelivered && dispatchResult?.queuedFinal === false) {
4075
5175
  logger.debug('Reply completed without queued final block, waiting before fallback', {
4076
5176
  senderId: msgCtx.senderId,
4077
5177
  chatId: msgCtx.chatId,
4078
5178
  counts: dispatchResult.counts,
5179
+ fallbackDelayMs: shouldWaitForEmptyReplyFallback
5180
+ ? EMPTY_REPLY_FALLBACK_GRACE_MS
5181
+ : REPLY_FALLBACK_GRACE_MS,
4079
5182
  });
4080
- await waitReplyFallbackGraceWindow();
5183
+ if (shouldWaitForEmptyReplyFallback) {
5184
+ await waitEmptyReplyFallbackGraceWindow();
5185
+ } else {
5186
+ await waitReplyFallbackGraceWindow();
5187
+ }
4081
5188
  }
4082
5189
 
4083
- if (!replyDelivered && dispatchResult?.queuedFinal === false) {
5190
+ if (!replyDelivered && isEmptyDispatchResult(dispatchResult)) {
5191
+ replyDelivered = true;
5192
+ logger.warn('Reply completed without any user-visible payload or tool activity; sending fallback notice', {
5193
+ senderId: msgCtx.senderId,
5194
+ chatId: msgCtx.chatId,
5195
+ messageId: msgCtx.messageId,
5196
+ counts: dispatchResult.counts,
5197
+ });
5198
+ await sendService.sendText(sendCtx, emptyReplyFallback(msgCtx.language));
5199
+ } else if (!replyDelivered && dispatchResult?.queuedFinal === false) {
4084
5200
  logger.warn('Reply completed without a final user-visible message; fallback notice suppressed', {
4085
5201
  senderId: msgCtx.senderId,
4086
5202
  chatId: msgCtx.chatId,
5203
+ messageId: msgCtx.messageId,
4087
5204
  counts: dispatchResult.counts,
4088
5205
  });
4089
5206
  } else if (replyDelivered && dispatchResult?.queuedFinal === false) {
4090
5207
  logger.debug('Late reply arrived during fallback grace window, skipping fallback', {
4091
5208
  senderId: msgCtx.senderId,
4092
5209
  chatId: msgCtx.chatId,
5210
+ messageId: msgCtx.messageId,
4093
5211
  });
4094
5212
  }
4095
5213
  } catch (err) {
@@ -4106,7 +5224,9 @@ export const bitrix24Plugin = {
4106
5224
  });
4107
5225
  }
4108
5226
  } finally {
4109
- await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
5227
+ mediaService.scheduleDownloadedMediaCleanup(
5228
+ downloadedMedia.map((mediaItem) => mediaItem.path),
5229
+ );
4110
5230
  }
4111
5231
  };
4112
5232
  const directTextCoalescer = new BufferedDirectMessageCoalescer({
@@ -4298,6 +5418,16 @@ export const bitrix24Plugin = {
4298
5418
  textLen: msgCtx.text.length,
4299
5419
  });
4300
5420
 
5421
+ if (hasRecentOutboundMessage(msgCtx.messageId, ctx.accountId)) {
5422
+ logger.debug('Skipping recent outbound Bitrix24 message echo', {
5423
+ senderId: msgCtx.senderId,
5424
+ chatId: msgCtx.chatId,
5425
+ messageId: msgCtx.messageId,
5426
+ eventScope: msgCtx.eventScope,
5427
+ });
5428
+ return;
5429
+ }
5430
+
4301
5431
  const pendingForwardContext = msgCtx.isForwarded
4302
5432
  ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
4303
5433
  : null;
@@ -4324,7 +5454,7 @@ export const bitrix24Plugin = {
4324
5454
  chatId: msgCtx.chatInternalId,
4325
5455
  })
4326
5456
  : null;
4327
- const agentWatchRules = config.agentMode && msgCtx.eventScope === 'user'
5457
+ const agentWatchRules = config.agentMode
4328
5458
  ? resolveAgentWatchRules({
4329
5459
  config,
4330
5460
  dialogId: msgCtx.chatId,
@@ -4334,43 +5464,71 @@ export const bitrix24Plugin = {
4334
5464
  const watchRule = msgCtx.isGroup && groupAccess?.groupAllowed
4335
5465
  ? findMatchingWatchRule(msgCtx, groupAccess?.watch)
4336
5466
  : undefined;
4337
- const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
5467
+ const botId = String(bot.botId);
5468
+ const ownerBotDmParticipantIds = new Set(
5469
+ [msgCtx.senderId, msgCtx.chatId, msgCtx.chatInternalId]
5470
+ .map((value) => String(value ?? '').trim())
5471
+ .filter(Boolean),
5472
+ );
5473
+ const isOwnerBotUserDmMirror = Boolean(
5474
+ config.agentMode
5475
+ && msgCtx.eventScope === 'user'
5476
+ && msgCtx.isDm
4338
5477
  && webhookOwnerId
4339
- && msgCtx.senderId === webhookOwnerId
5478
+ && ownerBotDmParticipantIds.has(webhookOwnerId)
5479
+ && ownerBotDmParticipantIds.has(botId)
5480
+ );
5481
+ const isOwnerAuthoredMessage = Boolean(
5482
+ webhookOwnerId && msgCtx.senderId === webhookOwnerId
5483
+ );
5484
+ const isCurrentBotAuthoredMessage = msgCtx.senderId === botId;
5485
+ const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
5486
+ && (isOwnerAuthoredMessage || isCurrentBotAuthoredMessage)
4340
5487
  ? undefined
4341
5488
  : watchRule;
4342
- const agentWatchRule = msgCtx.eventScope === 'user'
4343
- ? findMatchingWatchRule(msgCtx, agentWatchRules)
5489
+ const agentWatchRule = findMatchingWatchRule(msgCtx, agentWatchRules);
5490
+ const activeAgentWatchRule = agentWatchRule?.mode === 'notifyOwnerDm'
5491
+ && !isOwnerBotUserDmMirror
5492
+ && !isOwnerAuthoredMessage
5493
+ && !isCurrentBotAuthoredMessage
5494
+ ? agentWatchRule
4344
5495
  : undefined;
4345
5496
 
4346
5497
  /** Shorthand: record message in RAM history for this dialog. */
4347
5498
  const recordHistory = (body?: string) =>
4348
5499
  appendMessageToHistory({ historyCache, historyKey, historyLimit, msgCtx, body });
4349
5500
 
4350
- if (msgCtx.eventScope === 'user') {
4351
- const isBotDialogUserEvent = msgCtx.isDm && msgCtx.chatId === String(bot.botId);
4352
- const isBotAuthoredUserEvent = msgCtx.senderId === String(bot.botId);
4353
-
4354
- if (isBotDialogUserEvent || isBotAuthoredUserEvent) {
4355
- logger.debug('Skipping agent-mode user event for bot-owned conversation', {
4356
- senderId: msgCtx.senderId,
4357
- chatId: msgCtx.chatId,
4358
- messageId: msgCtx.messageId,
4359
- isBotDialogUserEvent,
4360
- isBotAuthoredUserEvent,
4361
- });
4362
- return;
4363
- }
5501
+ if (isOwnerBotUserDmMirror) {
5502
+ logger.debug('Skipping mirrored agent-mode owner DM user event; bot channel will handle it', {
5503
+ senderId: msgCtx.senderId,
5504
+ chatId: msgCtx.chatId,
5505
+ messageId: msgCtx.messageId,
5506
+ eventScope: msgCtx.eventScope,
5507
+ webhookOwnerId,
5508
+ botId,
5509
+ });
5510
+ return;
5511
+ }
4364
5512
 
5513
+ if (msgCtx.eventScope === 'user') {
4365
5514
  recordHistory();
4366
5515
 
4367
- if (webhookOwnerId && msgCtx.senderId !== webhookOwnerId && agentWatchRule?.mode === 'notifyOwnerDm') {
4368
- await notifyWebhookOwnerAboutWatchMatch(msgCtx, agentWatchRule);
4369
- logger.debug('User-event watch matched and notified webhook owner in DM', {
4370
- senderId: msgCtx.senderId,
4371
- chatId: msgCtx.chatId,
4372
- messageId: msgCtx.messageId,
4373
- });
5516
+ if (activeAgentWatchRule) {
5517
+ if (hasRecentWatchNotification(msgCtx.messageId, ctx.accountId)) {
5518
+ logger.debug('Skipping duplicate agent watch notification for recent message', {
5519
+ senderId: msgCtx.senderId,
5520
+ chatId: msgCtx.chatId,
5521
+ messageId: msgCtx.messageId,
5522
+ eventScope: msgCtx.eventScope,
5523
+ });
5524
+ } else if (await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeAgentWatchRule)) {
5525
+ markRecentWatchNotification(msgCtx.messageId, ctx.accountId);
5526
+ logger.debug('User-event watch matched and notified webhook owner in DM', {
5527
+ senderId: msgCtx.senderId,
5528
+ chatId: msgCtx.chatId,
5529
+ messageId: msgCtx.messageId,
5530
+ });
5531
+ }
4374
5532
  }
4375
5533
  return;
4376
5534
  }
@@ -4395,6 +5553,7 @@ export const bitrix24Plugin = {
4395
5553
  if (
4396
5554
  msgCtx.isGroup
4397
5555
  && !activeWatchRule
5556
+ && !activeAgentWatchRule
4398
5557
  && groupAccess?.requireMention
4399
5558
  && !msgCtx.wasMentioned
4400
5559
  ) {
@@ -4411,12 +5570,42 @@ export const bitrix24Plugin = {
4411
5570
  if (activeWatchRule?.mode === 'notifyOwnerDm') {
4412
5571
  recordHistory();
4413
5572
 
4414
- await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule);
4415
- logger.debug('Group watch matched and notified webhook owner in DM', {
4416
- senderId: msgCtx.senderId,
4417
- chatId: msgCtx.chatId,
4418
- messageId: msgCtx.messageId,
4419
- });
5573
+ if (hasRecentWatchNotification(msgCtx.messageId, ctx.accountId)) {
5574
+ logger.debug('Skipping duplicate group watch notification for recent message', {
5575
+ senderId: msgCtx.senderId,
5576
+ chatId: msgCtx.chatId,
5577
+ messageId: msgCtx.messageId,
5578
+ eventScope: msgCtx.eventScope,
5579
+ });
5580
+ } else if (await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule)) {
5581
+ markRecentWatchNotification(msgCtx.messageId, ctx.accountId);
5582
+ logger.debug('Group watch matched and notified webhook owner in DM', {
5583
+ senderId: msgCtx.senderId,
5584
+ chatId: msgCtx.chatId,
5585
+ messageId: msgCtx.messageId,
5586
+ });
5587
+ }
5588
+ return;
5589
+ }
5590
+
5591
+ if (activeAgentWatchRule) {
5592
+ recordHistory();
5593
+
5594
+ if (hasRecentWatchNotification(msgCtx.messageId, ctx.accountId)) {
5595
+ logger.debug('Skipping duplicate bot-scope agent watch notification for recent message', {
5596
+ senderId: msgCtx.senderId,
5597
+ chatId: msgCtx.chatId,
5598
+ messageId: msgCtx.messageId,
5599
+ eventScope: msgCtx.eventScope,
5600
+ });
5601
+ } else if (await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeAgentWatchRule)) {
5602
+ markRecentWatchNotification(msgCtx.messageId, ctx.accountId);
5603
+ logger.debug('Bot-scope agent watch matched and notified webhook owner in DM', {
5604
+ senderId: msgCtx.senderId,
5605
+ chatId: msgCtx.chatId,
5606
+ messageId: msgCtx.messageId,
5607
+ });
5608
+ }
4420
5609
  return;
4421
5610
  }
4422
5611
 
@@ -4729,9 +5918,14 @@ export const bitrix24Plugin = {
4729
5918
  accountId: ctx.accountId,
4730
5919
  peer: conversation.peer,
4731
5920
  });
5921
+ const inlineButtonsAgentHint = buildBitrix24InlineButtonsAgentHint();
4732
5922
  const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
4733
- const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(commandMessageId);
5923
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ isDm });
5924
+ const nativeReplyAgentHint = canUseNativeReply
5925
+ ? buildBitrix24NativeReplyAgentHint(commandMessageId)
5926
+ : undefined;
4734
5927
  const commandBodyForAgent = [
5928
+ inlineButtonsAgentHint,
4735
5929
  fileDeliveryAgentHint,
4736
5930
  nativeReplyAgentHint,
4737
5931
  commandText,
@@ -4797,10 +5991,12 @@ export const bitrix24Plugin = {
4797
5991
  const sendOptions = {
4798
5992
  keyboard,
4799
5993
  convertMarkdown: formattedPayload.convertMarkdown,
4800
- ...(replyDirective.replyToMessageId ? { replyToMessageId: replyDirective.replyToMessageId } : {}),
5994
+ ...(canUseNativeReply && replyDirective.replyToMessageId
5995
+ ? { replyToMessageId: replyDirective.replyToMessageId }
5996
+ : {}),
4801
5997
  };
4802
5998
  if (!commandReplyDelivered) {
4803
- if (isDm || replyDirective.replyToMessageId) {
5999
+ if (isDm || (canUseNativeReply && replyDirective.replyToMessageId)) {
4804
6000
  commandReplyDelivered = true;
4805
6001
  await sendService.sendText(sendCtx, formattedPayload.text, {
4806
6002
  ...sendOptions,
@@ -4828,21 +6024,69 @@ export const bitrix24Plugin = {
4828
6024
  },
4829
6025
  });
4830
6026
 
6027
+ if (!commandReplyDelivered && hasRecentMediaDelivery(sendCtx.dialogId, commandMessageId, ctx.accountId)) {
6028
+ commandReplyDelivered = true;
6029
+ logger.debug('Suppressing command fallback after successful Bitrix24 file upload in the same turn', {
6030
+ commandName,
6031
+ senderId,
6032
+ dialogId,
6033
+ messageId: commandMessageId,
6034
+ });
6035
+ }
6036
+
6037
+ if (!commandReplyDelivered && hasRecentSuccessfulAction(commandMessageId, ctx.accountId)) {
6038
+ commandReplyDelivered = true;
6039
+ logger.debug('Suppressing command fallback after successful Bitrix24 action in the same turn', {
6040
+ commandName,
6041
+ senderId,
6042
+ dialogId,
6043
+ messageId: commandMessageId,
6044
+ });
6045
+ }
6046
+
6047
+ const shouldWaitForEmptyCommandReplyFallback = !commandReplyDelivered
6048
+ && isEmptyDispatchResult(dispatchResult);
6049
+
4831
6050
  if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
4832
6051
  logger.debug('Command reply completed without queued final block, waiting before fallback', {
4833
6052
  commandName,
4834
6053
  senderId,
4835
6054
  dialogId,
4836
6055
  counts: dispatchResult.counts,
6056
+ fallbackDelayMs: shouldWaitForEmptyCommandReplyFallback
6057
+ ? EMPTY_REPLY_FALLBACK_GRACE_MS
6058
+ : REPLY_FALLBACK_GRACE_MS,
4837
6059
  });
4838
- await waitReplyFallbackGraceWindow();
6060
+ if (shouldWaitForEmptyCommandReplyFallback) {
6061
+ await waitEmptyReplyFallbackGraceWindow();
6062
+ } else {
6063
+ await waitReplyFallbackGraceWindow();
6064
+ }
4839
6065
  }
4840
6066
 
4841
- if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
6067
+ if (!commandReplyDelivered && isEmptyDispatchResult(dispatchResult)) {
6068
+ commandReplyDelivered = true;
6069
+ logger.warn('Command reply completed without any user-visible payload or tool activity; sending fallback notice', {
6070
+ commandName,
6071
+ senderId,
6072
+ dialogId,
6073
+ messageId: commandMessageId,
6074
+ counts: dispatchResult.counts,
6075
+ });
6076
+ const fallbackText = emptyReplyFallback(cmdCtx.language);
6077
+ if (isDm) {
6078
+ await sendService.sendText(sendCtx, fallbackText);
6079
+ } else {
6080
+ await sendService.answerCommandText(commandSendCtx, fallbackText, {
6081
+ keyboard: defaultCommandKeyboard,
6082
+ });
6083
+ }
6084
+ } else if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
4842
6085
  logger.warn('Command reply completed without a final user-visible message; fallback notice suppressed', {
4843
6086
  commandName,
4844
6087
  senderId,
4845
6088
  dialogId,
6089
+ messageId: commandMessageId,
4846
6090
  counts: dispatchResult.counts,
4847
6091
  });
4848
6092
  } else if (commandReplyDelivered && dispatchResult?.queuedFinal === false) {
@@ -4850,6 +6094,7 @@ export const bitrix24Plugin = {
4850
6094
  commandName,
4851
6095
  senderId,
4852
6096
  dialogId,
6097
+ messageId: commandMessageId,
4853
6098
  });
4854
6099
  }
4855
6100
  } catch (err) {