@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.
@@ -14,7 +14,7 @@ import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
14
14
  import { Bitrix24ApiError, createVerboseLogger, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
15
15
  import { getBitrix24Runtime } from './runtime.js';
16
16
  import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply, getCommandRegistrationPayload, } from './commands.js';
17
- import { accessApproved, accessDenied, commandKeyboardLabels, groupPairingPending, mediaDownloadFailed, groupChatUnsupported, newSessionReplyTexts, onboardingDisclaimerMessage, onboardingMessage, normalizeNewSessionReply, ownerAndAllowedUsersOnly, personalBotOwnerOnly, welcomeKeyboardLabels, watchOwnerDmNotice, } from './i18n.js';
17
+ import { accessApproved, accessDenied, commandKeyboardLabels, emptyReplyFallback, groupPairingPending, mediaDownloadFailed, groupChatUnsupported, newSessionReplyTexts, onboardingDisclaimerMessage, onboardingMessage, normalizeNewSessionReply, ownerAndAllowedUsersOnly, personalBotOwnerOnly, welcomeKeyboardLabels, watchOwnerDmNotice, } from './i18n.js';
18
18
  import { HistoryCache } from './history-cache.js';
19
19
  import { markdownToBbCode } from './message-utils.js';
20
20
  const PHASE_STATUS_DURATION_SECONDS = 8;
@@ -24,6 +24,7 @@ const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
24
24
  const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
25
25
  const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
26
26
  const REPLY_FALLBACK_GRACE_MS = 750;
27
+ const EMPTY_REPLY_FALLBACK_GRACE_MS = 15 * 1000;
27
28
  const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
28
29
  const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
29
30
  const MEDIA_DOWNLOAD_CONCURRENCY = 2;
@@ -40,11 +41,23 @@ const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
40
41
  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;
41
42
  const NATIVE_FORWARD_ACK_TTL_MS = 60 * 1000;
42
43
  const NATIVE_REACTION_ACK_TTL_MS = 60 * 1000;
44
+ const NATIVE_FORWARD_SUCCESS_REACTION = 'whiteHeavyCheckMark';
45
+ const REACTION_TYPING_RESET_DURATION_SECONDS = 1;
46
+ const GENERIC_OUTBOUND_FILE_NAME_RE = /^(translated?|translation|output|result|file|document)(?:[_-][a-z]{2,8})?$/i;
47
+ const GENERIC_INBOUND_FILE_NAME_RE = /^(?:file[_-]\d+|[0-9a-f]{8,}(?:-[0-9a-f]{4,})*(?:_file[_-]?\d+)?)$/i;
48
+ const RECENT_MEDIA_DELIVERY_TTL_MS = 60 * 1000;
49
+ const RECENT_SUCCESSFUL_ACTION_TTL_MS = 60 * 1000;
50
+ const RECENT_OUTBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
51
+ const RECENT_WATCH_NOTIFICATION_TTL_MS = 30 * 60 * 1000;
43
52
  const RECENT_INBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
44
53
  const RECENT_INBOUND_MESSAGE_LIMIT = 20;
45
54
  const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
46
55
  const pendingNativeForwardAcks = new Map();
47
56
  const pendingNativeReactionAcks = new Map();
57
+ const recentSuccessfulMediaDeliveries = new Map();
58
+ const recentSuccessfulActionsByMessage = new Map();
59
+ const recentOutboundMessagesById = new Map();
60
+ const recentWatchNotificationsByMessage = new Map();
48
61
  const recentInboundMessagesByDialog = new Map();
49
62
  const inboundMessageContextById = new Map();
50
63
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
@@ -185,9 +198,324 @@ function resolvePendingNativeForwardAckDialogId(currentMessageId, fallbackDialog
185
198
  }
186
199
  return inboundMessageContextById.get(messageKey)?.dialogId ?? fallbackDialogId;
187
200
  }
201
+ function resolveRecentInboundDialogId(currentMessageId, accountId) {
202
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
203
+ if (!messageKey) {
204
+ return undefined;
205
+ }
206
+ pruneRecentInboundMessages();
207
+ return inboundMessageContextById.get(messageKey)?.dialogId;
208
+ }
209
+ function splitFileNameParts(fileName) {
210
+ const normalizedFileName = basename(fileName).trim();
211
+ const lastDotIndex = normalizedFileName.lastIndexOf('.');
212
+ if (lastDotIndex <= 0) {
213
+ return { stem: normalizedFileName, extension: '' };
214
+ }
215
+ return {
216
+ stem: normalizedFileName.slice(0, lastDotIndex).trim(),
217
+ extension: normalizedFileName.slice(lastDotIndex).trim(),
218
+ };
219
+ }
220
+ function normalizeSourceFileStem(stem) {
221
+ return stem
222
+ .trim()
223
+ .replace(/\s+\(\d+\)$/u, '')
224
+ .trim();
225
+ }
226
+ function isGenericOutboundFileName(fileName) {
227
+ const { stem } = splitFileNameParts(fileName);
228
+ return GENERIC_OUTBOUND_FILE_NAME_RE.test(stem);
229
+ }
230
+ function isSemanticInboundFileName(fileName) {
231
+ const { stem } = splitFileNameParts(fileName);
232
+ return Boolean(stem) && !GENERIC_INBOUND_FILE_NAME_RE.test(stem);
233
+ }
234
+ function inferTranslationSuffixFromBody(body) {
235
+ const normalizedBody = body.toLowerCase();
236
+ if (!/translate|translation|перевед|перевод/u.test(normalizedBody)) {
237
+ return undefined;
238
+ }
239
+ const languagePatterns = [
240
+ ['en', /english|английск/u],
241
+ ['ru', /russian|русск/u],
242
+ ['de', /german|немец/u],
243
+ ['fr', /french|француз/u],
244
+ ['es', /spanish|испан/u],
245
+ ['it', /italian|итальян/u],
246
+ ['pt', /portuguese|португал/u],
247
+ ['zh', /chinese|китай/u],
248
+ ['ja', /japanese|япон/u],
249
+ ['ko', /korean|корей/u],
250
+ ];
251
+ for (const [code, pattern] of languagePatterns) {
252
+ if (pattern.test(normalizedBody)) {
253
+ return `_${code}`;
254
+ }
255
+ }
256
+ return '_translated';
257
+ }
258
+ function inferTranslationSuffix(requestedFileName, body) {
259
+ const { stem } = splitFileNameParts(requestedFileName);
260
+ const suffixMatch = stem.match(/(?:^|[_-])(en|ru|de|fr|es|it|pt|zh|ja|ko)$/i);
261
+ if (suffixMatch?.[1]) {
262
+ return `_${suffixMatch[1].toLowerCase()}`;
263
+ }
264
+ return inferTranslationSuffixFromBody(body);
265
+ }
266
+ function resolveRecentInboundContext(params) {
267
+ pruneRecentInboundMessages();
268
+ const explicitKey = buildAccountMessageScopeKey(params.accountId, params.currentMessageId);
269
+ const explicitEntry = explicitKey ? inboundMessageContextById.get(explicitKey) : undefined;
270
+ if (explicitEntry) {
271
+ return explicitEntry;
272
+ }
273
+ const dialogKey = buildAccountDialogScopeKey(params.accountId, params.dialogId);
274
+ if (!dialogKey) {
275
+ return undefined;
276
+ }
277
+ const recentEntries = recentInboundMessagesByDialog.get(dialogKey);
278
+ if (!recentEntries?.length) {
279
+ return undefined;
280
+ }
281
+ for (let index = recentEntries.length - 1; index >= 0; index -= 1) {
282
+ const entry = recentEntries[index];
283
+ const messageKey = buildAccountMessageScopeKey(params.accountId, entry.messageId);
284
+ if (!messageKey) {
285
+ continue;
286
+ }
287
+ const inboundEntry = inboundMessageContextById.get(messageKey);
288
+ if (inboundEntry) {
289
+ return inboundEntry;
290
+ }
291
+ }
292
+ return undefined;
293
+ }
294
+ function resolveSemanticOutboundFileName(params) {
295
+ const requestedBaseName = basename(params.requestedFileName).trim();
296
+ if (!requestedBaseName || !isGenericOutboundFileName(requestedBaseName)) {
297
+ return requestedBaseName;
298
+ }
299
+ const inboundContext = resolveRecentInboundContext({
300
+ dialogId: params.dialogId,
301
+ currentMessageId: params.currentMessageId,
302
+ accountId: params.accountId,
303
+ });
304
+ if (!inboundContext || inboundContext.mediaNames.length !== 1) {
305
+ return requestedBaseName;
306
+ }
307
+ const sourceFileName = inboundContext.mediaNames[0];
308
+ if (!isSemanticInboundFileName(sourceFileName)) {
309
+ return requestedBaseName;
310
+ }
311
+ const { stem: sourceStemRaw, extension: sourceExtension } = splitFileNameParts(sourceFileName);
312
+ const { extension: requestedExtension } = splitFileNameParts(requestedBaseName);
313
+ const normalizedSourceStem = normalizeSourceFileStem(sourceStemRaw);
314
+ if (!normalizedSourceStem) {
315
+ return requestedBaseName;
316
+ }
317
+ const suffix = inferTranslationSuffix(requestedBaseName, inboundContext.body) ?? '';
318
+ const nextStem = suffix && !normalizedSourceStem.endsWith(suffix)
319
+ ? `${normalizedSourceStem}${suffix}`
320
+ : normalizedSourceStem;
321
+ const nextExtension = requestedExtension || sourceExtension;
322
+ return nextExtension ? `${nextStem}${nextExtension}` : nextStem;
323
+ }
324
+ async function maybeSendReactionTypingReset(params) {
325
+ const dialogId = typeof params.dialogId === 'string' ? params.dialogId.trim() : '';
326
+ if (!dialogId) {
327
+ return;
328
+ }
329
+ const sendTyping = params.sendService.sendTyping;
330
+ if (typeof sendTyping !== 'function') {
331
+ return;
332
+ }
333
+ try {
334
+ await sendTyping.call(params.sendService, { webhookUrl: params.webhookUrl, bot: params.bot, dialogId }, REACTION_TYPING_RESET_DURATION_SECONDS);
335
+ }
336
+ catch (error) {
337
+ defaultLogger.debug('Failed to send Bitrix24 reaction typing reset', {
338
+ dialogId,
339
+ error: error instanceof Error ? error.message : String(error),
340
+ });
341
+ }
342
+ }
343
+ async function maybeAddNativeForwardSuccessReaction(params) {
344
+ const messageId = toMessageId(params.currentMessageId);
345
+ if (!messageId) {
346
+ return;
347
+ }
348
+ const addReaction = params.api.addReaction;
349
+ if (typeof addReaction !== 'function') {
350
+ return;
351
+ }
352
+ let shouldResetTyping = false;
353
+ try {
354
+ await addReaction.call(params.api, params.webhookUrl, params.bot, messageId, NATIVE_FORWARD_SUCCESS_REACTION);
355
+ shouldResetTyping = true;
356
+ }
357
+ catch (error) {
358
+ const errorMessage = error instanceof Error ? error.message : String(error);
359
+ if (errorMessage.includes('REACTION_ALREADY_SET')) {
360
+ defaultLogger.debug('Native forward success reaction already set', { messageId });
361
+ shouldResetTyping = true;
362
+ }
363
+ else {
364
+ defaultLogger.warn('Failed to add native forward success reaction', {
365
+ messageId,
366
+ error: errorMessage,
367
+ });
368
+ }
369
+ }
370
+ if (shouldResetTyping) {
371
+ await maybeSendReactionTypingReset({
372
+ sendService: params.sendService,
373
+ webhookUrl: params.webhookUrl,
374
+ bot: params.bot,
375
+ dialogId: params.dialogId,
376
+ });
377
+ }
378
+ }
188
379
  function buildPendingNativeReactionAckKey(currentMessageId, accountId) {
189
380
  return buildAccountMessageScopeKey(accountId, currentMessageId);
190
381
  }
