@ihazz/bitrix24 1.1.12 → 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.
Files changed (48) hide show
  1. package/README.md +77 -4
  2. package/dist/src/api.d.ts +10 -5
  3. package/dist/src/api.d.ts.map +1 -1
  4. package/dist/src/api.js +42 -8
  5. package/dist/src/api.js.map +1 -1
  6. package/dist/src/channel.d.ts +20 -1
  7. package/dist/src/channel.d.ts.map +1 -1
  8. package/dist/src/channel.js +2303 -81
  9. package/dist/src/channel.js.map +1 -1
  10. package/dist/src/i18n.d.ts +1 -0
  11. package/dist/src/i18n.d.ts.map +1 -1
  12. package/dist/src/i18n.js +79 -68
  13. package/dist/src/i18n.js.map +1 -1
  14. package/dist/src/inbound-handler.d.ts +10 -0
  15. package/dist/src/inbound-handler.d.ts.map +1 -1
  16. package/dist/src/inbound-handler.js +281 -16
  17. package/dist/src/inbound-handler.js.map +1 -1
  18. package/dist/src/media-service.d.ts +4 -0
  19. package/dist/src/media-service.d.ts.map +1 -1
  20. package/dist/src/media-service.js +147 -14
  21. package/dist/src/media-service.js.map +1 -1
  22. package/dist/src/message-utils.d.ts.map +1 -1
  23. package/dist/src/message-utils.js +113 -4
  24. package/dist/src/message-utils.js.map +1 -1
  25. package/dist/src/runtime.d.ts +1 -0
  26. package/dist/src/runtime.d.ts.map +1 -1
  27. package/dist/src/runtime.js.map +1 -1
  28. package/dist/src/send-service.d.ts +2 -1
  29. package/dist/src/send-service.d.ts.map +1 -1
  30. package/dist/src/send-service.js +34 -5
  31. package/dist/src/send-service.js.map +1 -1
  32. package/dist/src/state-paths.d.ts +1 -0
  33. package/dist/src/state-paths.d.ts.map +1 -1
  34. package/dist/src/state-paths.js +9 -0
  35. package/dist/src/state-paths.js.map +1 -1
  36. package/dist/src/types.d.ts +92 -0
  37. package/dist/src/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/api.ts +62 -13
  40. package/src/channel.ts +3746 -843
  41. package/src/i18n.ts +81 -68
  42. package/src/inbound-handler.ts +357 -17
  43. package/src/media-service.ts +185 -15
  44. package/src/message-utils.ts +144 -4
  45. package/src/runtime.ts +1 -0
  46. package/src/send-service.ts +52 -4
  47. package/src/state-paths.ts +11 -0
  48. package/src/types.ts +122 -0
@@ -14,8 +14,9 @@ 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
+ import { markdownToBbCode } from './message-utils.js';
19
20
  const PHASE_STATUS_DURATION_SECONDS = 8;
20
21
  const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
21
22
  const THINKING_STATUS_DURATION_SECONDS = 30;
@@ -23,6 +24,7 @@ const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
23
24
  const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
24
25
  const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
25
26
  const REPLY_FALLBACK_GRACE_MS = 750;
27
+ const EMPTY_REPLY_FALLBACK_GRACE_MS = 15 * 1000;
26
28
  const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
27
29
  const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
28
30
  const MEDIA_DOWNLOAD_CONCURRENCY = 2;
@@ -37,7 +39,27 @@ const FORWARDED_CONTEXT_RANGE = 5;
37
39
  const ACTIVE_SESSION_NAMESPACE_TTL_MS = 180 * 24 * 60 * 60 * 1000;
38
40
  const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
39
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;
42
+ const NATIVE_FORWARD_ACK_TTL_MS = 60 * 1000;
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;
52
+ const RECENT_INBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
53
+ const RECENT_INBOUND_MESSAGE_LIMIT = 20;
40
54
  const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
55
+ const pendingNativeForwardAcks = new Map();
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();
61
+ const recentInboundMessagesByDialog = new Map();
62
+ const inboundMessageContextById = new Map();
41
63
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
42
64
  // B24 uses named reaction codes, not Unicode emoji.
43
65
  // Map common Unicode emoji to their B24 equivalents.
@@ -109,6 +131,789 @@ function toMessageId(value) {
109
131
  const parsed = Number(value);
110
132
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
111
133
  }
