@ihazz/bitrix24 1.1.12 → 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 (44) 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 +1253 -42
  9. package/dist/src/channel.js.map +1 -1
  10. package/dist/src/i18n.js +68 -68
  11. package/dist/src/i18n.js.map +1 -1
  12. package/dist/src/inbound-handler.js +85 -7
  13. package/dist/src/inbound-handler.js.map +1 -1
  14. package/dist/src/media-service.d.ts +2 -0
  15. package/dist/src/media-service.d.ts.map +1 -1
  16. package/dist/src/media-service.js +117 -14
  17. package/dist/src/media-service.js.map +1 -1
  18. package/dist/src/message-utils.d.ts.map +1 -1
  19. package/dist/src/message-utils.js +73 -3
  20. package/dist/src/message-utils.js.map +1 -1
  21. package/dist/src/runtime.d.ts +1 -0
  22. package/dist/src/runtime.d.ts.map +1 -1
  23. package/dist/src/runtime.js.map +1 -1
  24. package/dist/src/send-service.d.ts +1 -0
  25. package/dist/src/send-service.d.ts.map +1 -1
  26. package/dist/src/send-service.js +26 -3
  27. package/dist/src/send-service.js.map +1 -1
  28. package/dist/src/state-paths.d.ts +1 -0
  29. package/dist/src/state-paths.d.ts.map +1 -1
  30. package/dist/src/state-paths.js +9 -0
  31. package/dist/src/state-paths.js.map +1 -1
  32. package/dist/src/types.d.ts +92 -0
  33. package/dist/src/types.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/api.ts +62 -13
  36. package/src/channel.ts +1734 -76
  37. package/src/i18n.ts +68 -68
  38. package/src/inbound-handler.ts +110 -7
  39. package/src/media-service.ts +146 -15
  40. package/src/message-utils.ts +90 -3
  41. package/src/runtime.ts +1 -0
  42. package/src/send-service.ts +40 -2
  43. package/src/state-paths.ts +11 -0
  44. package/src/types.ts +122 -0
package/src/channel.ts CHANGED
@@ -52,8 +52,11 @@ import {
52
52
  watchOwnerDmNotice,
53
53
  } from './i18n.js';
54
54
  import { HistoryCache } from './history-cache.js';
55
- import type { ConversationMeta } from './history-cache.js';
55
+ import type { ConversationMeta, HistoryEntry } from './history-cache.js';
56
56
  import type {
57
+ B24Attach,
58
+ B24AttachBlock,
59
+ B24AttachColorToken,
57
60
  B24MsgContext,
58
61
  B24InputActionStatusCode,
59
62
  B24V2FetchEventItem,
@@ -68,9 +71,12 @@ import type {
68
71
  B24V2GetMessageResult,
69
72
  B24V2User,
70
73
  B24Keyboard,
74
+ ChannelMessageToolDiscovery,
75
+ ExtractedToolSendTarget,
71
76
  KeyboardButton,
72
77
  Logger,
73
78
  } from './types.js';
79
+ import { markdownToBbCode } from './message-utils.js';
74
80
 
75
81
  const PHASE_STATUS_DURATION_SECONDS = 8;
76
82
  const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
@@ -93,7 +99,24 @@ const FORWARDED_CONTEXT_RANGE = 5;
93
99
  const ACTIVE_SESSION_NAMESPACE_TTL_MS = 180 * 24 * 60 * 60 * 1000;
94
100
  const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
95
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;
96
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
+ }>();
97
120
 
98
121
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
99
122
  // B24 uses named reaction codes, not Unicode emoji.
@@ -169,6 +192,606 @@ function toMessageId(value: string | number | undefined): number | undefined {
169
192
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
170
193
  }