382
+ function pruneRecentSuccessfulActions(now = Date.now()) {
383
+ for (const [key, expiresAt] of recentSuccessfulActionsByMessage.entries()) {
384
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
385
+ recentSuccessfulActionsByMessage.delete(key);
386
+ }
387
+ }
388
+ }
389
+ function markRecentSuccessfulAction(currentMessageId, accountId) {
390
+ const key = buildAccountMessageScopeKey(accountId, currentMessageId);
391
+ if (!key) {
392
+ return;
393
+ }
394
+ pruneRecentSuccessfulActions();
395
+ recentSuccessfulActionsByMessage.set(key, Date.now() + RECENT_SUCCESSFUL_ACTION_TTL_MS);
396
+ }
397
+ function hasRecentSuccessfulAction(currentMessageId, accountId) {
398
+ const key = buildAccountMessageScopeKey(accountId, currentMessageId);
399
+ if (!key) {
400
+ return false;
401
+ }
402
+ pruneRecentSuccessfulActions();
403
+ return recentSuccessfulActionsByMessage.has(key);
404
+ }
405
+ function pruneRecentOutboundMessages(now = Date.now()) {
406
+ for (const [key, expiresAt] of recentOutboundMessagesById.entries()) {
407
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
408
+ recentOutboundMessagesById.delete(key);
409
+ }
410
+ }
411
+ }
412
+ function markRecentOutboundMessage(messageId, accountId) {
413
+ const key = buildAccountMessageScopeKey(accountId, messageId);
414
+ if (!key) {
415
+ return;
416
+ }
417
+ pruneRecentOutboundMessages();
418
+ recentOutboundMessagesById.set(key, Date.now() + RECENT_OUTBOUND_MESSAGE_TTL_MS);
419
+ }
420
+ function hasRecentOutboundMessage(messageId, accountId) {
421
+ const key = buildAccountMessageScopeKey(accountId, messageId);
422
+ if (!key) {
423
+ return false;
424
+ }
425
+ pruneRecentOutboundMessages();
426
+ return recentOutboundMessagesById.has(key);
427
+ }
428
+ function pruneRecentWatchNotifications(now = Date.now()) {
429
+ for (const [key, expiresAt] of recentWatchNotificationsByMessage.entries()) {
430
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
431
+ recentWatchNotificationsByMessage.delete(key);
432
+ }
433
+ }
434
+ }
435
+ function markRecentWatchNotification(messageId, accountId) {
436
+ const key = buildAccountMessageScopeKey(accountId, messageId);
437
+ if (!key) {
438
+ return;
439
+ }
440
+ pruneRecentWatchNotifications();
441
+ recentWatchNotificationsByMessage.set(key, Date.now() + RECENT_WATCH_NOTIFICATION_TTL_MS);
442
+ }
443
+ function hasRecentWatchNotification(messageId, accountId) {
444
+ const key = buildAccountMessageScopeKey(accountId, messageId);
445
+ if (!key) {
446
+ return false;
447
+ }
448
+ pruneRecentWatchNotifications();
449
+ return recentWatchNotificationsByMessage.has(key);
450
+ }
451
+ function buildRecentMediaDeliveryKey(dialogId, currentMessageId, accountId) {
452
+ return buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
453
+ }
454
+ function normalizeMediaDeliveryName(mediaUrl) {
455
+ return basename(mediaUrl.trim()).toLowerCase();
456
+ }
457
+ function pruneRecentMediaDeliveries(now = Date.now()) {
458
+ for (const [key, entry] of recentSuccessfulMediaDeliveries.entries()) {
459
+ if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
460
+ recentSuccessfulMediaDeliveries.delete(key);
461
+ }
462
+ }
463
+ }
464
+ function markRecentMediaDelivery(params) {
465
+ const key = buildRecentMediaDeliveryKey(params.dialogId, params.currentMessageId, params.accountId);
466
+ if (!key || params.mediaUrls.length === 0) {
467
+ return;
468
+ }
469
+ pruneRecentMediaDeliveries();
470
+ const mediaByName = recentSuccessfulMediaDeliveries.get(key)?.mediaByName ?? new Map();
471
+ const normalizedMessageId = String(params.messageId ?? '').trim();
472
+ for (const mediaUrl of params.mediaUrls) {
473
+ const mediaName = normalizeMediaDeliveryName(mediaUrl);
474
+ if (!mediaName) {
475
+ continue;
476
+ }
477
+ mediaByName.set(mediaName, normalizedMessageId);
478
+ }
479
+ recentSuccessfulMediaDeliveries.set(key, {
480
+ expiresAt: Date.now() + RECENT_MEDIA_DELIVERY_TTL_MS,
481
+ mediaByName,
482
+ });
483
+ }
484
+ function findRecentMediaDelivery(params) {
485
+ const key = buildRecentMediaDeliveryKey(params.dialogId, params.currentMessageId, params.accountId);
486
+ if (!key || params.mediaUrls.length === 0) {
487
+ return null;
488
+ }
489
+ pruneRecentMediaDeliveries();
490
+ const entry = recentSuccessfulMediaDeliveries.get(key);
491
+ if (!entry) {
492
+ return null;
493
+ }
494
+ const normalizedNames = params.mediaUrls
495
+ .map((mediaUrl) => normalizeMediaDeliveryName(mediaUrl))
496
+ .filter(Boolean);
497
+ if (normalizedNames.length === 0) {
498
+ return null;
499
+ }
500
+ for (const mediaName of normalizedNames) {
501
+ if (!entry.mediaByName.has(mediaName)) {
502
+ return null;
503
+ }
504
+ }
505
+ const lastMessageId = normalizedNames
506
+ .map((mediaName) => entry.mediaByName.get(mediaName) ?? '')
507
+ .find(Boolean);
508
+ return lastMessageId ? { messageId: lastMessageId } : {};
509
+ }
510
+ function hasRecentMediaDelivery(dialogId, currentMessageId, accountId) {
511
+ const key = buildRecentMediaDeliveryKey(dialogId, currentMessageId, accountId);
512
+ if (!key) {
513
+ return false;
514
+ }
515
+ pruneRecentMediaDeliveries();
516
+ const entry = recentSuccessfulMediaDeliveries.get(key);
517
+ return Boolean(entry && entry.mediaByName.size > 0);
518
+ }
191
519
  function prunePendingNativeReactionAcks(now = Date.now()) {
192
520
  for (const [key, entry] of pendingNativeReactionAcks.entries()) {
193
521
  if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
@@ -239,7 +567,7 @@ function pruneRecentInboundMessages(now = Date.now()) {
239
567
  }
240
568
  }
241
569
  }
242
- function rememberRecentInboundMessage(dialogId, messageId, body, timestamp = Date.now(), accountId) {
570
+ function rememberRecentInboundMessage(dialogId, messageId, body, mediaNames = [], timestamp = Date.now(), accountId) {
243
571
  const normalizedMessageId = toMessageId(messageId);
244
572
  const normalizedBody = body.trim();
245
573
  const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
@@ -259,6 +587,10 @@ function rememberRecentInboundMessage(dialogId, messageId, body, timestamp = Dat
259
587
  dialogId,
260
588
  dialogKey,
261
589
  body: normalizedBody,
590
+ mediaNames: mediaNames
591
+ .filter((name) => typeof name === 'string')
592
+ .map((name) => name.trim())
593
+ .filter(Boolean),
262
594
  timestamp,
263
595
  });
264
596
  }
@@ -381,6 +713,19 @@ function extractReplyDirective(params) {
381
713
  }
382
714
  return { cleanText };
383
715
  }