134
+ function resolveStateAccountId(accountId) {
135
+ const normalizedAccountId = typeof accountId === 'string'
136
+ ? accountId.trim()
137
+ : '';
138
+ if (normalizedAccountId) {
139
+ return normalizedAccountId;
140
+ }
141
+ const gatewayAccountId = gatewayState?.accountId?.trim();
142
+ return gatewayAccountId || 'default';
143
+ }
144
+ function buildAccountDialogScopeKey(accountId, dialogId) {
145
+ const normalizedDialogId = dialogId.trim();
146
+ if (!normalizedDialogId) {
147
+ return undefined;
148
+ }
149
+ return `${resolveStateAccountId(accountId)}:${normalizedDialogId}`;
150
+ }
151
+ function buildAccountMessageScopeKey(accountId, currentMessageId) {
152
+ const normalizedMessageId = toMessageId(currentMessageId);
153
+ if (!normalizedMessageId) {
154
+ return undefined;
155
+ }
156
+ return `${resolveStateAccountId(accountId)}:${normalizedMessageId}`;
157
+ }
158
+ function buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId) {
159
+ const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
160
+ const normalizedMessageId = toMessageId(currentMessageId);
161
+ if (!dialogKey || !normalizedMessageId) {
162
+ return undefined;
163
+ }
164
+ return `${dialogKey}:${normalizedMessageId}`;
165
+ }
166
+ function prunePendingNativeForwardAcks(now = Date.now()) {
167
+ for (const [key, expiresAt] of pendingNativeForwardAcks.entries()) {
168
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
169
+ pendingNativeForwardAcks.delete(key);
170
+ }
171
+ }
172
+ }
173
+ function markPendingNativeForwardAck(dialogId, currentMessageId, accountId) {
174
+ const key = buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
175
+ if (!key) {
176
+ return;
177
+ }
178
+ prunePendingNativeForwardAcks();
179
+ pendingNativeForwardAcks.set(key, Date.now() + NATIVE_FORWARD_ACK_TTL_MS);
180
+ }
181
+ function consumePendingNativeForwardAck(dialogId, currentMessageId, accountId) {
182
+ const key = buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
183
+ if (!key) {
184
+ return false;
185
+ }
186
+ prunePendingNativeForwardAcks();
187
+ const expiresAt = pendingNativeForwardAcks.get(key);
188
+ if (!expiresAt) {
189
+ return false;
190
+ }
191
+ pendingNativeForwardAcks.delete(key);
192
+ return true;
193
+ }
194
+ function resolvePendingNativeForwardAckDialogId(currentMessageId, fallbackDialogId, accountId) {
195
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
196
+ if (!messageKey) {
197
+ return fallbackDialogId;
198
+ }
199
+ return inboundMessageContextById.get(messageKey)?.dialogId ?? fallbackDialogId;
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
+ }
379
+ function buildPendingNativeReactionAckKey(currentMessageId, accountId) {
380
+ return buildAccountMessageScopeKey(accountId, currentMessageId);
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
+ }
519
+ function prunePendingNativeReactionAcks(now = Date.now()) {
520
+ for (const [key, entry] of pendingNativeReactionAcks.entries()) {
521
+ if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
522
+ pendingNativeReactionAcks.delete(key);
523
+ }
524
+ }
525
+ }
526
+ function markPendingNativeReactionAck(currentMessageId, emoji, accountId) {
527
+ const key = buildPendingNativeReactionAckKey(currentMessageId, accountId);
528
+ const normalizedEmoji = emoji.trim();
529
+ if (!key || !normalizedEmoji) {
530
+ return;
531
+ }
532
+ prunePendingNativeReactionAcks();
533
+ pendingNativeReactionAcks.set(key, {
534
+ emoji: normalizedEmoji,
535
+ expiresAt: Date.now() + NATIVE_REACTION_ACK_TTL_MS,
536
+ });
537
+ }
538
+ function consumePendingNativeReactionAck(currentMessageId, text, accountId) {
539
+ const key = buildPendingNativeReactionAckKey(currentMessageId, accountId);
540
+ if (!key) {
541
+ return false;
542
+ }
543
+ prunePendingNativeReactionAcks();
544
+ const pendingAck = pendingNativeReactionAcks.get(key);
545
+ if (!pendingAck) {
546
+ return false;
547
+ }
548
+ if (normalizeComparableMessageText(text) !== pendingAck.emoji) {
549
+ return false;
550
+ }
551
+ pendingNativeReactionAcks.delete(key);
552
+ return true;
553
+ }
554
+ function pruneRecentInboundMessages(now = Date.now()) {
555
+ for (const [dialogKey, entries] of recentInboundMessagesByDialog.entries()) {
556
+ const nextEntries = entries.filter((entry) => Number.isFinite(entry.timestamp) && entry.timestamp > now - RECENT_INBOUND_MESSAGE_TTL_MS);
557
+ if (nextEntries.length === 0) {
558
+ recentInboundMessagesByDialog.delete(dialogKey);
559
+ }
560
+ else if (nextEntries.length !== entries.length) {
561
+ recentInboundMessagesByDialog.set(dialogKey, nextEntries);
562
+ }
563
+ }
564
+ for (const [messageId, entry] of inboundMessageContextById.entries()) {
565
+ if (!Number.isFinite(entry.timestamp) || entry.timestamp <= now - RECENT_INBOUND_MESSAGE_TTL_MS) {
566
+ inboundMessageContextById.delete(messageId);
567
+ }
568
+ }
569
+ }
570
+ function rememberRecentInboundMessage(dialogId, messageId, body, mediaNames = [], timestamp = Date.now(), accountId) {
571
+ const normalizedMessageId = toMessageId(messageId);
572
+ const normalizedBody = body.trim();
573
+ const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
574
+ const messageKey = buildAccountMessageScopeKey(accountId, normalizedMessageId);
575
+ if (!dialogKey || !messageKey || !normalizedBody) {
576
+ return;
577
+ }
578
+ pruneRecentInboundMessages(timestamp);
579
+ const id = String(normalizedMessageId);
580
+ const currentEntries = recentInboundMessagesByDialog.get(dialogKey) ?? [];
581
+ const nextEntries = currentEntries
582
+ .filter((entry) => entry.messageId !== id)
583
+ .concat({ messageId: id, body: normalizedBody, timestamp })
584
+ .slice(-RECENT_INBOUND_MESSAGE_LIMIT);
585
+ recentInboundMessagesByDialog.set(dialogKey, nextEntries);
586
+ inboundMessageContextById.set(messageKey, {
587
+ dialogId,
588
+ dialogKey,
589
+ body: normalizedBody,
590
+ mediaNames: mediaNames
591
+ .filter((name) => typeof name === 'string')
592
+ .map((name) => name.trim())
593
+ .filter(Boolean),
594
+ timestamp,
595
+ });
596
+ }
597
+ function findPreviousRecentInboundMessage(currentMessageId, accountId) {
598
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
599
+ const normalizedMessageId = toMessageId(currentMessageId);
600
+ if (!messageKey || !normalizedMessageId) {
601
+ return undefined;
602
+ }
603
+ pruneRecentInboundMessages();
604
+ const currentEntry = inboundMessageContextById.get(messageKey);
605
+ if (!currentEntry) {
606
+ return undefined;
607
+ }
608
+ const dialogEntries = recentInboundMessagesByDialog.get(currentEntry.dialogKey);
609
+ if (!dialogEntries || dialogEntries.length === 0) {
610
+ return undefined;
611
+ }
612
+ const currentIndex = dialogEntries.findIndex((entry) => entry.messageId === String(normalizedMessageId));
613
+ if (currentIndex <= 0) {
614
+ return undefined;
615
+ }
616
+ for (let index = currentIndex - 1; index >= 0; index -= 1) {
617
+ const entry = dialogEntries[index];
618
+ if (entry.body.trim()) {
619
+ return entry;
620
+ }
621
+ }
622
+ return undefined;
623
+ }
624
+ const INLINE_REPLY_DIRECTIVE_RE = /\[\[\s*(reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
625
+ function normalizeComparableMessageText(value) {
626
+ return value
627
+ .replace(/\s+/g, ' ')
628
+ .trim();
629
+ }
630
+ function normalizeBitrix24DialogTarget(raw) {
631
+ const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
632
+ if (!stripped) {
633
+ return '';
634
+ }
635
+ const bracketedUserMatch = stripped.match(/^\[USER=(\d+)(?:[^\]]*)\][\s\S]*?\[\/USER\]$/iu);
636
+ if (bracketedUserMatch?.[1]) {
637
+ return bracketedUserMatch[1];
638
+ }
639
+ const bracketedChatMatch = stripped.match(/^\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]$/iu);
640
+ if (bracketedChatMatch?.[1]) {
641
+ return `chat${bracketedChatMatch[1]}`;
642
+ }
643
+ const plainUserMatch = stripped.match(/^USER=(\d+)$/iu);
644
+ if (plainUserMatch?.[1]) {
645
+ return plainUserMatch[1];
646
+ }
647
+ const plainChatMatch = stripped.match(/^CHAT=(?:chat)?(\d+)$/iu);
648
+ if (plainChatMatch?.[1]) {
649
+ return `chat${plainChatMatch[1]}`;
650
+ }
651
+ const prefixedChatMatch = stripped.match(/^chat(\d+)$/iu);
652
+ if (prefixedChatMatch?.[1]) {
653
+ return `chat${prefixedChatMatch[1]}`;
654
+ }
655
+ return stripped;
656
+ }
657
+ function looksLikeBitrix24DialogTarget(raw) {
658
+ const normalized = normalizeBitrix24DialogTarget(raw);
659
+ return /^\d+$/.test(normalized) || /^chat\d+$/iu.test(normalized);
660
+ }
661
+ function extractBitrix24DialogTargetsFromText(text) {
662
+ const targets = new Set();
663
+ for (const match of text.matchAll(/\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]/giu)) {
664
+ if (match[1]) {
665
+ targets.add(`chat${match[1]}`);
666
+ }
667
+ }
668
+ for (const match of text.matchAll(/\[USER=(\d+)(?:[^\]]*)\][\s\S]*?\[\/USER\]/giu)) {
669
+ if (match[1]) {
670
+ targets.add(match[1]);
671
+ }
672
+ }
673
+ for (const match of text.matchAll(/\bCHAT=(?:chat)?(\d+)\b/giu)) {
674
+ if (match[1]) {
675
+ targets.add(`chat${match[1]}`);
676
+ }
677
+ }
678
+ for (const match of text.matchAll(/\bUSER=(\d+)\b/giu)) {
679
+ if (match[1]) {
680
+ targets.add(match[1]);
681
+ }
682
+ }
683
+ return [...targets];
684
+ }
685
+ function isLikelyForwardControlMessage(text) {
686
+ return extractBitrix24DialogTargetsFromText(text).length > 0;
687
+ }
688
+ function extractReplyDirective(params) {
689
+ let wantsCurrentReply = false;
690
+ let inlineReplyToId;
691
+ const cleanText = cleanupTextAfterInlineMediaExtraction(params.text.replace(INLINE_REPLY_DIRECTIVE_RE, (_match, _rawDirective, explicitId) => {
692
+ if (typeof explicitId === 'string' && explicitId.trim()) {
693
+ inlineReplyToId = explicitId.trim();
694
+ }
695
+ else {
696
+ wantsCurrentReply = true;
697
+ }
698
+ return ' ';
699
+ }));
700
+ const explicitReplyToMessageId = parseActionMessageIds(params.explicitReplyToId)[0];
701
+ if (explicitReplyToMessageId) {
702
+ return { cleanText, replyToMessageId: explicitReplyToMessageId };
703
+ }
704
+ const inlineReplyToMessageId = parseActionMessageIds(inlineReplyToId)[0];
705
+ if (inlineReplyToMessageId) {
706
+ return { cleanText, replyToMessageId: inlineReplyToMessageId };
707
+ }
708
+ if (wantsCurrentReply) {
709
+ const currentReplyToMessageId = parseActionMessageIds(params.currentMessageId)[0];
710
+ if (currentReplyToMessageId) {
711
+ return { cleanText, replyToMessageId: currentReplyToMessageId };
712
+ }
713
+ }
714
+ return { cleanText };
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
+ }
729
+ function findNativeForwardTarget(params) {
730
+ const deliveredText = normalizeComparableMessageText(params.deliveredText);
731
+ if (!deliveredText) {
732
+ return undefined;
733
+ }
734
+ if (deliveredText === normalizeComparableMessageText(params.requestText)) {
735
+ return undefined;
736
+ }
737
+ const currentMessageId = toMessageId(params.currentMessageId);
738
+ const matchedEntry = [...params.historyEntries]
739
+ .reverse()
740
+ .find((entry) => {
741
+ if (normalizeComparableMessageText(entry.body) !== deliveredText) {
742
+ return false;
743
+ }
744
+ return currentMessageId === undefined || toMessageId(entry.messageId) !== currentMessageId;
745
+ });
746
+ if (!matchedEntry) {
747
+ return undefined;
748
+ }
749
+ return toMessageId(matchedEntry.messageId);
750
+ }
751
+ function findImplicitForwardTargetFromRecentInbound(params) {
752
+ const normalizedDeliveredText = normalizeComparableMessageText(params.deliveredText);
753
+ const normalizedRequestText = normalizeComparableMessageText(params.requestText);
754
+ const targetDialogIds = extractBitrix24DialogTargetsFromText(params.requestText);
755
+ const isForwardMarkerOnly = Boolean(normalizedDeliveredText) && FORWARD_MARKER_RE.test(normalizedDeliveredText);
756
+ if (!normalizedDeliveredText
757
+ || (!isForwardMarkerOnly
758
+ && (targetDialogIds.length === 0
759
+ || normalizedDeliveredText !== normalizedRequestText))) {
760
+ return undefined;
761
+ }
762
+ const previousInboundMessage = findPreviousRecentInboundMessage(params.currentMessageId, params.accountId);
763
+ if (previousInboundMessage) {
764
+ return Number(previousInboundMessage.messageId);
765
+ }
766
+ const previousHistoryEntry = [...(params.historyEntries ?? [])]
767
+ .reverse()
768
+ .find((entry) => toMessageId(entry.messageId));
769
+ return toMessageId(previousHistoryEntry?.messageId);
770
+ }
771
+ function findPreviousForwardableMessageId(params) {
772
+ // If the user replied to a specific message, that's the source to forward
773
+ const replyToId = toMessageId(params.replyToMessageId);
774
+ if (replyToId) {
775
+ return replyToId;
776
+ }
777
+ const normalizedCurrentBody = normalizeComparableMessageText(params.currentBody);
778
+ const previousHistoryEntry = [...(params.historyEntries ?? [])]
779
+ .reverse()
780
+ .find((entry) => {
781
+ const messageId = toMessageId(entry.messageId);
782
+ const normalizedBody = normalizeComparableMessageText(entry.body);
783
+ if (!messageId || !normalizedBody) {
784
+ return false;
785
+ }
786
+ if (normalizedCurrentBody && normalizedBody === normalizedCurrentBody) {
787
+ return false;
788
+ }
789
+ return !isLikelyForwardControlMessage(entry.body);
790
+ });
791
+ if (previousHistoryEntry) {
792
+ return toMessageId(previousHistoryEntry.messageId);
793
+ }
794
+ const previousInboundMessage = findPreviousRecentInboundMessage(params.currentMessageId, params.accountId);
795
+ if (previousInboundMessage) {
796
+ if (normalizeComparableMessageText(previousInboundMessage.body) !== normalizedCurrentBody
797
+ && !isLikelyForwardControlMessage(previousInboundMessage.body)) {
798
+ return Number(previousInboundMessage.messageId);
799
+ }
800
+ }
801
+ return undefined;
802
+ }
803
+ function buildBitrix24NativeForwardAgentHint(params) {
804
+ const targetDialogIds = extractBitrix24DialogTargetsFromText(params.currentBody);
805
+ const sourceMessageId = findPreviousForwardableMessageId(params);
806
+ if (!sourceMessageId) {
807
+ return undefined;
808
+ }
809
+ const formattedTargets = targetDialogIds
810
+ .map((dialogId) => (dialogId.startsWith('chat') ? `${dialogId} (chat)` : `${dialogId} (user)`))
811
+ .join(', ');
812
+ const lines = [
813
+ '[Bitrix24 native forward instruction]',
814
+ 'Use this only if you intentionally want a native Bitrix24 forward instead of repeating visible text.',
815
+ `Previous Bitrix24 message id available for forwarding: ${sourceMessageId}.`,
816
+ 'Use action "send" with "forwardMessageIds" set to that source message id.',
817
+ 'If the tool requires a non-empty message field for a pure forward, use "↩️".',
818
+ 'Do not repeat the source message text or the user instruction text when doing a native forward.',
819
+ ];
820
+ if (formattedTargets) {
821
+ lines.push(`Explicit target dialog ids mentioned in the current message: ${formattedTargets}.`);
822
+ lines.push('If you forward to those explicit targets, send one message-tool call per target dialog id and set "to" to the exact dialog id shown above.');
823
+ lines.push('Do not use CHAT=xxx or USER=xxx format for "to" — use "chat520" or "77" directly.');
824
+ }
825
+ else {
826
+ lines.push('If you forward in the current dialog, do not set "to" to another dialog id.');
827
+ }
828
+ lines.push('[/Bitrix24 native forward instruction]');
829
+ return lines.join('\n');
830
+ }
831
+ function buildBitrix24NativeReplyAgentHint(currentMessageId) {
832
+ const normalizedMessageId = toMessageId(currentMessageId);
833
+ if (!normalizedMessageId) {
834
+ return undefined;
835
+ }
836
+ return [
837
+ '[Bitrix24 native reply instruction]',
838
+ `Current Bitrix24 message id: ${normalizedMessageId}.`,
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.',
841
+ 'Do not use a native reply unless you actually want reply threading.',
842
+ '[/Bitrix24 native reply instruction]',
843
+ ].join('\n');
844
+ }
845
+ function buildBitrix24FileDeliveryAgentHint() {
846
+ return [
847
+ '[Bitrix24 file delivery instruction]',
848
+ 'When you need to deliver a real file or document to the user, prefer a structured reply payload with field "mediaUrl" or "mediaUrls".',
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.',
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".',
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.',
862
+ '[/Bitrix24 file delivery instruction]',
863
+ ].join('\n');
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
+ }
888
+ function readExplicitForwardMessageIds(rawParams) {
889
+ return parseActionMessageIds(rawParams.forwardMessageIds
890
+ ?? rawParams.forwardIds);
891
+ }
892
+ function resolveActionForwardMessageIds(params) {
893
+ const explicitForwardMessages = readExplicitForwardMessageIds(params.rawParams);
894
+ if (explicitForwardMessages.length > 0) {
895
+ return explicitForwardMessages;
896
+ }
897
+ const aliasedForwardMessages = parseActionMessageIds(params.rawParams.messageIds
898
+ ?? params.rawParams.message_ids
899
+ ?? params.rawParams.messageId
900
+ ?? params.rawParams.message_id
901
+ ?? params.rawParams.replyToMessageId
902
+ ?? params.rawParams.replyToId);
903
+ if (aliasedForwardMessages.length > 0) {
904
+ return aliasedForwardMessages;
905
+ }
906
+ const previousInboundMessage = findPreviousRecentInboundMessage(params.toolContext?.currentMessageId, params.accountId);
907
+ return previousInboundMessage ? [Number(previousInboundMessage.messageId)] : [];
908
+ }
909
+ const FORWARD_MARKER_RE = /^[\s↩️➡️→⤵️🔄📨]+$/u;
910
+ function normalizeForwardActionText(text) {
911
+ const normalizedText = typeof text === 'string' ? text.trim() : '';
912
+ if (!normalizedText || FORWARD_MARKER_RE.test(normalizedText)) {
913
+ return '';
914
+ }
915
+ return normalizedText;
916
+ }
112
917
  function escapeBbCodeText(value) {
113
918
  return value
114
919
  .replace(/\[/g, '(')
@@ -262,6 +1067,9 @@ function createReplyStatusHeartbeat(params) {
262
1067
  async function waitReplyFallbackGraceWindow() {
263
1068
  await new Promise((resolve) => setTimeout(resolve, REPLY_FALLBACK_GRACE_MS));
264
1069
  }
1070
+ async function waitEmptyReplyFallbackGraceWindow() {
1071
+ await new Promise((resolve) => setTimeout(resolve, EMPTY_REPLY_FALLBACK_GRACE_MS));
1072
+ }
265
1073
  export function canCoalesceDirectMessage(msgCtx, config) {
266
1074
  return msgCtx.isDm
267
1075
  && config.dmPolicy !== 'pairing'
@@ -741,6 +1549,19 @@ class BufferedDirectMessageCoalescer {
741
1549
  let gatewayState = null;
742
1550
  export function __setGatewayStateForTests(state) {
743
1551
  gatewayState = state;
1552
+ if (state === null) {
1553
+ pendingNativeForwardAcks.clear();
1554
+ pendingNativeReactionAcks.clear();
1555
+ recentSuccessfulMediaDeliveries.clear();
1556
+ recentSuccessfulActionsByMessage.clear();
1557
+ recentOutboundMessagesById.clear();
1558
+ recentWatchNotificationsByMessage.clear();
1559
+ recentInboundMessagesByDialog.clear();
1560
+ inboundMessageContextById.clear();
1561
+ }
1562
+ }
1563
+ export function __rememberRecentInboundMessageForTests(params) {
1564
+ rememberRecentInboundMessage(params.dialogId, params.messageId, params.body, params.mediaNames, params.timestamp, params.accountId);
744
1565
  }
745
1566
  // ─── Keyboard layouts ────────────────────────────────────────────────────────
746
1567
  export function buildWelcomeKeyboard(language) {
@@ -768,6 +1589,638 @@ export function buildDefaultCommandKeyboard(language) {
768
1589
  }
769
1590
  export const DEFAULT_COMMAND_KEYBOARD = buildDefaultCommandKeyboard();
770
1591
  export const DEFAULT_WELCOME_KEYBOARD = buildWelcomeKeyboard();
1592
+ const BITRIX24_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'delete'];
1593
+ const BITRIX24_DISCOVERY_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'delete'];
1594
+ function buildMessageToolButtonsSchema() {
1595
+ return {
1596
+ type: 'array',
1597
+ description: 'Optional Bitrix24 message buttons as rows of button objects. Each button object can include text, optional callback_data and optional style.',
1598
+ items: {
1599
+ type: 'array',
1600
+ items: {
1601
+ type: 'object',
1602
+ description: 'Single button object. Use text, optional callback_data, and optional style=primary|attention|danger.',
1603
+ },
1604
+ },
1605
+ };
1606
+ }
1607
+ function buildMessageToolAttachSchema() {
1608
+ return {
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.',
1610
+ };
1611
+ }
1612
+ function buildMessageToolIdListSchema(description) {
1613
+ return {
1614
+ description: `${description} Can be an integer, a string, or an array of integers.`,
1615
+ };
1616
+ }
1617
+ function resolveConfiguredBitrix24ActionAccounts(cfg, accountId) {
1618
+ if (accountId) {
1619
+ const account = resolveAccount(cfg, accountId);
1620
+ return account.enabled && account.configured ? [account.accountId] : [];
1621
+ }
1622
+ return listAccountIds(cfg).filter((candidateId) => {
1623
+ const account = resolveAccount(cfg, candidateId);
1624
+ return account.enabled && account.configured;
1625
+ });
1626
+ }
1627
+ function describeBitrix24MessageTool(params) {
1628
+ const configuredAccounts = resolveConfiguredBitrix24ActionAccounts(params.cfg, params.accountId);
1629
+ if (configuredAccounts.length === 0) {
1630
+ return {
1631
+ actions: [],
1632
+ capabilities: [],
1633
+ schema: null,
1634
+ };
1635
+ }
1636
+ return {
1637
+ actions: [...BITRIX24_DISCOVERY_ACTION_NAMES],
1638
+ capabilities: ['interactive', 'buttons', 'cards'],
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.'),
1647
+ },
1648
+ },
1649
+ };
1650
+ }
1651
+ function extractBitrix24ToolSend(args) {
1652
+ if ((typeof args.action === 'string' ? args.action.trim() : '') !== 'sendMessage') {
1653
+ return null;
1654
+ }
1655
+ const to = typeof args.to === 'string' ? normalizeBitrix24DialogTarget(args.to) : '';
1656
+ if (!to) {
1657
+ return null;
1658
+ }
1659
+ const accountId = typeof args.accountId === 'string'
1660
+ ? args.accountId.trim()
1661
+ : undefined;
1662
+ const threadId = typeof args.threadId === 'number'
1663
+ ? String(args.threadId)
1664
+ : typeof args.threadId === 'string'
1665
+ ? args.threadId.trim()
1666
+ : '';
1667
+ return {
1668
+ to,
1669
+ ...(accountId ? { accountId } : {}),
1670
+ ...(threadId ? { threadId } : {}),
1671
+ };
1672
+ }
1673
+ function readActionTextParam(params) {
1674
+ const rawText = params.message ?? params.text ?? params.content;
1675
+ return typeof rawText === 'string' ? rawText : null;
1676
+ }
1677
+ function isPlainObject(value) {
1678
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1679
+ }
1680
+ function isAttachColorToken(value) {
1681
+ return value === 'primary'
1682
+ || value === 'secondary'
1683
+ || value === 'alert'
1684
+ || value === 'base';
1685
+ }
1686
+ function isPresent(value) {
1687
+ return value != null;
1688
+ }
1689
+ function isAttachBlock(value) {
1690
+ return isPlainObject(value) && Object.keys(value).length > 0;
1691
+ }
1692
+ function isBitrix24Attach(value) {
1693
+ if (Array.isArray(value)) {
1694
+ return value.length > 0 && value.every(isAttachBlock);
1695
+ }
1696
+ if (!isPlainObject(value) || !Array.isArray(value.BLOCKS) || value.BLOCKS.length === 0) {
1697
+ return false;
1698
+ }
1699
+ if (value.COLOR_TOKEN !== undefined && !isAttachColorToken(value.COLOR_TOKEN)) {
1700
+ return false;
1701
+ }
1702
+ return value.BLOCKS.every(isAttachBlock);
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
+ }
2060
+ function parseActionAttach(rawValue) {
2061
+ if (rawValue == null) {
2062
+ return undefined;
2063
+ }
2064
+ let parsed = rawValue;
2065
+ if (typeof rawValue === 'string') {
2066
+ const trimmed = rawValue.trim();
2067
+ if (!trimmed) {
2068
+ return undefined;
2069
+ }
2070
+ try {
2071
+ parsed = JSON.parse(trimmed);
2072
+ }
2073
+ catch {
2074
+ return undefined;
2075
+ }
2076
+ }
2077
+ return normalizeBitrix24Attach(parsed);
2078
+ }
2079
+ function hasMeaningfulAttachInput(rawValue) {
2080
+ if (rawValue == null) {
2081
+ return false;
2082
+ }
2083
+ if (typeof rawValue === 'string') {
2084
+ const trimmed = rawValue.trim();
2085
+ if (!trimmed) {
2086
+ return false;
2087
+ }
2088
+ try {
2089
+ return hasMeaningfulAttachInput(JSON.parse(trimmed));
2090
+ }
2091
+ catch {
2092
+ return true;
2093
+ }
2094
+ }
2095
+ if (Array.isArray(rawValue)) {
2096
+ return rawValue.length > 0;
2097
+ }
2098
+ if (isPlainObject(rawValue)) {
2099
+ if (Array.isArray(rawValue.BLOCKS)) {
2100
+ return rawValue.BLOCKS.length > 0;
2101
+ }
2102
+ return Object.keys(rawValue).length > 0;
2103
+ }
2104
+ return true;
2105
+ }
2106
+ function readActionTargetParam(params, fallbackTarget) {
2107
+ const rawTarget = params.to ?? params.target ?? fallbackTarget;
2108
+ return typeof rawTarget === 'string' ? normalizeBitrix24DialogTarget(rawTarget) : '';
2109
+ }
2110
+ function parseActionMessageIds(rawValue) {
2111
+ const ids = [];
2112
+ const seen = new Set();
2113
+ const append = (value) => {
2114
+ if (Array.isArray(value)) {
2115
+ value.forEach(append);
2116
+ return;
2117
+ }
2118
+ if (typeof value === 'number') {
2119
+ const normalizedId = Math.trunc(value);
2120
+ if (Number.isFinite(normalizedId) && normalizedId > 0 && !seen.has(normalizedId)) {
2121
+ seen.add(normalizedId);
2122
+ ids.push(normalizedId);
2123
+ }
2124
+ return;
2125
+ }
2126
+ if (typeof value !== 'string') {
2127
+ return;
2128
+ }
2129
+ const trimmed = value.trim();
2130
+ if (!trimmed) {
2131
+ return;
2132
+ }
2133
+ if (trimmed.startsWith('[')) {
2134
+ try {
2135
+ append(JSON.parse(trimmed));
2136
+ return;
2137
+ }
2138
+ catch {
2139
+ // Fall through to scalar / CSV parsing.
2140
+ }
2141
+ }
2142
+ trimmed
2143
+ .split(/[,\s]+/)
2144
+ .forEach((part) => {
2145
+ if (!part) {
2146
+ return;
2147
+ }
2148
+ const normalizedId = Number(part);
2149
+ if (Number.isFinite(normalizedId) && normalizedId > 0 && !seen.has(normalizedId)) {
2150
+ seen.add(normalizedId);
2151
+ ids.push(normalizedId);
2152
+ }
2153
+ });
2154
+ };
2155
+ append(rawValue);
2156
+ return ids;
2157
+ }
2158
+ function parseActionKeyboard(rawButtons) {
2159
+ if (rawButtons == null) {
2160
+ return undefined;
2161
+ }
2162
+ try {
2163
+ const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
2164
+ if (Array.isArray(parsed)) {
2165
+ const keyboard = convertButtonsToKeyboard(parsed);
2166
+ return keyboard.length > 0 ? keyboard : undefined;
2167
+ }
2168
+ }
2169
+ catch {
2170
+ // Ignore invalid buttons payload and send without keyboard.
2171
+ }
2172
+ return undefined;
2173
+ }
2174
+ function collectActionMediaUrls(params) {
2175
+ const mediaUrls = [];
2176
+ const seen = new Set();
2177
+ const append = (value) => {
2178
+ if (Array.isArray(value)) {
2179
+ value.forEach(append);
2180
+ return;
2181
+ }
2182
+ if (typeof value !== 'string') {
2183
+ return;
2184
+ }
2185
+ const trimmed = value.trim();
2186
+ if (!trimmed) {
2187
+ return;
2188
+ }
2189
+ if (trimmed.startsWith('[')) {
2190
+ try {
2191
+ append(JSON.parse(trimmed));
2192
+ return;
2193
+ }
2194
+ catch {
2195
+ // Fall through to scalar handling.
2196
+ }
2197
+ }
2198
+ if (!seen.has(trimmed)) {
2199
+ seen.add(trimmed);
2200
+ mediaUrls.push(trimmed);
2201
+ }
2202
+ };
2203
+ append(params.mediaUrl);
2204
+ append(params.mediaUrls);
2205
+ append(params.media);
2206
+ append(params.filePath);
2207
+ append(params.filePaths);
2208
+ append(params.path);
2209
+ append(params.paths);
2210
+ return mediaUrls;
2211
+ }
2212
+ function buildActionMessageText(params) {
2213
+ const normalizedText = typeof params.text === 'string'
2214
+ ? markdownToBbCode(params.text.trim())
2215
+ : '';
2216
+ if (normalizedText) {
2217
+ return normalizedText;
2218
+ }
2219
+ if (params.attach) {
2220
+ return null;
2221
+ }
2222
+ return params.keyboard ? ' ' : null;
2223
+ }
771
2224
  function parseRegisteredCommandTrigger(callbackData) {
772
2225
  const trimmed = callbackData.trim();
773
2226
  const isSlashCommand = trimmed.startsWith('/');
@@ -844,6 +2297,13 @@ export function extractKeyboardFromPayload(payload) {
844
2297
  }
845
2298
  return undefined;
846
2299
  }
2300
+ export function extractAttachFromPayload(payload) {
2301
+ const cd = payload.channelData;
2302
+ if (!cd)
2303
+ return undefined;
2304
+ const b24Data = cd.bitrix24;
2305
+ return parseActionAttach(b24Data?.attach ?? b24Data?.attachments);
2306
+ }
847
2307
  /**
848
2308
  * Extract inline button JSON embedded in message text by the agent.
849
2309
  *
@@ -874,6 +2334,13 @@ export function extractInlineButtonsFromText(text) {
874
2334
  return undefined;
875
2335
  }
876
2336
  }
2337
+ function cleanupTextAfterInlineMediaExtraction(text) {
2338
+ return text
2339
+ .replace(/[ \t]+\n/g, '\n')
2340
+ .replace(/\n{3,}/g, '\n\n')
2341
+ .replace(/[ \t]{2,}/g, ' ')
2342
+ .trim();
2343
+ }
877
2344
  function normalizeCommandReplyPayload(params) {
878
2345
  const { commandName, commandParams, text, language } = params;
879
2346
  if (commandName === 'models' && commandParams.trim() === '') {
@@ -1130,12 +2597,13 @@ export async function handleWebhookRequest(req, res) {
1130
2597
  // ─── Outbound adapter helpers ────────────────────────────────────────────────
1131
2598
  function resolveOutboundSendCtx(params) {
1132
2599
  const { config } = resolveAccount(params.cfg, params.accountId);
1133
- if (!config.webhookUrl || !gatewayState)
2600
+ const dialogId = normalizeBitrix24DialogTarget(params.to);
2601
+ if (!config.webhookUrl || !gatewayState || !dialogId)
1134
2602
  return null;
1135
2603
  return {
1136
2604
  webhookUrl: config.webhookUrl,
1137
2605
  bot: gatewayState.bot,
1138
- dialogId: params.to,
2606
+ dialogId,
1139
2607
  };
1140
2608
  }
1141
2609
  function collectOutboundMediaUrls(input) {
@@ -1155,16 +2623,22 @@ async function uploadOutboundMedia(params) {
1155
2623
  let lastMessageId = '';
1156
2624
  let message = params.initialMessage;
1157
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
+ });
1158
2632
  const result = await params.mediaService.uploadMediaToChat({
1159
2633
  localPath: mediaUrl,
1160
- fileName: basename(mediaUrl),
2634
+ fileName: outboundFileName,
1161
2635
  webhookUrl: params.sendCtx.webhookUrl,
1162
2636
  bot: params.sendCtx.bot,
1163
2637
  dialogId: params.sendCtx.dialogId,
1164
2638
  message: message || undefined,
1165
2639
  });
1166
2640
  if (!result.ok) {
1167
- throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
2641
+ throw new Error(`Failed to upload media: ${outboundFileName}`);
1168
2642
  }
1169
2643
  if (result.messageId) {
1170
2644
  lastMessageId = String(result.messageId);
@@ -1196,13 +2670,10 @@ export const bitrix24Plugin = {
1196
2670
  inlineButtons: 'all',
1197
2671
  },
1198
2672
  messaging: {
1199
- normalizeTarget: (raw) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
2673
+ normalizeTarget: (raw) => normalizeBitrix24DialogTarget(raw),
1200
2674
  targetResolver: {
1201
- hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
1202
- looksLikeId: (raw, _normalized) => {
1203
- const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
1204
- return /^\d+$/.test(stripped);
1205
- },
2675
+ hint: 'Use a numeric dialog ID like "1", a group dialog like "chat42", or Bitrix mentions like "[USER=1]John[/USER]" / "[CHAT=42]Team[/CHAT]".',
2676
+ looksLikeId: (raw, _normalized) => looksLikeBitrix24DialogTarget(raw),
1206
2677
  },
1207
2678
  },
1208
2679
  config: {
@@ -1272,6 +2743,8 @@ export const bitrix24Plugin = {
1272
2743
  mediaService: gatewayState.mediaService,
1273
2744
  sendCtx,
1274
2745
  mediaUrls,
2746
+ currentMessageId: ctx.currentMessageId,
2747
+ accountId: ctx.accountId,
1275
2748
  initialMessage: ctx.text,
1276
2749
  });
1277
2750
  return { messageId };
@@ -1286,27 +2759,49 @@ export const bitrix24Plugin = {
1286
2759
  const sendCtx = resolveOutboundSendCtx(ctx);
1287
2760
  if (!sendCtx || !gatewayState)
1288
2761
  throw new Error('Bitrix24 gateway not started');
2762
+ const text = ctx.text;
1289
2763
  const keyboard = ctx.payload?.channelData
1290
2764
  ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
1291
2765
  : undefined;
2766
+ const attach = ctx.payload?.channelData
2767
+ ? extractAttachFromPayload({ channelData: ctx.payload.channelData })
2768
+ : undefined;
1292
2769
  const mediaUrls = collectOutboundMediaUrls({
1293
2770
  mediaUrl: ctx.mediaUrl,
1294
2771
  payload: ctx.payload,
1295
2772
  });
1296
2773
  if (mediaUrls.length > 0) {
2774
+ const initialMessage = !keyboard && !attach ? text || undefined : undefined;
1297
2775
  const uploadedMessageId = await uploadOutboundMedia({
1298
2776
  mediaService: gatewayState.mediaService,
1299
2777
  sendCtx,
1300
2778
  mediaUrls,
2779
+ currentMessageId: ctx.currentMessageId,
2780
+ accountId: ctx.accountId,
2781
+ initialMessage,
1301
2782
  });
1302
- if (ctx.text) {
1303
- const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
2783
+ if ((text && !initialMessage) || keyboard || attach) {
2784
+ if (attach) {
2785
+ const messageId = await gatewayState.api.sendMessage(sendCtx.webhookUrl, sendCtx.bot, sendCtx.dialogId, buildActionMessageText({ text, keyboard, attach }), {
2786
+ ...(keyboard ? { keyboard } : {}),
2787
+ attach,
2788
+ });
2789
+ return { messageId: String(messageId || uploadedMessageId) };
2790
+ }
2791
+ const result = await gatewayState.sendService.sendText(sendCtx, text || '', keyboard ? { keyboard } : undefined);
1304
2792
  return { messageId: String(result.messageId ?? uploadedMessageId) };
1305
2793
  }
1306
2794
  return { messageId: uploadedMessageId };
1307
2795
  }
1308
- if (ctx.text) {
1309
- const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
2796
+ if (text || keyboard || attach) {
2797
+ if (attach) {
2798
+ const messageId = await gatewayState.api.sendMessage(sendCtx.webhookUrl, sendCtx.bot, sendCtx.dialogId, buildActionMessageText({ text, keyboard, attach }), {
2799
+ ...(keyboard ? { keyboard } : {}),
2800
+ attach,
2801
+ });
2802
+ return { messageId: String(messageId ?? '') };
2803
+ }
2804
+ const result = await gatewayState.sendService.sendText(sendCtx, text || '', keyboard ? { keyboard } : undefined);
1310
2805
  return { messageId: String(result.messageId ?? '') };
1311
2806
  }
1312
2807
  return { messageId: '' };
@@ -1314,11 +2809,13 @@ export const bitrix24Plugin = {
1314
2809
  },
1315
2810
  // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
1316
2811
  actions: {
2812
+ describeMessageTool: (params) => describeBitrix24MessageTool(params),
2813
+ extractToolSend: (params) => extractBitrix24ToolSend(params.args),
1317
2814
  listActions: (_params) => {
1318
- return ['react', 'send'];
2815
+ return [...BITRIX24_DISCOVERY_ACTION_NAMES];
1319
2816
  },
1320
2817
  supportsAction: (params) => {
1321
- return params.action === 'react' || params.action === 'send';
2818
+ return BITRIX24_ACTION_NAMES.includes(params.action);
1322
2819
  },
1323
2820
  handleAction: async (ctx) => {
1324
2821
  // Helper: wrap payload as gateway-compatible tool result
@@ -1326,36 +2823,276 @@ export const bitrix24Plugin = {
1326
2823
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1327
2824
  details: payload,
1328
2825
  });
2826
+ const markSuccessfulAction = (currentMessageId) => {
2827
+ markRecentSuccessfulAction(currentMessageId, ctx.accountId);
2828
+ };
1329
2829
  // ─── Send with buttons ──────────────────────────────────────────────
1330
2830
  if (ctx.action === 'send') {
1331
- // Only intercept send when buttons are present; otherwise let gateway handle normally
1332
2831
  const rawButtons = ctx.params.buttons;
1333
- if (!rawButtons)
1334
- return null;
2832
+ const rawAttach = ctx.params.attach ?? ctx.params.attachments;
1335
2833
  const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1336
2834
  if (!config.webhookUrl || !gatewayState)
1337
2835
  return null;
1338
2836
  const bot = gatewayState.bot;
1339
- const to = String(ctx.params.to ?? ctx.to ?? '').trim();
2837
+ const to = readActionTargetParam(ctx.params, ctx.to);
1340
2838
  if (!to) {
1341
2839
  defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
1342
2840
  return null;
1343
2841
  }
1344
2842
  const sendCtx = { webhookUrl: config.webhookUrl, bot, dialogId: to };
1345
- const message = String(ctx.params.message ?? '').trim();
1346
- // Parse buttons: may be array or JSON string
1347
- let buttons;
1348
- try {
1349
- const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1350
- if (Array.isArray(parsed))
1351
- buttons = parsed;
2843
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ dialogId: to });
2844
+ const messageText = readActionTextParam(ctx.params);
2845
+ const message = typeof messageText === 'string' ? messageText.trim() : '';
2846
+ const keyboard = parseActionKeyboard(rawButtons);
2847
+ const attach = parseActionAttach(rawAttach);
2848
+ const mediaUrls = collectActionMediaUrls(ctx.params);
2849
+ const toolContext = ctx.toolContext;
2850
+ const currentToolMessageId = toolContext?.currentMessageId;
2851
+ const currentInboundContextKey = buildAccountMessageScopeKey(ctx.accountId, currentToolMessageId);
2852
+ const currentInboundContext = currentInboundContextKey
2853
+ ? inboundMessageContextById.get(currentInboundContextKey)
2854
+ : undefined;
2855
+ const forwardAckDialogId = resolvePendingNativeForwardAckDialogId(currentToolMessageId, to, ctx.accountId);
2856
+ const explicitForwardMessages = readExplicitForwardMessageIds(ctx.params);
2857
+ const referencedReplyMessageId = parseActionMessageIds(ctx.params.replyToMessageId ?? ctx.params.replyToId)[0];
2858
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
2859
+ return toolResult({ ok: false, reason: 'invalid_attach', hint: 'Bitrix24 attach must use ATTACH rich blocks. Do not pass uploaded files here; use mediaUrl/mediaUrls for file uploads.' });
2860
+ }
2861
+ if (explicitForwardMessages.length > 0 && mediaUrls.length > 0) {
2862
+ return toolResult({
2863
+ ok: false,
2864
+ reason: 'unsupported_payload_combo',
2865
+ hint: 'Bitrix24 native forward cannot be combined with mediaUrl/mediaUrls uploads in one send action. Send the forward separately.',
2866
+ });
2867
+ }
2868
+ if (!message && !keyboard && !attach && mediaUrls.length === 0 && explicitForwardMessages.length === 0) {
2869
+ return toolResult({
2870
+ ok: false,
2871
+ reason: 'missing_payload',
2872
+ hint: 'Provide message text, buttons, rich attach blocks or mediaUrl/mediaUrls for Bitrix24 send.',
2873
+ });
1352
2874
  }
1353
- catch {
1354
- // invalid buttons JSON — send without keyboard
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
+ });
1355
2893
  }
1356
- const keyboard = buttons?.length ? convertButtonsToKeyboard(buttons) : undefined;
1357
2894
  try {
2895
+ if (explicitForwardMessages.length > 0) {
2896
+ const normalizedForwardText = normalizeForwardActionText(messageText);
2897
+ if (attach) {
2898
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: normalizedForwardText, keyboard, attach }), {
2899
+ ...(keyboard ? { keyboard } : {}),
2900
+ attach,
2901
+ forwardMessages: explicitForwardMessages,
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
+ });
2911
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2912
+ markSuccessfulAction(currentToolMessageId);
2913
+ return toolResult({
2914
+ channel: 'bitrix24',
2915
+ to,
2916
+ via: 'direct',
2917
+ mediaUrl: null,
2918
+ forwarded: true,
2919
+ forwardMessages: explicitForwardMessages,
2920
+ result: { messageId: String(messageId ?? '') },
2921
+ });
2922
+ }
2923
+ const result = await gatewayState.sendService.sendText(sendCtx, normalizedForwardText || (keyboard ? ' ' : ''), {
2924
+ ...(keyboard ? { keyboard } : {}),
2925
+ forwardMessages: explicitForwardMessages,
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
+ });
2935
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2936
+ markSuccessfulAction(currentToolMessageId);
2937
+ return toolResult({
2938
+ channel: 'bitrix24',
2939
+ to,
2940
+ via: 'direct',
2941
+ mediaUrl: null,
2942
+ forwarded: true,
2943
+ forwardMessages: explicitForwardMessages,
2944
+ result: { messageId: String(result.messageId ?? '') },
2945
+ });
2946
+ }
2947
+ if (canUseNativeReply && referencedReplyMessageId && !attach && mediaUrls.length === 0) {
2948
+ let referencedMessageText = '';
2949
+ try {
2950
+ const referencedMessage = await gatewayState.api.getMessage(config.webhookUrl, bot, referencedReplyMessageId);
2951
+ referencedMessageText = resolveFetchedMessageBody(referencedMessage.message?.text ?? '');
2952
+ }
2953
+ catch (err) {
2954
+ defaultLogger.debug('Failed to hydrate referenced Bitrix24 message for send action', err);
2955
+ }
2956
+ const normalizedReferencedMessage = normalizeComparableMessageText(referencedMessageText);
2957
+ const normalizedMessage = normalizeComparableMessageText(message);
2958
+ if (normalizedReferencedMessage && normalizedReferencedMessage === normalizedMessage) {
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
+ });
2968
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2969
+ markSuccessfulAction(currentToolMessageId);
2970
+ return toolResult({
2971
+ channel: 'bitrix24',
2972
+ to,
2973
+ via: 'direct',
2974
+ mediaUrl: null,
2975
+ forwarded: true,
2976
+ forwardMessages: [referencedReplyMessageId],
2977
+ result: { messageId: String(result.messageId ?? '') },
2978
+ });
2979
+ }
2980
+ const sendOptions = {
2981
+ ...(keyboard ? { keyboard } : {}),
2982
+ ...(canUseNativeReply ? { replyToMessageId: referencedReplyMessageId } : {}),
2983
+ };
2984
+ const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
2985
+ markSuccessfulAction(currentToolMessageId);
2986
+ return toolResult({
2987
+ channel: 'bitrix24',
2988
+ to,
2989
+ via: 'direct',
2990
+ mediaUrl: null,
2991
+ ...(canUseNativeReply ? {
2992
+ replied: true,
2993
+ replyToMessageId: referencedReplyMessageId,
2994
+ } : {}),
2995
+ result: { messageId: String(result.messageId ?? '') },
2996
+ });
2997
+ }
2998
+ const previousInboundMessage = findPreviousRecentInboundMessage(currentToolMessageId, ctx.accountId);
2999
+ if (!referencedReplyMessageId
3000
+ && !attach
3001
+ && mediaUrls.length === 0
3002
+ && !keyboard
3003
+ && previousInboundMessage
3004
+ && currentInboundContext
3005
+ && normalizeComparableMessageText(message)
3006
+ && normalizeComparableMessageText(message) === normalizeComparableMessageText(previousInboundMessage.body)
3007
+ && normalizeComparableMessageText(currentInboundContext.body) !== normalizeComparableMessageText(message)) {
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
+ });
3017
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3018
+ markSuccessfulAction(currentToolMessageId);
3019
+ return toolResult({
3020
+ channel: 'bitrix24',
3021
+ to,
3022
+ via: 'direct',
3023
+ mediaUrl: null,
3024
+ forwarded: true,
3025
+ forwardMessages: [Number(previousInboundMessage.messageId)],
3026
+ result: { messageId: String(result.messageId ?? '') },
3027
+ });
3028
+ }
3029
+ if (mediaUrls.length > 0) {
3030
+ const initialMessage = !keyboard && !attach ? message || undefined : undefined;
3031
+ const uploadedMessageId = await uploadOutboundMedia({
3032
+ mediaService: gatewayState.mediaService,
3033
+ sendCtx,
3034
+ mediaUrls,
3035
+ currentMessageId: currentToolMessageId,
3036
+ accountId: ctx.accountId,
3037
+ initialMessage,
3038
+ });
3039
+ markRecentMediaDelivery({
3040
+ dialogId: to,
3041
+ currentMessageId: currentToolMessageId,
3042
+ mediaUrls,
3043
+ accountId: ctx.accountId,
3044
+ messageId: uploadedMessageId,
3045
+ });
3046
+ if ((message && !initialMessage) || keyboard || attach) {
3047
+ if (attach) {
3048
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: message, keyboard, attach }), {
3049
+ ...(keyboard ? { keyboard } : {}),
3050
+ attach,
3051
+ });
3052
+ markSuccessfulAction(currentToolMessageId);
3053
+ return toolResult({
3054
+ channel: 'bitrix24',
3055
+ to,
3056
+ via: 'direct',
3057
+ mediaUrl: mediaUrls[0] ?? null,
3058
+ result: { messageId: String(messageId ?? uploadedMessageId) },
3059
+ });
3060
+ }
3061
+ const result = await gatewayState.sendService.sendText(sendCtx, message || '', keyboard ? { keyboard } : undefined);
3062
+ markSuccessfulAction(currentToolMessageId);
3063
+ return toolResult({
3064
+ channel: 'bitrix24',
3065
+ to,
3066
+ via: 'direct',
3067
+ mediaUrl: mediaUrls[0] ?? null,
3068
+ result: { messageId: String(result.messageId ?? uploadedMessageId) },
3069
+ });
3070
+ }
3071
+ markSuccessfulAction(currentToolMessageId);
3072
+ return toolResult({
3073
+ channel: 'bitrix24',
3074
+ to,
3075
+ via: 'direct',
3076
+ mediaUrl: mediaUrls[0] ?? null,
3077
+ result: { messageId: uploadedMessageId },
3078
+ });
3079
+ }
3080
+ if (attach) {
3081
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: message, keyboard, attach }), {
3082
+ ...(keyboard ? { keyboard } : {}),
3083
+ attach,
3084
+ });
3085
+ markSuccessfulAction(currentToolMessageId);
3086
+ return toolResult({
3087
+ channel: 'bitrix24',
3088
+ to,
3089
+ via: 'direct',
3090
+ mediaUrl: null,
3091
+ result: { messageId: String(messageId ?? '') },
3092
+ });
3093
+ }
1358
3094
  const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', keyboard ? { keyboard } : undefined);
3095
+ markSuccessfulAction(currentToolMessageId);
1359
3096
  return toolResult({
1360
3097
  channel: 'bitrix24',
1361
3098
  to,
@@ -1366,9 +3103,174 @@ export const bitrix24Plugin = {
1366
3103
  }
1367
3104
  catch (err) {
1368
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
+ }
1369
3114
  return toolResult({ ok: false, error: errMsg });
1370
3115
  }
1371
3116
  }
3117
+ if (ctx.action === 'reply') {
3118
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
3119
+ if (!config.webhookUrl || !gatewayState) {
3120
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
3121
+ }
3122
+ const params = ctx.params;
3123
+ const bot = gatewayState.bot;
3124
+ const to = readActionTargetParam(params, ctx.to);
3125
+ if (!to) {
3126
+ return toolResult({ ok: false, reason: 'missing_target', hint: 'Bitrix24 reply requires a target dialog id in "to" or "target". Do not retry.' });
3127
+ }
3128
+ const toolContext = ctx.toolContext;
3129
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ dialogId: to });
3130
+ const replyToMessageId = parseActionMessageIds(params.replyToMessageId
3131
+ ?? params.replyToId
3132
+ ?? params.messageId
3133
+ ?? params.message_id
3134
+ ?? toolContext?.currentMessageId)[0];
3135
+ if (!replyToMessageId) {
3136
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Bitrix24 reply requires a valid target messageId. Do not retry.' });
3137
+ }
3138
+ const text = readActionTextParam(params);
3139
+ const normalizedText = typeof text === 'string' ? text.trim() : '';
3140
+ const keyboard = parseActionKeyboard(params.buttons);
3141
+ const rawAttach = params.attach ?? params.attachments;
3142
+ const attach = parseActionAttach(rawAttach);
3143
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
3144
+ return toolResult({ ok: false, reason: 'invalid_attach', hint: 'Bitrix24 attach must use ATTACH rich blocks. Do not pass uploaded files here; use mediaUrl/mediaUrls for file uploads.' });
3145
+ }
3146
+ if (!normalizedText && !keyboard && !attach) {
3147
+ return toolResult({ ok: false, reason: 'missing_payload', hint: 'Provide reply text, buttons or rich attach blocks for Bitrix24 reply.' });
3148
+ }
3149
+ try {
3150
+ if (attach) {
3151
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: normalizedText, keyboard, attach }), {
3152
+ ...(keyboard ? { keyboard } : {}),
3153
+ attach,
3154
+ ...(canUseNativeReply ? { replyToMessageId } : {}),
3155
+ });
3156
+ markSuccessfulAction(toolContext?.currentMessageId);
3157
+ return toolResult({
3158
+ ok: true,
3159
+ to,
3160
+ ...(canUseNativeReply ? {
3161
+ replied: true,
3162
+ replyToMessageId,
3163
+ } : {}),
3164
+ messageId: String(messageId ?? ''),
3165
+ });
3166
+ }
3167
+ const sendOptions = {
3168
+ ...(keyboard ? { keyboard } : {}),
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);
3173
+ return toolResult({
3174
+ ok: true,
3175
+ to,
3176
+ ...(canUseNativeReply ? {
3177
+ replied: true,
3178
+ replyToMessageId,
3179
+ } : {}),
3180
+ messageId: String(result.messageId ?? ''),
3181
+ });
3182
+ }
3183
+ catch (err) {
3184
+ const errMsg = err instanceof Error ? err.message : String(err);
3185
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to send Bitrix24 reply: ${errMsg}. Do not retry.` });
3186
+ }
3187
+ }
3188
+ // Note: "forward" action is not supported by OpenClaw runtime (not in
3189
+ // MESSAGE_ACTION_TARGET_MODE), so the runtime blocks target before it
3190
+ // reaches handleAction. All forwarding goes through action "send" with
3191
+ // forwardMessageIds parameter instead.
3192
+ if (ctx.action === 'edit') {
3193
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
3194
+ if (!config.webhookUrl || !gatewayState) {
3195
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
3196
+ }
3197
+ const bot = gatewayState.bot;
3198
+ const api = gatewayState.api;
3199
+ const params = ctx.params;
3200
+ const toolContext = ctx.toolContext;
3201
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
3202
+ const messageId = Number(rawMessageId);
3203
+ if (!Number.isFinite(messageId) || messageId <= 0) {
3204
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 edits. Do not retry.' });
3205
+ }
3206
+ let keyboard;
3207
+ if (params.buttons === 'N') {
3208
+ keyboard = 'N';
3209
+ }
3210
+ else if (params.buttons != null) {
3211
+ try {
3212
+ const parsed = typeof params.buttons === 'string'
3213
+ ? JSON.parse(params.buttons)
3214
+ : params.buttons;
3215
+ if (Array.isArray(parsed)) {
3216
+ keyboard = convertButtonsToKeyboard(parsed);
3217
+ }
3218
+ }
3219
+ catch {
3220
+ // Ignore invalid buttons payload and update text only.
3221
+ }
3222
+ }
3223
+ const message = readActionTextParam(params);
3224
+ const rawAttach = params.attach ?? params.attachments;
3225
+ const attach = parseActionAttach(rawAttach);
3226
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
3227
+ return toolResult({ ok: false, reason: 'invalid_attach', hint: 'Bitrix24 attach must use ATTACH rich blocks. Do not pass uploaded files here; use mediaUrl/mediaUrls for file uploads.' });
3228
+ }
3229
+ if (message == null && !keyboard && !attach) {
3230
+ return toolResult({ ok: false, reason: 'missing_payload', hint: 'Provide message text, buttons or rich attach blocks for Bitrix24 edits.' });
3231
+ }
3232
+ try {
3233
+ await api.updateMessage(config.webhookUrl, bot, messageId, message, {
3234
+ ...(keyboard ? { keyboard } : {}),
3235
+ ...(attach ? { attach } : {}),
3236
+ });
3237
+ markSuccessfulAction(toolContext?.currentMessageId);
3238
+ return toolResult({ ok: true, edited: true, messageId });
3239
+ }
3240
+ catch (err) {
3241
+ const errMsg = err instanceof Error ? err.message : String(err);
3242
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to edit message: ${errMsg}. Do not retry.` });
3243
+ }
3244
+ }
3245
+ if (ctx.action === 'delete') {
3246
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
3247
+ if (!config.webhookUrl || !gatewayState) {
3248
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
3249
+ }
3250
+ const bot = gatewayState.bot;
3251
+ const api = gatewayState.api;
3252
+ const params = ctx.params;
3253
+ const toolContext = ctx.toolContext;
3254
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
3255
+ const messageId = Number(rawMessageId);
3256
+ if (!Number.isFinite(messageId) || messageId <= 0) {
3257
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 deletes. Do not retry.' });
3258
+ }
3259
+ const complete = params.complete == null
3260
+ ? undefined
3261
+ : (params.complete === true
3262
+ || params.complete === 'true'
3263
+ || params.complete === 'Y');
3264
+ try {
3265
+ await api.deleteMessage(config.webhookUrl, bot, messageId, complete);
3266
+ markSuccessfulAction(toolContext?.currentMessageId);
3267
+ return toolResult({ ok: true, deleted: true, messageId });
3268
+ }
3269
+ catch (err) {
3270
+ const errMsg = err instanceof Error ? err.message : String(err);
3271
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to delete message: ${errMsg}. Do not retry.` });
3272
+ }
3273
+ }
1372
3274
  // ─── React ────────────────────────────────────────────────────────────
1373
3275
  if (ctx.action !== 'react')
1374
3276
  return null;
@@ -1383,6 +3285,10 @@ export const bitrix24Plugin = {
1383
3285
  const toolContext = ctx.toolContext;
1384
3286
  const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
1385
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);
1386
3292
  if (!Number.isFinite(messageId) || messageId <= 0) {
1387
3293
  return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
1388
3294
  }
@@ -1396,6 +3302,7 @@ export const bitrix24Plugin = {
1396
3302
  }
1397
3303
  try {
1398
3304
  await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
3305
+ markSuccessfulAction(toolContext?.currentMessageId);
1399
3306
  return toolResult({ ok: true, removed: true });
1400
3307
  }
1401
3308
  catch (err) {
@@ -1418,12 +3325,28 @@ export const bitrix24Plugin = {
1418
3325
  }
1419
3326
  try {
1420
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
+ });
3334
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
3335
+ markSuccessfulAction(toolContext?.currentMessageId);
1421
3336
  return toolResult({ ok: true, added: emoji });
1422
3337
  }
1423
3338
  catch (err) {
1424
3339
  const errMsg = err instanceof Error ? err.message : String(err);
1425
3340
  const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
1426
3341
  if (isAlreadySet) {
3342
+ await maybeSendReactionTypingReset({
3343
+ sendService: gatewayState.sendService,
3344
+ webhookUrl: config.webhookUrl,
3345
+ bot,
3346
+ dialogId: reactionAckDialogId,
3347
+ });
3348
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
3349
+ markSuccessfulAction(toolContext?.currentMessageId);
1427
3350
  return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
1428
3351
  }
1429
3352
  return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
@@ -1561,6 +3484,30 @@ export const bitrix24Plugin = {
1561
3484
  }
1562
3485
  const sendService = new SendService(api, logger);
1563
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
+ };
1564
3511
  const hydrateReplyEntry = async (replyToMessageId) => {
1565
3512
  const bitrixMessageId = toMessageId(replyToMessageId);
1566
3513
  if (!bitrixMessageId) {
@@ -1723,9 +3670,27 @@ export const bitrix24Plugin = {
1723
3670
  query: bodyWithReply,
1724
3671
  historyCache,
1725
3672
  });
1726
- const bodyForAgent = crossChatContext
1727
- ? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
1728
- : bodyWithReply;
3673
+ const inlineButtonsAgentHint = buildBitrix24InlineButtonsAgentHint();
3674
+ const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
3675
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ isDm: msgCtx.isDm });
3676
+ const nativeReplyAgentHint = canUseNativeReply
3677
+ ? buildBitrix24NativeReplyAgentHint(msgCtx.messageId)
3678
+ : undefined;
3679
+ const nativeForwardAgentHint = buildBitrix24NativeForwardAgentHint({
3680
+ accountId: ctx.accountId,
3681
+ currentBody: body,
3682
+ currentMessageId: msgCtx.messageId,
3683
+ replyToMessageId: msgCtx.replyToMessageId,
3684
+ historyEntries: previousEntries,
3685
+ });
3686
+ const bodyForAgent = [
3687
+ inlineButtonsAgentHint,
3688
+ fileDeliveryAgentHint,
3689
+ nativeReplyAgentHint,
3690
+ nativeForwardAgentHint,
3691
+ crossChatContext,
3692
+ bodyWithReply,
3693
+ ].filter(Boolean).join('\n\n');
1729
3694
  const combinedBody = msgCtx.isGroup
1730
3695
  ? buildHistoryContext({
1731
3696
  entries: previousEntries,
@@ -1733,6 +3698,7 @@ export const bitrix24Plugin = {
1733
3698
  })
1734
3699
  : bodyForAgent;
1735
3700
  recordHistory(body);
3701
+ rememberRecentInboundMessage(conversation.dialogId, msgCtx.messageId, body, msgCtx.media.map((mediaItem) => mediaItem.name), msgCtx.timestamp ?? Date.now(), ctx.accountId);
1736
3702
  // Resolve which agent handles this conversation
1737
3703
  const route = runtime.channel.routing.resolveAgentRoute({
1738
3704
  cfg,
@@ -1785,15 +3751,56 @@ export const bitrix24Plugin = {
1785
3751
  deliver: async (payload) => {
1786
3752
  await replyStatusHeartbeat.stopAndWait();
1787
3753
  const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
3754
+ const hadRecentMediaDelivery = mediaUrls.length === 0
3755
+ && hasRecentMediaDelivery(sendCtx.dialogId, msgCtx.messageId, ctx.accountId);
1788
3756
  if (mediaUrls.length > 0) {
1789
- await uploadOutboundMedia({
3757
+ const uploadedMessageId = await uploadOutboundMedia({
1790
3758
  mediaService,
1791
3759
  sendCtx,
1792
3760
  mediaUrls,
1793
3761
  });
3762
+ markRecentMediaDelivery({
3763
+ dialogId: sendCtx.dialogId,
3764
+ currentMessageId: msgCtx.messageId,
3765
+ mediaUrls,
3766
+ accountId: ctx.accountId,
3767
+ messageId: uploadedMessageId,
3768
+ });
1794
3769
  }
1795
3770
  if (payload.text) {
1796
- let text = 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
+ }
3780
+ if (consumePendingNativeForwardAck(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
3781
+ replyDelivered = true;
3782
+ logger.debug('Suppressing trailing acknowledgement after native Bitrix24 forward', {
3783
+ senderId: msgCtx.senderId,
3784
+ chatId: msgCtx.chatId,
3785
+ messageId: msgCtx.messageId,
3786
+ });
3787
+ return;
3788
+ }
3789
+ const replyDirective = extractReplyDirective({
3790
+ text: payload.text,
3791
+ currentMessageId: msgCtx.messageId,
3792
+ explicitReplyToId: payload.replyToId,
3793
+ });
3794
+ let text = replyDirective.cleanText;
3795
+ if (consumePendingNativeReactionAck(msgCtx.messageId, text, ctx.accountId)) {
3796
+ replyDelivered = true;
3797
+ logger.debug('Suppressing trailing acknowledgement after native Bitrix24 reaction', {
3798
+ senderId: msgCtx.senderId,
3799
+ chatId: msgCtx.chatId,
3800
+ messageId: msgCtx.messageId,
3801
+ });
3802
+ return;
3803
+ }
1797
3804
  let keyboard = extractKeyboardFromPayload(payload);
1798
3805
  // Fallback: agent may embed button JSON in text as [[{...}]]
1799
3806
  if (!keyboard) {
@@ -1803,8 +3810,52 @@ export const bitrix24Plugin = {
1803
3810
  keyboard = extracted.keyboard;
1804
3811
  }
1805
3812
  }
3813
+ const nativeForwardMessageId = findNativeForwardTarget({
3814
+ accountId: ctx.accountId,
3815
+ requestText: body,
3816
+ deliveredText: text,
3817
+ historyEntries: previousEntries,
3818
+ currentMessageId: msgCtx.messageId,
3819
+ }) ?? findImplicitForwardTargetFromRecentInbound({
3820
+ accountId: ctx.accountId,
3821
+ requestText: body,
3822
+ deliveredText: text,
3823
+ currentMessageId: msgCtx.messageId,
3824
+ historyEntries: previousEntries,
3825
+ });
3826
+ const nativeForwardTargets = nativeForwardMessageId
3827
+ ? extractBitrix24DialogTargetsFromText(body)
3828
+ : [];
3829
+ const nativeReplyToMessageId = nativeForwardMessageId
3830
+ ? undefined
3831
+ : (canUseNativeReply ? replyDirective.replyToMessageId : undefined);
3832
+ const sendOptions = {
3833
+ ...(keyboard ? { keyboard } : {}),
3834
+ ...(nativeReplyToMessageId ? { replyToMessageId: nativeReplyToMessageId } : {}),
3835
+ ...(nativeForwardMessageId ? { forwardMessages: [nativeForwardMessageId] } : {}),
3836
+ };
1806
3837
  replyDelivered = true;
1807
- await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
3838
+ if (nativeForwardMessageId && nativeForwardTargets.length > 0) {
3839
+ const forwardResults = await Promise.allSettled(nativeForwardTargets.map((dialogId) => sendService.sendText({ ...sendCtx, dialogId }, '', { forwardMessages: [nativeForwardMessageId] })));
3840
+ const firstFailure = forwardResults.find((result) => result.status === 'rejected');
3841
+ const hasSuccess = forwardResults.some((result) => result.status === 'fulfilled');
3842
+ if (firstFailure && !hasSuccess) {
3843
+ throw firstFailure.reason;
3844
+ }
3845
+ if (firstFailure) {
3846
+ logger.warn('Failed to deliver Bitrix24 native forward to one or more explicit targets', {
3847
+ senderId: msgCtx.senderId,
3848
+ chatId: msgCtx.chatId,
3849
+ messageId: msgCtx.messageId,
3850
+ targets: nativeForwardTargets,
3851
+ error: firstFailure.reason instanceof Error
3852
+ ? firstFailure.reason.message
3853
+ : String(firstFailure.reason),
3854
+ });
3855
+ }
3856
+ return;
3857
+ }
3858
+ await sendService.sendText(sendCtx, nativeForwardMessageId ? '' : text, Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
1808
3859
  }
1809
3860
  },
1810
3861
  onReplyStart: async () => {
@@ -1815,18 +3866,55 @@ export const bitrix24Plugin = {
1815
3866
  },
1816
3867
  },
1817
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);
1818
3887
  if (!replyDelivered && dispatchResult?.queuedFinal === false) {
1819
3888
  logger.debug('Reply completed without queued final block, waiting before fallback', {
1820
3889
  senderId: msgCtx.senderId,
1821
3890
  chatId: msgCtx.chatId,
1822
3891
  counts: dispatchResult.counts,
3892
+ fallbackDelayMs: shouldWaitForEmptyReplyFallback
3893
+ ? EMPTY_REPLY_FALLBACK_GRACE_MS
3894
+ : REPLY_FALLBACK_GRACE_MS,
3895
+ });
3896
+ if (shouldWaitForEmptyReplyFallback) {
3897
+ await waitEmptyReplyFallbackGraceWindow();
3898
+ }
3899
+ else {
3900
+ await waitReplyFallbackGraceWindow();
3901
+ }
3902
+ }
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,
1823
3910
  });
1824
- await waitReplyFallbackGraceWindow();
3911
+ await sendService.sendText(sendCtx, emptyReplyFallback(msgCtx.language));
1825
3912
  }
1826
- if (!replyDelivered && dispatchResult?.queuedFinal === false) {
3913
+ else if (!replyDelivered && dispatchResult?.queuedFinal === false) {
1827
3914
  logger.warn('Reply completed without a final user-visible message; fallback notice suppressed', {
1828
3915
  senderId: msgCtx.senderId,
1829
3916
  chatId: msgCtx.chatId,
3917
+ messageId: msgCtx.messageId,
1830
3918
  counts: dispatchResult.counts,
1831
3919
  });
1832
3920
  }
@@ -1834,6 +3922,7 @@ export const bitrix24Plugin = {
1834
3922
  logger.debug('Late reply arrived during fallback grace window, skipping fallback', {
1835
3923
  senderId: msgCtx.senderId,
1836
3924
  chatId: msgCtx.chatId,
3925
+ messageId: msgCtx.messageId,
1837
3926
  });
1838
3927
  }
1839
3928
  }
@@ -1852,7 +3941,7 @@ export const bitrix24Plugin = {
1852
3941
  }
1853
3942
  }
1854
3943
  finally {
1855
- await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
3944
+ mediaService.scheduleDownloadedMediaCleanup(downloadedMedia.map((mediaItem) => mediaItem.path));
1856
3945
  }
1857
3946
  };
1858
3947
  const directTextCoalescer = new BufferedDirectMessageCoalescer({
@@ -2005,6 +4094,15 @@ export const bitrix24Plugin = {
2005
4094
  messageId: msgCtx.messageId,
2006
4095
  textLen: msgCtx.text.length,
2007
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
+ }
2008
4106
  const pendingForwardContext = msgCtx.isForwarded
2009
4107
  ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
2010
4108
  : null;
@@ -2029,7 +4127,7 @@ export const bitrix24Plugin = {
2029
4127
  chatId: msgCtx.chatInternalId,
2030
4128
  })
2031
4129
  : null;
2032
- const agentWatchRules = config.agentMode && msgCtx.eventScope === 'user'
4130
+ const agentWatchRules = config.agentMode
2033
4131
  ? resolveAgentWatchRules({
2034
4132
  config,
2035
4133
  dialogId: msgCtx.chatId,
@@ -2039,37 +4137,61 @@ export const bitrix24Plugin = {
2039
4137
  const watchRule = msgCtx.isGroup && groupAccess?.groupAllowed
2040
4138
  ? findMatchingWatchRule(msgCtx, groupAccess?.watch)
2041
4139
  : undefined;
2042
- 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
2043
4147
  && webhookOwnerId
2044
- && 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)
2045
4154
  ? undefined
2046
4155
  : watchRule;
2047
- const agentWatchRule = msgCtx.eventScope === 'user'
2048
- ? findMatchingWatchRule(msgCtx, agentWatchRules)
4156
+ const agentWatchRule = findMatchingWatchRule(msgCtx, agentWatchRules);
4157
+ const activeAgentWatchRule = agentWatchRule?.mode === 'notifyOwnerDm'
4158
+ && !isOwnerBotUserDmMirror
4159
+ && !isOwnerAuthoredMessage
4160
+ && !isCurrentBotAuthoredMessage
4161
+ ? agentWatchRule
2049
4162
  : undefined;
2050
4163
  /** Shorthand: record message in RAM history for this dialog. */
2051
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
+ }
2052
4176
  if (msgCtx.eventScope === 'user') {
2053
- const isBotDialogUserEvent = msgCtx.isDm && msgCtx.chatId === String(bot.botId);
2054
- const isBotAuthoredUserEvent = msgCtx.senderId === String(bot.botId);
2055
- if (isBotDialogUserEvent || isBotAuthoredUserEvent) {
2056
- logger.debug('Skipping agent-mode user event for bot-owned conversation', {
2057
- senderId: msgCtx.senderId,
2058
- chatId: msgCtx.chatId,
2059
- messageId: msgCtx.messageId,
2060
- isBotDialogUserEvent,
2061
- isBotAuthoredUserEvent,
2062
- });
2063
- return;
2064
- }
2065
4177
  recordHistory();
2066
- if (webhookOwnerId && msgCtx.senderId !== webhookOwnerId && agentWatchRule?.mode === 'notifyOwnerDm') {
2067
- await notifyWebhookOwnerAboutWatchMatch(msgCtx, agentWatchRule);
2068
- logger.debug('User-event watch matched and notified webhook owner in DM', {
2069
- senderId: msgCtx.senderId,
2070
- chatId: msgCtx.chatId,
2071
- messageId: msgCtx.messageId,
2072
- });
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
+ }
2073
4195
  }
2074
4196
  return;
2075
4197
  }
