@ihazz/bitrix24 1.1.11 → 1.1.13

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 (46) 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 +18 -1
  7. package/dist/src/channel.d.ts.map +1 -1
  8. package/dist/src/channel.js +1274 -71
  9. package/dist/src/channel.js.map +1 -1
  10. package/dist/src/i18n.d.ts +0 -1
  11. package/dist/src/i18n.d.ts.map +1 -1
  12. package/dist/src/i18n.js +68 -79
  13. package/dist/src/i18n.js.map +1 -1
  14. package/dist/src/inbound-handler.js +85 -7
  15. package/dist/src/inbound-handler.js.map +1 -1
  16. package/dist/src/media-service.d.ts +2 -0
  17. package/dist/src/media-service.d.ts.map +1 -1
  18. package/dist/src/media-service.js +117 -14
  19. package/dist/src/media-service.js.map +1 -1
  20. package/dist/src/message-utils.d.ts.map +1 -1
  21. package/dist/src/message-utils.js +73 -3
  22. package/dist/src/message-utils.js.map +1 -1
  23. package/dist/src/runtime.d.ts +1 -0
  24. package/dist/src/runtime.d.ts.map +1 -1
  25. package/dist/src/runtime.js.map +1 -1
  26. package/dist/src/send-service.d.ts +1 -0
  27. package/dist/src/send-service.d.ts.map +1 -1
  28. package/dist/src/send-service.js +26 -3
  29. package/dist/src/send-service.js.map +1 -1
  30. package/dist/src/state-paths.d.ts +1 -0
  31. package/dist/src/state-paths.d.ts.map +1 -1
  32. package/dist/src/state-paths.js +9 -0
  33. package/dist/src/state-paths.js.map +1 -1
  34. package/dist/src/types.d.ts +92 -0
  35. package/dist/src/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/api.ts +62 -13
  38. package/src/channel.ts +1739 -96
  39. package/src/i18n.ts +68 -81
  40. package/src/inbound-handler.ts +110 -7
  41. package/src/media-service.ts +146 -15
  42. package/src/message-utils.ts +90 -3
  43. package/src/runtime.ts +1 -0
  44. package/src/send-service.ts +40 -2
  45. package/src/state-paths.ts +11 -0
  46. package/src/types.ts +122 -0
package/src/channel.ts CHANGED
@@ -48,13 +48,15 @@ import {
48
48
  normalizeNewSessionReply,
49
49
  ownerAndAllowedUsersOnly,
50
50
  personalBotOwnerOnly,
51
- replyGenerationFailed,
52
51
  welcomeKeyboardLabels,
53
52
  watchOwnerDmNotice,
54
53
  } from './i18n.js';
55
54
  import { HistoryCache } from './history-cache.js';