716
+ function isBitrix24DirectDialogId(dialogId) {
717
+ const normalizedDialogId = String(dialogId ?? '').trim();
718
+ return normalizedDialogId.length > 0 && !normalizedDialogId.startsWith('chat');
719
+ }
720
+ function shouldUseBitrix24NativeReply(params) {
721
+ if (typeof params.isDm === 'boolean') {
722
+ return !params.isDm;
723
+ }
724
+ if (!params.dialogId) {
725
+ return false;
726
+ }
727
+ return !isBitrix24DirectDialogId(params.dialogId);
728
+ }
384
729
  function findNativeForwardTarget(params) {
385
730
  const deliveredText = normalizeComparableMessageText(params.deliveredText);
386
731
  if (!deliveredText) {
@@ -491,8 +836,8 @@ function buildBitrix24NativeReplyAgentHint(currentMessageId) {
491
836
  return [
492
837
  '[Bitrix24 native reply instruction]',
493
838
  `Current Bitrix24 message id: ${normalizedMessageId}.`,
494
- 'If you intentionally want a native Bitrix24 reply to the current inbound message, set "replyToMessageId" to that id.',
495
- 'For text-only payloads you can also prepend [[reply_to_current]] to the reply text.',
839
+ 'Only in group chats, if you intentionally want a native Bitrix24 reply to the current inbound message, set "replyToMessageId" to that id.',
840
+ 'For text-only payloads in group chats, you can also prepend [[reply_to_current]] to the reply text.',
496
841
  'Do not use a native reply unless you actually want reply threading.',
497
842
  '[/Bitrix24 native reply instruction]',
498
843
  ].join('\n');
@@ -500,13 +845,46 @@ function buildBitrix24NativeReplyAgentHint(currentMessageId) {
500
845
  function buildBitrix24FileDeliveryAgentHint() {
501
846
  return [
502
847
  '[Bitrix24 file delivery instruction]',
503
- 'When you need to deliver a real file or document to the user, use structured reply payload field "mediaUrl" or "mediaUrls".',
848
+ 'When you need to deliver a real file or document to the user, prefer a structured reply payload with field "mediaUrl" or "mediaUrls".',
504
849
  'Set "mediaUrl" to the local path of the generated file in the OpenClaw workspace or managed media directory.',
850
+ '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.',
851
+ 'If read on an attached Bitrix24 media path fails, fall back to the inline <file> content from the current prompt instead of giving up.',
852
+ '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.',
505
853
  'Use "text" only for an optional short caption.',
854
+ 'For file requests, follow this order exactly: read/prepare content, write the output file, send that file once, then stop.',
855
+ 'Never call the file-delivery tool before the write tool has succeeded for that file path.',
856
+ '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.',
857
+ 'After one successful file upload in a turn, do not send the same file again and do not send an extra text-only confirmation.',
858
+ '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.',
859
+ '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.',
860
+ 'If the user asked for a file and you have not emitted the actual file delivery payload yet, do not answer with "NO_REPLY".',
506
861
  '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.',
507
862
  '[/Bitrix24 file delivery instruction]',
508
863
  ].join('\n');
509
864
  }
865
+ export function buildBitrix24InlineButtonsAgentHint() {
866
+ return [
867
+ '[Bitrix24 buttons instruction]',
868
+ '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.',
869
+ '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"}]].',
870
+ 'The channel will strip that markup from the visible text and render native Bitrix24 buttons automatically.',
871
+ '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.',
872
+ '[/Bitrix24 buttons instruction]',
873
+ ].join('\n');
874
+ }
875
+ function getDispatchCount(counts, key) {
876
+ const value = counts?.[key];
877
+ return Number.isFinite(value) ? Number(value) : 0;
878
+ }
879
+ function isEmptyDispatchResult(dispatchResult) {
880
+ if (!dispatchResult || dispatchResult.queuedFinal !== false) {
881
+ return false;
882
+ }
883
+ const toolCount = getDispatchCount(dispatchResult.counts, 'tool');
884
+ const blockCount = getDispatchCount(dispatchResult.counts, 'block');
885
+ const finalCount = getDispatchCount(dispatchResult.counts, 'final');
886
+ return toolCount + blockCount + finalCount === 0;
887
+ }
510
888
  function readExplicitForwardMessageIds(rawParams) {
511
889
  return parseActionMessageIds(rawParams.forwardMessageIds
512
890
  ?? rawParams.forwardIds);
@@ -689,6 +1067,9 @@ function createReplyStatusHeartbeat(params) {
689
1067
  async function waitReplyFallbackGraceWindow() {
690
1068
  await new Promise((resolve) => setTimeout(resolve, REPLY_FALLBACK_GRACE_MS));
691
1069
  }
1070
+ async function waitEmptyReplyFallbackGraceWindow() {
1071
+ await new Promise((resolve) => setTimeout(resolve, EMPTY_REPLY_FALLBACK_GRACE_MS));
1072
+ }
692
1073
  export function canCoalesceDirectMessage(msgCtx, config) {
693
1074
  return msgCtx.isDm
694
1075
  && config.dmPolicy !== 'pairing'
@@ -1171,12 +1552,16 @@ export function __setGatewayStateForTests(state) {
1171
1552
  if (state === null) {
1172
1553
  pendingNativeForwardAcks.clear();
1173
1554
  pendingNativeReactionAcks.clear();
1555
+ recentSuccessfulMediaDeliveries.clear();
1556
+ recentSuccessfulActionsByMessage.clear();
1557
+ recentOutboundMessagesById.clear();
1558
+ recentWatchNotificationsByMessage.clear();
1174
1559
  recentInboundMessagesByDialog.clear();
1175
1560
  inboundMessageContextById.clear();
1176
1561
  }
1177
1562
  }
1178
1563
  export function __rememberRecentInboundMessageForTests(params) {
1179
- rememberRecentInboundMessage(params.dialogId, params.messageId, params.body, params.timestamp, params.accountId);
1564
+ rememberRecentInboundMessage(params.dialogId, params.messageId, params.body, params.mediaNames, params.timestamp, params.accountId);
1180
1565
  }
1181
1566
  // ─── Keyboard layouts ────────────────────────────────────────────────────────
1182
1567
  export function buildWelcomeKeyboard(language) {
@@ -1209,83 +1594,24 @@ const BITRIX24_DISCOVERY_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'dele
1209
1594
  function buildMessageToolButtonsSchema() {
1210
1595
  return {
1211
1596
  type: 'array',
1212
- description: 'Optional Bitrix24 message buttons as rows of button objects.',
1597
+ description: 'Optional Bitrix24 message buttons as rows of button objects. Each button object can include text, optional callback_data and optional style.',
1213
1598
  items: {
1214
1599
  type: 'array',
1215
1600
  items: {
1216
1601
  type: 'object',
1217
- additionalProperties: false,
1218
- properties: {
1219
- text: {
1220
- type: 'string',
1221
- description: 'Visible button label.',
1222
- },
1223
- callback_data: {
1224
- type: 'string',
1225
- description: 'Registered command like /help, or any plain text payload to send when tapped.',
1226
- },
1227
- style: {
1228
- type: 'string',
1229
- enum: ['primary', 'attention', 'danger'],
1230
- description: 'Optional Bitrix24 button accent.',
1231
- },
1232
- },
1233
- required: ['text'],
1602
+ description: 'Single button object. Use text, optional callback_data, and optional style=primary|attention|danger.',
1234
1603
  },
1235
1604
  },
1236
1605
  };
1237
1606
  }
1238
1607
  function buildMessageToolAttachSchema() {
1239
- const blockSchema = {
1240
- type: 'object',
1241
- description: 'Single Bitrix24 ATTACH rich block. Supported top-level keys are MESSAGE, LINK, IMAGE, FILE, DELIMITER, GRID or USER.',
1242
- };
1243
1608
  return {
1244
- 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.',
1245
- anyOf: [
1246
- {
1247
- type: 'object',
1248
- additionalProperties: false,
1249
- properties: {
1250
- ID: { type: 'integer' },
1251
- COLOR_TOKEN: {
1252
- type: 'string',
1253
- enum: ['primary', 'secondary', 'alert', 'base'],
1254
- },
1255
- COLOR: { type: 'string' },
1256
- BLOCKS: {
1257
- type: 'array',
1258
- items: blockSchema,
1259
- },
1260
- },
1261
- required: ['BLOCKS'],
1262
- },
1263
- {
1264
- type: 'array',
1265
- items: blockSchema,
1266
- },
1267
- ],
1609
+ 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.',
1268
1610
  };
1269
1611
  }
1270
1612
  function buildMessageToolIdListSchema(description) {
1271
1613
  return {
1272
- anyOf: [
1273
- {
1274
- type: 'integer',
1275
- description,
1276
- },
1277
- {
1278
- type: 'string',
1279
- description: `${description} Can also be a comma-separated list or JSON array string.`,
1280
- },
1281
- {
1282
- type: 'array',
1283
- description,
1284
- items: {
1285
- type: 'integer',
1286
- },
1287
- },
1288
- ],
1614
+ description: `${description} Can be an integer, a string, or an array of integers.`,
1289
1615
  };
1290
1616
  }
1291
1617
  function resolveConfiguredBitrix24ActionAccounts(cfg, accountId) {
@@ -1310,22 +1636,16 @@ function describeBitrix24MessageTool(params) {
1310
1636
  return {
1311
1637
  actions: [...BITRIX24_DISCOVERY_ACTION_NAMES],
1312
1638
  capabilities: ['interactive', 'buttons', 'cards'],
1313
- schema: [
1314
- {
1315
- properties: {
1316
- buttons: buildMessageToolButtonsSchema(),
1317
- },
1318
- },
1319
- {
1320
- properties: {
1321
- attach: buildMessageToolAttachSchema(),
1322
- forwardMessageIds: buildMessageToolIdListSchema('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.'),
1323
- forwardIds: buildMessageToolIdListSchema('Alias for forwardMessageIds. Use with action="send".'),
1324
- replyToId: buildMessageToolIdListSchema('Bitrix24 message id to reply to natively inside the current dialog.'),
1325
- replyToMessageId: buildMessageToolIdListSchema('Alias for replyToId.'),
1326
- },
1639
+ schema: {
1640
+ properties: {
1641
+ buttons: buildMessageToolButtonsSchema(),
1642
+ attach: buildMessageToolAttachSchema(),
1643
+ forwardMessageIds: buildMessageToolIdListSchema('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.'),
1644
+ forwardIds: buildMessageToolIdListSchema('Alias for forwardMessageIds. Use with action="send".'),
1645
+ replyToId: buildMessageToolIdListSchema('Bitrix24 message id to reply to natively inside the current dialog.'),
1646
+ replyToMessageId: buildMessageToolIdListSchema('Alias for replyToId.'),
1327
1647
  },
1328
- ],
1648
+ },
1329
1649
  };
1330
1650
  }
1331
1651
  function extractBitrix24ToolSend(args) {
@@ -1363,6 +1683,9 @@ function isAttachColorToken(value) {
1363
1683
  || value === 'alert'
1364
1684
  || value === 'base';
1365
1685
  }
1686
+ function isPresent(value) {
1687
+ return value != null;
1688
+ }
1366
1689
  function isAttachBlock(value) {
1367
1690
  return isPlainObject(value) && Object.keys(value).length > 0;
1368
1691
  }
@@ -1378,6 +1701,362 @@ function isBitrix24Attach(value) {
1378
1701
  }
1379
1702
  return value.BLOCKS.every(isAttachBlock);
1380
1703
  }
1704
+ function readAttachString(value) {
1705
+ if (typeof value !== 'string') {
1706
+ return undefined;
1707
+ }
1708
+ const trimmed = value.trim();
1709
+ return trimmed ? trimmed : undefined;
1710
+ }
1711
+ function readAttachNumber(value) {
1712
+ if (typeof value === 'number' && Number.isFinite(value)) {
1713
+ return Math.trunc(value);
1714
+ }
1715
+ if (typeof value === 'string') {
1716
+ const trimmed = value.trim();
1717
+ if (!trimmed) {
1718
+ return undefined;
1719
+ }
1720
+ const normalized = Number(trimmed);
1721
+ if (Number.isFinite(normalized)) {
1722
+ return Math.trunc(normalized);
1723
+ }
1724
+ }
1725
+ return undefined;
1726
+ }
1727
+ function normalizeAttachColorTokenValue(value) {
1728
+ if (value === undefined) {
1729
+ return undefined;
1730
+ }
1731
+ if (typeof value !== 'string') {
1732
+ return undefined;
1733
+ }
1734
+ const normalized = value.trim().toLowerCase();
1735
+ return isAttachColorToken(normalized) ? normalized : undefined;
1736
+ }
1737
+ function normalizeAttachGridDisplayValue(value) {
1738
+ if (typeof value !== 'string') {
1739
+ return undefined;
1740
+ }
1741
+ const normalized = value.trim().toUpperCase();
1742
+ return normalized === 'BLOCK'
1743
+ || normalized === 'LINE'
1744
+ || normalized === 'ROW'
1745
+ || normalized === 'TABLE'
1746
+ ? normalized
1747
+ : undefined;
1748
+ }
1749
+ function normalizeAttachGridItem(rawValue) {
1750
+ if (!isPlainObject(rawValue)) {
1751
+ return undefined;
1752
+ }
1753
+ const display = normalizeAttachGridDisplayValue(rawValue.DISPLAY) ?? 'LINE';
1754
+ const name = readAttachString(rawValue.NAME);
1755
+ const value = readAttachString(rawValue.VALUE);
1756
+ const width = readAttachNumber(rawValue.WIDTH);
1757
+ const height = readAttachNumber(rawValue.HEIGHT);
1758
+ const colorToken = normalizeAttachColorTokenValue(rawValue.COLOR_TOKEN);
1759
+ const color = readAttachString(rawValue.COLOR);
1760
+ const link = readAttachString(rawValue.LINK);
1761
+ const userId = readAttachNumber(rawValue.USER_ID);
1762
+ const chatId = readAttachNumber(rawValue.CHAT_ID);
1763
+ if (!name && !value && !link && userId === undefined && chatId === undefined) {
1764
+ return undefined;
1765
+ }
1766
+ return {
1767
+ DISPLAY: display,
1768
+ ...(name ? { NAME: name } : {}),
1769
+ ...(value ? { VALUE: value } : {}),
1770
+ ...(width !== undefined ? { WIDTH: width } : {}),
1771
+ ...(height !== undefined ? { HEIGHT: height } : {}),
1772
+ ...(colorToken ? { COLOR_TOKEN: colorToken } : {}),
1773
+ ...(color ? { COLOR: color } : {}),
1774
+ ...(link ? { LINK: link } : {}),
1775
+ ...(userId !== undefined ? { USER_ID: userId } : {}),
1776
+ ...(chatId !== undefined ? { CHAT_ID: chatId } : {}),
1777
+ };
1778
+ }
1779
+ function normalizeAttachLinkValue(rawValue, aliasSource) {
1780
+ if (typeof rawValue === 'string') {
1781
+ const link = readAttachString(rawValue);
1782
+ if (!link) {
1783
+ return undefined;
1784
+ }
1785
+ const aliasName = aliasSource
1786
+ ? readAttachString(aliasSource.NAME) ?? readAttachString(aliasSource.TITLE)
1787
+ : undefined;
1788
+ const aliasDesc = aliasSource ? readAttachString(aliasSource.DESC) : undefined;
1789
+ const aliasHtml = aliasSource ? readAttachString(aliasSource.HTML) : undefined;
1790
+ const aliasPreview = aliasSource ? readAttachString(aliasSource.PREVIEW) : undefined;
1791
+ const aliasWidth = aliasSource ? readAttachNumber(aliasSource.WIDTH) : undefined;
1792
+ const aliasHeight = aliasSource ? readAttachNumber(aliasSource.HEIGHT) : undefined;
1793
+ const aliasUserId = aliasSource ? readAttachNumber(aliasSource.USER_ID) : undefined;
1794
+ const aliasChatId = aliasSource ? readAttachNumber(aliasSource.CHAT_ID) : undefined;
1795
+ const aliasNetworkId = aliasSource ? readAttachString(aliasSource.NETWORK_ID) : undefined;
1796
+ return {
1797
+ LINK: link,
1798
+ ...(aliasName ? { NAME: aliasName } : {}),
1799
+ ...(aliasDesc ? { DESC: aliasDesc } : {}),
1800
+ ...(aliasHtml ? { HTML: aliasHtml } : {}),
1801
+ ...(aliasPreview ? { PREVIEW: aliasPreview } : {}),
1802
+ ...(aliasWidth !== undefined ? { WIDTH: aliasWidth } : {}),
1803
+ ...(aliasHeight !== undefined ? { HEIGHT: aliasHeight } : {}),
1804
+ ...(aliasUserId !== undefined ? { USER_ID: aliasUserId } : {}),
1805
+ ...(aliasChatId !== undefined ? { CHAT_ID: aliasChatId } : {}),
1806
+ ...(aliasNetworkId ? { NETWORK_ID: aliasNetworkId } : {}),
1807
+ };
1808
+ }
1809
+ if (!isPlainObject(rawValue)) {
1810
+ return undefined;
1811
+ }
1812
+ const link = readAttachString(rawValue.LINK);
1813
+ if (!link) {
1814
+ return undefined;
1815
+ }
1816
+ const name = readAttachString(rawValue.NAME) ?? readAttachString(rawValue.TITLE);
1817
+ const desc = readAttachString(rawValue.DESC);
1818
+ const html = readAttachString(rawValue.HTML);
1819
+ const preview = readAttachString(rawValue.PREVIEW);
1820
+ const width = readAttachNumber(rawValue.WIDTH);
1821
+ const height = readAttachNumber(rawValue.HEIGHT);
1822
+ const userId = readAttachNumber(rawValue.USER_ID);
1823
+ const chatId = readAttachNumber(rawValue.CHAT_ID);
1824
+ const networkId = readAttachString(rawValue.NETWORK_ID);
1825
+ return {
1826
+ LINK: link,
1827
+ ...(name ? { NAME: name } : {}),
1828
+ ...(desc ? { DESC: desc } : {}),
1829
+ ...(html ? { HTML: html } : {}),
1830
+ ...(preview ? { PREVIEW: preview } : {}),
1831
+ ...(width !== undefined ? { WIDTH: width } : {}),
1832
+ ...(height !== undefined ? { HEIGHT: height } : {}),
1833
+ ...(userId !== undefined ? { USER_ID: userId } : {}),
1834
+ ...(chatId !== undefined ? { CHAT_ID: chatId } : {}),
1835
+ ...(networkId ? { NETWORK_ID: networkId } : {}),
1836
+ };
1837
+ }
1838
+ function normalizeAttachImageItem(rawValue) {
1839
+ const normalized = normalizeAttachLinkValue(rawValue);
1840
+ if (!normalized) {
1841
+ return undefined;
1842
+ }
1843
+ return {
1844
+ LINK: normalized.LINK,
1845
+ ...(normalized.NAME ? { NAME: normalized.NAME } : {}),
1846
+ ...(normalized.PREVIEW ? { PREVIEW: normalized.PREVIEW } : {}),
1847
+ ...(normalized.WIDTH !== undefined ? { WIDTH: normalized.WIDTH } : {}),
1848
+ ...(normalized.HEIGHT !== undefined ? { HEIGHT: normalized.HEIGHT } : {}),
1849
+ };
1850
+ }
1851
+ function normalizeAttachFileItem(rawValue) {
1852
+ if (typeof rawValue === 'string') {
1853
+ const link = readAttachString(rawValue);
1854
+ return link ? { LINK: link } : undefined;
1855
+ }
1856
+ if (!isPlainObject(rawValue)) {
1857
+ return undefined;
1858
+ }
1859
+ const link = readAttachString(rawValue.LINK);
1860
+ if (!link) {
1861
+ return undefined;
1862
+ }
1863
+ const name = readAttachString(rawValue.NAME);
1864
+ const size = readAttachNumber(rawValue.SIZE);
1865
+ return {
1866
+ LINK: link,
1867
+ ...(name ? { NAME: name } : {}),
1868
+ ...(size !== undefined ? { SIZE: size } : {}),
1869
+ };
1870
+ }
1871
+ function normalizeAttachUserValue(rawValue, aliasSource) {
1872
+ if (typeof rawValue === 'number' || typeof rawValue === 'string') {
1873
+ const userId = readAttachNumber(rawValue);
1874
+ if (userId === undefined) {
1875
+ return undefined;
1876
+ }
1877
+ const aliasName = aliasSource
1878
+ ? readAttachString(aliasSource.NAME) ?? readAttachString(aliasSource.USER_NAME)
1879
+ : undefined;
1880
+ const aliasAvatar = aliasSource ? readAttachString(aliasSource.AVATAR) : undefined;
1881
+ const aliasLink = aliasSource ? readAttachString(aliasSource.LINK) : undefined;
1882
+ const aliasNetworkId = aliasSource ? readAttachString(aliasSource.NETWORK_ID) : undefined;
1883
+ return {
1884
+ USER_ID: userId,
1885
+ ...(aliasName ? { NAME: aliasName } : {}),
1886
+ ...(aliasAvatar ? { AVATAR: aliasAvatar } : {}),
1887
+ ...(aliasLink ? { LINK: aliasLink } : {}),
1888
+ ...(aliasNetworkId ? { NETWORK_ID: aliasNetworkId } : {}),
1889
+ };
1890
+ }
1891
+ if (!isPlainObject(rawValue)) {
1892
+ return undefined;
1893
+ }
1894
+ const userId = readAttachNumber(rawValue.USER_ID);
1895
+ const name = readAttachString(rawValue.NAME) ?? readAttachString(rawValue.USER_NAME);
1896
+ const avatar = readAttachString(rawValue.AVATAR);
1897
+ const link = readAttachString(rawValue.LINK);
1898
+ const networkId = readAttachString(rawValue.NETWORK_ID);
1899
+ if (userId === undefined && !name && !avatar && !link && !networkId) {
1900
+ return undefined;
1901
+ }
1902
+ return {
1903
+ ...(name ? { NAME: name } : {}),
1904
+ ...(avatar ? { AVATAR: avatar } : {}),
1905
+ ...(link ? { LINK: link } : {}),
1906
+ ...(userId !== undefined ? { USER_ID: userId } : {}),
1907
+ ...(networkId ? { NETWORK_ID: networkId } : {}),
1908
+ };
1909
+ }
1910
+ function normalizeAttachBlock(rawValue) {
1911
+ if (!isPlainObject(rawValue)) {
1912
+ return undefined;
1913
+ }
1914
+ const message = readAttachString(rawValue.MESSAGE);
1915
+ if (message && Object.keys(rawValue).every((key) => key === 'MESSAGE' || key === 'TYPE')) {
1916
+ return { MESSAGE: message };
1917
+ }
1918
+ if ('LINK' in rawValue && rawValue.LINK !== undefined) {
1919
+ const linkValue = normalizeAttachLinkValue(rawValue.LINK, rawValue);
1920
+ if (linkValue) {
1921
+ return { LINK: linkValue };
1922
+ }
1923
+ }
1924
+ if ('IMAGE' in rawValue && rawValue.IMAGE !== undefined) {
1925
+ const rawImage = rawValue.IMAGE;
1926
+ const imageItems = Array.isArray(rawImage)
1927
+ ? rawImage.map(normalizeAttachImageItem).filter(isPresent)
1928
+ : [normalizeAttachImageItem(rawImage)].filter(isPresent);
1929
+ if (imageItems.length > 0) {
1930
+ return { IMAGE: imageItems.length === 1 ? imageItems[0] : imageItems };
1931
+ }
1932
+ }
1933
+ if ('FILE' in rawValue && rawValue.FILE !== undefined) {
1934
+ const rawFile = rawValue.FILE;
1935
+ const fileItems = Array.isArray(rawFile)
1936
+ ? rawFile.map(normalizeAttachFileItem).filter(isPresent)
1937
+ : [normalizeAttachFileItem(rawFile)].filter(isPresent);
1938
+ if (fileItems.length > 0) {
1939
+ return { FILE: fileItems.length === 1 ? fileItems[0] : fileItems };
1940
+ }
1941
+ }
1942
+ if ('DELIMITER' in rawValue && isPlainObject(rawValue.DELIMITER)) {
1943
+ const delimiter = rawValue.DELIMITER;
1944
+ const size = readAttachNumber(delimiter.SIZE);
1945
+ const color = readAttachString(delimiter.COLOR);
1946
+ if (size !== undefined || color) {
1947
+ return {
1948
+ DELIMITER: {
1949
+ ...(size !== undefined ? { SIZE: size } : {}),
1950
+ ...(color ? { COLOR: color } : {}),
1951
+ },
1952
+ };
1953
+ }
1954
+ }
1955
+ if ('GRID' in rawValue && Array.isArray(rawValue.GRID)) {
1956
+ const gridItems = rawValue.GRID.map(normalizeAttachGridItem).filter(isPresent);
1957
+ if (gridItems.length > 0) {
1958
+ return { GRID: gridItems };
1959
+ }
1960
+ }
1961
+ if ('USER' in rawValue && rawValue.USER !== undefined) {
1962
+ const userValue = normalizeAttachUserValue(rawValue.USER, rawValue);
1963
+ if (userValue) {
1964
+ return { USER: userValue };
1965
+ }
1966
+ }
1967
+ const rawType = readAttachString(rawValue.TYPE)?.toUpperCase();
1968
+ if (!rawType) {
1969
+ return undefined;
1970
+ }
1971
+ if (rawType === 'MESSAGE') {
1972
+ const title = readAttachString(rawValue.TITLE);
1973
+ return message
1974
+ ? { MESSAGE: message }
1975
+ : title
1976
+ ? { MESSAGE: `[B]${title}[/B]` }
1977
+ : undefined;
1978
+ }
1979
+ if (rawType === 'TITLE') {
1980
+ const title = readAttachString(rawValue.TITLE) ?? message;
1981
+ return title ? { MESSAGE: `[B]${title}[/B]` } : undefined;
1982
+ }
1983
+ if (rawType === 'LINK') {
1984
+ const linkValue = normalizeAttachLinkValue(rawValue.LINK ?? rawValue, rawValue);
1985
+ return linkValue ? { LINK: linkValue } : undefined;
1986
+ }
1987
+ if (rawType === 'USER') {
1988
+ const userValue = normalizeAttachUserValue(rawValue.USER ?? rawValue.USER_ID ?? rawValue, rawValue);
1989
+ return userValue ? { USER: userValue } : undefined;
1990
+ }
1991
+ if (rawType === 'GRID') {
1992
+ const rawGridItems = Array.isArray(rawValue.GRID)
1993
+ ? rawValue.GRID
1994
+ : Array.isArray(rawValue.ITEMS)
1995
+ ? rawValue.ITEMS
1996
+ : Array.isArray(rawValue.ROWS)
1997
+ ? rawValue.ROWS
1998
+ : [];
1999
+ const gridItems = rawGridItems.map(normalizeAttachGridItem).filter(isPresent);
2000
+ return gridItems.length > 0 ? { GRID: gridItems } : undefined;
2001
+ }
2002
+ if (rawType === 'DELIMITER') {
2003
+ const size = readAttachNumber(rawValue.SIZE);
2004
+ const color = readAttachString(rawValue.COLOR);
2005
+ return size !== undefined || color
2006
+ ? {
2007
+ DELIMITER: {
2008
+ ...(size !== undefined ? { SIZE: size } : {}),
2009
+ ...(color ? { COLOR: color } : {}),
2010
+ },
2011
+ }
2012
+ : undefined;
2013
+ }
2014
+ if (rawType === 'IMAGE') {
2015
+ const rawImage = rawValue.IMAGE ?? rawValue.LINK ?? rawValue;
2016
+ const imageItems = Array.isArray(rawImage)
2017
+ ? rawImage.map(normalizeAttachImageItem).filter(isPresent)
2018
+ : [normalizeAttachImageItem(rawImage)].filter(isPresent);
2019
+ return imageItems.length > 0
2020
+ ? { IMAGE: imageItems.length === 1 ? imageItems[0] : imageItems }
2021
+ : undefined;
2022
+ }
2023
+ if (rawType === 'FILE') {
2024
+ const rawFile = rawValue.FILE ?? rawValue.LINK ?? rawValue;
2025
+ const fileItems = Array.isArray(rawFile)
2026
+ ? rawFile.map(normalizeAttachFileItem).filter(isPresent)
2027
+ : [normalizeAttachFileItem(rawFile)].filter(isPresent);
2028
+ return fileItems.length > 0
2029
+ ? { FILE: fileItems.length === 1 ? fileItems[0] : fileItems }
2030
+ : undefined;
2031
+ }
2032
+ return undefined;
2033
+ }
2034
+ function normalizeBitrix24Attach(rawValue) {
2035
+ if (Array.isArray(rawValue)) {
2036
+ const blocks = rawValue.map(normalizeAttachBlock).filter(isPresent);
2037
+ return blocks.length > 0 ? blocks : undefined;
2038
+ }
2039
+ if (!isPlainObject(rawValue)) {
2040
+ return undefined;
2041
+ }
2042
+ if (Array.isArray(rawValue.BLOCKS)) {
2043
+ const blocks = rawValue.BLOCKS.map(normalizeAttachBlock).filter(isPresent);
2044
+ if (blocks.length === 0) {
2045
+ return undefined;
2046
+ }
2047
+ const id = readAttachNumber(rawValue.ID);
2048
+ const colorToken = normalizeAttachColorTokenValue(rawValue.COLOR_TOKEN);
2049
+ const color = readAttachString(rawValue.COLOR);
2050
+ return {
2051
+ ...(id !== undefined ? { ID: id } : {}),
2052
+ ...(colorToken ? { COLOR_TOKEN: colorToken } : {}),
2053
+ ...(color ? { COLOR: color } : {}),
2054
+ BLOCKS: blocks,
2055
+ };
2056
+ }
2057
+ const block = normalizeAttachBlock(rawValue);
2058
+ return block ? [block] : undefined;
2059
+ }
1381
2060
  function parseActionAttach(rawValue) {
1382
2061
  if (rawValue == null) {
1383
2062
  return undefined;
@@ -1395,7 +2074,7 @@ function parseActionAttach(rawValue) {
1395
2074
  return undefined;
1396
2075
  }
1397
2076
  }
1398
- return isBitrix24Attach(parsed) ? parsed : undefined;
2077
+ return normalizeBitrix24Attach(parsed);
1399
2078
  }
1400
2079
  function hasMeaningfulAttachInput(rawValue) {
1401
2080
  if (rawValue == null) {
@@ -1523,8 +2202,11 @@ function collectActionMediaUrls(params) {
1523
2202
  };
1524
2203
  append(params.mediaUrl);
1525
2204
  append(params.mediaUrls);
2205
+ append(params.media);
1526
2206
  append(params.filePath);
1527
2207
  append(params.filePaths);
2208
+ append(params.path);
2209
+ append(params.paths);
1528
2210
  return mediaUrls;
1529
2211
  }
1530
2212
  function buildActionMessageText(params) {
@@ -1941,16 +2623,22 @@ async function uploadOutboundMedia(params) {
1941
2623
  let lastMessageId = '';
1942
2624
  let message = params.initialMessage;
1943
2625
  for (const mediaUrl of params.mediaUrls) {
2626
+ const outboundFileName = resolveSemanticOutboundFileName({
2627
+ requestedFileName: basename(mediaUrl),
2628
+ dialogId: params.sendCtx.dialogId,
2629
+ currentMessageId: params.currentMessageId,
2630
+ accountId: params.accountId,
2631
+ });
1944
2632
  const result = await params.mediaService.uploadMediaToChat({
1945
2633
  localPath: mediaUrl,
1946
- fileName: basename(mediaUrl),
2634
+ fileName: outboundFileName,
1947
2635
  webhookUrl: params.sendCtx.webhookUrl,
1948
2636
  bot: params.sendCtx.bot,
1949
2637
  dialogId: params.sendCtx.dialogId,
1950
2638
  message: message || undefined,
1951
2639
  });
1952
2640
  if (!result.ok) {
1953
- throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
2641
+ throw new Error(`Failed to upload media: ${outboundFileName}`);
1954
2642
  }
1955
2643
  if (result.messageId) {
1956
2644
  lastMessageId = String(result.messageId);
@@ -2055,6 +2743,8 @@ export const bitrix24Plugin = {
2055
2743
  mediaService: gatewayState.mediaService,
2056
2744
  sendCtx,
2057
2745
  mediaUrls,
2746
+ currentMessageId: ctx.currentMessageId,
2747
+ accountId: ctx.accountId,
2058
2748
  initialMessage: ctx.text,
2059
2749
  });
2060
2750
  return { messageId };
@@ -2086,6 +2776,8 @@ export const bitrix24Plugin = {
2086
2776
  mediaService: gatewayState.mediaService,
2087
2777
  sendCtx,
2088
2778
  mediaUrls,
2779
+ currentMessageId: ctx.currentMessageId,
2780
+ accountId: ctx.accountId,
2089
2781
  initialMessage,
2090
2782
  });
2091
2783
  if ((text && !initialMessage) || keyboard || attach) {
@@ -2131,6 +2823,9 @@ export const bitrix24Plugin = {
2131
2823
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
2132
2824
  details: payload,
2133
2825
  });
2826
+ const markSuccessfulAction = (currentMessageId) => {
2827
+ markRecentSuccessfulAction(currentMessageId, ctx.accountId);
2828
+ };
2134
2829
  // ─── Send with buttons ──────────────────────────────────────────────
2135
2830
  if (ctx.action === 'send') {
2136
2831
  const rawButtons = ctx.params.buttons;
@@ -2145,6 +2840,7 @@ export const bitrix24Plugin = {
2145
2840
  return null;
2146
2841
  }
2147
2842
  const sendCtx = { webhookUrl: config.webhookUrl, bot, dialogId: to };
2843
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ dialogId: to });
2148
2844
  const messageText = readActionTextParam(ctx.params);
2149
2845
  const message = typeof messageText === 'string' ? messageText.trim() : '';
2150
2846
  const keyboard = parseActionKeyboard(rawButtons);
@@ -2176,6 +2872,25 @@ export const bitrix24Plugin = {
2176
2872
  hint: 'Provide message text, buttons, rich attach blocks or mediaUrl/mediaUrls for Bitrix24 send.',
2177
2873
  });
2178
2874
  }
2875
+ const duplicateMediaDelivery = mediaUrls.length > 0
2876
+ ? findRecentMediaDelivery({
2877
+ dialogId: to,
2878
+ currentMessageId: currentToolMessageId,
2879
+ mediaUrls,
2880
+ accountId: ctx.accountId,
2881
+ })
2882
+ : null;
2883
+ if (duplicateMediaDelivery) {
2884
+ markSuccessfulAction(currentToolMessageId);
2885
+ return toolResult({
2886
+ channel: 'bitrix24',
2887
+ to,
2888
+ via: 'direct',
2889
+ mediaUrl: mediaUrls[0] ?? null,
2890
+ duplicateSuppressed: true,
2891
+ result: { messageId: duplicateMediaDelivery.messageId ?? '' },
2892
+ });
2893
+ }
2179
2894
  try {
2180
2895
  if (explicitForwardMessages.length > 0) {
2181
2896
  const normalizedForwardText = normalizeForwardActionText(messageText);
@@ -2185,7 +2900,16 @@ export const bitrix24Plugin = {
2185
2900
  attach,
2186
2901
  forwardMessages: explicitForwardMessages,
2187
2902
  });
2903
+ await maybeAddNativeForwardSuccessReaction({
2904
+ api: gatewayState.api,
2905
+ sendService: gatewayState.sendService,
2906
+ webhookUrl: config.webhookUrl,
2907
+ bot,
2908
+ dialogId: forwardAckDialogId,
2909
+ currentMessageId: currentToolMessageId,
2910
+ });
2188
2911
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2912
+ markSuccessfulAction(currentToolMessageId);
2189
2913
  return toolResult({
2190
2914
  channel: 'bitrix24',
2191
2915
  to,
@@ -2200,7 +2924,16 @@ export const bitrix24Plugin = {
2200
2924
  ...(keyboard ? { keyboard } : {}),
2201
2925
  forwardMessages: explicitForwardMessages,
2202
2926
  });
2927
+ await maybeAddNativeForwardSuccessReaction({
2928
+ api: gatewayState.api,
2929
+ sendService: gatewayState.sendService,
2930
+ webhookUrl: config.webhookUrl,
2931
+ bot,
2932
+ dialogId: forwardAckDialogId,
2933
+ currentMessageId: currentToolMessageId,
2934
+ });
2203
2935
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2936
+ markSuccessfulAction(currentToolMessageId);
2204
2937
  return toolResult({
2205
2938
  channel: 'bitrix24',
2206
2939
  to,
@@ -2211,7 +2944,7 @@ export const bitrix24Plugin = {
2211
2944
  result: { messageId: String(result.messageId ?? '') },
2212
2945
  });
2213
2946
  }
2214
- if (referencedReplyMessageId && !attach && mediaUrls.length === 0) {
2947
+ if (canUseNativeReply && referencedReplyMessageId && !attach && mediaUrls.length === 0) {
2215
2948
  let referencedMessageText = '';
2216
2949
  try {
2217
2950
  const referencedMessage = await gatewayState.api.getMessage(config.webhookUrl, bot, referencedReplyMessageId);
@@ -2224,7 +2957,16 @@ export const bitrix24Plugin = {
2224
2957
  const normalizedMessage = normalizeComparableMessageText(message);
2225
2958
  if (normalizedReferencedMessage && normalizedReferencedMessage === normalizedMessage) {
2226
2959
  const result = await gatewayState.sendService.sendText(sendCtx, '', { forwardMessages: [referencedReplyMessageId] });
2960
+ await maybeAddNativeForwardSuccessReaction({
2961
+ api: gatewayState.api,
2962
+ sendService: gatewayState.sendService,
2963
+ webhookUrl: config.webhookUrl,
2964
+ bot,
2965
+ dialogId: forwardAckDialogId,
2966
+ currentMessageId: currentToolMessageId,
2967
+ });
2227
2968
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2969
+ markSuccessfulAction(currentToolMessageId);
2228
2970
  return toolResult({
2229
2971
  channel: 'bitrix24',
2230
2972
  to,
@@ -2235,17 +2977,21 @@ export const bitrix24Plugin = {
2235
2977
  result: { messageId: String(result.messageId ?? '') },
2236
2978
  });
2237
2979
  }
2238
- const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', {
2980
+ const sendOptions = {
2239
2981
  ...(keyboard ? { keyboard } : {}),
2240
- replyToMessageId: referencedReplyMessageId,
2241
- });
2982
+ ...(canUseNativeReply ? { replyToMessageId: referencedReplyMessageId } : {}),
2983
+ };
2984
+ const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
2985
+ markSuccessfulAction(currentToolMessageId);
2242
2986
  return toolResult({
2243
2987
  channel: 'bitrix24',
2244
2988
  to,
2245
2989
  via: 'direct',
2246
2990
  mediaUrl: null,
2247
- replied: true,
2248
- replyToMessageId: referencedReplyMessageId,
2991
+ ...(canUseNativeReply ? {
2992
+ replied: true,
2993
+ replyToMessageId: referencedReplyMessageId,
2994
+ } : {}),
2249
2995
  result: { messageId: String(result.messageId ?? '') },
2250
2996
  });
2251
2997
  }
@@ -2260,7 +3006,16 @@ export const bitrix24Plugin = {
2260
3006
  && normalizeComparableMessageText(message) === normalizeComparableMessageText(previousInboundMessage.body)
2261
3007
  && normalizeComparableMessageText(currentInboundContext.body) !== normalizeComparableMessageText(message)) {
2262
3008
  const result = await gatewayState.sendService.sendText(sendCtx, '', { forwardMessages: [Number(previousInboundMessage.messageId)] });
3009
+ await maybeAddNativeForwardSuccessReaction({
3010
+ api: gatewayState.api,
3011
+ sendService: gatewayState.sendService,
3012
+ webhookUrl: config.webhookUrl,
3013
+ bot,
3014
+ dialogId: forwardAckDialogId,
3015
+ currentMessageId: currentToolMessageId,
3016
+ });
2263
3017
  markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3018
+ markSuccessfulAction(currentToolMessageId);
2264
3019
  return toolResult({
2265
3020
  channel: 'bitrix24',
2266
3021
  to,
@@ -2277,14 +3032,24 @@ export const bitrix24Plugin = {
2277
3032
  mediaService: gatewayState.mediaService,
2278
3033
  sendCtx,
2279
3034
  mediaUrls,
3035
+ currentMessageId: currentToolMessageId,
3036
+ accountId: ctx.accountId,
2280
3037
  initialMessage,
2281
3038
  });
3039
+ markRecentMediaDelivery({
3040
+ dialogId: to,
3041
+ currentMessageId: currentToolMessageId,
3042
+ mediaUrls,
3043
+ accountId: ctx.accountId,
3044
+ messageId: uploadedMessageId,
3045
+ });
2282
3046
  if ((message && !initialMessage) || keyboard || attach) {
2283
3047
  if (attach) {
2284
3048
  const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: message, keyboard, attach }), {
2285
3049
  ...(keyboard ? { keyboard } : {}),
2286
3050
  attach,
2287
3051
  });
3052
+ markSuccessfulAction(currentToolMessageId);
2288
3053
  return toolResult({
2289
3054
  channel: 'bitrix24',
2290
3055
  to,
@@ -2294,6 +3059,7 @@ export const bitrix24Plugin = {
2294
3059
  });
2295
3060
  }
2296
3061
  const result = await gatewayState.sendService.sendText(sendCtx, message || '', keyboard ? { keyboard } : undefined);
3062
+ markSuccessfulAction(currentToolMessageId);
2297
3063
  return toolResult({
2298
3064
  channel: 'bitrix24',
2299
3065
  to,
@@ -2302,6 +3068,7 @@ export const bitrix24Plugin = {
2302
3068
  result: { messageId: String(result.messageId ?? uploadedMessageId) },
2303
3069
  });
2304
3070
  }
3071
+ markSuccessfulAction(currentToolMessageId);
2305
3072
  return toolResult({
2306
3073
  channel: 'bitrix24',
2307
3074
  to,
@@ -2315,6 +3082,7 @@ export const bitrix24Plugin = {
2315
3082
  ...(keyboard ? { keyboard } : {}),
2316
3083
  attach,
2317
3084
  });
3085
+ markSuccessfulAction(currentToolMessageId);
2318
3086
  return toolResult({
2319
3087
  channel: 'bitrix24',
2320
3088
  to,
@@ -2324,6 +3092,7 @@ export const bitrix24Plugin = {
2324
3092
  });
2325
3093
  }
2326
3094
  const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', keyboard ? { keyboard } : undefined);
3095
+ markSuccessfulAction(currentToolMessageId);
2327
3096
  return toolResult({
2328
3097
  channel: 'bitrix24',
2329
3098
  to,
@@ -2334,6 +3103,14 @@ export const bitrix24Plugin = {
2334
3103
  }
2335
3104
  catch (err) {
2336
3105
  const errMsg = err instanceof Error ? err.message : String(err);
3106
+ if (errMsg.startsWith('Failed to upload media: ')) {
3107
+ return toolResult({
3108
+ ok: false,
3109
+ reason: 'media_upload_failed',
3110
+ error: errMsg,
3111
+ 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.',
3112
+ });
3113
+ }
2337
3114
  return toolResult({ ok: false, error: errMsg });
2338
3115
  }
2339
3116
  }
@@ -2349,6 +3126,7 @@ export const bitrix24Plugin = {
2349
3126
  return toolResult({ ok: false, reason: 'missing_target', hint: 'Bitrix24 reply requires a target dialog id in "to" or "target". Do not retry.' });
2350
3127
  }
2351
3128
  const toolContext = ctx.toolContext;
3129
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ dialogId: to });
2352
3130
  const replyToMessageId = parseActionMessageIds(params.replyToMessageId
2353
3131
  ?? params.replyToId
2354
3132
  ?? params.messageId
@@ -2373,25 +3151,32 @@ export const bitrix24Plugin = {
2373
3151
  const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: normalizedText, keyboard, attach }), {
2374
3152
  ...(keyboard ? { keyboard } : {}),
2375
3153
  attach,
2376
- replyToMessageId,
3154
+ ...(canUseNativeReply ? { replyToMessageId } : {}),
2377
3155
  });
3156
+ markSuccessfulAction(toolContext?.currentMessageId);
2378
3157
  return toolResult({
2379
3158
  ok: true,
2380
- replied: true,
2381
3159
  to,
2382
- replyToMessageId,
3160
+ ...(canUseNativeReply ? {
3161
+ replied: true,
3162
+ replyToMessageId,
3163
+ } : {}),
2383
3164
  messageId: String(messageId ?? ''),
2384
3165
  });
2385
3166
  }
2386
- const result = await gatewayState.sendService.sendText({ webhookUrl: config.webhookUrl, bot, dialogId: to }, normalizedText || ' ', {
3167
+ const sendOptions = {
2387
3168
  ...(keyboard ? { keyboard } : {}),
2388
- replyToMessageId,
2389
- });
3169
+ ...(canUseNativeReply ? { replyToMessageId } : {}),
3170
+ };
3171
+ const result = await gatewayState.sendService.sendText({ webhookUrl: config.webhookUrl, bot, dialogId: to }, normalizedText || ' ', Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
3172
+ markSuccessfulAction(toolContext?.currentMessageId);
2390
3173
  return toolResult({
2391
3174
  ok: true,
2392
- replied: true,
2393
3175
  to,
2394
- replyToMessageId,
3176
+ ...(canUseNativeReply ? {
3177
+ replied: true,
3178
+ replyToMessageId,
3179
+ } : {}),
2395
3180
  messageId: String(result.messageId ?? ''),
2396
3181
  });
2397
3182
  }
@@ -2449,6 +3234,7 @@ export const bitrix24Plugin = {
2449
3234
  ...(keyboard ? { keyboard } : {}),
2450
3235
  ...(attach ? { attach } : {}),
2451
3236
  });
3237
+ markSuccessfulAction(toolContext?.currentMessageId);
2452
3238
  return toolResult({ ok: true, edited: true, messageId });
2453
3239
  }
2454
3240
  catch (err) {
@@ -2477,6 +3263,7 @@ export const bitrix24Plugin = {
2477
3263
  || params.complete === 'Y');
2478
3264
  try {
2479
3265
  await api.deleteMessage(config.webhookUrl, bot, messageId, complete);
3266
+ markSuccessfulAction(toolContext?.currentMessageId);
2480
3267
  return toolResult({ ok: true, deleted: true, messageId });
2481
3268
  }
2482
3269
  catch (err) {
@@ -2498,6 +3285,10 @@ export const bitrix24Plugin = {
2498
3285
  const toolContext = ctx.toolContext;
2499
3286
  const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
2500
3287
  const messageId = Number(rawMessageId);
3288
+ const reactionAckSourceMessageId = typeof rawMessageId === 'string' || typeof rawMessageId === 'number'
3289
+ ? rawMessageId
3290
+ : toolContext?.currentMessageId;
3291
+ const reactionAckDialogId = resolveRecentInboundDialogId(reactionAckSourceMessageId, ctx.accountId);
2501
3292
  if (!Number.isFinite(messageId) || messageId <= 0) {
2502
3293
  return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
2503
3294
  }
@@ -2511,6 +3302,7 @@ export const bitrix24Plugin = {
2511
3302
  }
2512
3303
  try {
2513
3304
  await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
3305
+ markSuccessfulAction(toolContext?.currentMessageId);
2514
3306
  return toolResult({ ok: true, removed: true });
2515
3307
  }
2516
3308
  catch (err) {
@@ -2533,14 +3325,28 @@ export const bitrix24Plugin = {
2533
3325
  }
2534
3326
  try {
2535
3327
  await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
3328
+ await maybeSendReactionTypingReset({
3329
+ sendService: gatewayState.sendService,
3330
+ webhookUrl: config.webhookUrl,
3331
+ bot,
3332
+ dialogId: reactionAckDialogId,
3333
+ });
2536
3334
  markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
3335
+ markSuccessfulAction(toolContext?.currentMessageId);
2537
3336
  return toolResult({ ok: true, added: emoji });
2538
3337
  }
2539
3338
  catch (err) {
2540
3339
  const errMsg = err instanceof Error ? err.message : String(err);
2541
3340
  const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
2542
3341
  if (isAlreadySet) {
3342
+ await maybeSendReactionTypingReset({
3343
+ sendService: gatewayState.sendService,
3344
+ webhookUrl: config.webhookUrl,
3345
+ bot,
3346
+ dialogId: reactionAckDialogId,
3347
+ });
2543
3348
  markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
3349
+ markSuccessfulAction(toolContext?.currentMessageId);
2544
3350
  return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
2545
3351
  }
2546
3352
  return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
@@ -2678,6 +3484,30 @@ export const bitrix24Plugin = {
2678
3484
  }
2679
3485
  const sendService = new SendService(api, logger);
2680
3486
  const mediaService = new MediaService(api, logger);
3487
+ const originalSendText = sendService.sendText.bind(sendService);
3488
+ sendService.sendText = async (...args) => {
3489
+ const result = await originalSendText(...args);
3490
+ markRecentOutboundMessage(result?.messageId, ctx.accountId);
3491
+ return result;
3492
+ };
3493
+ const originalAnswerCommandText = sendService.answerCommandText.bind(sendService);
3494
+ sendService.answerCommandText = async (...args) => {
3495
+ const result = await originalAnswerCommandText(...args);
3496
+ markRecentOutboundMessage(result?.messageId, ctx.accountId);
3497
+ return result;
3498
+ };
3499
+ const originalApiSendMessage = api.sendMessage.bind(api);
3500
+ api.sendMessage = async (...args) => {
3501
+ const messageId = await originalApiSendMessage(...args);
3502
+ markRecentOutboundMessage(messageId, ctx.accountId);
3503
+ return messageId;
3504
+ };
3505
+ const originalUploadMediaToChat = mediaService.uploadMediaToChat.bind(mediaService);
3506
+ mediaService.uploadMediaToChat = async (...args) => {
3507
+ const result = await originalUploadMediaToChat(...args);
3508
+ markRecentOutboundMessage(result?.messageId, ctx.accountId);
3509
+ return result;
3510
+ };
2681
3511
  const hydrateReplyEntry = async (replyToMessageId) => {
2682
3512
  const bitrixMessageId = toMessageId(replyToMessageId);
2683
3513
  if (!bitrixMessageId) {
@@ -2840,8 +3670,12 @@ export const bitrix24Plugin = {
2840
3670
  query: bodyWithReply,
2841
3671
  historyCache,
2842
3672
  });
3673
+ const inlineButtonsAgentHint = buildBitrix24InlineButtonsAgentHint();
2843
3674
  const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
2844
- const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(msgCtx.messageId);
3675
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ isDm: msgCtx.isDm });
3676
+ const nativeReplyAgentHint = canUseNativeReply
3677
+ ? buildBitrix24NativeReplyAgentHint(msgCtx.messageId)
3678
+ : undefined;
2845
3679
  const nativeForwardAgentHint = buildBitrix24NativeForwardAgentHint({
2846
3680
  accountId: ctx.accountId,
2847
3681
  currentBody: body,
@@ -2850,6 +3684,7 @@ export const bitrix24Plugin = {
2850
3684
  historyEntries: previousEntries,
2851
3685
  });
2852
3686
  const bodyForAgent = [
3687
+ inlineButtonsAgentHint,
2853
3688
  fileDeliveryAgentHint,
2854
3689
  nativeReplyAgentHint,
2855
3690
  nativeForwardAgentHint,
@@ -2863,7 +3698,7 @@ export const bitrix24Plugin = {
2863
3698
  })
2864
3699
  : bodyForAgent;
2865
3700
  recordHistory(body);
2866
- rememberRecentInboundMessage(conversation.dialogId, msgCtx.messageId, body, msgCtx.timestamp ?? Date.now(), ctx.accountId);
3701
+ rememberRecentInboundMessage(conversation.dialogId, msgCtx.messageId, body, msgCtx.media.map((mediaItem) => mediaItem.name), msgCtx.timestamp ?? Date.now(), ctx.accountId);
2867
3702
  // Resolve which agent handles this conversation
2868
3703
  const route = runtime.channel.routing.resolveAgentRoute({
2869
3704
  cfg,
@@ -2916,14 +3751,32 @@ export const bitrix24Plugin = {
2916
3751
  deliver: async (payload) => {
2917
3752
  await replyStatusHeartbeat.stopAndWait();
2918
3753
  const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
3754
+ const hadRecentMediaDelivery = mediaUrls.length === 0
3755
+ && hasRecentMediaDelivery(sendCtx.dialogId, msgCtx.messageId, ctx.accountId);
2919
3756
  if (mediaUrls.length > 0) {
2920
- await uploadOutboundMedia({
3757
+ const uploadedMessageId = await uploadOutboundMedia({
2921
3758
  mediaService,
2922
3759
  sendCtx,
2923
3760
  mediaUrls,
2924
3761
  });
3762
+ markRecentMediaDelivery({
3763
+ dialogId: sendCtx.dialogId,
3764
+ currentMessageId: msgCtx.messageId,
3765
+ mediaUrls,
3766
+ accountId: ctx.accountId,
3767
+ messageId: uploadedMessageId,
3768
+ });
2925
3769
  }
2926
3770
  if (payload.text) {
3771
+ if (hadRecentMediaDelivery) {
3772
+ replyDelivered = true;
3773
+ logger.debug('Suppressing trailing text after successful Bitrix24 file upload', {
3774
+ senderId: msgCtx.senderId,
3775
+ chatId: msgCtx.chatId,
3776
+ messageId: msgCtx.messageId,
3777
+ });
3778
+ return;
3779
+ }
2927
3780
  if (consumePendingNativeForwardAck(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
2928
3781
  replyDelivered = true;
2929
3782
  logger.debug('Suppressing trailing acknowledgement after native Bitrix24 forward', {
@@ -2975,7 +3828,7 @@ export const bitrix24Plugin = {
2975
3828
  : [];
2976
3829
  const nativeReplyToMessageId = nativeForwardMessageId
2977
3830
  ? undefined
2978
- : replyDirective.replyToMessageId;
3831
+ : (canUseNativeReply ? replyDirective.replyToMessageId : undefined);
2979
3832
  const sendOptions = {
2980
3833
  ...(keyboard ? { keyboard } : {}),
2981
3834
  ...(nativeReplyToMessageId ? { replyToMessageId: nativeReplyToMessageId } : {}),
@@ -3013,18 +3866,55 @@ export const bitrix24Plugin = {
3013
3866
  },
3014
3867
  },
3015
3868
  });
3869
+ if (!replyDelivered && hasRecentMediaDelivery(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
3870
+ replyDelivered = true;
3871
+ logger.debug('Suppressing fallback after successful Bitrix24 file upload in the same turn', {
3872
+ senderId: msgCtx.senderId,
3873
+ chatId: msgCtx.chatId,
3874
+ messageId: msgCtx.messageId,
3875
+ });
3876
+ }
3877
+ if (!replyDelivered && hasRecentSuccessfulAction(msgCtx.messageId, ctx.accountId)) {
3878
+ replyDelivered = true;
3879
+ logger.debug('Suppressing fallback after successful Bitrix24 action in the same turn', {
3880
+ senderId: msgCtx.senderId,
3881
+ chatId: msgCtx.chatId,
3882
+ messageId: msgCtx.messageId,
3883
+ });
3884
+ }
3885
+ const shouldWaitForEmptyReplyFallback = !replyDelivered
3886
+ && isEmptyDispatchResult(dispatchResult);
3016
3887
  if (!replyDelivered && dispatchResult?.queuedFinal === false) {
3017
3888
  logger.debug('Reply completed without queued final block, waiting before fallback', {
3018
3889
  senderId: msgCtx.senderId,
3019
3890
  chatId: msgCtx.chatId,
3020
3891
  counts: dispatchResult.counts,
3892
+ fallbackDelayMs: shouldWaitForEmptyReplyFallback
3893
+ ? EMPTY_REPLY_FALLBACK_GRACE_MS
3894
+ : REPLY_FALLBACK_GRACE_MS,
3021
3895
  });
3022
- await waitReplyFallbackGraceWindow();
3896
+ if (shouldWaitForEmptyReplyFallback) {
3897
+ await waitEmptyReplyFallbackGraceWindow();
3898
+ }
3899
+ else {
3900
+ await waitReplyFallbackGraceWindow();
3901
+ }
3023
3902
  }
3024
- if (!replyDelivered && dispatchResult?.queuedFinal === false) {
3903
+ if (!replyDelivered && isEmptyDispatchResult(dispatchResult)) {
3904
+ replyDelivered = true;
3905
+ logger.warn('Reply completed without any user-visible payload or tool activity; sending fallback notice', {
3906
+ senderId: msgCtx.senderId,
3907
+ chatId: msgCtx.chatId,
3908
+ messageId: msgCtx.messageId,
3909
+ counts: dispatchResult.counts,
3910
+ });
3911
+ await sendService.sendText(sendCtx, emptyReplyFallback(msgCtx.language));
3912
+ }
3913
+ else if (!replyDelivered && dispatchResult?.queuedFinal === false) {
3025
3914
  logger.warn('Reply completed without a final user-visible message; fallback notice suppressed', {
3026
3915
  senderId: msgCtx.senderId,
3027
3916
  chatId: msgCtx.chatId,
3917
+ messageId: msgCtx.messageId,
3028
3918
  counts: dispatchResult.counts,
3029
3919
  });
3030
3920
  }
@@ -3032,6 +3922,7 @@ export const bitrix24Plugin = {
3032
3922
  logger.debug('Late reply arrived during fallback grace window, skipping fallback', {
3033
3923
  senderId: msgCtx.senderId,
3034
3924
  chatId: msgCtx.chatId,
3925
+ messageId: msgCtx.messageId,
3035
3926
  });
3036
3927
  }
3037
3928
  }
@@ -3050,7 +3941,7 @@ export const bitrix24Plugin = {
3050
3941
  }
3051
3942
  }
3052
3943
  finally {
3053
- await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
3944
+ mediaService.scheduleDownloadedMediaCleanup(downloadedMedia.map((mediaItem) => mediaItem.path));
3054
3945
  }
3055
3946
  };
3056
3947
  const directTextCoalescer = new BufferedDirectMessageCoalescer({
@@ -3203,6 +4094,15 @@ export const bitrix24Plugin = {
3203
4094
  messageId: msgCtx.messageId,
3204
4095
  textLen: msgCtx.text.length,
3205
4096
  });
4097
+ if (hasRecentOutboundMessage(msgCtx.messageId, ctx.accountId)) {
4098
+ logger.debug('Skipping recent outbound Bitrix24 message echo', {
4099
+ senderId: msgCtx.senderId,
4100
+ chatId: msgCtx.chatId,
4101
+ messageId: msgCtx.messageId,
4102
+ eventScope: msgCtx.eventScope,
4103
+ });
4104
+ return;
4105
+ }
3206
4106
  const pendingForwardContext = msgCtx.isForwarded
3207
4107
  ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
3208
4108
  : null;
@@ -3227,7 +4127,7 @@ export const bitrix24Plugin = {
3227
4127
  chatId: msgCtx.chatInternalId,
3228
4128
  })
3229
4129
  : null;
3230
- const agentWatchRules = config.agentMode && msgCtx.eventScope === 'user'
4130
+ const agentWatchRules = config.agentMode
3231
4131
  ? resolveAgentWatchRules({
3232
4132
  config,
3233
4133
  dialogId: msgCtx.chatId,
@@ -3237,37 +4137,61 @@ export const bitrix24Plugin = {
3237
4137
  const watchRule = msgCtx.isGroup && groupAccess?.groupAllowed
3238
4138
  ? findMatchingWatchRule(msgCtx, groupAccess?.watch)
3239
4139
  : undefined;
3240
- const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
4140
+ const botId = String(bot.botId);
4141
+ const ownerBotDmParticipantIds = new Set([msgCtx.senderId, msgCtx.chatId, msgCtx.chatInternalId]
4142
+ .map((value) => String(value ?? '').trim())
4143
+ .filter(Boolean));
4144
+ const isOwnerBotUserDmMirror = Boolean(config.agentMode
4145
+ && msgCtx.eventScope === 'user'
4146
+ && msgCtx.isDm
3241
4147
  && webhookOwnerId
3242
- && msgCtx.senderId === webhookOwnerId
4148
+ && ownerBotDmParticipantIds.has(webhookOwnerId)
4149
+ && ownerBotDmParticipantIds.has(botId));
4150
+ const isOwnerAuthoredMessage = Boolean(webhookOwnerId && msgCtx.senderId === webhookOwnerId);
4151
+ const isCurrentBotAuthoredMessage = msgCtx.senderId === botId;
4152
+ const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
4153
+ && (isOwnerAuthoredMessage || isCurrentBotAuthoredMessage)
3243
4154
  ? undefined
3244
4155
  : watchRule;
3245
- const agentWatchRule = msgCtx.eventScope === 'user'
3246
- ? findMatchingWatchRule(msgCtx, agentWatchRules)
4156
+ const agentWatchRule = findMatchingWatchRule(msgCtx, agentWatchRules);
4157
+ const activeAgentWatchRule = agentWatchRule?.mode === 'notifyOwnerDm'
4158
+ && !isOwnerBotUserDmMirror
4159
+ && !isOwnerAuthoredMessage
4160
+ && !isCurrentBotAuthoredMessage
4161
+ ? agentWatchRule
3247
4162
  : undefined;
3248
4163
  /** Shorthand: record message in RAM history for this dialog. */
3249
4164
  const recordHistory = (body) => appendMessageToHistory({ historyCache, historyKey, historyLimit, msgCtx, body });
4165
+ if (isOwnerBotUserDmMirror) {
4166
+ logger.debug('Skipping mirrored agent-mode owner DM user event; bot channel will handle it', {
4167
+ senderId: msgCtx.senderId,
4168
+ chatId: msgCtx.chatId,
4169
+ messageId: msgCtx.messageId,
4170
+ eventScope: msgCtx.eventScope,
4171
+ webhookOwnerId,
4172
+ botId,
4173
+ });
4174
+ return;
4175
+ }
3250
4176
  if (msgCtx.eventScope === 'user') {
3251
- const isBotDialogUserEvent = msgCtx.isDm && msgCtx.chatId === String(bot.botId);
3252
- const isBotAuthoredUserEvent = msgCtx.senderId === String(bot.botId);
3253
- if (isBotDialogUserEvent || isBotAuthoredUserEvent) {
3254
- logger.debug('Skipping agent-mode user event for bot-owned conversation', {
3255
- senderId: msgCtx.senderId,
3256
- chatId: msgCtx.chatId,
3257
- messageId: msgCtx.messageId,
3258
- isBotDialogUserEvent,
3259
- isBotAuthoredUserEvent,
3260
- });
3261
- return;
3262
- }
3263
4177
  recordHistory();
3264
- if (webhookOwnerId && msgCtx.senderId !== webhookOwnerId && agentWatchRule?.mode === 'notifyOwnerDm') {
3265
- await notifyWebhookOwnerAboutWatchMatch(msgCtx, agentWatchRule);
3266
- logger.debug('User-event watch matched and notified webhook owner in DM', {
3267
- senderId: msgCtx.senderId,
3268
- chatId: msgCtx.chatId,
3269
- messageId: msgCtx.messageId,
3270
- });
4178
+ if (activeAgentWatchRule) {
4179
+ if (hasRecentWatchNotification(msgCtx.messageId, ctx.accountId)) {
4180
+ logger.debug('Skipping duplicate agent watch notification for recent message', {
4181
+ senderId: msgCtx.senderId,
4182
+ chatId: msgCtx.chatId,
4183
+ messageId: msgCtx.messageId,
4184
+ eventScope: msgCtx.eventScope,
4185
+ });
4186
+ }
4187
+ else if (await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeAgentWatchRule)) {
4188
+ markRecentWatchNotification(msgCtx.messageId, ctx.accountId);
4189
+ logger.debug('User-event watch matched and notified webhook owner in DM', {
4190
+ senderId: msgCtx.senderId,
4191
+ chatId: msgCtx.chatId,
4192
+ messageId: msgCtx.messageId,
4193
+ });
4194
+ }
3271
4195
  }
3272
4196
  return;
3273
4197
  }
@@ -3288,6 +4212,7 @@ export const bitrix24Plugin = {
3288
4212
  await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
3289
4213
  if (msgCtx.isGroup
3290
4214
  && !activeWatchRule
4215
+ && !activeAgentWatchRule
3291
4216
  && groupAccess?.requireMention
3292
4217
  && !msgCtx.wasMentioned) {
3293
4218
  recordHistory();
@@ -3300,12 +4225,42 @@ export const bitrix24Plugin = {
3300
4225
  }
3301
4226
  if (activeWatchRule?.mode === 'notifyOwnerDm') {
3302
4227
  recordHistory();
3303
- await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule);
3304
- logger.debug('Group watch matched and notified webhook owner in DM', {
3305
- senderId: msgCtx.senderId,
3306
- chatId: msgCtx.chatId,
3307
- messageId: msgCtx.messageId,
3308
- });
4228
+ if (hasRecentWatchNotification(msgCtx.messageId, ctx.accountId)) {
4229
+ logger.debug('Skipping duplicate group watch notification for recent message', {
4230
+ senderId: msgCtx.senderId,
4231
+ chatId: msgCtx.chatId,
4232
+ messageId: msgCtx.messageId,
4233
+ eventScope: msgCtx.eventScope,
4234
+ });
4235
+ }
4236
+ else if (await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule)) {
4237
+ markRecentWatchNotification(msgCtx.messageId, ctx.accountId);
4238
+ logger.debug('Group watch matched and notified webhook owner in DM', {
4239
+ senderId: msgCtx.senderId,
4240
+ chatId: msgCtx.chatId,
4241
+ messageId: msgCtx.messageId,
4242
+ });
4243
+ }
4244
+ return;
4245
+ }
4246
+ if (activeAgentWatchRule) {
4247
+ recordHistory();
4248
+ if (hasRecentWatchNotification(msgCtx.messageId, ctx.accountId)) {
4249
+ logger.debug('Skipping duplicate bot-scope agent watch notification for recent message', {
4250
+ senderId: msgCtx.senderId,
4251
+ chatId: msgCtx.chatId,
4252
+ messageId: msgCtx.messageId,
4253
+ eventScope: msgCtx.eventScope,
4254
+ });
4255
+ }
4256
+ else if (await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeAgentWatchRule)) {
4257
+ markRecentWatchNotification(msgCtx.messageId, ctx.accountId);
4258
+ logger.debug('Bot-scope agent watch matched and notified webhook owner in DM', {
4259
+ senderId: msgCtx.senderId,
4260
+ chatId: msgCtx.chatId,
4261
+ messageId: msgCtx.messageId,
4262
+ });
4263
+ }
3309
4264
  return;
3310
4265
  }
3311
4266
  const accessResult = activeWatchRule
@@ -3537,9 +4492,14 @@ export const bitrix24Plugin = {
3537
4492
  accountId: ctx.accountId,
3538
4493
  peer: conversation.peer,
3539
4494
  });
4495
+ const inlineButtonsAgentHint = buildBitrix24InlineButtonsAgentHint();
3540
4496
  const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
3541
- const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(commandMessageId);
4497
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ isDm });
4498
+ const nativeReplyAgentHint = canUseNativeReply
4499
+ ? buildBitrix24NativeReplyAgentHint(commandMessageId)
4500
+ : undefined;
3542
4501
  const commandBodyForAgent = [
4502
+ inlineButtonsAgentHint,
3543
4503
  fileDeliveryAgentHint,
3544
4504
  nativeReplyAgentHint,
3545
4505
  commandText,
@@ -3601,10 +4561,12 @@ export const bitrix24Plugin = {
3601
4561
  const sendOptions = {
3602
4562
  keyboard,
3603
4563
  convertMarkdown: formattedPayload.convertMarkdown,
3604
- ...(replyDirective.replyToMessageId ? { replyToMessageId: replyDirective.replyToMessageId } : {}),
4564
+ ...(canUseNativeReply && replyDirective.replyToMessageId
4565
+ ? { replyToMessageId: replyDirective.replyToMessageId }
4566
+ : {}),
3605
4567
  };
3606
4568
  if (!commandReplyDelivered) {
3607
- if (isDm || replyDirective.replyToMessageId) {
4569
+ if (isDm || (canUseNativeReply && replyDirective.replyToMessageId)) {
3608
4570
  commandReplyDelivered = true;
3609
4571
  await sendService.sendText(sendCtx, formattedPayload.text, {
3610
4572
  ...sendOptions,
@@ -3631,20 +4593,68 @@ export const bitrix24Plugin = {
3631
4593
  },
3632
4594
  },
3633
4595
  });
4596
+ if (!commandReplyDelivered && hasRecentMediaDelivery(sendCtx.dialogId, commandMessageId, ctx.accountId)) {
4597
+ commandReplyDelivered = true;
4598
+ logger.debug('Suppressing command fallback after successful Bitrix24 file upload in the same turn', {
4599
+ commandName,
4600
+ senderId,
4601
+ dialogId,
4602
+ messageId: commandMessageId,
4603
+ });
4604
+ }
4605
+ if (!commandReplyDelivered && hasRecentSuccessfulAction(commandMessageId, ctx.accountId)) {
4606
+ commandReplyDelivered = true;
4607
+ logger.debug('Suppressing command fallback after successful Bitrix24 action in the same turn', {
4608
+ commandName,
4609
+ senderId,
4610
+ dialogId,
4611
+ messageId: commandMessageId,
4612
+ });
4613
+ }
4614
+ const shouldWaitForEmptyCommandReplyFallback = !commandReplyDelivered
4615
+ && isEmptyDispatchResult(dispatchResult);
3634
4616
  if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
3635
4617
  logger.debug('Command reply completed without queued final block, waiting before fallback', {
3636
4618
  commandName,
3637
4619
  senderId,
3638
4620
  dialogId,
3639
4621
  counts: dispatchResult.counts,
4622
+ fallbackDelayMs: shouldWaitForEmptyCommandReplyFallback
4623
+ ? EMPTY_REPLY_FALLBACK_GRACE_MS
4624
+ : REPLY_FALLBACK_GRACE_MS,
3640
4625
  });
3641
- await waitReplyFallbackGraceWindow();
4626
+ if (shouldWaitForEmptyCommandReplyFallback) {
4627
+ await waitEmptyReplyFallbackGraceWindow();
4628
+ }
4629
+ else {
4630
+ await waitReplyFallbackGraceWindow();
4631
+ }
3642
4632
  }
3643
- if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
4633
+ if (!commandReplyDelivered && isEmptyDispatchResult(dispatchResult)) {
4634
+ commandReplyDelivered = true;
4635
+ logger.warn('Command reply completed without any user-visible payload or tool activity; sending fallback notice', {
4636
+ commandName,
4637
+ senderId,
4638
+ dialogId,
4639
+ messageId: commandMessageId,
4640
+ counts: dispatchResult.counts,
4641
+ });
4642
+ const fallbackText = emptyReplyFallback(cmdCtx.language);
4643
+ if (isDm) {
4644
+ await sendService.sendText(sendCtx, fallbackText);
4645
+ }
4646
+ else {
4647
+ await sendService.answerCommandText(commandSendCtx, fallbackText, {
4648
+ keyboard: defaultCommandKeyboard,
4649
+ });
4650
+ }
4651
+ }
4652
+ else if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
3644
4653
  logger.warn('Command reply completed without a final user-visible message; fallback notice suppressed', {
3645
4654
  commandName,
3646
4655
  senderId,
3647
4656
  dialogId,
4657
+ messageId: commandMessageId,
3648
4658
  counts: dispatchResult.counts,
3649
4659
  });
3650
4660
  }
@@ -3653,6 +4663,7 @@ export const bitrix24Plugin = {
3653
4663
  commandName,
3654
4664
  senderId,
3655
4665
  dialogId,
4666
+ messageId: commandMessageId,
3656
4667
  });
3657
4668
  }
3658
4669
  }