@@ -2090,6 +4212,7 @@ export const bitrix24Plugin = {
2090
4212
  await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
2091
4213
  if (msgCtx.isGroup
2092
4214
  && !activeWatchRule
4215
+ && !activeAgentWatchRule
2093
4216
  && groupAccess?.requireMention
2094
4217
  && !msgCtx.wasMentioned) {
2095
4218
  recordHistory();
@@ -2102,12 +4225,42 @@ export const bitrix24Plugin = {
2102
4225
  }
2103
4226
  if (activeWatchRule?.mode === 'notifyOwnerDm') {
2104
4227
  recordHistory();
2105
- await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule);
2106
- logger.debug('Group watch matched and notified webhook owner in DM', {
2107
- senderId: msgCtx.senderId,
2108
- chatId: msgCtx.chatId,
2109
- messageId: msgCtx.messageId,
2110
- });
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
+ }
2111
4264
  return;
2112
4265
  }
2113
4266
  const accessResult = activeWatchRule
@@ -2339,10 +4492,22 @@ export const bitrix24Plugin = {
2339
4492
  accountId: ctx.accountId,
2340
4493
  peer: conversation.peer,
2341
4494
  });
4495
+ const inlineButtonsAgentHint = buildBitrix24InlineButtonsAgentHint();
4496
+ const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
4497
+ const canUseNativeReply = shouldUseBitrix24NativeReply({ isDm });
4498
+ const nativeReplyAgentHint = canUseNativeReply
4499
+ ? buildBitrix24NativeReplyAgentHint(commandMessageId)
4500
+ : undefined;
4501
+ const commandBodyForAgent = [
4502
+ inlineButtonsAgentHint,
4503
+ fileDeliveryAgentHint,
4504
+ nativeReplyAgentHint,
4505
+ commandText,
4506
+ ].filter(Boolean).join('\n\n');
2342
4507
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
2343
4508
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
2344
4509
  Body: commandText,