56
- import type { ConversationMeta } from './history-cache.js';
55
+ import type { ConversationMeta, HistoryEntry } from './history-cache.js';
57
56
  import type {
57
+ B24Attach,
58
+ B24AttachBlock,
59
+ B24AttachColorToken,
58
60
  B24MsgContext,
59
61
  B24InputActionStatusCode,
60
62
  B24V2FetchEventItem,
@@ -69,9 +71,12 @@ import type {
69
71
  B24V2GetMessageResult,
70
72
  B24V2User,
71
73
  B24Keyboard,
74
+ ChannelMessageToolDiscovery,
75
+ ExtractedToolSendTarget,
72
76
  KeyboardButton,
73
77
  Logger,
74
78
  } from './types.js';
79
+ import { markdownToBbCode } from './message-utils.js';
75
80
 
76
81
  const PHASE_STATUS_DURATION_SECONDS = 8;
77
82
  const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
@@ -94,7 +99,24 @@ const FORWARDED_CONTEXT_RANGE = 5;
94
99
  const ACTIVE_SESSION_NAMESPACE_TTL_MS = 180 * 24 * 60 * 60 * 1000;
95
100
  const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
96
101
  const ACTIVE_SESSION_NAMESPACE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
102
+ const NATIVE_FORWARD_ACK_TTL_MS = 60 * 1000;
103
+ const NATIVE_REACTION_ACK_TTL_MS = 60 * 1000;
104
+ const RECENT_INBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
105
+ const RECENT_INBOUND_MESSAGE_LIMIT = 20;
97
106
  const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
107
+ const pendingNativeForwardAcks = new Map<string, number>();
108
+ const pendingNativeReactionAcks = new Map<string, { emoji: string; expiresAt: number }>();
109
+ const recentInboundMessagesByDialog = new Map<string, Array<{
110
+ messageId: string;
111
+ body: string;
112
+ timestamp: number;
113
+ }>>();
114
+ const inboundMessageContextById = new Map<string, {
115
+ dialogId: string;
116
+ dialogKey: string;
117
+ body: string;
118
+ timestamp: number;
119
+ }>();
98
120
 
99
121
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
100
122
  // B24 uses named reaction codes, not Unicode emoji.
@@ -170,6 +192,606 @@ function toMessageId(value: string | number | undefined): number | undefined {
170
192
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
171
193
  }
172
194
 
195
+ function resolveStateAccountId(accountId?: string): string {
196
+ const normalizedAccountId = typeof accountId === 'string'
197
+ ? accountId.trim()
198
+ : '';
199
+ if (normalizedAccountId) {
200
+ return normalizedAccountId;
201
+ }
202
+
203
+ const gatewayAccountId = gatewayState?.accountId?.trim();
204
+ return gatewayAccountId || 'default';
205
+ }
206
+
207
+ function buildAccountDialogScopeKey(accountId: string | undefined, dialogId: string): string | undefined {
208
+ const normalizedDialogId = dialogId.trim();
209
+ if (!normalizedDialogId) {
210
+ return undefined;
211
+ }
212
+
213
+ return `${resolveStateAccountId(accountId)}:${normalizedDialogId}`;
214
+ }
215
+
216
+ function buildAccountMessageScopeKey(
217
+ accountId: string | undefined,
218
+ currentMessageId: string | number | undefined,
219
+ ): string | undefined {
220
+ const normalizedMessageId = toMessageId(currentMessageId);
221
+ if (!normalizedMessageId) {
222
+ return undefined;
223
+ }
224
+
225
+ return `${resolveStateAccountId(accountId)}:${normalizedMessageId}`;
226
+ }
227
+
228
+ function buildPendingNativeForwardAckKey(
229
+ dialogId: string,
230
+ currentMessageId: string | number | undefined,
231
+ accountId?: string,
232
+ ): string | undefined {
233
+ const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
234
+ const normalizedMessageId = toMessageId(currentMessageId);
235
+ if (!dialogKey || !normalizedMessageId) {
236
+ return undefined;
237
+ }
238
+
239
+ return `${dialogKey}:${normalizedMessageId}`;
240
+ }
241
+
242
+ function prunePendingNativeForwardAcks(now = Date.now()): void {
243
+ for (const [key, expiresAt] of pendingNativeForwardAcks.entries()) {
244
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
245
+ pendingNativeForwardAcks.delete(key);
246
+ }
247
+ }
248
+ }
249
+
250
+ function markPendingNativeForwardAck(
251
+ dialogId: string,
252
+ currentMessageId: string | number | undefined,
253
+ accountId?: string,
254
+ ): void {
255
+ const key = buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
256
+ if (!key) {
257
+ return;
258
+ }
259
+
260
+ prunePendingNativeForwardAcks();
261
+ pendingNativeForwardAcks.set(key, Date.now() + NATIVE_FORWARD_ACK_TTL_MS);
262
+ }
263
+
264
+ function consumePendingNativeForwardAck(
265
+ dialogId: string,
266
+ currentMessageId: string | number | undefined,
267
+ accountId?: string,
268
+ ): boolean {
269
+ const key = buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
270
+ if (!key) {
271
+ return false;
272
+ }
273
+
274
+ prunePendingNativeForwardAcks();
275
+ const expiresAt = pendingNativeForwardAcks.get(key);
276
+ if (!expiresAt) {
277
+ return false;
278
+ }
279
+
280
+ pendingNativeForwardAcks.delete(key);
281
+ return true;
282
+ }
283
+
284
+ function resolvePendingNativeForwardAckDialogId(
285
+ currentMessageId: string | number | undefined,
286
+ fallbackDialogId: string,
287
+ accountId?: string,
288
+ ): string {
289
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
290
+ if (!messageKey) {
291
+ return fallbackDialogId;
292
+ }
293
+
294
+ return inboundMessageContextById.get(messageKey)?.dialogId ?? fallbackDialogId;
295
+ }
296
+
297
+ function buildPendingNativeReactionAckKey(
298
+ currentMessageId: string | number | undefined,
299
+ accountId?: string,
300
+ ): string | undefined {
301
+ return buildAccountMessageScopeKey(accountId, currentMessageId);
302
+ }
303
+
304
+ function prunePendingNativeReactionAcks(now = Date.now()): void {
305
+ for (const [key, entry] of pendingNativeReactionAcks.entries()) {
306
+ if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
307
+ pendingNativeReactionAcks.delete(key);
308
+ }
309
+ }
310
+ }
311
+
312
+ function markPendingNativeReactionAck(
313
+ currentMessageId: string | number | undefined,
314
+ emoji: string,
315
+ accountId?: string,
316
+ ): void {
317
+ const key = buildPendingNativeReactionAckKey(currentMessageId, accountId);
318
+ const normalizedEmoji = emoji.trim();
319
+ if (!key || !normalizedEmoji) {
320
+ return;
321
+ }
322
+
323
+ prunePendingNativeReactionAcks();
324
+ pendingNativeReactionAcks.set(key, {
325
+ emoji: normalizedEmoji,
326
+ expiresAt: Date.now() + NATIVE_REACTION_ACK_TTL_MS,
327
+ });
328
+ }
329
+
330
+ function consumePendingNativeReactionAck(
331
+ currentMessageId: string | number | undefined,
332
+ text: string,
333
+ accountId?: string,
334
+ ): boolean {
335
+ const key = buildPendingNativeReactionAckKey(currentMessageId, accountId);
336
+ if (!key) {
337
+ return false;
338
+ }
339
+
340
+ prunePendingNativeReactionAcks();
341
+ const pendingAck = pendingNativeReactionAcks.get(key);
342
+ if (!pendingAck) {
343
+ return false;
344
+ }
345
+
346
+ if (normalizeComparableMessageText(text) !== pendingAck.emoji) {
347
+ return false;
348
+ }
349
+
350
+ pendingNativeReactionAcks.delete(key);
351
+ return true;
352
+ }
353
+
354
+ function pruneRecentInboundMessages(now = Date.now()): void {
355
+ for (const [dialogKey, entries] of recentInboundMessagesByDialog.entries()) {
356
+ const nextEntries = entries.filter((entry) => Number.isFinite(entry.timestamp) && entry.timestamp > now - RECENT_INBOUND_MESSAGE_TTL_MS);
357
+ if (nextEntries.length === 0) {
358
+ recentInboundMessagesByDialog.delete(dialogKey);
359
+ } else if (nextEntries.length !== entries.length) {
360
+ recentInboundMessagesByDialog.set(dialogKey, nextEntries);
361
+ }
362
+ }
363
+
364
+ for (const [messageId, entry] of inboundMessageContextById.entries()) {
365
+ if (!Number.isFinite(entry.timestamp) || entry.timestamp <= now - RECENT_INBOUND_MESSAGE_TTL_MS) {
366
+ inboundMessageContextById.delete(messageId);
367
+ }
368
+ }
369
+ }
370
+
371
+ function rememberRecentInboundMessage(
372
+ dialogId: string,
373
+ messageId: string | number | undefined,
374
+ body: string,
375
+ timestamp = Date.now(),
376
+ accountId?: string,
377
+ ): void {
378
+ const normalizedMessageId = toMessageId(messageId);
379
+ const normalizedBody = body.trim();
380
+ const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
381
+ const messageKey = buildAccountMessageScopeKey(accountId, normalizedMessageId);
382
+ if (!dialogKey || !messageKey || !normalizedBody) {
383
+ return;
384
+ }
385
+
386
+ pruneRecentInboundMessages(timestamp);
387
+
388
+ const id = String(normalizedMessageId);
389
+ const currentEntries = recentInboundMessagesByDialog.get(dialogKey) ?? [];
390
+ const nextEntries = currentEntries
391
+ .filter((entry) => entry.messageId !== id)
392
+ .concat({ messageId: id, body: normalizedBody, timestamp })
393
+ .slice(-RECENT_INBOUND_MESSAGE_LIMIT);
394
+
395
+ recentInboundMessagesByDialog.set(dialogKey, nextEntries);
396
+ inboundMessageContextById.set(messageKey, {
397
+ dialogId,
398
+ dialogKey,
399
+ body: normalizedBody,
400
+ timestamp,
401
+ });
402
+ }
403
+
404
+ function findPreviousRecentInboundMessage(
405
+ currentMessageId: string | number | undefined,
406
+ accountId?: string,
407
+ ): { messageId: string; body: string } | undefined {
408
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
409
+ const normalizedMessageId = toMessageId(currentMessageId);
410
+ if (!messageKey || !normalizedMessageId) {
411
+ return undefined;
412
+ }
413
+
414
+ pruneRecentInboundMessages();
415
+
416
+ const currentEntry = inboundMessageContextById.get(messageKey);
417
+ if (!currentEntry) {
418
+ return undefined;
419
+ }
420
+
421
+ const dialogEntries = recentInboundMessagesByDialog.get(currentEntry.dialogKey);
422
+ if (!dialogEntries || dialogEntries.length === 0) {
423
+ return undefined;
424
+ }
425
+
426
+ const currentIndex = dialogEntries.findIndex((entry) => entry.messageId === String(normalizedMessageId));
427
+ if (currentIndex <= 0) {
428
+ return undefined;
429
+ }
430
+
431
+ for (let index = currentIndex - 1; index >= 0; index -= 1) {
432
+ const entry = dialogEntries[index];
433
+ if (entry.body.trim()) {
434
+ return entry;
435
+ }
436
+ }
437
+
438
+ return undefined;
439
+ }
440
+
441
+ const INLINE_REPLY_DIRECTIVE_RE = /\[\[\s*(reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
442
+
443
+ function normalizeComparableMessageText(value: string): string {
444
+ return value
445
+ .replace(/\s+/g, ' ')
446
+ .trim();
447
+ }
448
+
449
+ function normalizeBitrix24DialogTarget(raw: string): string {
450
+ const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
451
+ if (!stripped) {
452
+ return '';
453
+ }
454
+
455
+ const bracketedUserMatch = stripped.match(/^\[USER=(\d+)(?:[^\]]*)\][\s\S]*?\[\/USER\]$/iu);
456
+ if (bracketedUserMatch?.[1]) {
457
+ return bracketedUserMatch[1];
458
+ }
459
+
460
+ const bracketedChatMatch = stripped.match(/^\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]$/iu);
461
+ if (bracketedChatMatch?.[1]) {
462
+ return `chat${bracketedChatMatch[1]}`;
463
+ }
464
+
465
+ const plainUserMatch = stripped.match(/^USER=(\d+)$/iu);
466
+ if (plainUserMatch?.[1]) {
467
+ return plainUserMatch[1];
468
+ }
469
+
470
+ const plainChatMatch = stripped.match(/^CHAT=(?:chat)?(\d+)$/iu);
471
+ if (plainChatMatch?.[1]) {
472
+ return `chat${plainChatMatch[1]}`;
473
+ }
474
+
475
+ const prefixedChatMatch = stripped.match(/^chat(\d+)$/iu);
476
+ if (prefixedChatMatch?.[1]) {
477
+ return `chat${prefixedChatMatch[1]}`;
478
+ }
479
+
480
+ return stripped;
481
+ }
482
+
483
+ function looksLikeBitrix24DialogTarget(raw: string): boolean {
484
+ const normalized = normalizeBitrix24DialogTarget(raw);
485
+ return /^\d+$/.test(normalized) || /^chat\d+$/iu.test(normalized);
486
+ }
487
+
488
+ function extractBitrix24DialogTargetsFromText(text: string): string[] {
489
+ const targets = new Set<string>();
490
+
491
+ for (const match of text.matchAll(/\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]/giu)) {
492
+ if (match[1]) {
493
+ targets.add(`chat${match[1]}`);
494
+ }
495
+ }
496
+
497
+ for (const match of text.matchAll(/\[USER=(\d+)(?:[^\]]*)\][\s\S]*?\[\/USER\]/giu)) {
498
+ if (match[1]) {
499
+ targets.add(match[1]);
500
+ }
501
+ }
502
+
503
+ for (const match of text.matchAll(/\bCHAT=(?:chat)?(\d+)\b/giu)) {
504
+ if (match[1]) {
505
+ targets.add(`chat${match[1]}`);
506
+ }
507
+ }
508
+
509
+ for (const match of text.matchAll(/\bUSER=(\d+)\b/giu)) {
510
+ if (match[1]) {
511
+ targets.add(match[1]);
512
+ }
513
+ }
514
+
515
+ return [...targets];
516
+ }
517
+
518
+ function isLikelyForwardControlMessage(text: string): boolean {
519
+ return extractBitrix24DialogTargetsFromText(text).length > 0;
520
+ }
521
+
522
+ function extractReplyDirective(params: {
523
+ text: string;
524
+ currentMessageId?: string | number;
525
+ explicitReplyToId?: unknown;
526
+ }): { cleanText: string; replyToMessageId?: number } {
527
+ let wantsCurrentReply = false;
528
+ let inlineReplyToId: string | undefined;
529
+
530
+ const cleanText = cleanupTextAfterInlineMediaExtraction(
531
+ params.text.replace(INLINE_REPLY_DIRECTIVE_RE, (_match: string, _rawDirective: string, explicitId?: string) => {
532
+ if (typeof explicitId === 'string' && explicitId.trim()) {
533
+ inlineReplyToId = explicitId.trim();
534
+ } else {
535
+ wantsCurrentReply = true;
536
+ }
537
+ return ' ';
538
+ }),
539
+ );
540
+
541
+ const explicitReplyToMessageId = parseActionMessageIds(params.explicitReplyToId)[0];
542
+ if (explicitReplyToMessageId) {
543
+ return { cleanText, replyToMessageId: explicitReplyToMessageId };
544
+ }
545
+
546
+ const inlineReplyToMessageId = parseActionMessageIds(inlineReplyToId)[0];
547
+ if (inlineReplyToMessageId) {
548
+ return { cleanText, replyToMessageId: inlineReplyToMessageId };
549
+ }
550
+
551
+ if (wantsCurrentReply) {
552
+ const currentReplyToMessageId = parseActionMessageIds(params.currentMessageId)[0];
553
+ if (currentReplyToMessageId) {
554
+ return { cleanText, replyToMessageId: currentReplyToMessageId };
555
+ }
556
+ }
557
+
558
+ return { cleanText };
559
+ }
560
+
561
+ function findNativeForwardTarget(params: {
562
+ accountId?: string;
563
+ requestText: string;
564
+ deliveredText: string;
565
+ historyEntries: HistoryEntry[];
566
+ currentMessageId?: string | number;
567
+ }): number | undefined {
568
+ const deliveredText = normalizeComparableMessageText(params.deliveredText);
569
+ if (!deliveredText) {
570
+ return undefined;
571
+ }
572
+
573
+ if (deliveredText === normalizeComparableMessageText(params.requestText)) {
574
+ return undefined;
575
+ }
576
+
577
+ const currentMessageId = toMessageId(params.currentMessageId);
578
+
579
+ const matchedEntry = [...params.historyEntries]
580
+ .reverse()
581
+ .find((entry) => {
582
+ if (normalizeComparableMessageText(entry.body) !== deliveredText) {
583
+ return false;
584
+ }
585
+
586
+ return currentMessageId === undefined || toMessageId(entry.messageId) !== currentMessageId;
587
+ });
588
+
589
+ if (!matchedEntry) {
590
+ return undefined;
591
+ }
592
+
593
+ return toMessageId(matchedEntry.messageId);
594
+ }
595
+
596
+ function findImplicitForwardTargetFromRecentInbound(params: {
597
+ accountId?: string;
598
+ requestText: string;
599
+ deliveredText: string;
600
+ currentMessageId?: string | number;
601
+ historyEntries?: HistoryEntry[];
602
+ }): number | undefined {
603
+ const normalizedDeliveredText = normalizeComparableMessageText(params.deliveredText);
604
+ const normalizedRequestText = normalizeComparableMessageText(params.requestText);
605
+ const targetDialogIds = extractBitrix24DialogTargetsFromText(params.requestText);
606
+ const isForwardMarkerOnly = Boolean(normalizedDeliveredText) && FORWARD_MARKER_RE.test(normalizedDeliveredText);
607
+
608
+ if (
609
+ !normalizedDeliveredText
610
+ || (
611
+ !isForwardMarkerOnly
612
+ && (
613
+ targetDialogIds.length === 0
614
+ || normalizedDeliveredText !== normalizedRequestText
615
+ )
616
+ )
617
+ ) {
618
+ return undefined;
619
+ }
620
+
621
+ const previousInboundMessage = findPreviousRecentInboundMessage(
622
+ params.currentMessageId,
623
+ params.accountId,
624
+ );
625
+ if (previousInboundMessage) {
626
+ return Number(previousInboundMessage.messageId);
627
+ }
628
+
629
+ const previousHistoryEntry = [...(params.historyEntries ?? [])]
630
+ .reverse()
631
+ .find((entry) => toMessageId(entry.messageId));
632
+ return toMessageId(previousHistoryEntry?.messageId);
633
+ }
634
+
635
+ function findPreviousForwardableMessageId(params: {
636
+ accountId?: string;
637
+ currentBody: string;
638
+ currentMessageId?: string | number;
639
+ replyToMessageId?: string | number;
640
+ historyEntries?: HistoryEntry[];
641
+ }): number | undefined {
642
+ // If the user replied to a specific message, that's the source to forward
643
+ const replyToId = toMessageId(params.replyToMessageId);
644
+ if (replyToId) {
645
+ return replyToId;
646
+ }
647
+
648
+ const normalizedCurrentBody = normalizeComparableMessageText(params.currentBody);
649
+ const previousHistoryEntry = [...(params.historyEntries ?? [])]
650
+ .reverse()
651
+ .find((entry) => {
652
+ const messageId = toMessageId(entry.messageId);
653
+ const normalizedBody = normalizeComparableMessageText(entry.body);
654
+ if (!messageId || !normalizedBody) {
655
+ return false;
656
+ }
657
+
658
+ if (normalizedCurrentBody && normalizedBody === normalizedCurrentBody) {
659
+ return false;
660
+ }
661
+
662
+ return !isLikelyForwardControlMessage(entry.body);
663
+ });
664
+ if (previousHistoryEntry) {
665
+ return toMessageId(previousHistoryEntry.messageId);
666
+ }
667
+
668
+ const previousInboundMessage = findPreviousRecentInboundMessage(
669
+ params.currentMessageId,
670
+ params.accountId,
671
+ );
672
+ if (previousInboundMessage) {
673
+ if (
674
+ normalizeComparableMessageText(previousInboundMessage.body) !== normalizedCurrentBody
675
+ && !isLikelyForwardControlMessage(previousInboundMessage.body)
676
+ ) {
677
+ return Number(previousInboundMessage.messageId);
678
+ }
679
+ }
680
+
681
+ return undefined;
682
+ }
683
+
684
+ function buildBitrix24NativeForwardAgentHint(params: {
685
+ accountId?: string;
686
+ currentBody: string;
687
+ currentMessageId?: string | number;
688
+ replyToMessageId?: string | number;
689
+ historyEntries?: HistoryEntry[];
690
+ }): string | undefined {
691
+ const targetDialogIds = extractBitrix24DialogTargetsFromText(params.currentBody);
692
+ const sourceMessageId = findPreviousForwardableMessageId(params);
693
+ if (!sourceMessageId) {
694
+ return undefined;
695
+ }
696
+
697
+ const formattedTargets = targetDialogIds
698
+ .map((dialogId) => (dialogId.startsWith('chat') ? `${dialogId} (chat)` : `${dialogId} (user)`))
699
+ .join(', ');
700
+
701
+ const lines = [
702
+ '[Bitrix24 native forward instruction]',
703
+ 'Use this only if you intentionally want a native Bitrix24 forward instead of repeating visible text.',
704
+ `Previous Bitrix24 message id available for forwarding: ${sourceMessageId}.`,
705
+ 'Use action "send" with "forwardMessageIds" set to that source message id.',
706
+ 'If the tool requires a non-empty message field for a pure forward, use "↩️".',
707
+ 'Do not repeat the source message text or the user instruction text when doing a native forward.',
708
+ ];
709
+
710
+ if (formattedTargets) {
711
+ lines.push(`Explicit target dialog ids mentioned in the current message: ${formattedTargets}.`);
712
+ 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.');
713
+ lines.push('Do not use CHAT=xxx or USER=xxx format for "to" — use "chat520" or "77" directly.');
714
+ } else {
715
+ lines.push('If you forward in the current dialog, do not set "to" to another dialog id.');
716
+ }
717
+
718
+ lines.push('[/Bitrix24 native forward instruction]');
719
+ return lines.join('\n');
720
+ }
721
+
722
+ function buildBitrix24NativeReplyAgentHint(currentMessageId: string | number | undefined): string | undefined {
723
+ const normalizedMessageId = toMessageId(currentMessageId);
724
+ if (!normalizedMessageId) {
725
+ return undefined;
726
+ }
727
+
728
+ return [
729
+ '[Bitrix24 native reply instruction]',
730
+ `Current Bitrix24 message id: ${normalizedMessageId}.`,
731
+ 'If you intentionally want a native Bitrix24 reply to the current inbound message, set "replyToMessageId" to that id.',
732
+ 'For text-only payloads you can also prepend [[reply_to_current]] to the reply text.',
733
+ 'Do not use a native reply unless you actually want reply threading.',
734
+ '[/Bitrix24 native reply instruction]',
735
+ ].join('\n');
736
+ }
737
+
738
+ function buildBitrix24FileDeliveryAgentHint(): string {
739
+ return [
740
+ '[Bitrix24 file delivery instruction]',
741
+ 'When you need to deliver a real file or document to the user, use structured reply payload field "mediaUrl" or "mediaUrls".',
742
+ 'Set "mediaUrl" to the local path of the generated file in the OpenClaw workspace or managed media directory.',
743
+ 'Use "text" only for an optional short caption.',
744
+ 'Do not place local file paths inside markdown links or plain text, because that only sends text to the user and does not upload the file.',
745
+ '[/Bitrix24 file delivery instruction]',
746
+ ].join('\n');
747
+ }
748
+
749
+ function readExplicitForwardMessageIds(rawParams: Record<string, unknown>): number[] {
750
+ return parseActionMessageIds(
751
+ rawParams.forwardMessageIds
752
+ ?? rawParams.forwardIds,
753
+ );
754
+ }
755
+
756
+ function resolveActionForwardMessageIds(params: {
757
+ accountId?: string;
758
+ rawParams: Record<string, unknown>;
759
+ toolContext?: { currentMessageId?: string | number };
760
+ }): number[] {
761
+ const explicitForwardMessages = readExplicitForwardMessageIds(params.rawParams);
762
+ if (explicitForwardMessages.length > 0) {
763
+ return explicitForwardMessages;
764
+ }
765
+
766
+ const aliasedForwardMessages = parseActionMessageIds(
767
+ params.rawParams.messageIds
768
+ ?? params.rawParams.message_ids
769
+ ?? params.rawParams.messageId
770
+ ?? params.rawParams.message_id
771
+ ?? params.rawParams.replyToMessageId
772
+ ?? params.rawParams.replyToId,
773
+ );
774
+ if (aliasedForwardMessages.length > 0) {
775
+ return aliasedForwardMessages;
776
+ }
777
+
778
+ const previousInboundMessage = findPreviousRecentInboundMessage(
779
+ params.toolContext?.currentMessageId,
780
+ params.accountId,
781
+ );
782
+ return previousInboundMessage ? [Number(previousInboundMessage.messageId)] : [];
783
+ }
784
+
785
+ const FORWARD_MARKER_RE = /^[\s↩️➡️→⤵️🔄📨]+$/u;
786
+
787
+ function normalizeForwardActionText(text: string | null): string {
788
+ const normalizedText = typeof text === 'string' ? text.trim() : '';
789
+ if (!normalizedText || FORWARD_MARKER_RE.test(normalizedText)) {
790
+ return '';
791
+ }
792
+ return normalizedText;
793
+ }
794
+
173
795
  function escapeBbCodeText(value: string): string {
174
796
  return value
175
797
  .replace(/\[/g, '(')
@@ -1055,6 +1677,28 @@ let gatewayState: GatewayState | null = null;
1055
1677
 
1056
1678
  export function __setGatewayStateForTests(state: GatewayState | null): void {
1057
1679
  gatewayState = state;
1680
+ if (state === null) {
1681
+ pendingNativeForwardAcks.clear();
1682
+ pendingNativeReactionAcks.clear();
1683
+ recentInboundMessagesByDialog.clear();
1684
+ inboundMessageContextById.clear();
1685
+ }
1686
+ }
1687
+
1688
+ export function __rememberRecentInboundMessageForTests(params: {
1689
+ accountId?: string;
1690
+ dialogId: string;
1691
+ messageId: string | number;
1692
+ body: string;
1693
+ timestamp?: number;
1694
+ }): void {
1695
+ rememberRecentInboundMessage(
1696
+ params.dialogId,
1697
+ params.messageId,
1698
+ params.body,
1699
+ params.timestamp,
1700
+ params.accountId,
1701
+ );
1058
1702
  }
1059
1703
 
1060
1704
  // ─── Keyboard layouts ────────────────────────────────────────────────────────
@@ -1072,30 +1716,441 @@ export function buildWelcomeKeyboard(language?: string): B24Keyboard {
1072
1716
  ];
1073
1717
  }
1074
1718
 
1075
- /** Default keyboard shown with command responses and welcome messages. */
1076
- export function buildDefaultCommandKeyboard(language?: string): B24Keyboard {
1077
- const labels = commandKeyboardLabels(language);
1719
+ /** Default keyboard shown with command responses and welcome messages. */
1720
+ export function buildDefaultCommandKeyboard(language?: string): B24Keyboard {
1721
+ const labels = commandKeyboardLabels(language);
1722
+
1723
+ return [
1724
+ { TEXT: labels.help, COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
1725
+ { TEXT: labels.status, COMMAND: 'status', DISPLAY: 'LINE' },
1726
+ { TEXT: labels.commands, COMMAND: 'commands', DISPLAY: 'LINE' },
1727
+ { TYPE: 'NEWLINE' },
1728
+ { TEXT: labels.newSession, COMMAND: 'new', DISPLAY: 'LINE' },
1729
+ { TEXT: labels.models, COMMAND: 'models', DISPLAY: 'LINE' },
1730
+ ];
1731
+ }
1732
+
1733
+ export const DEFAULT_COMMAND_KEYBOARD: B24Keyboard = buildDefaultCommandKeyboard();
1734
+ export const DEFAULT_WELCOME_KEYBOARD: B24Keyboard = buildWelcomeKeyboard();
1735
+
1736
+ // ─── Keyboard / Button conversion ────────────────────────────────────────────
1737
+
1738
+ /** Generic button format used by OpenClaw channelData. */
1739
+ export interface ChannelButton {
1740
+ text: string;
1741
+ callback_data?: string;
1742
+ style?: string;
1743
+ }
1744
+
1745
+ const BITRIX24_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'delete'] as const;
1746
+ const BITRIX24_DISCOVERY_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'delete'] as const;
1747
+
1748
+ function buildMessageToolButtonsSchema(): Record<string, unknown> {
1749
+ return {
1750
+ type: 'array',
1751
+ description: 'Optional Bitrix24 message buttons as rows of button objects.',
1752
+ items: {
1753
+ type: 'array',
1754
+ items: {
1755
+ type: 'object',
1756
+ additionalProperties: false,
1757
+ properties: {
1758
+ text: {
1759
+ type: 'string',
1760
+ description: 'Visible button label.',
1761
+ },
1762
+ callback_data: {
1763
+ type: 'string',
1764
+ description: 'Registered command like /help, or any plain text payload to send when tapped.',
1765
+ },
1766
+ style: {
1767
+ type: 'string',
1768
+ enum: ['primary', 'attention', 'danger'],
1769
+ description: 'Optional Bitrix24 button accent.',
1770
+ },
1771
+ },
1772
+ required: ['text'],
1773
+ },
1774
+ },
1775
+ };
1776
+ }
1777
+
1778
+ function buildMessageToolAttachSchema(): Record<string, unknown> {
1779
+ const blockSchema = {
1780
+ type: 'object',
1781
+ description: 'Single Bitrix24 ATTACH rich block. Supported top-level keys are MESSAGE, LINK, IMAGE, FILE, DELIMITER, GRID or USER.',
1782
+ };
1783
+
1784
+ return {
1785
+ description: 'Bitrix24 ATTACH rich layout blocks. Use this for rich cards, not for binary file uploads. For a status-owner-link card, prefer blocks like USER, LINK and GRID. Example: [{"USER":{"NAME":"Alice"}},{"LINK":{"NAME":"Open","LINK":"https://example.com"}},{"GRID":[{"NAME":"Status","VALUE":"In progress","DISPLAY":"LINE"},{"NAME":"Owner","VALUE":"Alice","DISPLAY":"LINE"}]}]. Use mediaUrl/mediaUrls for actual files and media.',
1786
+ anyOf: [
1787
+ {
1788
+ type: 'object',
1789
+ additionalProperties: false,
1790
+ properties: {
1791
+ ID: { type: 'integer' },
1792
+ COLOR_TOKEN: {
1793
+ type: 'string',
1794
+ enum: ['primary', 'secondary', 'alert', 'base'],
1795
+ },
1796
+ COLOR: { type: 'string' },
1797
+ BLOCKS: {
1798
+ type: 'array',
1799
+ items: blockSchema,
1800
+ },
1801
+ },
1802
+ required: ['BLOCKS'],
1803
+ },
1804
+ {
1805
+ type: 'array',
1806
+ items: blockSchema,
1807
+ },
1808
+ ],
1809
+ };
1810
+ }
1811
+
1812
+ function buildMessageToolIdListSchema(description: string): Record<string, unknown> {
1813
+ return {
1814
+ anyOf: [
1815
+ {
1816
+ type: 'integer',
1817
+ description,
1818
+ },
1819
+ {
1820
+ type: 'string',
1821
+ description: `${description} Can also be a comma-separated list or JSON array string.`,
1822
+ },
1823
+ {
1824
+ type: 'array',
1825
+ description,
1826
+ items: {
1827
+ type: 'integer',
1828
+ },
1829
+ },
1830
+ ],
1831
+ };
1832
+ }
1833
+
1834
+ function resolveConfiguredBitrix24ActionAccounts(
1835
+ cfg: Record<string, unknown>,
1836
+ accountId?: string,
1837
+ ): string[] {
1838
+ if (accountId) {
1839
+ const account = resolveAccount(cfg, accountId);
1840
+ return account.enabled && account.configured ? [account.accountId] : [];
1841
+ }
1842
+
1843
+ return listAccountIds(cfg).filter((candidateId) => {
1844
+ const account = resolveAccount(cfg, candidateId);
1845
+ return account.enabled && account.configured;
1846
+ });
1847
+ }
1848
+
1849
+ function describeBitrix24MessageTool(params: {
1850
+ cfg: Record<string, unknown>;
1851
+ accountId?: string;
1852
+ }): ChannelMessageToolDiscovery {
1853
+ const configuredAccounts = resolveConfiguredBitrix24ActionAccounts(
1854
+ params.cfg,
1855
+ params.accountId,
1856
+ );
1857
+
1858
+ if (configuredAccounts.length === 0) {
1859
+ return {
1860
+ actions: [],
1861
+ capabilities: [],
1862
+ schema: null,
1863
+ };
1864
+ }
1865
+
1866
+ return {
1867
+ actions: [...BITRIX24_DISCOVERY_ACTION_NAMES],
1868
+ capabilities: ['interactive', 'buttons', 'cards'],
1869
+ schema: [
1870
+ {
1871
+ properties: {
1872
+ buttons: buildMessageToolButtonsSchema(),
1873
+ },
1874
+ },
1875
+ {
1876
+ properties: {
1877
+ attach: buildMessageToolAttachSchema(),
1878
+ forwardMessageIds: buildMessageToolIdListSchema(
1879
+ 'Bitrix24 source message id(s) to forward natively. Use with action="send" plus to/target. Prefer the referenced message id, not the current instruction message id. The message field must be non-empty — use "↩️" (emoji) for a pure forward without extra text.',
1880
+ ),
1881
+ forwardIds: buildMessageToolIdListSchema(
1882
+ 'Alias for forwardMessageIds. Use with action="send".',
1883
+ ),
1884
+ replyToId: buildMessageToolIdListSchema(
1885
+ 'Bitrix24 message id to reply to natively inside the current dialog.',
1886
+ ),
1887
+ replyToMessageId: buildMessageToolIdListSchema(
1888
+ 'Alias for replyToId.',
1889
+ ),
1890
+ },
1891
+ },
1892
+ ],
1893
+ };
1894
+ }
1895
+
1896
+ function extractBitrix24ToolSend(args: Record<string, unknown>): ExtractedToolSendTarget | null {
1897
+ if ((typeof args.action === 'string' ? args.action.trim() : '') !== 'sendMessage') {
1898
+ return null;
1899
+ }
1900
+
1901
+ const to = typeof args.to === 'string' ? normalizeBitrix24DialogTarget(args.to) : '';
1902
+ if (!to) {
1903
+ return null;
1904
+ }
1905
+
1906
+ const accountId = typeof args.accountId === 'string'
1907
+ ? args.accountId.trim()
1908
+ : undefined;
1909
+ const threadId = typeof args.threadId === 'number'
1910
+ ? String(args.threadId)
1911
+ : typeof args.threadId === 'string'
1912
+ ? args.threadId.trim()
1913
+ : '';
1914
+
1915
+ return {
1916
+ to,
1917
+ ...(accountId ? { accountId } : {}),
1918
+ ...(threadId ? { threadId } : {}),
1919
+ };
1920
+ }
1921
+
1922
+ function readActionTextParam(params: Record<string, unknown>): string | null {
1923
+ const rawText = params.message ?? params.text ?? params.content;
1924
+ return typeof rawText === 'string' ? rawText : null;
1925
+ }
1926
+
1927
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
1928
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1929
+ }
1930
+
1931
+ function isAttachColorToken(value: unknown): value is B24AttachColorToken {
1932
+ return value === 'primary'
1933
+ || value === 'secondary'
1934
+ || value === 'alert'
1935
+ || value === 'base';
1936
+ }
1937
+
1938
+ function isAttachBlock(value: unknown): value is B24AttachBlock {
1939
+ return isPlainObject(value) && Object.keys(value).length > 0;
1940
+ }
1941
+
1942
+ function isBitrix24Attach(value: unknown): value is B24Attach {
1943
+ if (Array.isArray(value)) {
1944
+ return value.length > 0 && value.every(isAttachBlock);
1945
+ }
1946
+
1947
+ if (!isPlainObject(value) || !Array.isArray(value.BLOCKS) || value.BLOCKS.length === 0) {
1948
+ return false;
1949
+ }
1950
+
1951
+ if (value.COLOR_TOKEN !== undefined && !isAttachColorToken(value.COLOR_TOKEN)) {
1952
+ return false;
1953
+ }
1954
+
1955
+ return value.BLOCKS.every(isAttachBlock);
1956
+ }
1957
+
1958
+ function parseActionAttach(rawValue: unknown): B24Attach | undefined {
1959
+ if (rawValue == null) {
1960
+ return undefined;
1961
+ }
1962
+
1963
+ let parsed = rawValue;
1964
+ if (typeof rawValue === 'string') {
1965
+ const trimmed = rawValue.trim();
1966
+ if (!trimmed) {
1967
+ return undefined;
1968
+ }
1969
+
1970
+ try {
1971
+ parsed = JSON.parse(trimmed);
1972
+ } catch {
1973
+ return undefined;
1974
+ }
1975
+ }
1976
+
1977
+ return isBitrix24Attach(parsed) ? parsed : undefined;
1978
+ }
1979
+
1980
+ function hasMeaningfulAttachInput(rawValue: unknown): boolean {
1981
+ if (rawValue == null) {
1982
+ return false;
1983
+ }
1984
+
1985
+ if (typeof rawValue === 'string') {
1986
+ const trimmed = rawValue.trim();
1987
+ if (!trimmed) {
1988
+ return false;
1989
+ }
1990
+
1991
+ try {
1992
+ return hasMeaningfulAttachInput(JSON.parse(trimmed));
1993
+ } catch {
1994
+ return true;
1995
+ }
1996
+ }
1997
+
1998
+ if (Array.isArray(rawValue)) {
1999
+ return rawValue.length > 0;
2000
+ }
2001
+
2002
+ if (isPlainObject(rawValue)) {
2003
+ if (Array.isArray(rawValue.BLOCKS)) {
2004
+ return rawValue.BLOCKS.length > 0;
2005
+ }
2006
+
2007
+ return Object.keys(rawValue).length > 0;
2008
+ }
2009
+
2010
+ return true;
2011
+ }
2012
+
2013
+ function readActionTargetParam(
2014
+ params: Record<string, unknown>,
2015
+ fallbackTarget?: unknown,
2016
+ ): string {
2017
+ const rawTarget = params.to ?? params.target ?? fallbackTarget;
2018
+ return typeof rawTarget === 'string' ? normalizeBitrix24DialogTarget(rawTarget) : '';
2019
+ }
2020
+
2021
+ function parseActionMessageIds(rawValue: unknown): number[] {
2022
+ const ids: number[] = [];
2023
+ const seen = new Set<number>();
2024
+
2025
+ const append = (value: unknown) => {
2026
+ if (Array.isArray(value)) {
2027
+ value.forEach(append);
2028
+ return;
2029
+ }
2030
+
2031
+ if (typeof value === 'number') {
2032
+ const normalizedId = Math.trunc(value);
2033
+ if (Number.isFinite(normalizedId) && normalizedId > 0 && !seen.has(normalizedId)) {
2034
+ seen.add(normalizedId);
2035
+ ids.push(normalizedId);
2036
+ }
2037
+ return;
2038
+ }
2039
+
2040
+ if (typeof value !== 'string') {
2041
+ return;
2042
+ }
2043
+
2044
+ const trimmed = value.trim();
2045
+ if (!trimmed) {
2046
+ return;
2047
+ }
2048
+
2049
+ if (trimmed.startsWith('[')) {
2050
+ try {
2051
+ append(JSON.parse(trimmed));
2052
+ return;
2053
+ } catch {
2054
+ // Fall through to scalar / CSV parsing.
2055
+ }
2056
+ }
2057
+
2058
+ trimmed
2059
+ .split(/[,\s]+/)
2060
+ .forEach((part) => {
2061
+ if (!part) {
2062
+ return;
2063
+ }
2064
+ const normalizedId = Number(part);
2065
+ if (Number.isFinite(normalizedId) && normalizedId > 0 && !seen.has(normalizedId)) {
2066
+ seen.add(normalizedId);
2067
+ ids.push(normalizedId);
2068
+ }
2069
+ });
2070
+ };
2071
+
2072
+ append(rawValue);
2073
+ return ids;
2074
+ }
2075
+
2076
+ function parseActionKeyboard(rawButtons: unknown): B24Keyboard | undefined {
2077
+ if (rawButtons == null) {
2078
+ return undefined;
2079
+ }
2080
+
2081
+ try {
2082
+ const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
2083
+ if (Array.isArray(parsed)) {
2084
+ const keyboard = convertButtonsToKeyboard(parsed as ChannelButton[][] | ChannelButton[]);
2085
+ return keyboard.length > 0 ? keyboard : undefined;
2086
+ }
2087
+ } catch {
2088
+ // Ignore invalid buttons payload and send without keyboard.
2089
+ }
2090
+
2091
+ return undefined;
2092
+ }
2093
+
2094
+ function collectActionMediaUrls(params: Record<string, unknown>): string[] {
2095
+ const mediaUrls: string[] = [];
2096
+ const seen = new Set<string>();
2097
+
2098
+ const append = (value: unknown) => {
2099
+ if (Array.isArray(value)) {
2100
+ value.forEach(append);
2101
+ return;
2102
+ }
2103
+
2104
+ if (typeof value !== 'string') {
2105
+ return;
2106
+ }
2107
+
2108
+ const trimmed = value.trim();
2109
+ if (!trimmed) {
2110
+ return;
2111
+ }
2112
+
2113
+ if (trimmed.startsWith('[')) {
2114
+ try {
2115
+ append(JSON.parse(trimmed));
2116
+ return;
2117
+ } catch {
2118
+ // Fall through to scalar handling.
2119
+ }
2120
+ }
2121
+
2122
+ if (!seen.has(trimmed)) {
2123
+ seen.add(trimmed);
2124
+ mediaUrls.push(trimmed);
2125
+ }
2126
+ };
1078
2127
 
1079
- return [
1080
- { TEXT: labels.help, COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
1081
- { TEXT: labels.status, COMMAND: 'status', DISPLAY: 'LINE' },
1082
- { TEXT: labels.commands, COMMAND: 'commands', DISPLAY: 'LINE' },
1083
- { TYPE: 'NEWLINE' },
1084
- { TEXT: labels.newSession, COMMAND: 'new', DISPLAY: 'LINE' },
1085
- { TEXT: labels.models, COMMAND: 'models', DISPLAY: 'LINE' },
1086
- ];
2128
+ append(params.mediaUrl);
2129
+ append(params.mediaUrls);
2130
+ append(params.filePath);
2131
+ append(params.filePaths);
2132
+
2133
+ return mediaUrls;
1087
2134
  }
1088
2135
 
1089
- export const DEFAULT_COMMAND_KEYBOARD: B24Keyboard = buildDefaultCommandKeyboard();
1090
- export const DEFAULT_WELCOME_KEYBOARD: B24Keyboard = buildWelcomeKeyboard();
2136
+ function buildActionMessageText(params: {
2137
+ text: string | null;
2138
+ keyboard?: B24Keyboard;
2139
+ attach?: B24Attach;
2140
+ }): string | null {
2141
+ const normalizedText = typeof params.text === 'string'
2142
+ ? markdownToBbCode(params.text.trim())
2143
+ : '';
2144
+
2145
+ if (normalizedText) {
2146
+ return normalizedText;
2147
+ }
1091
2148
 
1092
- // ─── Keyboard / Button conversion ────────────────────────────────────────────
2149
+ if (params.attach) {
2150
+ return null;
2151
+ }
1093
2152
 
1094
- /** Generic button format used by OpenClaw channelData. */
1095
- export interface ChannelButton {
1096
- text: string;
1097
- callback_data?: string;
1098
- style?: string;
2153
+ return params.keyboard ? ' ' : null;
1099
2154
  }
1100
2155
 
1101
2156
  function parseRegisteredCommandTrigger(callbackData: string): { command: string; commandParams?: string } | undefined {
@@ -1188,6 +2243,16 @@ export function extractKeyboardFromPayload(
1188
2243
  return undefined;
1189
2244
  }
1190
2245
 
2246
+ export function extractAttachFromPayload(
2247
+ payload: { channelData?: Record<string, unknown> },
2248
+ ): B24Attach | undefined {
2249
+ const cd = payload.channelData;
2250
+ if (!cd) return undefined;
2251
+
2252
+ const b24Data = cd.bitrix24 as { attach?: unknown; attachments?: unknown } | undefined;
2253
+ return parseActionAttach(b24Data?.attach ?? b24Data?.attachments);
2254
+ }
2255
+
1191
2256
  /**
1192
2257
  * Extract inline button JSON embedded in message text by the agent.
1193
2258
  *
@@ -1221,6 +2286,14 @@ export function extractInlineButtonsFromText(
1221
2286
  }
1222
2287
  }
1223
2288
 
2289
+ function cleanupTextAfterInlineMediaExtraction(text: string): string {
2290
+ return text
2291
+ .replace(/[ \t]+\n/g, '\n')
2292
+ .replace(/\n{3,}/g, '\n\n')
2293
+ .replace(/[ \t]{2,}/g, ' ')
2294
+ .trim();
2295
+ }
2296
+
1224
2297
  function normalizeCommandReplyPayload(params: {
1225
2298
  commandName: string;
1226
2299
  commandParams: string;
@@ -1578,11 +2651,12 @@ function resolveOutboundSendCtx(params: {
1578
2651
  accountId?: string;
1579
2652
  }): SendContext | null {
1580
2653
  const { config } = resolveAccount(params.cfg, params.accountId);
1581
- if (!config.webhookUrl || !gatewayState) return null;
2654
+ const dialogId = normalizeBitrix24DialogTarget(params.to);
2655
+ if (!config.webhookUrl || !gatewayState || !dialogId) return null;
1582
2656
  return {
1583
2657
  webhookUrl: config.webhookUrl,
1584
2658
  bot: gatewayState.bot,
1585
- dialogId: params.to,
2659
+ dialogId,
1586
2660
  };
1587
2661
  }
1588
2662
 
@@ -1666,13 +2740,10 @@ export const bitrix24Plugin = {
1666
2740
  },
1667
2741
 
1668
2742
  messaging: {
1669
- normalizeTarget: (raw: string) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
2743
+ normalizeTarget: (raw: string) => normalizeBitrix24DialogTarget(raw),
1670
2744
  targetResolver: {
1671
- hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
1672
- looksLikeId: (raw: string, _normalized: string) => {
1673
- const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
1674
- return /^\d+$/.test(stripped);
1675
- },
2745
+ 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]".',
2746
+ looksLikeId: (raw: string, _normalized: string) => looksLikeBitrix24DialogTarget(raw),
1676
2747
  },
1677
2748
  },
1678
2749
 
@@ -1787,25 +2858,46 @@ export const bitrix24Plugin = {
1787
2858
  const sendCtx = resolveOutboundSendCtx(ctx);
1788
2859
  if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
1789
2860
 
2861
+ const text = ctx.text;
2862
+
1790
2863
  const keyboard = ctx.payload?.channelData
1791
2864
  ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
1792
2865
  : undefined;
2866
+ const attach = ctx.payload?.channelData
2867
+ ? extractAttachFromPayload({ channelData: ctx.payload.channelData })
2868
+ : undefined;
1793
2869
  const mediaUrls = collectOutboundMediaUrls({
1794
2870
  mediaUrl: ctx.mediaUrl,
1795
2871
  payload: ctx.payload as { mediaUrl?: string; mediaUrls?: string[] } | undefined,
1796
2872
  });
1797
2873
 
1798
2874
  if (mediaUrls.length > 0) {
2875
+ const initialMessage = !keyboard && !attach ? text || undefined : undefined;
1799
2876
  const uploadedMessageId = await uploadOutboundMedia({
1800
2877
  mediaService: gatewayState.mediaService,
1801
2878
  sendCtx,
1802
2879
  mediaUrls,
2880
+ initialMessage,
1803
2881
  });
1804
2882
 
1805
- if (ctx.text) {
2883
+ if ((text && !initialMessage) || keyboard || attach) {
2884
+ if (attach) {
2885
+ const messageId = await gatewayState.api.sendMessage(
2886
+ sendCtx.webhookUrl,
2887
+ sendCtx.bot,
2888
+ sendCtx.dialogId,
2889
+ buildActionMessageText({ text, keyboard, attach }),
2890
+ {
2891
+ ...(keyboard ? { keyboard } : {}),
2892
+ attach,
2893
+ },
2894
+ );
2895
+ return { messageId: String(messageId || uploadedMessageId) };
2896
+ }
2897
+
1806
2898
  const result = await gatewayState.sendService.sendText(
1807
2899
  sendCtx,
1808
- ctx.text,
2900
+ text || '',
1809
2901
  keyboard ? { keyboard } : undefined,
1810
2902
  );
1811
2903
  return { messageId: String(result.messageId ?? uploadedMessageId) };
@@ -1814,10 +2906,24 @@ export const bitrix24Plugin = {
1814
2906
  return { messageId: uploadedMessageId };
1815
2907
  }
1816
2908
 
1817
- if (ctx.text) {
2909
+ if (text || keyboard || attach) {
2910
+ if (attach) {
2911
+ const messageId = await gatewayState.api.sendMessage(
2912
+ sendCtx.webhookUrl,
2913
+ sendCtx.bot,
2914
+ sendCtx.dialogId,
2915
+ buildActionMessageText({ text, keyboard, attach }),
2916
+ {
2917
+ ...(keyboard ? { keyboard } : {}),
2918
+ attach,
2919
+ },
2920
+ );
2921
+ return { messageId: String(messageId ?? '') };
2922
+ }
2923
+
1818
2924
  const result = await gatewayState.sendService.sendText(
1819
2925
  sendCtx,
1820
- ctx.text,
2926
+ text || '',
1821
2927
  keyboard ? { keyboard } : undefined,
1822
2928
  );
1823
2929
  return { messageId: String(result.messageId ?? '') };
@@ -1829,12 +2935,23 @@ export const bitrix24Plugin = {
1829
2935
  // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
1830
2936
 
1831
2937
  actions: {
2938
+ describeMessageTool: (params: {
2939
+ cfg: Record<string, unknown>;
2940
+ accountId?: string;
2941
+ }): ChannelMessageToolDiscovery => describeBitrix24MessageTool(params),
2942
+
2943
+ extractToolSend: (params: {
2944
+ args: Record<string, unknown>;
2945
+ }): ExtractedToolSendTarget | null => extractBitrix24ToolSend(params.args),
2946
+
1832
2947
  listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
1833
- return ['react', 'send'];
2948
+ return [...BITRIX24_DISCOVERY_ACTION_NAMES];
1834
2949
  },
1835
2950
 
1836
2951
  supportsAction: (params: { action: string }): boolean => {
1837
- return params.action === 'react' || params.action === 'send';
2952
+ return BITRIX24_ACTION_NAMES.includes(
2953
+ params.action as typeof BITRIX24_ACTION_NAMES[number],
2954
+ );
1838
2955
  },
1839
2956
 
1840
2957
  handleAction: async (ctx: {
@@ -1853,35 +2970,270 @@ export const bitrix24Plugin = {
1853
2970
 
1854
2971
  // ─── Send with buttons ──────────────────────────────────────────────
1855
2972
  if (ctx.action === 'send') {
1856
- // Only intercept send when buttons are present; otherwise let gateway handle normally
1857
2973
  const rawButtons = ctx.params.buttons;
1858
- if (!rawButtons) return null;
2974
+ const rawAttach = ctx.params.attach ?? ctx.params.attachments;
1859
2975
 
1860
2976
  const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1861
2977
  if (!config.webhookUrl || !gatewayState) return null;
1862
2978
 
1863
2979
  const bot = gatewayState.bot;
1864
- const to = String(ctx.params.to ?? ctx.to ?? '').trim();
2980
+ const to = readActionTargetParam(ctx.params, ctx.to);
1865
2981
  if (!to) {
1866
2982
  defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
1867
2983
  return null;
1868
2984
  }
1869
2985
 
1870
2986
  const sendCtx: SendContext = { webhookUrl: config.webhookUrl, bot, dialogId: to };
1871
- const message = String(ctx.params.message ?? '').trim();
2987
+ const messageText = readActionTextParam(ctx.params);
2988
+ const message = typeof messageText === 'string' ? messageText.trim() : '';
2989
+ const keyboard = parseActionKeyboard(rawButtons);
2990
+ const attach = parseActionAttach(rawAttach);
2991
+ const mediaUrls = collectActionMediaUrls(ctx.params);
2992
+ const toolContext = (ctx as Record<string, unknown>).toolContext as
2993
+ | { currentMessageId?: string | number } | undefined;
2994
+ const currentToolMessageId = toolContext?.currentMessageId;
2995
+ const currentInboundContextKey = buildAccountMessageScopeKey(
2996
+ ctx.accountId,
2997
+ currentToolMessageId,
2998
+ );
2999
+ const currentInboundContext = currentInboundContextKey
3000
+ ? inboundMessageContextById.get(currentInboundContextKey)
3001
+ : undefined;
3002
+ const forwardAckDialogId = resolvePendingNativeForwardAckDialogId(
3003
+ currentToolMessageId,
3004
+ to,
3005
+ ctx.accountId,
3006
+ );
3007
+ const explicitForwardMessages = readExplicitForwardMessageIds(ctx.params);
3008
+ const referencedReplyMessageId = parseActionMessageIds(
3009
+ ctx.params.replyToMessageId ?? ctx.params.replyToId,
3010
+ )[0];
3011
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
3012
+ 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.' });
3013
+ }
3014
+ if (explicitForwardMessages.length > 0 && mediaUrls.length > 0) {
3015
+ return toolResult({
3016
+ ok: false,
3017
+ reason: 'unsupported_payload_combo',
3018
+ hint: 'Bitrix24 native forward cannot be combined with mediaUrl/mediaUrls uploads in one send action. Send the forward separately.',
3019
+ });
3020
+ }
3021
+ if (!message && !keyboard && !attach && mediaUrls.length === 0 && explicitForwardMessages.length === 0) {
3022
+ return toolResult({
3023
+ ok: false,
3024
+ reason: 'missing_payload',
3025
+ hint: 'Provide message text, buttons, rich attach blocks or mediaUrl/mediaUrls for Bitrix24 send.',
3026
+ });
3027
+ }
1872
3028
 
1873
- // Parse buttons: may be array or JSON string
1874
- let buttons: ChannelButton[][] | undefined;
1875
3029
  try {
1876
- const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1877
- if (Array.isArray(parsed)) buttons = parsed;
1878
- } catch {
1879
- // invalid buttons JSON — send without keyboard
1880
- }
3030
+ if (explicitForwardMessages.length > 0) {
3031
+ const normalizedForwardText = normalizeForwardActionText(messageText);
3032
+
3033
+ if (attach) {
3034
+ const messageId = await gatewayState.api.sendMessage(
3035
+ config.webhookUrl,
3036
+ bot,
3037
+ to,
3038
+ buildActionMessageText({ text: normalizedForwardText, keyboard, attach }),
3039
+ {
3040
+ ...(keyboard ? { keyboard } : {}),
3041
+ attach,
3042
+ forwardMessages: explicitForwardMessages,
3043
+ },
3044
+ );
3045
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3046
+ return toolResult({
3047
+ channel: 'bitrix24',
3048
+ to,
3049
+ via: 'direct',
3050
+ mediaUrl: null,
3051
+ forwarded: true,
3052
+ forwardMessages: explicitForwardMessages,
3053
+ result: { messageId: String(messageId ?? '') },
3054
+ });
3055
+ }
3056
+
3057
+ const result = await gatewayState.sendService.sendText(
3058
+ sendCtx,
3059
+ normalizedForwardText || (keyboard ? ' ' : ''),
3060
+ {
3061
+ ...(keyboard ? { keyboard } : {}),
3062
+ forwardMessages: explicitForwardMessages,
3063
+ },
3064
+ );
3065
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3066
+ return toolResult({
3067
+ channel: 'bitrix24',
3068
+ to,
3069
+ via: 'direct',
3070
+ mediaUrl: null,
3071
+ forwarded: true,
3072
+ forwardMessages: explicitForwardMessages,
3073
+ result: { messageId: String(result.messageId ?? '') },
3074
+ });
3075
+ }
1881
3076
 
1882
- const keyboard = buttons?.length ? convertButtonsToKeyboard(buttons) : undefined;
3077
+ if (referencedReplyMessageId && !attach && mediaUrls.length === 0) {
3078
+ let referencedMessageText = '';
3079
+
3080
+ try {
3081
+ const referencedMessage = await gatewayState.api.getMessage(
3082
+ config.webhookUrl,
3083
+ bot,
3084
+ referencedReplyMessageId,
3085
+ );
3086
+ referencedMessageText = resolveFetchedMessageBody(referencedMessage.message?.text ?? '');
3087
+ } catch (err) {
3088
+ defaultLogger.debug('Failed to hydrate referenced Bitrix24 message for send action', err);
3089
+ }
3090
+
3091
+ const normalizedReferencedMessage = normalizeComparableMessageText(referencedMessageText);
3092
+ const normalizedMessage = normalizeComparableMessageText(message);
3093
+
3094
+ if (normalizedReferencedMessage && normalizedReferencedMessage === normalizedMessage) {
3095
+ const result = await gatewayState.sendService.sendText(
3096
+ sendCtx,
3097
+ '',
3098
+ { forwardMessages: [referencedReplyMessageId] },
3099
+ );
3100
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3101
+ return toolResult({
3102
+ channel: 'bitrix24',
3103
+ to,
3104
+ via: 'direct',
3105
+ mediaUrl: null,
3106
+ forwarded: true,
3107
+ forwardMessages: [referencedReplyMessageId],
3108
+ result: { messageId: String(result.messageId ?? '') },
3109
+ });
3110
+ }
3111
+
3112
+ const result = await gatewayState.sendService.sendText(
3113
+ sendCtx,
3114
+ message || ' ',
3115
+ {
3116
+ ...(keyboard ? { keyboard } : {}),
3117
+ replyToMessageId: referencedReplyMessageId,
3118
+ },
3119
+ );
3120
+ return toolResult({
3121
+ channel: 'bitrix24',
3122
+ to,
3123
+ via: 'direct',
3124
+ mediaUrl: null,
3125
+ replied: true,
3126
+ replyToMessageId: referencedReplyMessageId,
3127
+ result: { messageId: String(result.messageId ?? '') },
3128
+ });
3129
+ }
3130
+
3131
+ const previousInboundMessage = findPreviousRecentInboundMessage(
3132
+ currentToolMessageId,
3133
+ ctx.accountId,
3134
+ );
3135
+
3136
+ if (
3137
+ !referencedReplyMessageId
3138
+ && !attach
3139
+ && mediaUrls.length === 0
3140
+ && !keyboard
3141
+ && previousInboundMessage
3142
+ && currentInboundContext
3143
+ && normalizeComparableMessageText(message)
3144
+ && normalizeComparableMessageText(message) === normalizeComparableMessageText(previousInboundMessage.body)
3145
+ && normalizeComparableMessageText(currentInboundContext.body) !== normalizeComparableMessageText(message)
3146
+ ) {
3147
+ const result = await gatewayState.sendService.sendText(
3148
+ sendCtx,
3149
+ '',
3150
+ { forwardMessages: [Number(previousInboundMessage.messageId)] },
3151
+ );
3152
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
3153
+ return toolResult({
3154
+ channel: 'bitrix24',
3155
+ to,
3156
+ via: 'direct',
3157
+ mediaUrl: null,
3158
+ forwarded: true,
3159
+ forwardMessages: [Number(previousInboundMessage.messageId)],
3160
+ result: { messageId: String(result.messageId ?? '') },
3161
+ });
3162
+ }
3163
+
3164
+ if (mediaUrls.length > 0) {
3165
+ const initialMessage = !keyboard && !attach ? message || undefined : undefined;
3166
+ const uploadedMessageId = await uploadOutboundMedia({
3167
+ mediaService: gatewayState.mediaService,
3168
+ sendCtx,
3169
+ mediaUrls,
3170
+ initialMessage,
3171
+ });
3172
+
3173
+ if ((message && !initialMessage) || keyboard || attach) {
3174
+ if (attach) {
3175
+ const messageId = await gatewayState.api.sendMessage(
3176
+ config.webhookUrl,
3177
+ bot,
3178
+ to,
3179
+ buildActionMessageText({ text: message, keyboard, attach }),
3180
+ {
3181
+ ...(keyboard ? { keyboard } : {}),
3182
+ attach,
3183
+ },
3184
+ );
3185
+ return toolResult({
3186
+ channel: 'bitrix24',
3187
+ to,
3188
+ via: 'direct',
3189
+ mediaUrl: mediaUrls[0] ?? null,
3190
+ result: { messageId: String(messageId ?? uploadedMessageId) },
3191
+ });
3192
+ }
3193
+
3194
+ const result = await gatewayState.sendService.sendText(
3195
+ sendCtx,
3196
+ message || '',
3197
+ keyboard ? { keyboard } : undefined,
3198
+ );
3199
+ return toolResult({
3200
+ channel: 'bitrix24',
3201
+ to,
3202
+ via: 'direct',
3203
+ mediaUrl: mediaUrls[0] ?? null,
3204
+ result: { messageId: String(result.messageId ?? uploadedMessageId) },
3205
+ });
3206
+ }
3207
+
3208
+ return toolResult({
3209
+ channel: 'bitrix24',
3210
+ to,
3211
+ via: 'direct',
3212
+ mediaUrl: mediaUrls[0] ?? null,
3213
+ result: { messageId: uploadedMessageId },
3214
+ });
3215
+ }
3216
+
3217
+ if (attach) {
3218
+ const messageId = await gatewayState.api.sendMessage(
3219
+ config.webhookUrl,
3220
+ bot,
3221
+ to,
3222
+ buildActionMessageText({ text: message, keyboard, attach }),
3223
+ {
3224
+ ...(keyboard ? { keyboard } : {}),
3225
+ attach,
3226
+ },
3227
+ );
3228
+ return toolResult({
3229
+ channel: 'bitrix24',
3230
+ to,
3231
+ via: 'direct',
3232
+ mediaUrl: null,
3233
+ result: { messageId: String(messageId ?? '') },
3234
+ });
3235
+ }
1883
3236
 
1884
- try {
1885
3237
  const result = await gatewayState.sendService.sendText(
1886
3238
  sendCtx, message || ' ', keyboard ? { keyboard } : undefined,
1887
3239
  );
@@ -1898,6 +3250,192 @@ export const bitrix24Plugin = {
1898
3250
  }
1899
3251
  }
1900
3252
 
3253
+ if (ctx.action === 'reply') {
3254
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
3255
+ if (!config.webhookUrl || !gatewayState) {
3256
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
3257
+ }
3258
+
3259
+ const params = ctx.params;
3260
+ const bot = gatewayState.bot;
3261
+ const to = readActionTargetParam(params, ctx.to);
3262
+ if (!to) {
3263
+ return toolResult({ ok: false, reason: 'missing_target', hint: 'Bitrix24 reply requires a target dialog id in "to" or "target". Do not retry.' });
3264
+ }
3265
+
3266
+ const toolContext = (ctx as Record<string, unknown>).toolContext as
3267
+ | { currentMessageId?: string | number } | undefined;
3268
+ const replyToMessageId = parseActionMessageIds(
3269
+ params.replyToMessageId
3270
+ ?? params.replyToId
3271
+ ?? params.messageId
3272
+ ?? params.message_id
3273
+ ?? toolContext?.currentMessageId,
3274
+ )[0];
3275
+
3276
+ if (!replyToMessageId) {
3277
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Bitrix24 reply requires a valid target messageId. Do not retry.' });
3278
+ }
3279
+
3280
+ const text = readActionTextParam(params);
3281
+ const normalizedText = typeof text === 'string' ? text.trim() : '';
3282
+ const keyboard = parseActionKeyboard(params.buttons);
3283
+ const rawAttach = params.attach ?? params.attachments;
3284
+ const attach = parseActionAttach(rawAttach);
3285
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
3286
+ 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.' });
3287
+ }
3288
+
3289
+ if (!normalizedText && !keyboard && !attach) {
3290
+ return toolResult({ ok: false, reason: 'missing_payload', hint: 'Provide reply text, buttons or rich attach blocks for Bitrix24 reply.' });
3291
+ }
3292
+
3293
+ try {
3294
+ if (attach) {
3295
+ const messageId = await gatewayState.api.sendMessage(
3296
+ config.webhookUrl,
3297
+ bot,
3298
+ to,
3299
+ buildActionMessageText({ text: normalizedText, keyboard, attach }),
3300
+ {
3301
+ ...(keyboard ? { keyboard } : {}),
3302
+ attach,
3303
+ replyToMessageId,
3304
+ },
3305
+ );
3306
+ return toolResult({
3307
+ ok: true,
3308
+ replied: true,
3309
+ to,
3310
+ replyToMessageId,
3311
+ messageId: String(messageId ?? ''),
3312
+ });
3313
+ }
3314
+
3315
+ const result = await gatewayState.sendService.sendText(
3316
+ { webhookUrl: config.webhookUrl, bot, dialogId: to },
3317
+ normalizedText || ' ',
3318
+ {
3319
+ ...(keyboard ? { keyboard } : {}),
3320
+ replyToMessageId,
3321
+ },
3322
+ );
3323
+ return toolResult({
3324
+ ok: true,
3325
+ replied: true,
3326
+ to,
3327
+ replyToMessageId,
3328
+ messageId: String(result.messageId ?? ''),
3329
+ });
3330
+ } catch (err) {
3331
+ const errMsg = err instanceof Error ? err.message : String(err);
3332
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to send Bitrix24 reply: ${errMsg}. Do not retry.` });
3333
+ }
3334
+ }
3335
+
3336
+ // Note: "forward" action is not supported by OpenClaw runtime (not in
3337
+ // MESSAGE_ACTION_TARGET_MODE), so the runtime blocks target before it
3338
+ // reaches handleAction. All forwarding goes through action "send" with
3339
+ // forwardMessageIds parameter instead.
3340
+
3341
+ if (ctx.action === 'edit') {
3342
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
3343
+ if (!config.webhookUrl || !gatewayState) {
3344
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
3345
+ }
3346
+
3347
+ const bot = gatewayState.bot;
3348
+ const api = gatewayState.api;
3349
+ const params = ctx.params;
3350
+ const toolContext = (ctx as Record<string, unknown>).toolContext as
3351
+ | { currentMessageId?: string | number } | undefined;
3352
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
3353
+ const messageId = Number(rawMessageId);
3354
+
3355
+ if (!Number.isFinite(messageId) || messageId <= 0) {
3356
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 edits. Do not retry.' });
3357
+ }
3358
+
3359
+ let keyboard: B24Keyboard | 'N' | undefined;
3360
+ if (params.buttons === 'N') {
3361
+ keyboard = 'N';
3362
+ } else if (params.buttons != null) {
3363
+ try {
3364
+ const parsed = typeof params.buttons === 'string'
3365
+ ? JSON.parse(params.buttons)
3366
+ : params.buttons;
3367
+ if (Array.isArray(parsed)) {
3368
+ keyboard = convertButtonsToKeyboard(parsed as ChannelButton[][] | ChannelButton[]);
3369
+ }
3370
+ } catch {
3371
+ // Ignore invalid buttons payload and update text only.
3372
+ }
3373
+ }
3374
+
3375
+ const message = readActionTextParam(params);
3376
+ const rawAttach = params.attach ?? params.attachments;
3377
+ const attach = parseActionAttach(rawAttach);
3378
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
3379
+ 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.' });
3380
+ }
3381
+
3382
+ if (message == null && !keyboard && !attach) {
3383
+ return toolResult({ ok: false, reason: 'missing_payload', hint: 'Provide message text, buttons or rich attach blocks for Bitrix24 edits.' });
3384
+ }
3385
+
3386
+ try {
3387
+ await api.updateMessage(
3388
+ config.webhookUrl,
3389
+ bot,
3390
+ messageId,
3391
+ message,
3392
+ {
3393
+ ...(keyboard ? { keyboard } : {}),
3394
+ ...(attach ? { attach } : {}),
3395
+ },
3396
+ );
3397
+ return toolResult({ ok: true, edited: true, messageId });
3398
+ } catch (err) {
3399
+ const errMsg = err instanceof Error ? err.message : String(err);
3400
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to edit message: ${errMsg}. Do not retry.` });
3401
+ }
3402
+ }
3403
+
3404
+ if (ctx.action === 'delete') {
3405
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
3406
+ if (!config.webhookUrl || !gatewayState) {
3407
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
3408
+ }
3409
+
3410
+ const bot = gatewayState.bot;
3411
+ const api = gatewayState.api;
3412
+ const params = ctx.params;
3413
+ const toolContext = (ctx as Record<string, unknown>).toolContext as
3414
+ | { currentMessageId?: string | number } | undefined;
3415
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
3416
+ const messageId = Number(rawMessageId);
3417
+
3418
+ if (!Number.isFinite(messageId) || messageId <= 0) {
3419
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 deletes. Do not retry.' });
3420
+ }
3421
+
3422
+ const complete = params.complete == null
3423
+ ? undefined
3424
+ : (
3425
+ params.complete === true
3426
+ || params.complete === 'true'
3427
+ || params.complete === 'Y'
3428
+ );
3429
+
3430
+ try {
3431
+ await api.deleteMessage(config.webhookUrl, bot, messageId, complete);
3432
+ return toolResult({ ok: true, deleted: true, messageId });
3433
+ } catch (err) {
3434
+ const errMsg = err instanceof Error ? err.message : String(err);
3435
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to delete message: ${errMsg}. Do not retry.` });
3436
+ }
3437
+ }
3438
+
1901
3439
  // ─── React ────────────────────────────────────────────────────────────
1902
3440
  if (ctx.action !== 'react') return null;
1903
3441
 
@@ -1955,11 +3493,13 @@ export const bitrix24Plugin = {
1955
3493
 
1956
3494
  try {
1957
3495
  await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
3496
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
1958
3497
  return toolResult({ ok: true, added: emoji });
1959
3498
  } catch (err) {
1960
3499
  const errMsg = err instanceof Error ? err.message : String(err);
1961
3500
  const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
1962
3501
  if (isAlreadySet) {
3502
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
1963
3503
  return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
1964
3504
  }
1965
3505
  return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
@@ -2327,9 +3867,22 @@ export const bitrix24Plugin = {
2327
3867
  query: bodyWithReply,
2328
3868
  historyCache,
2329
3869
  });
2330
- const bodyForAgent = crossChatContext
2331
- ? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
2332
- : bodyWithReply;
3870
+ const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
3871
+ const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(msgCtx.messageId);
3872
+ const nativeForwardAgentHint = buildBitrix24NativeForwardAgentHint({
3873
+ accountId: ctx.accountId,
3874
+ currentBody: body,
3875
+ currentMessageId: msgCtx.messageId,
3876
+ replyToMessageId: msgCtx.replyToMessageId,
3877
+ historyEntries: previousEntries,
3878
+ });
3879
+ const bodyForAgent = [
3880
+ fileDeliveryAgentHint,
3881
+ nativeReplyAgentHint,
3882
+ nativeForwardAgentHint,
3883
+ crossChatContext,
3884
+ bodyWithReply,
3885
+ ].filter(Boolean).join('\n\n');
2333
3886
  const combinedBody = msgCtx.isGroup
2334
3887
  ? buildHistoryContext({
2335
3888
  entries: previousEntries,
@@ -2338,6 +3891,13 @@ export const bitrix24Plugin = {
2338
3891
  : bodyForAgent;
2339
3892
 
2340
3893
  recordHistory(body);
3894
+ rememberRecentInboundMessage(
3895
+ conversation.dialogId,
3896
+ msgCtx.messageId,
3897
+ body,
3898
+ msgCtx.timestamp ?? Date.now(),
3899
+ ctx.accountId,
3900
+ );
2341
3901
 
2342
3902
  // Resolve which agent handles this conversation
2343
3903
  const route = runtime.channel.routing.resolveAgentRoute({
@@ -2402,7 +3962,31 @@ export const bitrix24Plugin = {
2402
3962
  });
2403
3963
  }
2404
3964
  if (payload.text) {
2405
- let text = payload.text;
3965
+ if (consumePendingNativeForwardAck(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
3966
+ replyDelivered = true;
3967
+ logger.debug('Suppressing trailing acknowledgement after native Bitrix24 forward', {
3968
+ senderId: msgCtx.senderId,
3969
+ chatId: msgCtx.chatId,
3970
+ messageId: msgCtx.messageId,
3971
+ });
3972
+ return;
3973
+ }
3974
+
3975
+ const replyDirective = extractReplyDirective({
3976
+ text: payload.text,
3977
+ currentMessageId: msgCtx.messageId,
3978
+ explicitReplyToId: (payload as Record<string, unknown>).replyToId,
3979
+ });
3980
+ let text = replyDirective.cleanText;
3981
+ if (consumePendingNativeReactionAck(msgCtx.messageId, text, ctx.accountId)) {
3982
+ replyDelivered = true;
3983
+ logger.debug('Suppressing trailing acknowledgement after native Bitrix24 reaction', {
3984
+ senderId: msgCtx.senderId,
3985
+ chatId: msgCtx.chatId,
3986
+ messageId: msgCtx.messageId,
3987
+ });
3988
+ return;
3989
+ }
2406
3990
  let keyboard = extractKeyboardFromPayload(payload);
2407
3991
 
2408
3992
  // Fallback: agent may embed button JSON in text as [[{...}]]
@@ -2414,8 +3998,68 @@ export const bitrix24Plugin = {
2414
3998
  }
2415
3999
  }
2416
4000
 
4001
+ const nativeForwardMessageId = findNativeForwardTarget({
4002
+ accountId: ctx.accountId,
4003
+ requestText: body,
4004
+ deliveredText: text,
4005
+ historyEntries: previousEntries,
4006
+ currentMessageId: msgCtx.messageId,
4007
+ }) ?? findImplicitForwardTargetFromRecentInbound({
4008
+ accountId: ctx.accountId,
4009
+ requestText: body,
4010
+ deliveredText: text,
4011
+ currentMessageId: msgCtx.messageId,
4012
+ historyEntries: previousEntries,
4013
+ });
4014
+ const nativeForwardTargets = nativeForwardMessageId
4015
+ ? extractBitrix24DialogTargetsFromText(body)
4016
+ : [];
4017
+ const nativeReplyToMessageId = nativeForwardMessageId
4018
+ ? undefined
4019
+ : replyDirective.replyToMessageId;
4020
+ const sendOptions = {
4021
+ ...(keyboard ? { keyboard } : {}),
4022
+ ...(nativeReplyToMessageId ? { replyToMessageId: nativeReplyToMessageId } : {}),
4023
+ ...(nativeForwardMessageId ? { forwardMessages: [nativeForwardMessageId] } : {}),
4024
+ };
4025
+
2417
4026
  replyDelivered = true;
2418
- await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
4027
+ if (nativeForwardMessageId && nativeForwardTargets.length > 0) {
4028
+ const forwardResults = await Promise.allSettled(
4029
+ nativeForwardTargets.map((dialogId) => sendService.sendText(
4030
+ { ...sendCtx, dialogId },
4031
+ '',
4032
+ { forwardMessages: [nativeForwardMessageId] },
4033
+ )),
4034
+ );
4035
+ const firstFailure = forwardResults.find(
4036
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
4037
+ );
4038
+ const hasSuccess = forwardResults.some((result) => result.status === 'fulfilled');
4039
+
4040
+ if (firstFailure && !hasSuccess) {
4041
+ throw firstFailure.reason;
4042
+ }
4043
+
4044
+ if (firstFailure) {
4045
+ logger.warn('Failed to deliver Bitrix24 native forward to one or more explicit targets', {
4046
+ senderId: msgCtx.senderId,
4047
+ chatId: msgCtx.chatId,
4048
+ messageId: msgCtx.messageId,
4049
+ targets: nativeForwardTargets,
4050
+ error: firstFailure.reason instanceof Error
4051
+ ? firstFailure.reason.message
4052
+ : String(firstFailure.reason),
4053
+ });
4054
+ }
4055
+ return;
4056
+ }
4057
+
4058
+ await sendService.sendText(
4059
+ sendCtx,
4060
+ nativeForwardMessageId ? '' : text,
4061
+ Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
4062
+ );
2419
4063
  }
2420
4064
  },
2421
4065
  onReplyStart: async () => {
@@ -2437,11 +4081,11 @@ export const bitrix24Plugin = {
2437
4081
  }
2438
4082
 
2439
4083
  if (!replyDelivered && dispatchResult?.queuedFinal === false) {
2440
- await sendService.sendText(
2441
- sendCtx,
2442
- replyGenerationFailed(msgCtx.language),
2443
- { convertMarkdown: false },
2444
- );
4084
+ logger.warn('Reply completed without a final user-visible message; fallback notice suppressed', {
4085
+ senderId: msgCtx.senderId,
4086
+ chatId: msgCtx.chatId,
4087
+ counts: dispatchResult.counts,
4088
+ });
2445
4089
  } else if (replyDelivered && dispatchResult?.queuedFinal === false) {
2446
4090
  logger.debug('Late reply arrived during fallback grace window, skipping fallback', {
2447
4091
  senderId: msgCtx.senderId,
@@ -2456,11 +4100,10 @@ export const bitrix24Plugin = {
2456
4100
  }
2457
4101
 
2458
4102
  if (!replyDelivered && dispatchFailed) {
2459
- await sendService.sendText(
2460
- sendCtx,
2461
- replyGenerationFailed(msgCtx.language),
2462
- { convertMarkdown: false },
2463
- );
4103
+ logger.warn('Reply dispatch failed before any user-visible message; fallback notice suppressed', {
4104
+ senderId: msgCtx.senderId,
4105
+ chatId: msgCtx.chatId,
4106
+ });
2464
4107
  }
2465
4108
  } finally {
2466
4109
  await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
@@ -3086,12 +4729,19 @@ export const bitrix24Plugin = {
3086
4729
  accountId: ctx.accountId,
3087
4730
  peer: conversation.peer,
3088
4731
  });
4732
+ const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
4733
+ const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(commandMessageId);
4734
+ const commandBodyForAgent = [
4735
+ fileDeliveryAgentHint,
4736
+ nativeReplyAgentHint,
4737
+ commandText,
4738
+ ].filter(Boolean).join('\n\n');
3089
4739
 
3090
4740
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
3091
4741
 
3092
4742
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
3093
4743
  Body: commandText,
3094
- BodyForAgent: commandText,
4744
+ BodyForAgent: commandBodyForAgent,
3095
4745
  RawBody: commandText,
3096
4746
  CommandBody: commandText,
3097
4747
  CommandAuthorized: true,
@@ -3131,20 +4781,29 @@ export const bitrix24Plugin = {
3131
4781
  deliver: async (payload) => {
3132
4782
  await replyStatusHeartbeat.stopAndWait();
3133
4783
  if (payload.text) {
4784
+ const replyDirective = extractReplyDirective({
4785
+ text: payload.text,
4786
+ currentMessageId: commandMessageId,
4787
+ explicitReplyToId: (payload as Record<string, unknown>).replyToId,
4788
+ });
3134
4789
  const keyboard = extractKeyboardFromPayload(payload)
3135
4790
  ?? defaultSessionKeyboard;
3136
4791
  const formattedPayload = normalizeCommandReplyPayload({
3137
4792
  commandName,
3138
4793
  commandParams,
3139
- text: payload.text,
4794
+ text: replyDirective.cleanText,
3140
4795
  language: cmdCtx.language,
3141
4796
  });
4797
+ const sendOptions = {
4798
+ keyboard,
4799
+ convertMarkdown: formattedPayload.convertMarkdown,
4800
+ ...(replyDirective.replyToMessageId ? { replyToMessageId: replyDirective.replyToMessageId } : {}),
4801
+ };
3142
4802
  if (!commandReplyDelivered) {
3143
- if (isDm) {
4803
+ if (isDm || replyDirective.replyToMessageId) {
3144
4804
  commandReplyDelivered = true;
3145
4805
  await sendService.sendText(sendCtx, formattedPayload.text, {
3146
- keyboard,
3147
- convertMarkdown: formattedPayload.convertMarkdown,
4806
+ ...sendOptions,
3148
4807
  });
3149
4808
  } else {
3150
4809
  commandReplyDelivered = true;
@@ -3157,10 +4816,7 @@ export const bitrix24Plugin = {
3157
4816
  }
3158
4817
 
3159
4818
  commandReplyDelivered = true;
3160
- await sendService.sendText(sendCtx, formattedPayload.text, {
3161
- keyboard,
3162
- convertMarkdown: formattedPayload.convertMarkdown,
3163
- });
4819
+ await sendService.sendText(sendCtx, formattedPayload.text, sendOptions);
3164
4820
  }
3165
4821
  },
3166
4822
  onReplyStart: async () => {
@@ -3183,18 +4839,12 @@ export const bitrix24Plugin = {
3183
4839
  }
3184
4840
 
3185
4841
  if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
3186
- const fallbackText = replyGenerationFailed(cmdCtx.language);
3187
- if (isDm) {
3188
- await sendService.sendText(sendCtx, fallbackText, {
3189
- keyboard: defaultSessionKeyboard,
3190
- convertMarkdown: false,
3191
- });
3192
- } else {
3193
- await sendService.answerCommandText(commandSendCtx, fallbackText, {
3194
- keyboard: defaultSessionKeyboard,
3195
- convertMarkdown: false,
3196
- });
3197
- }
4842
+ logger.warn('Command reply completed without a final user-visible message; fallback notice suppressed', {
4843
+ commandName,
4844
+ senderId,
4845
+ dialogId,
4846
+ counts: dispatchResult.counts,
4847
+ });
3198
4848
  } else if (commandReplyDelivered && dispatchResult?.queuedFinal === false) {
3199
4849
  logger.debug('Late command reply arrived during fallback grace window, skipping fallback', {
3200
4850
  commandName,
@@ -3210,18 +4860,11 @@ export const bitrix24Plugin = {
3210
4860
  }
3211
4861
 
3212
4862
  if (!commandReplyDelivered && commandDispatchFailed) {
3213
- const fallbackText = replyGenerationFailed(cmdCtx.language);
3214
- if (isDm) {
3215
- await sendService.sendText(sendCtx, fallbackText, {
3216
- keyboard: defaultSessionKeyboard,
3217
- convertMarkdown: false,
3218
- });
3219
- } else {
3220
- await sendService.answerCommandText(commandSendCtx, fallbackText, {
3221
- keyboard: defaultSessionKeyboard,
3222
- convertMarkdown: false,
3223
- });
3224
- }
4863
+ logger.warn('Command reply dispatch failed before any user-visible message; fallback notice suppressed', {
4864
+ commandName,
4865
+ senderId,
4866
+ dialogId,
4867
+ });
3225
4868
  }
3226
4869
  },
3227
4870