171
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
+
172
795
  function escapeBbCodeText(value: string): string {
173
796
  return value
174
797
  .replace(/\[/g, '(')
@@ -1050,51 +1673,484 @@ interface GatewayState {
1050
1673
  eventMode: 'fetch' | 'webhook';
1051
1674
  }
1052
1675
 
1053
- let gatewayState: GatewayState | null = null;
1676
+ let gatewayState: GatewayState | null = null;
1677
+
1678
+ export function __setGatewayStateForTests(state: GatewayState | null): void {
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
+ );
1702
+ }
1703
+
1704
+ // ─── Keyboard layouts ────────────────────────────────────────────────────────
1705
+
1706
+ export function buildWelcomeKeyboard(language?: string): B24Keyboard {
1707
+ const labels = welcomeKeyboardLabels(language);
1708
+
1709
+ return [
1710
+ { TEXT: labels.todayTasks, ACTION: 'SEND', ACTION_VALUE: labels.todayTasks, DISPLAY: 'LINE' },
1711
+ { TEXT: labels.stalledDeals, ACTION: 'SEND', ACTION_VALUE: labels.stalledDeals, DISPLAY: 'LINE' },
1712
+ { TYPE: 'NEWLINE' },
1713
+ { TEXT: labels.newSession, COMMAND: 'new', DISPLAY: 'LINE' },
1714
+ { TEXT: labels.commands, COMMAND: 'commands', DISPLAY: 'LINE' },
1715
+ { TEXT: labels.help, COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
1716
+ ];
1717
+ }
1718
+
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
+ }
1054
2090
 
1055
- export function __setGatewayStateForTests(state: GatewayState | null): void {
1056
- gatewayState = state;
2091
+ return undefined;
1057
2092
  }
1058
2093
 
1059
- // ─── Keyboard layouts ────────────────────────────────────────────────────────
2094
+ function collectActionMediaUrls(params: Record<string, unknown>): string[] {
2095
+ const mediaUrls: string[] = [];
2096
+ const seen = new Set<string>();
1060
2097
 
1061
- export function buildWelcomeKeyboard(language?: string): B24Keyboard {
1062
- const labels = welcomeKeyboardLabels(language);
2098
+ const append = (value: unknown) => {
2099
+ if (Array.isArray(value)) {
2100
+ value.forEach(append);
2101
+ return;
2102
+ }
1063
2103
 
1064
- return [
1065
- { TEXT: labels.todayTasks, ACTION: 'SEND', ACTION_VALUE: labels.todayTasks, DISPLAY: 'LINE' },
1066
- { TEXT: labels.stalledDeals, ACTION: 'SEND', ACTION_VALUE: labels.stalledDeals, DISPLAY: 'LINE' },
1067
- { TYPE: 'NEWLINE' },
1068
- { TEXT: labels.newSession, COMMAND: 'new', DISPLAY: 'LINE' },
1069
- { TEXT: labels.commands, COMMAND: 'commands', DISPLAY: 'LINE' },
1070
- { TEXT: labels.help, COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
1071
- ];
1072
- }
2104
+ if (typeof value !== 'string') {
2105
+ return;
2106
+ }
1073
2107
 
1074
- /** Default keyboard shown with command responses and welcome messages. */
1075
- export function buildDefaultCommandKeyboard(language?: string): B24Keyboard {
1076
- const labels = commandKeyboardLabels(language);
2108
+ const trimmed = value.trim();
2109
+ if (!trimmed) {
2110
+ return;
2111
+ }
1077
2112
 
1078
- return [
1079
- { TEXT: labels.help, COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
1080
- { TEXT: labels.status, COMMAND: 'status', DISPLAY: 'LINE' },
1081
- { TEXT: labels.commands, COMMAND: 'commands', DISPLAY: 'LINE' },
1082
- { TYPE: 'NEWLINE' },
1083
- { TEXT: labels.newSession, COMMAND: 'new', DISPLAY: 'LINE' },
1084
- { TEXT: labels.models, COMMAND: 'models', DISPLAY: 'LINE' },
1085
- ];
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
+ };
2127
+
2128
+ append(params.mediaUrl);
2129
+ append(params.mediaUrls);
2130
+ append(params.filePath);
2131
+ append(params.filePaths);
2132
+
2133
+ return mediaUrls;
1086
2134
  }
1087
2135
 
1088
- export const DEFAULT_COMMAND_KEYBOARD: B24Keyboard = buildDefaultCommandKeyboard();
1089
- 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
+ }
1090
2148
 
1091
- // ─── Keyboard / Button conversion ────────────────────────────────────────────
2149
+ if (params.attach) {
2150
+ return null;
2151
+ }
1092
2152
 
1093
- /** Generic button format used by OpenClaw channelData. */
1094
- export interface ChannelButton {
1095
- text: string;
1096
- callback_data?: string;
1097
- style?: string;
2153
+ return params.keyboard ? ' ' : null;
1098
2154
  }
1099
2155
 
1100
2156
  function parseRegisteredCommandTrigger(callbackData: string): { command: string; commandParams?: string } | undefined {
@@ -1187,6 +2243,16 @@ export function extractKeyboardFromPayload(
1187
2243
  return undefined;
1188
2244
  }
1189
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
+
1190
2256
  /**
1191
2257
  * Extract inline button JSON embedded in message text by the agent.
1192
2258
  *
@@ -1220,6 +2286,14 @@ export function extractInlineButtonsFromText(
1220
2286
  }
1221
2287
  }
1222
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
+
1223
2297
  function normalizeCommandReplyPayload(params: {
1224
2298
  commandName: string;
1225
2299
  commandParams: string;
@@ -1577,11 +2651,12 @@ function resolveOutboundSendCtx(params: {
1577
2651
  accountId?: string;
1578
2652
  }): SendContext | null {
1579
2653
  const { config } = resolveAccount(params.cfg, params.accountId);
1580
- if (!config.webhookUrl || !gatewayState) return null;
2654
+ const dialogId = normalizeBitrix24DialogTarget(params.to);
2655
+ if (!config.webhookUrl || !gatewayState || !dialogId) return null;
1581
2656
  return {
1582
2657
  webhookUrl: config.webhookUrl,
1583
2658
  bot: gatewayState.bot,
1584
- dialogId: params.to,
2659
+ dialogId,
1585
2660
  };
1586
2661
  }
1587
2662
 
@@ -1665,13 +2740,10 @@ export const bitrix24Plugin = {
1665
2740
  },
1666
2741
 
1667
2742
  messaging: {
1668
- normalizeTarget: (raw: string) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
2743
+ normalizeTarget: (raw: string) => normalizeBitrix24DialogTarget(raw),
1669
2744
  targetResolver: {
1670
- hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
1671
- looksLikeId: (raw: string, _normalized: string) => {
1672
- const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
1673
- return /^\d+$/.test(stripped);
1674
- },
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),
1675
2747
  },
1676
2748
  },
1677
2749
 
@@ -1786,25 +2858,46 @@ export const bitrix24Plugin = {
1786
2858
  const sendCtx = resolveOutboundSendCtx(ctx);
1787
2859
  if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
1788
2860
 
2861
+ const text = ctx.text;
2862
+
1789
2863
  const keyboard = ctx.payload?.channelData
1790
2864
  ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
1791
2865
  : undefined;
2866
+ const attach = ctx.payload?.channelData
2867
+ ? extractAttachFromPayload({ channelData: ctx.payload.channelData })
2868
+ : undefined;
1792
2869
  const mediaUrls = collectOutboundMediaUrls({
1793
2870
  mediaUrl: ctx.mediaUrl,
1794
2871
  payload: ctx.payload as { mediaUrl?: string; mediaUrls?: string[] } | undefined,
1795
2872
  });
1796
2873
 
1797
2874
  if (mediaUrls.length > 0) {
2875
+ const initialMessage = !keyboard && !attach ? text || undefined : undefined;
1798
2876
  const uploadedMessageId = await uploadOutboundMedia({
1799
2877
  mediaService: gatewayState.mediaService,
1800
2878
  sendCtx,
1801
2879
  mediaUrls,
2880
+ initialMessage,
1802
2881
  });
1803
2882
 
1804
- 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
+
1805
2898
  const result = await gatewayState.sendService.sendText(
1806
2899
  sendCtx,
1807
- ctx.text,
2900
+ text || '',
1808
2901
  keyboard ? { keyboard } : undefined,
1809
2902
  );
1810
2903
  return { messageId: String(result.messageId ?? uploadedMessageId) };
@@ -1813,10 +2906,24 @@ export const bitrix24Plugin = {
1813
2906
  return { messageId: uploadedMessageId };
1814
2907
  }
1815
2908
 
1816
- 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
+
1817
2924
  const result = await gatewayState.sendService.sendText(
1818
2925
  sendCtx,
1819
- ctx.text,
2926
+ text || '',
1820
2927
  keyboard ? { keyboard } : undefined,
1821
2928
  );
1822
2929
  return { messageId: String(result.messageId ?? '') };
@@ -1828,12 +2935,23 @@ export const bitrix24Plugin = {
1828
2935
  // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
1829
2936
 
1830
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
+
1831
2947
  listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
1832
- return ['react', 'send'];
2948
+ return [...BITRIX24_DISCOVERY_ACTION_NAMES];
1833
2949
  },
1834
2950
 
1835
2951
  supportsAction: (params: { action: string }): boolean => {
1836
- return params.action === 'react' || params.action === 'send';
2952
+ return BITRIX24_ACTION_NAMES.includes(
2953
+ params.action as typeof BITRIX24_ACTION_NAMES[number],
2954
+ );
1837
2955
  },
1838
2956
 
1839
2957
  handleAction: async (ctx: {
@@ -1852,35 +2970,270 @@ export const bitrix24Plugin = {
1852
2970
 
1853
2971
  // ─── Send with buttons ──────────────────────────────────────────────
1854
2972
  if (ctx.action === 'send') {
1855
- // Only intercept send when buttons are present; otherwise let gateway handle normally
1856
2973
  const rawButtons = ctx.params.buttons;
1857
- if (!rawButtons) return null;
2974
+ const rawAttach = ctx.params.attach ?? ctx.params.attachments;
1858
2975
 
1859
2976
  const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1860
2977
  if (!config.webhookUrl || !gatewayState) return null;
1861
2978
 
1862
2979
  const bot = gatewayState.bot;
1863
- const to = String(ctx.params.to ?? ctx.to ?? '').trim();
2980
+ const to = readActionTargetParam(ctx.params, ctx.to);
1864
2981
  if (!to) {
1865
2982
  defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
1866
2983
  return null;
1867
2984
  }
1868
2985
 
1869
2986
  const sendCtx: SendContext = { webhookUrl: config.webhookUrl, bot, dialogId: to };
1870
- 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
+ }
1871
3028
 
1872
- // Parse buttons: may be array or JSON string
1873
- let buttons: ChannelButton[][] | undefined;
1874
3029
  try {
1875
- const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1876
- if (Array.isArray(parsed)) buttons = parsed;
1877
- } catch {
1878
- // invalid buttons JSON — send without keyboard
1879
- }
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
+ }
1880
3076
 
1881
- 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
+ }
1882
3236
 
1883
- try {
1884
3237
  const result = await gatewayState.sendService.sendText(
1885
3238
  sendCtx, message || ' ', keyboard ? { keyboard } : undefined,
1886
3239
  );
@@ -1897,6 +3250,192 @@ export const bitrix24Plugin = {
1897
3250
  }
1898
3251
  }
1899
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
+
1900
3439
  // ─── React ────────────────────────────────────────────────────────────
1901
3440
  if (ctx.action !== 'react') return null;
1902
3441
 
@@ -1954,11 +3493,13 @@ export const bitrix24Plugin = {
1954
3493
 
1955
3494
  try {
1956
3495
  await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
3496
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
1957
3497
  return toolResult({ ok: true, added: emoji });
1958
3498
  } catch (err) {
1959
3499
  const errMsg = err instanceof Error ? err.message : String(err);
1960
3500
  const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
1961
3501
  if (isAlreadySet) {
3502
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
1962
3503
  return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
1963
3504
  }
1964
3505
  return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
@@ -2326,9 +3867,22 @@ export const bitrix24Plugin = {
2326
3867
  query: bodyWithReply,
2327
3868
  historyCache,
2328
3869
  });
2329
- const bodyForAgent = crossChatContext
2330
- ? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
2331
- : 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');
2332
3886
  const combinedBody = msgCtx.isGroup
2333
3887
  ? buildHistoryContext({
2334
3888
  entries: previousEntries,
@@ -2337,6 +3891,13 @@ export const bitrix24Plugin = {
2337
3891
  : bodyForAgent;
2338
3892
 
2339
3893
  recordHistory(body);
3894
+ rememberRecentInboundMessage(
3895
+ conversation.dialogId,
3896
+ msgCtx.messageId,
3897
+ body,
3898
+ msgCtx.timestamp ?? Date.now(),
3899
+ ctx.accountId,
3900
+ );
2340
3901
 
2341
3902
  // Resolve which agent handles this conversation
2342
3903
  const route = runtime.channel.routing.resolveAgentRoute({
@@ -2401,7 +3962,31 @@ export const bitrix24Plugin = {
2401
3962
  });
2402
3963
  }
2403
3964
  if (payload.text) {
2404
- 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
+ }
2405
3990
  let keyboard = extractKeyboardFromPayload(payload);
2406
3991
 
2407
3992
  // Fallback: agent may embed button JSON in text as [[{...}]]
@@ -2413,8 +3998,68 @@ export const bitrix24Plugin = {
2413
3998
  }
2414
3999
  }
2415
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
+
2416
4026
  replyDelivered = true;
2417
- 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
+ );
2418
4063
  }
2419
4064
  },
2420
4065
  onReplyStart: async () => {
@@ -3084,12 +4729,19 @@ export const bitrix24Plugin = {
3084
4729
  accountId: ctx.accountId,
3085
4730
  peer: conversation.peer,
3086
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');
3087
4739
 
3088
4740
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
3089
4741
 
3090
4742
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
3091
4743
  Body: commandText,
3092
- BodyForAgent: commandText,
4744
+ BodyForAgent: commandBodyForAgent,
3093
4745
  RawBody: commandText,
3094
4746
  CommandBody: commandText,
3095
4747
  CommandAuthorized: true,
@@ -3129,20 +4781,29 @@ export const bitrix24Plugin = {
3129
4781
  deliver: async (payload) => {
3130
4782
  await replyStatusHeartbeat.stopAndWait();
3131
4783
  if (payload.text) {
4784
+ const replyDirective = extractReplyDirective({
4785
+ text: payload.text,
4786
+ currentMessageId: commandMessageId,
4787
+ explicitReplyToId: (payload as Record<string, unknown>).replyToId,
4788
+ });
3132
4789
  const keyboard = extractKeyboardFromPayload(payload)
3133
4790
  ?? defaultSessionKeyboard;
3134
4791
  const formattedPayload = normalizeCommandReplyPayload({
3135
4792
  commandName,
3136
4793
  commandParams,
3137
- text: payload.text,
4794
+ text: replyDirective.cleanText,
3138
4795
  language: cmdCtx.language,
3139
4796
  });
4797
+ const sendOptions = {
4798
+ keyboard,
4799
+ convertMarkdown: formattedPayload.convertMarkdown,
4800
+ ...(replyDirective.replyToMessageId ? { replyToMessageId: replyDirective.replyToMessageId } : {}),
4801
+ };
3140
4802
  if (!commandReplyDelivered) {
3141
- if (isDm) {
4803
+ if (isDm || replyDirective.replyToMessageId) {
3142
4804
  commandReplyDelivered = true;
3143
4805
  await sendService.sendText(sendCtx, formattedPayload.text, {
3144
- keyboard,
3145
- convertMarkdown: formattedPayload.convertMarkdown,
4806
+ ...sendOptions,
3146
4807
  });
3147
4808
  } else {
3148
4809
  commandReplyDelivered = true;
@@ -3155,10 +4816,7 @@ export const bitrix24Plugin = {
3155
4816
  }
3156
4817
 
3157
4818
  commandReplyDelivered = true;
3158
- await sendService.sendText(sendCtx, formattedPayload.text, {
3159
- keyboard,
3160
- convertMarkdown: formattedPayload.convertMarkdown,
3161
- });
4819
+ await sendService.sendText(sendCtx, formattedPayload.text, sendOptions);
3162
4820
  }
3163
4821
  },
3164
4822
  onReplyStart: async () => {