2345
- BodyForAgent: commandText,
4510
+ BodyForAgent: commandBodyForAgent,
2346
4511
  RawBody: commandText,
2347
4512
  CommandBody: commandText,
2348
4513
  CommandAuthorized: true,
@@ -2380,20 +4545,31 @@ export const bitrix24Plugin = {
2380
4545
  deliver: async (payload) => {
2381
4546
  await replyStatusHeartbeat.stopAndWait();
2382
4547
  if (payload.text) {
4548
+ const replyDirective = extractReplyDirective({
4549
+ text: payload.text,
4550
+ currentMessageId: commandMessageId,
4551
+ explicitReplyToId: payload.replyToId,
4552
+ });
2383
4553
  const keyboard = extractKeyboardFromPayload(payload)
2384
4554
  ?? defaultSessionKeyboard;
2385
4555
  const formattedPayload = normalizeCommandReplyPayload({
2386
4556
  commandName,
2387
4557
  commandParams,
2388
- text: payload.text,
4558
+ text: replyDirective.cleanText,
2389
4559
  language: cmdCtx.language,
2390
4560
  });
4561
+ const sendOptions = {
4562
+ keyboard,
4563
+ convertMarkdown: formattedPayload.convertMarkdown,
4564
+ ...(canUseNativeReply && replyDirective.replyToMessageId
4565
+ ? { replyToMessageId: replyDirective.replyToMessageId }
4566
+ : {}),
4567
+ };
2391
4568
  if (!commandReplyDelivered) {
2392
- if (isDm) {
4569
+ if (isDm || (canUseNativeReply && replyDirective.replyToMessageId)) {
2393
4570
  commandReplyDelivered = true;
2394
4571
  await sendService.sendText(sendCtx, formattedPayload.text, {
2395
- keyboard,
2396
- convertMarkdown: formattedPayload.convertMarkdown,
4572
+ ...sendOptions,
2397
4573
  });
2398
4574
  }
2399
4575
  else {
@@ -2406,10 +4582,7 @@ export const bitrix24Plugin = {
2406
4582
  return;
2407
4583
  }
2408
4584
  commandReplyDelivered = true;
2409
- await sendService.sendText(sendCtx, formattedPayload.text, {
2410
- keyboard,
2411
- convertMarkdown: formattedPayload.convertMarkdown,
2412
- });
4585
+ await sendService.sendText(sendCtx, formattedPayload.text, sendOptions);
2413
4586
  }
2414
4587
  },
2415
4588
  onReplyStart: async () => {
@@ -2420,20 +4593,68 @@ export const bitrix24Plugin = {
2420
4593
  },
2421
4594
  },
2422
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);
2423
4616
  if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
2424
4617
  logger.debug('Command reply completed without queued final block, waiting before fallback', {
2425
4618
  commandName,
2426
4619
  senderId,
2427
4620
  dialogId,
2428
4621
  counts: dispatchResult.counts,
4622
+ fallbackDelayMs: shouldWaitForEmptyCommandReplyFallback
4623
+ ? EMPTY_REPLY_FALLBACK_GRACE_MS
4624
+ : REPLY_FALLBACK_GRACE_MS,
2429
4625
  });
2430
- await waitReplyFallbackGraceWindow();
4626
+ if (shouldWaitForEmptyCommandReplyFallback) {
4627
+ await waitEmptyReplyFallbackGraceWindow();
4628
+ }
4629
+ else {
4630
+ await waitReplyFallbackGraceWindow();
4631
+ }
2431
4632
  }
2432
- 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) {
2433
4653
  logger.warn('Command reply completed without a final user-visible message; fallback notice suppressed', {
2434
4654
  commandName,
2435
4655
  senderId,
2436
4656
  dialogId,
4657
+ messageId: commandMessageId,
2437
4658
  counts: dispatchResult.counts,
2438
4659
  });
2439
4660
  }
@@ -2442,6 +4663,7 @@ export const bitrix24Plugin = {
2442
4663
  commandName,
2443
4664
  senderId,
2444
4665
  dialogId,
4666
+ messageId: commandMessageId,
2445
4667
  });
2446
4668
  }
2447
4669
  }