@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
@@ -16,6 +16,7 @@ import { getBitrix24Runtime } from './runtime.js';
16
16
  import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply, getCommandRegistrationPayload, } from './commands.js';
17
17
  import { accessApproved, accessDenied, commandKeyboardLabels, groupPairingPending, mediaDownloadFailed, groupChatUnsupported, newSessionReplyTexts, onboardingDisclaimerMessage, onboardingMessage, normalizeNewSessionReply, ownerAndAllowedUsersOnly, personalBotOwnerOnly, welcomeKeyboardLabels, watchOwnerDmNotice, } from './i18n.js';
18
18
  import { HistoryCache } from './history-cache.js';
19
+ import { markdownToBbCode } from './message-utils.js';
19
20
  const PHASE_STATUS_DURATION_SECONDS = 8;
20
21
  const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
21
22
  const THINKING_STATUS_DURATION_SECONDS = 30;
@@ -37,7 +38,15 @@ const FORWARDED_CONTEXT_RANGE = 5;
37
38
  const ACTIVE_SESSION_NAMESPACE_TTL_MS = 180 * 24 * 60 * 60 * 1000;
38
39
  const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
39
40
  const ACTIVE_SESSION_NAMESPACE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
41
+ const NATIVE_FORWARD_ACK_TTL_MS = 60 * 1000;
42
+ const NATIVE_REACTION_ACK_TTL_MS = 60 * 1000;
43
+ const RECENT_INBOUND_MESSAGE_TTL_MS = 30 * 60 * 1000;
44
+ const RECENT_INBOUND_MESSAGE_LIMIT = 20;
40
45
  const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
46
+ const pendingNativeForwardAcks = new Map();
47
+ const pendingNativeReactionAcks = new Map();
48
+ const recentInboundMessagesByDialog = new Map();
49
+ const inboundMessageContextById = new Map();
41
50
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
42
51
  // B24 uses named reaction codes, not Unicode emoji.
43
52
  // Map common Unicode emoji to their B24 equivalents.
@@ -109,6 +118,424 @@ function toMessageId(value) {
109
118
  const parsed = Number(value);
110
119
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
111
120
  }
121
+ function resolveStateAccountId(accountId) {
122
+ const normalizedAccountId = typeof accountId === 'string'
123
+ ? accountId.trim()
124
+ : '';
125
+ if (normalizedAccountId) {
126
+ return normalizedAccountId;
127
+ }
128
+ const gatewayAccountId = gatewayState?.accountId?.trim();
129
+ return gatewayAccountId || 'default';
130
+ }
131
+ function buildAccountDialogScopeKey(accountId, dialogId) {
132
+ const normalizedDialogId = dialogId.trim();
133
+ if (!normalizedDialogId) {
134
+ return undefined;
135
+ }
136
+ return `${resolveStateAccountId(accountId)}:${normalizedDialogId}`;
137
+ }
138
+ function buildAccountMessageScopeKey(accountId, currentMessageId) {
139
+ const normalizedMessageId = toMessageId(currentMessageId);
140
+ if (!normalizedMessageId) {
141
+ return undefined;
142
+ }
143
+ return `${resolveStateAccountId(accountId)}:${normalizedMessageId}`;
144
+ }
145
+ function buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId) {
146
+ const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
147
+ const normalizedMessageId = toMessageId(currentMessageId);
148
+ if (!dialogKey || !normalizedMessageId) {
149
+ return undefined;
150
+ }
151
+ return `${dialogKey}:${normalizedMessageId}`;
152
+ }
153
+ function prunePendingNativeForwardAcks(now = Date.now()) {
154
+ for (const [key, expiresAt] of pendingNativeForwardAcks.entries()) {
155
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
156
+ pendingNativeForwardAcks.delete(key);
157
+ }
158
+ }
159
+ }
160
+ function markPendingNativeForwardAck(dialogId, currentMessageId, accountId) {
161
+ const key = buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
162
+ if (!key) {
163
+ return;
164
+ }
165
+ prunePendingNativeForwardAcks();
166
+ pendingNativeForwardAcks.set(key, Date.now() + NATIVE_FORWARD_ACK_TTL_MS);
167
+ }
168
+ function consumePendingNativeForwardAck(dialogId, currentMessageId, accountId) {
169
+ const key = buildPendingNativeForwardAckKey(dialogId, currentMessageId, accountId);
170
+ if (!key) {
171
+ return false;
172
+ }
173
+ prunePendingNativeForwardAcks();
174
+ const expiresAt = pendingNativeForwardAcks.get(key);
175
+ if (!expiresAt) {
176
+ return false;
177
+ }
178
+ pendingNativeForwardAcks.delete(key);
179
+ return true;
180
+ }
181
+ function resolvePendingNativeForwardAckDialogId(currentMessageId, fallbackDialogId, accountId) {
182
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
183
+ if (!messageKey) {
184
+ return fallbackDialogId;
185
+ }
186
+ return inboundMessageContextById.get(messageKey)?.dialogId ?? fallbackDialogId;
187
+ }
188
+ function buildPendingNativeReactionAckKey(currentMessageId, accountId) {
189
+ return buildAccountMessageScopeKey(accountId, currentMessageId);
190
+ }
191
+ function prunePendingNativeReactionAcks(now = Date.now()) {
192
+ for (const [key, entry] of pendingNativeReactionAcks.entries()) {
193
+ if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
194
+ pendingNativeReactionAcks.delete(key);
195
+ }
196
+ }
197
+ }
198
+ function markPendingNativeReactionAck(currentMessageId, emoji, accountId) {
199
+ const key = buildPendingNativeReactionAckKey(currentMessageId, accountId);
200
+ const normalizedEmoji = emoji.trim();
201
+ if (!key || !normalizedEmoji) {
202
+ return;
203
+ }
204
+ prunePendingNativeReactionAcks();
205
+ pendingNativeReactionAcks.set(key, {
206
+ emoji: normalizedEmoji,
207
+ expiresAt: Date.now() + NATIVE_REACTION_ACK_TTL_MS,
208
+ });
209
+ }
210
+ function consumePendingNativeReactionAck(currentMessageId, text, accountId) {
211
+ const key = buildPendingNativeReactionAckKey(currentMessageId, accountId);
212
+ if (!key) {
213
+ return false;
214
+ }
215
+ prunePendingNativeReactionAcks();
216
+ const pendingAck = pendingNativeReactionAcks.get(key);
217
+ if (!pendingAck) {
218
+ return false;
219
+ }
220
+ if (normalizeComparableMessageText(text) !== pendingAck.emoji) {
221
+ return false;
222
+ }
223
+ pendingNativeReactionAcks.delete(key);
224
+ return true;
225
+ }
226
+ function pruneRecentInboundMessages(now = Date.now()) {
227
+ for (const [dialogKey, entries] of recentInboundMessagesByDialog.entries()) {
228
+ const nextEntries = entries.filter((entry) => Number.isFinite(entry.timestamp) && entry.timestamp > now - RECENT_INBOUND_MESSAGE_TTL_MS);
229
+ if (nextEntries.length === 0) {
230
+ recentInboundMessagesByDialog.delete(dialogKey);
231
+ }
232
+ else if (nextEntries.length !== entries.length) {
233
+ recentInboundMessagesByDialog.set(dialogKey, nextEntries);
234
+ }
235
+ }
236
+ for (const [messageId, entry] of inboundMessageContextById.entries()) {
237
+ if (!Number.isFinite(entry.timestamp) || entry.timestamp <= now - RECENT_INBOUND_MESSAGE_TTL_MS) {
238
+ inboundMessageContextById.delete(messageId);
239
+ }
240
+ }
241
+ }
242
+ function rememberRecentInboundMessage(dialogId, messageId, body, timestamp = Date.now(), accountId) {
243
+ const normalizedMessageId = toMessageId(messageId);
244
+ const normalizedBody = body.trim();
245
+ const dialogKey = buildAccountDialogScopeKey(accountId, dialogId);
246
+ const messageKey = buildAccountMessageScopeKey(accountId, normalizedMessageId);
247
+ if (!dialogKey || !messageKey || !normalizedBody) {
248
+ return;
249
+ }
250
+ pruneRecentInboundMessages(timestamp);
251
+ const id = String(normalizedMessageId);
252
+ const currentEntries = recentInboundMessagesByDialog.get(dialogKey) ?? [];
253
+ const nextEntries = currentEntries
254
+ .filter((entry) => entry.messageId !== id)
255
+ .concat({ messageId: id, body: normalizedBody, timestamp })
256
+ .slice(-RECENT_INBOUND_MESSAGE_LIMIT);
257
+ recentInboundMessagesByDialog.set(dialogKey, nextEntries);
258
+ inboundMessageContextById.set(messageKey, {
259
+ dialogId,
260
+ dialogKey,
261
+ body: normalizedBody,
262
+ timestamp,
263
+ });
264
+ }
265
+ function findPreviousRecentInboundMessage(currentMessageId, accountId) {
266
+ const messageKey = buildAccountMessageScopeKey(accountId, currentMessageId);
267
+ const normalizedMessageId = toMessageId(currentMessageId);
268
+ if (!messageKey || !normalizedMessageId) {
269
+ return undefined;
270
+ }
271
+ pruneRecentInboundMessages();
272
+ const currentEntry = inboundMessageContextById.get(messageKey);
273
+ if (!currentEntry) {
274
+ return undefined;
275
+ }
276
+ const dialogEntries = recentInboundMessagesByDialog.get(currentEntry.dialogKey);
277
+ if (!dialogEntries || dialogEntries.length === 0) {
278
+ return undefined;
279
+ }
280
+ const currentIndex = dialogEntries.findIndex((entry) => entry.messageId === String(normalizedMessageId));
281
+ if (currentIndex <= 0) {
282
+ return undefined;
283
+ }
284
+ for (let index = currentIndex - 1; index >= 0; index -= 1) {
285
+ const entry = dialogEntries[index];
286
+ if (entry.body.trim()) {
287
+ return entry;
288
+ }
289
+ }
290
+ return undefined;
291
+ }
292
+ const INLINE_REPLY_DIRECTIVE_RE = /\[\[\s*(reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
293
+ function normalizeComparableMessageText(value) {
294
+ return value
295
+ .replace(/\s+/g, ' ')
296
+ .trim();
297
+ }
298
+ function normalizeBitrix24DialogTarget(raw) {
299
+ const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
300
+ if (!stripped) {
301
+ return '';
302
+ }
303
+ const bracketedUserMatch = stripped.match(/^\[USER=(\d+)(?:[^\]]*)\][\s\S]*?\[\/USER\]$/iu);
304
+ if (bracketedUserMatch?.[1]) {
305
+ return bracketedUserMatch[1];
306
+ }
307
+ const bracketedChatMatch = stripped.match(/^\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]$/iu);
308
+ if (bracketedChatMatch?.[1]) {
309
+ return `chat${bracketedChatMatch[1]}`;
310
+ }
311
+ const plainUserMatch = stripped.match(/^USER=(\d+)$/iu);
312
+ if (plainUserMatch?.[1]) {
313
+ return plainUserMatch[1];
314
+ }
315
+ const plainChatMatch = stripped.match(/^CHAT=(?:chat)?(\d+)$/iu);
316
+ if (plainChatMatch?.[1]) {
317
+ return `chat${plainChatMatch[1]}`;
318
+ }
319
+ const prefixedChatMatch = stripped.match(/^chat(\d+)$/iu);
320
+ if (prefixedChatMatch?.[1]) {
321
+ return `chat${prefixedChatMatch[1]}`;
322
+ }
323
+ return stripped;
324
+ }
325
+ function looksLikeBitrix24DialogTarget(raw) {
326
+ const normalized = normalizeBitrix24DialogTarget(raw);
327
+ return /^\d+$/.test(normalized) || /^chat\d+$/iu.test(normalized);
328
+ }
329
+ function extractBitrix24DialogTargetsFromText(text) {
330
+ const targets = new Set();
331
+ for (const match of text.matchAll(/\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]/giu)) {
332
+ if (match[1]) {
333
+ targets.add(`chat${match[1]}`);
334
+ }
335
+ }
336
+ for (const match of text.matchAll(/\[USER=(\d+)(?:[^\]]*)\][\s\S]*?\[\/USER\]/giu)) {
337
+ if (match[1]) {
338
+ targets.add(match[1]);
339
+ }
340
+ }
341
+ for (const match of text.matchAll(/\bCHAT=(?:chat)?(\d+)\b/giu)) {
342
+ if (match[1]) {
343
+ targets.add(`chat${match[1]}`);
344
+ }
345
+ }
346
+ for (const match of text.matchAll(/\bUSER=(\d+)\b/giu)) {
347
+ if (match[1]) {
348
+ targets.add(match[1]);
349
+ }
350
+ }
351
+ return [...targets];
352
+ }
353
+ function isLikelyForwardControlMessage(text) {
354
+ return extractBitrix24DialogTargetsFromText(text).length > 0;
355
+ }
356
+ function extractReplyDirective(params) {
357
+ let wantsCurrentReply = false;
358
+ let inlineReplyToId;
359
+ const cleanText = cleanupTextAfterInlineMediaExtraction(params.text.replace(INLINE_REPLY_DIRECTIVE_RE, (_match, _rawDirective, explicitId) => {
360
+ if (typeof explicitId === 'string' && explicitId.trim()) {
361
+ inlineReplyToId = explicitId.trim();
362
+ }
363
+ else {
364
+ wantsCurrentReply = true;
365
+ }
366
+ return ' ';
367
+ }));
368
+ const explicitReplyToMessageId = parseActionMessageIds(params.explicitReplyToId)[0];
369
+ if (explicitReplyToMessageId) {
370
+ return { cleanText, replyToMessageId: explicitReplyToMessageId };
371
+ }
372
+ const inlineReplyToMessageId = parseActionMessageIds(inlineReplyToId)[0];
373
+ if (inlineReplyToMessageId) {
374
+ return { cleanText, replyToMessageId: inlineReplyToMessageId };
375
+ }
376
+ if (wantsCurrentReply) {
377
+ const currentReplyToMessageId = parseActionMessageIds(params.currentMessageId)[0];
378
+ if (currentReplyToMessageId) {
379
+ return { cleanText, replyToMessageId: currentReplyToMessageId };
380
+ }
381
+ }
382
+ return { cleanText };
383
+ }
384
+ function findNativeForwardTarget(params) {
385
+ const deliveredText = normalizeComparableMessageText(params.deliveredText);
386
+ if (!deliveredText) {
387
+ return undefined;
388
+ }
389
+ if (deliveredText === normalizeComparableMessageText(params.requestText)) {
390
+ return undefined;
391
+ }
392
+ const currentMessageId = toMessageId(params.currentMessageId);
393
+ const matchedEntry = [...params.historyEntries]
394
+ .reverse()
395
+ .find((entry) => {
396
+ if (normalizeComparableMessageText(entry.body) !== deliveredText) {
397
+ return false;
398
+ }
399
+ return currentMessageId === undefined || toMessageId(entry.messageId) !== currentMessageId;
400
+ });
401
+ if (!matchedEntry) {
402
+ return undefined;
403
+ }
404
+ return toMessageId(matchedEntry.messageId);
405
+ }
406
+ function findImplicitForwardTargetFromRecentInbound(params) {
407
+ const normalizedDeliveredText = normalizeComparableMessageText(params.deliveredText);
408
+ const normalizedRequestText = normalizeComparableMessageText(params.requestText);
409
+ const targetDialogIds = extractBitrix24DialogTargetsFromText(params.requestText);
410
+ const isForwardMarkerOnly = Boolean(normalizedDeliveredText) && FORWARD_MARKER_RE.test(normalizedDeliveredText);
411
+ if (!normalizedDeliveredText
412
+ || (!isForwardMarkerOnly
413
+ && (targetDialogIds.length === 0
414
+ || normalizedDeliveredText !== normalizedRequestText))) {
415
+ return undefined;
416
+ }
417
+ const previousInboundMessage = findPreviousRecentInboundMessage(params.currentMessageId, params.accountId);
418
+ if (previousInboundMessage) {
419
+ return Number(previousInboundMessage.messageId);
420
+ }
421
+ const previousHistoryEntry = [...(params.historyEntries ?? [])]
422
+ .reverse()
423
+ .find((entry) => toMessageId(entry.messageId));
424
+ return toMessageId(previousHistoryEntry?.messageId);
425
+ }
426
+ function findPreviousForwardableMessageId(params) {
427
+ // If the user replied to a specific message, that's the source to forward
428
+ const replyToId = toMessageId(params.replyToMessageId);
429
+ if (replyToId) {
430
+ return replyToId;
431
+ }
432
+ const normalizedCurrentBody = normalizeComparableMessageText(params.currentBody);
433
+ const previousHistoryEntry = [...(params.historyEntries ?? [])]
434
+ .reverse()
435
+ .find((entry) => {
436
+ const messageId = toMessageId(entry.messageId);
437
+ const normalizedBody = normalizeComparableMessageText(entry.body);
438
+ if (!messageId || !normalizedBody) {
439
+ return false;
440
+ }
441
+ if (normalizedCurrentBody && normalizedBody === normalizedCurrentBody) {
442
+ return false;
443
+ }
444
+ return !isLikelyForwardControlMessage(entry.body);
445
+ });
446
+ if (previousHistoryEntry) {
447
+ return toMessageId(previousHistoryEntry.messageId);
448
+ }
449
+ const previousInboundMessage = findPreviousRecentInboundMessage(params.currentMessageId, params.accountId);
450
+ if (previousInboundMessage) {
451
+ if (normalizeComparableMessageText(previousInboundMessage.body) !== normalizedCurrentBody
452
+ && !isLikelyForwardControlMessage(previousInboundMessage.body)) {
453
+ return Number(previousInboundMessage.messageId);
454
+ }
455
+ }
456
+ return undefined;
457
+ }
458
+ function buildBitrix24NativeForwardAgentHint(params) {
459
+ const targetDialogIds = extractBitrix24DialogTargetsFromText(params.currentBody);
460
+ const sourceMessageId = findPreviousForwardableMessageId(params);
461
+ if (!sourceMessageId) {
462
+ return undefined;
463
+ }
464
+ const formattedTargets = targetDialogIds
465
+ .map((dialogId) => (dialogId.startsWith('chat') ? `${dialogId} (chat)` : `${dialogId} (user)`))
466
+ .join(', ');
467
+ const lines = [
468
+ '[Bitrix24 native forward instruction]',
469
+ 'Use this only if you intentionally want a native Bitrix24 forward instead of repeating visible text.',
470
+ `Previous Bitrix24 message id available for forwarding: ${sourceMessageId}.`,
471
+ 'Use action "send" with "forwardMessageIds" set to that source message id.',
472
+ 'If the tool requires a non-empty message field for a pure forward, use "↩️".',
473
+ 'Do not repeat the source message text or the user instruction text when doing a native forward.',
474
+ ];
475
+ if (formattedTargets) {
476
+ lines.push(`Explicit target dialog ids mentioned in the current message: ${formattedTargets}.`);
477
+ 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.');
478
+ lines.push('Do not use CHAT=xxx or USER=xxx format for "to" — use "chat520" or "77" directly.');
479
+ }
480
+ else {
481
+ lines.push('If you forward in the current dialog, do not set "to" to another dialog id.');
482
+ }
483
+ lines.push('[/Bitrix24 native forward instruction]');
484
+ return lines.join('\n');
485
+ }
486
+ function buildBitrix24NativeReplyAgentHint(currentMessageId) {
487
+ const normalizedMessageId = toMessageId(currentMessageId);
488
+ if (!normalizedMessageId) {
489
+ return undefined;
490
+ }
491
+ return [
492
+ '[Bitrix24 native reply instruction]',
493
+ `Current Bitrix24 message id: ${normalizedMessageId}.`,
494
+ 'If you intentionally want a native Bitrix24 reply to the current inbound message, set "replyToMessageId" to that id.',
495
+ 'For text-only payloads you can also prepend [[reply_to_current]] to the reply text.',
496
+ 'Do not use a native reply unless you actually want reply threading.',
497
+ '[/Bitrix24 native reply instruction]',
498
+ ].join('\n');
499
+ }
500
+ function buildBitrix24FileDeliveryAgentHint() {
501
+ return [
502
+ '[Bitrix24 file delivery instruction]',
503
+ 'When you need to deliver a real file or document to the user, use structured reply payload field "mediaUrl" or "mediaUrls".',
504
+ 'Set "mediaUrl" to the local path of the generated file in the OpenClaw workspace or managed media directory.',
505
+ 'Use "text" only for an optional short caption.',
506
+ 'Do not place local file paths inside markdown links or plain text, because that only sends text to the user and does not upload the file.',
507
+ '[/Bitrix24 file delivery instruction]',
508
+ ].join('\n');
509
+ }
510
+ function readExplicitForwardMessageIds(rawParams) {
511
+ return parseActionMessageIds(rawParams.forwardMessageIds
512
+ ?? rawParams.forwardIds);
513
+ }
514
+ function resolveActionForwardMessageIds(params) {
515
+ const explicitForwardMessages = readExplicitForwardMessageIds(params.rawParams);
516
+ if (explicitForwardMessages.length > 0) {
517
+ return explicitForwardMessages;
518
+ }
519
+ const aliasedForwardMessages = parseActionMessageIds(params.rawParams.messageIds
520
+ ?? params.rawParams.message_ids
521
+ ?? params.rawParams.messageId
522
+ ?? params.rawParams.message_id
523
+ ?? params.rawParams.replyToMessageId
524
+ ?? params.rawParams.replyToId);
525
+ if (aliasedForwardMessages.length > 0) {
526
+ return aliasedForwardMessages;
527
+ }
528
+ const previousInboundMessage = findPreviousRecentInboundMessage(params.toolContext?.currentMessageId, params.accountId);
529
+ return previousInboundMessage ? [Number(previousInboundMessage.messageId)] : [];
530
+ }
531
+ const FORWARD_MARKER_RE = /^[\s↩️➡️→⤵️🔄📨]+$/u;
532
+ function normalizeForwardActionText(text) {
533
+ const normalizedText = typeof text === 'string' ? text.trim() : '';
534
+ if (!normalizedText || FORWARD_MARKER_RE.test(normalizedText)) {
535
+ return '';
536
+ }
537
+ return normalizedText;
538
+ }
112
539
  function escapeBbCodeText(value) {
113
540
  return value
114
541
  .replace(/\[/g, '(')
@@ -741,6 +1168,15 @@ class BufferedDirectMessageCoalescer {
741
1168
  let gatewayState = null;
742
1169
  export function __setGatewayStateForTests(state) {
743
1170
  gatewayState = state;
1171
+ if (state === null) {
1172
+ pendingNativeForwardAcks.clear();
1173
+ pendingNativeReactionAcks.clear();
1174
+ recentInboundMessagesByDialog.clear();
1175
+ inboundMessageContextById.clear();
1176
+ }
1177
+ }
1178
+ export function __rememberRecentInboundMessageForTests(params) {
1179
+ rememberRecentInboundMessage(params.dialogId, params.messageId, params.body, params.timestamp, params.accountId);
744
1180
  }
745
1181
  // ─── Keyboard layouts ────────────────────────────────────────────────────────
746
1182
  export function buildWelcomeKeyboard(language) {
@@ -768,6 +1204,341 @@ export function buildDefaultCommandKeyboard(language) {
768
1204
  }
769
1205
  export const DEFAULT_COMMAND_KEYBOARD = buildDefaultCommandKeyboard();
770
1206
  export const DEFAULT_WELCOME_KEYBOARD = buildWelcomeKeyboard();
1207
+ const BITRIX24_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'delete'];
1208
+ const BITRIX24_DISCOVERY_ACTION_NAMES = ['send', 'reply', 'react', 'edit', 'delete'];
1209
+ function buildMessageToolButtonsSchema() {
1210
+ return {
1211
+ type: 'array',
1212
+ description: 'Optional Bitrix24 message buttons as rows of button objects.',
1213
+ items: {
1214
+ type: 'array',
1215
+ items: {
1216
+ type: 'object',
1217
+ additionalProperties: false,
1218
+ properties: {
1219
+ text: {
1220
+ type: 'string',
1221
+ description: 'Visible button label.',
1222
+ },
1223
+ callback_data: {
1224
+ type: 'string',
1225
+ description: 'Registered command like /help, or any plain text payload to send when tapped.',
1226
+ },
1227
+ style: {
1228
+ type: 'string',
1229
+ enum: ['primary', 'attention', 'danger'],
1230
+ description: 'Optional Bitrix24 button accent.',
1231
+ },
1232
+ },
1233
+ required: ['text'],
1234
+ },
1235
+ },
1236
+ };
1237
+ }
1238
+ function buildMessageToolAttachSchema() {
1239
+ const blockSchema = {
1240
+ type: 'object',
1241
+ description: 'Single Bitrix24 ATTACH rich block. Supported top-level keys are MESSAGE, LINK, IMAGE, FILE, DELIMITER, GRID or USER.',
1242
+ };
1243
+ return {
1244
+ description: 'Bitrix24 ATTACH rich layout blocks. Use this for rich cards, not for binary file uploads. For a status-owner-link card, prefer blocks like USER, LINK and GRID. Example: [{"USER":{"NAME":"Alice"}},{"LINK":{"NAME":"Open","LINK":"https://example.com"}},{"GRID":[{"NAME":"Status","VALUE":"In progress","DISPLAY":"LINE"},{"NAME":"Owner","VALUE":"Alice","DISPLAY":"LINE"}]}]. Use mediaUrl/mediaUrls for actual files and media.',
1245
+ anyOf: [
1246
+ {
1247
+ type: 'object',
1248
+ additionalProperties: false,
1249
+ properties: {
1250
+ ID: { type: 'integer' },
1251
+ COLOR_TOKEN: {
1252
+ type: 'string',
1253
+ enum: ['primary', 'secondary', 'alert', 'base'],
1254
+ },
1255
+ COLOR: { type: 'string' },
1256
+ BLOCKS: {
1257
+ type: 'array',
1258
+ items: blockSchema,
1259
+ },
1260
+ },
1261
+ required: ['BLOCKS'],
1262
+ },
1263
+ {
1264
+ type: 'array',
1265
+ items: blockSchema,
1266
+ },
1267
+ ],
1268
+ };
1269
+ }
1270
+ function buildMessageToolIdListSchema(description) {
1271
+ return {
1272
+ anyOf: [
1273
+ {
1274
+ type: 'integer',
1275
+ description,
1276
+ },
1277
+ {
1278
+ type: 'string',
1279
+ description: `${description} Can also be a comma-separated list or JSON array string.`,
1280
+ },
1281
+ {
1282
+ type: 'array',
1283
+ description,
1284
+ items: {
1285
+ type: 'integer',
1286
+ },
1287
+ },
1288
+ ],
1289
+ };
1290
+ }
1291
+ function resolveConfiguredBitrix24ActionAccounts(cfg, accountId) {
1292
+ if (accountId) {
1293
+ const account = resolveAccount(cfg, accountId);
1294
+ return account.enabled && account.configured ? [account.accountId] : [];
1295
+ }
1296
+ return listAccountIds(cfg).filter((candidateId) => {
1297
+ const account = resolveAccount(cfg, candidateId);
1298
+ return account.enabled && account.configured;
1299
+ });
1300
+ }
1301
+ function describeBitrix24MessageTool(params) {
1302
+ const configuredAccounts = resolveConfiguredBitrix24ActionAccounts(params.cfg, params.accountId);
1303
+ if (configuredAccounts.length === 0) {
1304
+ return {
1305
+ actions: [],
1306
+ capabilities: [],
1307
+ schema: null,
1308
+ };
1309
+ }
1310
+ return {
1311
+ actions: [...BITRIX24_DISCOVERY_ACTION_NAMES],
1312
+ capabilities: ['interactive', 'buttons', 'cards'],
1313
+ schema: [
1314
+ {
1315
+ properties: {
1316
+ buttons: buildMessageToolButtonsSchema(),
1317
+ },
1318
+ },
1319
+ {
1320
+ properties: {
1321
+ attach: buildMessageToolAttachSchema(),
1322
+ forwardMessageIds: buildMessageToolIdListSchema('Bitrix24 source message id(s) to forward natively. Use with action="send" plus to/target. Prefer the referenced message id, not the current instruction message id. The message field must be non-empty — use "↩️" (emoji) for a pure forward without extra text.'),
1323
+ forwardIds: buildMessageToolIdListSchema('Alias for forwardMessageIds. Use with action="send".'),
1324
+ replyToId: buildMessageToolIdListSchema('Bitrix24 message id to reply to natively inside the current dialog.'),
1325
+ replyToMessageId: buildMessageToolIdListSchema('Alias for replyToId.'),
1326
+ },
1327
+ },
1328
+ ],
1329
+ };
1330
+ }
1331
+ function extractBitrix24ToolSend(args) {
1332
+ if ((typeof args.action === 'string' ? args.action.trim() : '') !== 'sendMessage') {
1333
+ return null;
1334
+ }
1335
+ const to = typeof args.to === 'string' ? normalizeBitrix24DialogTarget(args.to) : '';
1336
+ if (!to) {
1337
+ return null;
1338
+ }
1339
+ const accountId = typeof args.accountId === 'string'
1340
+ ? args.accountId.trim()
1341
+ : undefined;
1342
+ const threadId = typeof args.threadId === 'number'
1343
+ ? String(args.threadId)
1344
+ : typeof args.threadId === 'string'
1345
+ ? args.threadId.trim()
1346
+ : '';
1347
+ return {
1348
+ to,
1349
+ ...(accountId ? { accountId } : {}),
1350
+ ...(threadId ? { threadId } : {}),
1351
+ };
1352
+ }
1353
+ function readActionTextParam(params) {
1354
+ const rawText = params.message ?? params.text ?? params.content;
1355
+ return typeof rawText === 'string' ? rawText : null;
1356
+ }
1357
+ function isPlainObject(value) {
1358
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1359
+ }
1360
+ function isAttachColorToken(value) {
1361
+ return value === 'primary'
1362
+ || value === 'secondary'
1363
+ || value === 'alert'
1364
+ || value === 'base';
1365
+ }
1366
+ function isAttachBlock(value) {
1367
+ return isPlainObject(value) && Object.keys(value).length > 0;
1368
+ }
1369
+ function isBitrix24Attach(value) {
1370
+ if (Array.isArray(value)) {
1371
+ return value.length > 0 && value.every(isAttachBlock);
1372
+ }
1373
+ if (!isPlainObject(value) || !Array.isArray(value.BLOCKS) || value.BLOCKS.length === 0) {
1374
+ return false;
1375
+ }
1376
+ if (value.COLOR_TOKEN !== undefined && !isAttachColorToken(value.COLOR_TOKEN)) {
1377
+ return false;
1378
+ }
1379
+ return value.BLOCKS.every(isAttachBlock);
1380
+ }
1381
+ function parseActionAttach(rawValue) {
1382
+ if (rawValue == null) {
1383
+ return undefined;
1384
+ }
1385
+ let parsed = rawValue;
1386
+ if (typeof rawValue === 'string') {
1387
+ const trimmed = rawValue.trim();
1388
+ if (!trimmed) {
1389
+ return undefined;
1390
+ }
1391
+ try {
1392
+ parsed = JSON.parse(trimmed);
1393
+ }
1394
+ catch {
1395
+ return undefined;
1396
+ }
1397
+ }
1398
+ return isBitrix24Attach(parsed) ? parsed : undefined;
1399
+ }
1400
+ function hasMeaningfulAttachInput(rawValue) {
1401
+ if (rawValue == null) {
1402
+ return false;
1403
+ }
1404
+ if (typeof rawValue === 'string') {
1405
+ const trimmed = rawValue.trim();
1406
+ if (!trimmed) {
1407
+ return false;
1408
+ }
1409
+ try {
1410
+ return hasMeaningfulAttachInput(JSON.parse(trimmed));
1411
+ }
1412
+ catch {
1413
+ return true;
1414
+ }
1415
+ }
1416
+ if (Array.isArray(rawValue)) {
1417
+ return rawValue.length > 0;
1418
+ }
1419
+ if (isPlainObject(rawValue)) {
1420
+ if (Array.isArray(rawValue.BLOCKS)) {
1421
+ return rawValue.BLOCKS.length > 0;
1422
+ }
1423
+ return Object.keys(rawValue).length > 0;
1424
+ }
1425
+ return true;
1426
+ }
1427
+ function readActionTargetParam(params, fallbackTarget) {
1428
+ const rawTarget = params.to ?? params.target ?? fallbackTarget;
1429
+ return typeof rawTarget === 'string' ? normalizeBitrix24DialogTarget(rawTarget) : '';
1430
+ }
1431
+ function parseActionMessageIds(rawValue) {
1432
+ const ids = [];
1433
+ const seen = new Set();
1434
+ const append = (value) => {
1435
+ if (Array.isArray(value)) {
1436
+ value.forEach(append);
1437
+ return;
1438
+ }
1439
+ if (typeof value === 'number') {
1440
+ const normalizedId = Math.trunc(value);
1441
+ if (Number.isFinite(normalizedId) && normalizedId > 0 && !seen.has(normalizedId)) {
1442
+ seen.add(normalizedId);
1443
+ ids.push(normalizedId);
1444
+ }
1445
+ return;
1446
+ }
1447
+ if (typeof value !== 'string') {
1448
+ return;
1449
+ }
1450
+ const trimmed = value.trim();
1451
+ if (!trimmed) {
1452
+ return;
1453
+ }
1454
+ if (trimmed.startsWith('[')) {
1455
+ try {
1456
+ append(JSON.parse(trimmed));
1457
+ return;
1458
+ }
1459
+ catch {
1460
+ // Fall through to scalar / CSV parsing.
1461
+ }
1462
+ }
1463
+ trimmed
1464
+ .split(/[,\s]+/)
1465
+ .forEach((part) => {
1466
+ if (!part) {
1467
+ return;
1468
+ }
1469
+ const normalizedId = Number(part);
1470
+ if (Number.isFinite(normalizedId) && normalizedId > 0 && !seen.has(normalizedId)) {
1471
+ seen.add(normalizedId);
1472
+ ids.push(normalizedId);
1473
+ }
1474
+ });
1475
+ };
1476
+ append(rawValue);
1477
+ return ids;
1478
+ }
1479
+ function parseActionKeyboard(rawButtons) {
1480
+ if (rawButtons == null) {
1481
+ return undefined;
1482
+ }
1483
+ try {
1484
+ const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1485
+ if (Array.isArray(parsed)) {
1486
+ const keyboard = convertButtonsToKeyboard(parsed);
1487
+ return keyboard.length > 0 ? keyboard : undefined;
1488
+ }
1489
+ }
1490
+ catch {
1491
+ // Ignore invalid buttons payload and send without keyboard.
1492
+ }
1493
+ return undefined;
1494
+ }
1495
+ function collectActionMediaUrls(params) {
1496
+ const mediaUrls = [];
1497
+ const seen = new Set();
1498
+ const append = (value) => {
1499
+ if (Array.isArray(value)) {
1500
+ value.forEach(append);
1501
+ return;
1502
+ }
1503
+ if (typeof value !== 'string') {
1504
+ return;
1505
+ }
1506
+ const trimmed = value.trim();
1507
+ if (!trimmed) {
1508
+ return;
1509
+ }
1510
+ if (trimmed.startsWith('[')) {
1511
+ try {
1512
+ append(JSON.parse(trimmed));
1513
+ return;
1514
+ }
1515
+ catch {
1516
+ // Fall through to scalar handling.
1517
+ }
1518
+ }
1519
+ if (!seen.has(trimmed)) {
1520
+ seen.add(trimmed);
1521
+ mediaUrls.push(trimmed);
1522
+ }
1523
+ };
1524
+ append(params.mediaUrl);
1525
+ append(params.mediaUrls);
1526
+ append(params.filePath);
1527
+ append(params.filePaths);
1528
+ return mediaUrls;
1529
+ }
1530
+ function buildActionMessageText(params) {
1531
+ const normalizedText = typeof params.text === 'string'
1532
+ ? markdownToBbCode(params.text.trim())
1533
+ : '';
1534
+ if (normalizedText) {
1535
+ return normalizedText;
1536
+ }
1537
+ if (params.attach) {
1538
+ return null;
1539
+ }
1540
+ return params.keyboard ? ' ' : null;
1541
+ }
771
1542
  function parseRegisteredCommandTrigger(callbackData) {
772
1543
  const trimmed = callbackData.trim();
773
1544
  const isSlashCommand = trimmed.startsWith('/');
@@ -844,6 +1615,13 @@ export function extractKeyboardFromPayload(payload) {
844
1615
  }
845
1616
  return undefined;
846
1617
  }
1618
+ export function extractAttachFromPayload(payload) {
1619
+ const cd = payload.channelData;
1620
+ if (!cd)
1621
+ return undefined;
1622
+ const b24Data = cd.bitrix24;
1623
+ return parseActionAttach(b24Data?.attach ?? b24Data?.attachments);
1624
+ }
847
1625
  /**
848
1626
  * Extract inline button JSON embedded in message text by the agent.
849
1627
  *
@@ -874,6 +1652,13 @@ export function extractInlineButtonsFromText(text) {
874
1652
  return undefined;
875
1653
  }
876
1654
  }
1655
+ function cleanupTextAfterInlineMediaExtraction(text) {
1656
+ return text
1657
+ .replace(/[ \t]+\n/g, '\n')
1658
+ .replace(/\n{3,}/g, '\n\n')
1659
+ .replace(/[ \t]{2,}/g, ' ')
1660
+ .trim();
1661
+ }
877
1662
  function normalizeCommandReplyPayload(params) {
878
1663
  const { commandName, commandParams, text, language } = params;
879
1664
  if (commandName === 'models' && commandParams.trim() === '') {
@@ -1130,12 +1915,13 @@ export async function handleWebhookRequest(req, res) {
1130
1915
  // ─── Outbound adapter helpers ────────────────────────────────────────────────
1131
1916
  function resolveOutboundSendCtx(params) {
1132
1917
  const { config } = resolveAccount(params.cfg, params.accountId);
1133
- if (!config.webhookUrl || !gatewayState)
1918
+ const dialogId = normalizeBitrix24DialogTarget(params.to);
1919
+ if (!config.webhookUrl || !gatewayState || !dialogId)
1134
1920
  return null;
1135
1921
  return {
1136
1922
  webhookUrl: config.webhookUrl,
1137
1923
  bot: gatewayState.bot,
1138
- dialogId: params.to,
1924
+ dialogId,
1139
1925
  };
1140
1926
  }
1141
1927
  function collectOutboundMediaUrls(input) {
@@ -1196,13 +1982,10 @@ export const bitrix24Plugin = {
1196
1982
  inlineButtons: 'all',
1197
1983
  },
1198
1984
  messaging: {
1199
- normalizeTarget: (raw) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
1985
+ normalizeTarget: (raw) => normalizeBitrix24DialogTarget(raw),
1200
1986
  targetResolver: {
1201
- hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
1202
- looksLikeId: (raw, _normalized) => {
1203
- const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
1204
- return /^\d+$/.test(stripped);
1205
- },
1987
+ 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]".',
1988
+ looksLikeId: (raw, _normalized) => looksLikeBitrix24DialogTarget(raw),
1206
1989
  },
1207
1990
  },
1208
1991
  config: {
@@ -1286,27 +2069,47 @@ export const bitrix24Plugin = {
1286
2069
  const sendCtx = resolveOutboundSendCtx(ctx);
1287
2070
  if (!sendCtx || !gatewayState)
1288
2071
  throw new Error('Bitrix24 gateway not started');
2072
+ const text = ctx.text;
1289
2073
  const keyboard = ctx.payload?.channelData
1290
2074
  ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
1291
2075
  : undefined;
2076
+ const attach = ctx.payload?.channelData
2077
+ ? extractAttachFromPayload({ channelData: ctx.payload.channelData })
2078
+ : undefined;
1292
2079
  const mediaUrls = collectOutboundMediaUrls({
1293
2080
  mediaUrl: ctx.mediaUrl,
1294
2081
  payload: ctx.payload,
1295
2082
  });
1296
2083
  if (mediaUrls.length > 0) {
2084
+ const initialMessage = !keyboard && !attach ? text || undefined : undefined;
1297
2085
  const uploadedMessageId = await uploadOutboundMedia({
1298
2086
  mediaService: gatewayState.mediaService,
1299
2087
  sendCtx,
1300
2088
  mediaUrls,
2089
+ initialMessage,
1301
2090
  });
1302
- if (ctx.text) {
1303
- const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
2091
+ if ((text && !initialMessage) || keyboard || attach) {
2092
+ if (attach) {
2093
+ const messageId = await gatewayState.api.sendMessage(sendCtx.webhookUrl, sendCtx.bot, sendCtx.dialogId, buildActionMessageText({ text, keyboard, attach }), {
2094
+ ...(keyboard ? { keyboard } : {}),
2095
+ attach,
2096
+ });
2097
+ return { messageId: String(messageId || uploadedMessageId) };
2098
+ }
2099
+ const result = await gatewayState.sendService.sendText(sendCtx, text || '', keyboard ? { keyboard } : undefined);
1304
2100
  return { messageId: String(result.messageId ?? uploadedMessageId) };
1305
2101
  }
1306
2102
  return { messageId: uploadedMessageId };
1307
2103
  }
1308
- if (ctx.text) {
1309
- const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
2104
+ if (text || keyboard || attach) {
2105
+ if (attach) {
2106
+ const messageId = await gatewayState.api.sendMessage(sendCtx.webhookUrl, sendCtx.bot, sendCtx.dialogId, buildActionMessageText({ text, keyboard, attach }), {
2107
+ ...(keyboard ? { keyboard } : {}),
2108
+ attach,
2109
+ });
2110
+ return { messageId: String(messageId ?? '') };
2111
+ }
2112
+ const result = await gatewayState.sendService.sendText(sendCtx, text || '', keyboard ? { keyboard } : undefined);
1310
2113
  return { messageId: String(result.messageId ?? '') };
1311
2114
  }
1312
2115
  return { messageId: '' };
@@ -1314,11 +2117,13 @@ export const bitrix24Plugin = {
1314
2117
  },
1315
2118
  // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
1316
2119
  actions: {
2120
+ describeMessageTool: (params) => describeBitrix24MessageTool(params),
2121
+ extractToolSend: (params) => extractBitrix24ToolSend(params.args),
1317
2122
  listActions: (_params) => {
1318
- return ['react', 'send'];
2123
+ return [...BITRIX24_DISCOVERY_ACTION_NAMES];
1319
2124
  },
1320
2125
  supportsAction: (params) => {
1321
- return params.action === 'react' || params.action === 'send';
2126
+ return BITRIX24_ACTION_NAMES.includes(params.action);
1322
2127
  },
1323
2128
  handleAction: async (ctx) => {
1324
2129
  // Helper: wrap payload as gateway-compatible tool result
@@ -1328,33 +2133,196 @@ export const bitrix24Plugin = {
1328
2133
  });
1329
2134
  // ─── Send with buttons ──────────────────────────────────────────────
1330
2135
  if (ctx.action === 'send') {
1331
- // Only intercept send when buttons are present; otherwise let gateway handle normally
1332
2136
  const rawButtons = ctx.params.buttons;
1333
- if (!rawButtons)
1334
- return null;
2137
+ const rawAttach = ctx.params.attach ?? ctx.params.attachments;
1335
2138
  const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1336
2139
  if (!config.webhookUrl || !gatewayState)
1337
2140
  return null;
1338
2141
  const bot = gatewayState.bot;
1339
- const to = String(ctx.params.to ?? ctx.to ?? '').trim();
2142
+ const to = readActionTargetParam(ctx.params, ctx.to);
1340
2143
  if (!to) {
1341
2144
  defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
1342
2145
  return null;
1343
2146
  }
1344
2147
  const sendCtx = { webhookUrl: config.webhookUrl, bot, dialogId: to };
1345
- const message = String(ctx.params.message ?? '').trim();
1346
- // Parse buttons: may be array or JSON string
1347
- let buttons;
1348
- try {
1349
- const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1350
- if (Array.isArray(parsed))
1351
- buttons = parsed;
2148
+ const messageText = readActionTextParam(ctx.params);
2149
+ const message = typeof messageText === 'string' ? messageText.trim() : '';
2150
+ const keyboard = parseActionKeyboard(rawButtons);
2151
+ const attach = parseActionAttach(rawAttach);
2152
+ const mediaUrls = collectActionMediaUrls(ctx.params);
2153
+ const toolContext = ctx.toolContext;
2154
+ const currentToolMessageId = toolContext?.currentMessageId;
2155
+ const currentInboundContextKey = buildAccountMessageScopeKey(ctx.accountId, currentToolMessageId);
2156
+ const currentInboundContext = currentInboundContextKey
2157
+ ? inboundMessageContextById.get(currentInboundContextKey)
2158
+ : undefined;
2159
+ const forwardAckDialogId = resolvePendingNativeForwardAckDialogId(currentToolMessageId, to, ctx.accountId);
2160
+ const explicitForwardMessages = readExplicitForwardMessageIds(ctx.params);
2161
+ const referencedReplyMessageId = parseActionMessageIds(ctx.params.replyToMessageId ?? ctx.params.replyToId)[0];
2162
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
2163
+ 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.' });
2164
+ }
2165
+ if (explicitForwardMessages.length > 0 && mediaUrls.length > 0) {
2166
+ return toolResult({
2167
+ ok: false,
2168
+ reason: 'unsupported_payload_combo',
2169
+ hint: 'Bitrix24 native forward cannot be combined with mediaUrl/mediaUrls uploads in one send action. Send the forward separately.',
2170
+ });
1352
2171
  }
1353
- catch {
1354
- // invalid buttons JSON — send without keyboard
2172
+ if (!message && !keyboard && !attach && mediaUrls.length === 0 && explicitForwardMessages.length === 0) {
2173
+ return toolResult({
2174
+ ok: false,
2175
+ reason: 'missing_payload',
2176
+ hint: 'Provide message text, buttons, rich attach blocks or mediaUrl/mediaUrls for Bitrix24 send.',
2177
+ });
1355
2178
  }
1356
- const keyboard = buttons?.length ? convertButtonsToKeyboard(buttons) : undefined;
1357
2179
  try {
2180
+ if (explicitForwardMessages.length > 0) {
2181
+ const normalizedForwardText = normalizeForwardActionText(messageText);
2182
+ if (attach) {
2183
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: normalizedForwardText, keyboard, attach }), {
2184
+ ...(keyboard ? { keyboard } : {}),
2185
+ attach,
2186
+ forwardMessages: explicitForwardMessages,
2187
+ });
2188
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2189
+ return toolResult({
2190
+ channel: 'bitrix24',
2191
+ to,
2192
+ via: 'direct',
2193
+ mediaUrl: null,
2194
+ forwarded: true,
2195
+ forwardMessages: explicitForwardMessages,
2196
+ result: { messageId: String(messageId ?? '') },
2197
+ });
2198
+ }
2199
+ const result = await gatewayState.sendService.sendText(sendCtx, normalizedForwardText || (keyboard ? ' ' : ''), {
2200
+ ...(keyboard ? { keyboard } : {}),
2201
+ forwardMessages: explicitForwardMessages,
2202
+ });
2203
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2204
+ return toolResult({
2205
+ channel: 'bitrix24',
2206
+ to,
2207
+ via: 'direct',
2208
+ mediaUrl: null,
2209
+ forwarded: true,
2210
+ forwardMessages: explicitForwardMessages,
2211
+ result: { messageId: String(result.messageId ?? '') },
2212
+ });
2213
+ }
2214
+ if (referencedReplyMessageId && !attach && mediaUrls.length === 0) {
2215
+ let referencedMessageText = '';
2216
+ try {
2217
+ const referencedMessage = await gatewayState.api.getMessage(config.webhookUrl, bot, referencedReplyMessageId);
2218
+ referencedMessageText = resolveFetchedMessageBody(referencedMessage.message?.text ?? '');
2219
+ }
2220
+ catch (err) {
2221
+ defaultLogger.debug('Failed to hydrate referenced Bitrix24 message for send action', err);
2222
+ }
2223
+ const normalizedReferencedMessage = normalizeComparableMessageText(referencedMessageText);
2224
+ const normalizedMessage = normalizeComparableMessageText(message);
2225
+ if (normalizedReferencedMessage && normalizedReferencedMessage === normalizedMessage) {
2226
+ const result = await gatewayState.sendService.sendText(sendCtx, '', { forwardMessages: [referencedReplyMessageId] });
2227
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2228
+ return toolResult({
2229
+ channel: 'bitrix24',
2230
+ to,
2231
+ via: 'direct',
2232
+ mediaUrl: null,
2233
+ forwarded: true,
2234
+ forwardMessages: [referencedReplyMessageId],
2235
+ result: { messageId: String(result.messageId ?? '') },
2236
+ });
2237
+ }
2238
+ const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', {
2239
+ ...(keyboard ? { keyboard } : {}),
2240
+ replyToMessageId: referencedReplyMessageId,
2241
+ });
2242
+ return toolResult({
2243
+ channel: 'bitrix24',
2244
+ to,
2245
+ via: 'direct',
2246
+ mediaUrl: null,
2247
+ replied: true,
2248
+ replyToMessageId: referencedReplyMessageId,
2249
+ result: { messageId: String(result.messageId ?? '') },
2250
+ });
2251
+ }
2252
+ const previousInboundMessage = findPreviousRecentInboundMessage(currentToolMessageId, ctx.accountId);
2253
+ if (!referencedReplyMessageId
2254
+ && !attach
2255
+ && mediaUrls.length === 0
2256
+ && !keyboard
2257
+ && previousInboundMessage
2258
+ && currentInboundContext
2259
+ && normalizeComparableMessageText(message)
2260
+ && normalizeComparableMessageText(message) === normalizeComparableMessageText(previousInboundMessage.body)
2261
+ && normalizeComparableMessageText(currentInboundContext.body) !== normalizeComparableMessageText(message)) {
2262
+ const result = await gatewayState.sendService.sendText(sendCtx, '', { forwardMessages: [Number(previousInboundMessage.messageId)] });
2263
+ markPendingNativeForwardAck(forwardAckDialogId, currentToolMessageId, ctx.accountId);
2264
+ return toolResult({
2265
+ channel: 'bitrix24',
2266
+ to,
2267
+ via: 'direct',
2268
+ mediaUrl: null,
2269
+ forwarded: true,
2270
+ forwardMessages: [Number(previousInboundMessage.messageId)],
2271
+ result: { messageId: String(result.messageId ?? '') },
2272
+ });
2273
+ }
2274
+ if (mediaUrls.length > 0) {
2275
+ const initialMessage = !keyboard && !attach ? message || undefined : undefined;
2276
+ const uploadedMessageId = await uploadOutboundMedia({
2277
+ mediaService: gatewayState.mediaService,
2278
+ sendCtx,
2279
+ mediaUrls,
2280
+ initialMessage,
2281
+ });
2282
+ if ((message && !initialMessage) || keyboard || attach) {
2283
+ if (attach) {
2284
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: message, keyboard, attach }), {
2285
+ ...(keyboard ? { keyboard } : {}),
2286
+ attach,
2287
+ });
2288
+ return toolResult({
2289
+ channel: 'bitrix24',
2290
+ to,
2291
+ via: 'direct',
2292
+ mediaUrl: mediaUrls[0] ?? null,
2293
+ result: { messageId: String(messageId ?? uploadedMessageId) },
2294
+ });
2295
+ }
2296
+ const result = await gatewayState.sendService.sendText(sendCtx, message || '', keyboard ? { keyboard } : undefined);
2297
+ return toolResult({
2298
+ channel: 'bitrix24',
2299
+ to,
2300
+ via: 'direct',
2301
+ mediaUrl: mediaUrls[0] ?? null,
2302
+ result: { messageId: String(result.messageId ?? uploadedMessageId) },
2303
+ });
2304
+ }
2305
+ return toolResult({
2306
+ channel: 'bitrix24',
2307
+ to,
2308
+ via: 'direct',
2309
+ mediaUrl: mediaUrls[0] ?? null,
2310
+ result: { messageId: uploadedMessageId },
2311
+ });
2312
+ }
2313
+ if (attach) {
2314
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: message, keyboard, attach }), {
2315
+ ...(keyboard ? { keyboard } : {}),
2316
+ attach,
2317
+ });
2318
+ return toolResult({
2319
+ channel: 'bitrix24',
2320
+ to,
2321
+ via: 'direct',
2322
+ mediaUrl: null,
2323
+ result: { messageId: String(messageId ?? '') },
2324
+ });
2325
+ }
1358
2326
  const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', keyboard ? { keyboard } : undefined);
1359
2327
  return toolResult({
1360
2328
  channel: 'bitrix24',
@@ -1369,6 +2337,153 @@ export const bitrix24Plugin = {
1369
2337
  return toolResult({ ok: false, error: errMsg });
1370
2338
  }
1371
2339
  }
2340
+ if (ctx.action === 'reply') {
2341
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
2342
+ if (!config.webhookUrl || !gatewayState) {
2343
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
2344
+ }
2345
+ const params = ctx.params;
2346
+ const bot = gatewayState.bot;
2347
+ const to = readActionTargetParam(params, ctx.to);
2348
+ if (!to) {
2349
+ return toolResult({ ok: false, reason: 'missing_target', hint: 'Bitrix24 reply requires a target dialog id in "to" or "target". Do not retry.' });
2350
+ }
2351
+ const toolContext = ctx.toolContext;
2352
+ const replyToMessageId = parseActionMessageIds(params.replyToMessageId
2353
+ ?? params.replyToId
2354
+ ?? params.messageId
2355
+ ?? params.message_id
2356
+ ?? toolContext?.currentMessageId)[0];
2357
+ if (!replyToMessageId) {
2358
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Bitrix24 reply requires a valid target messageId. Do not retry.' });
2359
+ }
2360
+ const text = readActionTextParam(params);
2361
+ const normalizedText = typeof text === 'string' ? text.trim() : '';
2362
+ const keyboard = parseActionKeyboard(params.buttons);
2363
+ const rawAttach = params.attach ?? params.attachments;
2364
+ const attach = parseActionAttach(rawAttach);
2365
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
2366
+ 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.' });
2367
+ }
2368
+ if (!normalizedText && !keyboard && !attach) {
2369
+ return toolResult({ ok: false, reason: 'missing_payload', hint: 'Provide reply text, buttons or rich attach blocks for Bitrix24 reply.' });
2370
+ }
2371
+ try {
2372
+ if (attach) {
2373
+ const messageId = await gatewayState.api.sendMessage(config.webhookUrl, bot, to, buildActionMessageText({ text: normalizedText, keyboard, attach }), {
2374
+ ...(keyboard ? { keyboard } : {}),
2375
+ attach,
2376
+ replyToMessageId,
2377
+ });
2378
+ return toolResult({
2379
+ ok: true,
2380
+ replied: true,
2381
+ to,
2382
+ replyToMessageId,
2383
+ messageId: String(messageId ?? ''),
2384
+ });
2385
+ }
2386
+ const result = await gatewayState.sendService.sendText({ webhookUrl: config.webhookUrl, bot, dialogId: to }, normalizedText || ' ', {
2387
+ ...(keyboard ? { keyboard } : {}),
2388
+ replyToMessageId,
2389
+ });
2390
+ return toolResult({
2391
+ ok: true,
2392
+ replied: true,
2393
+ to,
2394
+ replyToMessageId,
2395
+ messageId: String(result.messageId ?? ''),
2396
+ });
2397
+ }
2398
+ catch (err) {
2399
+ const errMsg = err instanceof Error ? err.message : String(err);
2400
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to send Bitrix24 reply: ${errMsg}. Do not retry.` });
2401
+ }
2402
+ }
2403
+ // Note: "forward" action is not supported by OpenClaw runtime (not in
2404
+ // MESSAGE_ACTION_TARGET_MODE), so the runtime blocks target before it
2405
+ // reaches handleAction. All forwarding goes through action "send" with
2406
+ // forwardMessageIds parameter instead.
2407
+ if (ctx.action === 'edit') {
2408
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
2409
+ if (!config.webhookUrl || !gatewayState) {
2410
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
2411
+ }
2412
+ const bot = gatewayState.bot;
2413
+ const api = gatewayState.api;
2414
+ const params = ctx.params;
2415
+ const toolContext = ctx.toolContext;
2416
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
2417
+ const messageId = Number(rawMessageId);
2418
+ if (!Number.isFinite(messageId) || messageId <= 0) {
2419
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 edits. Do not retry.' });
2420
+ }
2421
+ let keyboard;
2422
+ if (params.buttons === 'N') {
2423
+ keyboard = 'N';
2424
+ }
2425
+ else if (params.buttons != null) {
2426
+ try {
2427
+ const parsed = typeof params.buttons === 'string'
2428
+ ? JSON.parse(params.buttons)
2429
+ : params.buttons;
2430
+ if (Array.isArray(parsed)) {
2431
+ keyboard = convertButtonsToKeyboard(parsed);
2432
+ }
2433
+ }
2434
+ catch {
2435
+ // Ignore invalid buttons payload and update text only.
2436
+ }
2437
+ }
2438
+ const message = readActionTextParam(params);
2439
+ const rawAttach = params.attach ?? params.attachments;
2440
+ const attach = parseActionAttach(rawAttach);
2441
+ if (hasMeaningfulAttachInput(rawAttach) && !attach) {
2442
+ 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.' });
2443
+ }
2444
+ if (message == null && !keyboard && !attach) {
2445
+ return toolResult({ ok: false, reason: 'missing_payload', hint: 'Provide message text, buttons or rich attach blocks for Bitrix24 edits.' });
2446
+ }
2447
+ try {
2448
+ await api.updateMessage(config.webhookUrl, bot, messageId, message, {
2449
+ ...(keyboard ? { keyboard } : {}),
2450
+ ...(attach ? { attach } : {}),
2451
+ });
2452
+ return toolResult({ ok: true, edited: true, messageId });
2453
+ }
2454
+ catch (err) {
2455
+ const errMsg = err instanceof Error ? err.message : String(err);
2456
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to edit message: ${errMsg}. Do not retry.` });
2457
+ }
2458
+ }
2459
+ if (ctx.action === 'delete') {
2460
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
2461
+ if (!config.webhookUrl || !gatewayState) {
2462
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
2463
+ }
2464
+ const bot = gatewayState.bot;
2465
+ const api = gatewayState.api;
2466
+ const params = ctx.params;
2467
+ const toolContext = ctx.toolContext;
2468
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
2469
+ const messageId = Number(rawMessageId);
2470
+ if (!Number.isFinite(messageId) || messageId <= 0) {
2471
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 deletes. Do not retry.' });
2472
+ }
2473
+ const complete = params.complete == null
2474
+ ? undefined
2475
+ : (params.complete === true
2476
+ || params.complete === 'true'
2477
+ || params.complete === 'Y');
2478
+ try {
2479
+ await api.deleteMessage(config.webhookUrl, bot, messageId, complete);
2480
+ return toolResult({ ok: true, deleted: true, messageId });
2481
+ }
2482
+ catch (err) {
2483
+ const errMsg = err instanceof Error ? err.message : String(err);
2484
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to delete message: ${errMsg}. Do not retry.` });
2485
+ }
2486
+ }
1372
2487
  // ─── React ────────────────────────────────────────────────────────────
1373
2488
  if (ctx.action !== 'react')
1374
2489
  return null;
@@ -1418,12 +2533,14 @@ export const bitrix24Plugin = {
1418
2533
  }
1419
2534
  try {
1420
2535
  await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
2536
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
1421
2537
  return toolResult({ ok: true, added: emoji });
1422
2538
  }
1423
2539
  catch (err) {
1424
2540
  const errMsg = err instanceof Error ? err.message : String(err);
1425
2541
  const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
1426
2542
  if (isAlreadySet) {
2543
+ markPendingNativeReactionAck(toolContext?.currentMessageId, emoji, ctx.accountId);
1427
2544
  return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
1428
2545
  }
1429
2546
  return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
@@ -1723,9 +2840,22 @@ export const bitrix24Plugin = {
1723
2840
  query: bodyWithReply,
1724
2841
  historyCache,
1725
2842
  });
1726
- const bodyForAgent = crossChatContext
1727
- ? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
1728
- : bodyWithReply;
2843
+ const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
2844
+ const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(msgCtx.messageId);
2845
+ const nativeForwardAgentHint = buildBitrix24NativeForwardAgentHint({
2846
+ accountId: ctx.accountId,
2847
+ currentBody: body,
2848
+ currentMessageId: msgCtx.messageId,
2849
+ replyToMessageId: msgCtx.replyToMessageId,
2850
+ historyEntries: previousEntries,
2851
+ });
2852
+ const bodyForAgent = [
2853
+ fileDeliveryAgentHint,
2854
+ nativeReplyAgentHint,
2855
+ nativeForwardAgentHint,
2856
+ crossChatContext,
2857
+ bodyWithReply,
2858
+ ].filter(Boolean).join('\n\n');
1729
2859
  const combinedBody = msgCtx.isGroup
1730
2860
  ? buildHistoryContext({
1731
2861
  entries: previousEntries,
@@ -1733,6 +2863,7 @@ export const bitrix24Plugin = {
1733
2863
  })
1734
2864
  : bodyForAgent;
1735
2865
  recordHistory(body);
2866
+ rememberRecentInboundMessage(conversation.dialogId, msgCtx.messageId, body, msgCtx.timestamp ?? Date.now(), ctx.accountId);
1736
2867
  // Resolve which agent handles this conversation
1737
2868
  const route = runtime.channel.routing.resolveAgentRoute({
1738
2869
  cfg,
@@ -1793,7 +2924,30 @@ export const bitrix24Plugin = {
1793
2924
  });
1794
2925
  }
1795
2926
  if (payload.text) {
1796
- let text = payload.text;
2927
+ if (consumePendingNativeForwardAck(sendCtx.dialogId, msgCtx.messageId, ctx.accountId)) {
2928
+ replyDelivered = true;
2929
+ logger.debug('Suppressing trailing acknowledgement after native Bitrix24 forward', {
2930
+ senderId: msgCtx.senderId,
2931
+ chatId: msgCtx.chatId,
2932
+ messageId: msgCtx.messageId,
2933
+ });
2934
+ return;
2935
+ }
2936
+ const replyDirective = extractReplyDirective({
2937
+ text: payload.text,
2938
+ currentMessageId: msgCtx.messageId,
2939
+ explicitReplyToId: payload.replyToId,
2940
+ });
2941
+ let text = replyDirective.cleanText;
2942
+ if (consumePendingNativeReactionAck(msgCtx.messageId, text, ctx.accountId)) {
2943
+ replyDelivered = true;
2944
+ logger.debug('Suppressing trailing acknowledgement after native Bitrix24 reaction', {
2945
+ senderId: msgCtx.senderId,
2946
+ chatId: msgCtx.chatId,
2947
+ messageId: msgCtx.messageId,
2948
+ });
2949
+ return;
2950
+ }
1797
2951
  let keyboard = extractKeyboardFromPayload(payload);
1798
2952
  // Fallback: agent may embed button JSON in text as [[{...}]]
1799
2953
  if (!keyboard) {
@@ -1803,8 +2957,52 @@ export const bitrix24Plugin = {
1803
2957
  keyboard = extracted.keyboard;
1804
2958
  }
1805
2959
  }
2960
+ const nativeForwardMessageId = findNativeForwardTarget({
2961
+ accountId: ctx.accountId,
2962
+ requestText: body,
2963
+ deliveredText: text,
2964
+ historyEntries: previousEntries,
2965
+ currentMessageId: msgCtx.messageId,
2966
+ }) ?? findImplicitForwardTargetFromRecentInbound({
2967
+ accountId: ctx.accountId,
2968
+ requestText: body,
2969
+ deliveredText: text,
2970
+ currentMessageId: msgCtx.messageId,
2971
+ historyEntries: previousEntries,
2972
+ });
2973
+ const nativeForwardTargets = nativeForwardMessageId
2974
+ ? extractBitrix24DialogTargetsFromText(body)
2975
+ : [];
2976
+ const nativeReplyToMessageId = nativeForwardMessageId
2977
+ ? undefined
2978
+ : replyDirective.replyToMessageId;
2979
+ const sendOptions = {
2980
+ ...(keyboard ? { keyboard } : {}),
2981
+ ...(nativeReplyToMessageId ? { replyToMessageId: nativeReplyToMessageId } : {}),
2982
+ ...(nativeForwardMessageId ? { forwardMessages: [nativeForwardMessageId] } : {}),
2983
+ };
1806
2984
  replyDelivered = true;
1807
- await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
2985
+ if (nativeForwardMessageId && nativeForwardTargets.length > 0) {
2986
+ const forwardResults = await Promise.allSettled(nativeForwardTargets.map((dialogId) => sendService.sendText({ ...sendCtx, dialogId }, '', { forwardMessages: [nativeForwardMessageId] })));
2987
+ const firstFailure = forwardResults.find((result) => result.status === 'rejected');
2988
+ const hasSuccess = forwardResults.some((result) => result.status === 'fulfilled');
2989
+ if (firstFailure && !hasSuccess) {
2990
+ throw firstFailure.reason;
2991
+ }
2992
+ if (firstFailure) {
2993
+ logger.warn('Failed to deliver Bitrix24 native forward to one or more explicit targets', {
2994
+ senderId: msgCtx.senderId,
2995
+ chatId: msgCtx.chatId,
2996
+ messageId: msgCtx.messageId,
2997
+ targets: nativeForwardTargets,
2998
+ error: firstFailure.reason instanceof Error
2999
+ ? firstFailure.reason.message
3000
+ : String(firstFailure.reason),
3001
+ });
3002
+ }
3003
+ return;
3004
+ }
3005
+ await sendService.sendText(sendCtx, nativeForwardMessageId ? '' : text, Object.keys(sendOptions).length > 0 ? sendOptions : undefined);
1808
3006
  }
1809
3007
  },
1810
3008
  onReplyStart: async () => {
@@ -2339,10 +3537,17 @@ export const bitrix24Plugin = {
2339
3537
  accountId: ctx.accountId,
2340
3538
  peer: conversation.peer,
2341
3539
  });
3540
+ const fileDeliveryAgentHint = buildBitrix24FileDeliveryAgentHint();
3541
+ const nativeReplyAgentHint = buildBitrix24NativeReplyAgentHint(commandMessageId);
3542
+ const commandBodyForAgent = [
3543
+ fileDeliveryAgentHint,
3544
+ nativeReplyAgentHint,
3545
+ commandText,
3546
+ ].filter(Boolean).join('\n\n');
2342
3547
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
2343
3548
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
2344
3549
  Body: commandText,
2345
- BodyForAgent: commandText,
3550
+ BodyForAgent: commandBodyForAgent,
2346
3551
  RawBody: commandText,
2347
3552
  CommandBody: commandText,
2348
3553
  CommandAuthorized: true,
@@ -2380,20 +3585,29 @@ export const bitrix24Plugin = {
2380
3585
  deliver: async (payload) => {
2381
3586
  await replyStatusHeartbeat.stopAndWait();
2382
3587
  if (payload.text) {
3588
+ const replyDirective = extractReplyDirective({
3589
+ text: payload.text,
3590
+ currentMessageId: commandMessageId,
3591
+ explicitReplyToId: payload.replyToId,
3592
+ });
2383
3593
  const keyboard = extractKeyboardFromPayload(payload)
2384
3594
  ?? defaultSessionKeyboard;
2385
3595
  const formattedPayload = normalizeCommandReplyPayload({
2386
3596
  commandName,
2387
3597
  commandParams,
2388
- text: payload.text,
3598
+ text: replyDirective.cleanText,
2389
3599
  language: cmdCtx.language,
2390
3600
  });
3601
+ const sendOptions = {
3602
+ keyboard,
3603
+ convertMarkdown: formattedPayload.convertMarkdown,
3604
+ ...(replyDirective.replyToMessageId ? { replyToMessageId: replyDirective.replyToMessageId } : {}),
3605
+ };
2391
3606
  if (!commandReplyDelivered) {
2392
- if (isDm) {
3607
+ if (isDm || replyDirective.replyToMessageId) {
2393
3608
  commandReplyDelivered = true;
2394
3609
  await sendService.sendText(sendCtx, formattedPayload.text, {
2395
- keyboard,
2396
- convertMarkdown: formattedPayload.convertMarkdown,
3610
+ ...sendOptions,
2397
3611
  });
2398
3612
  }
2399
3613
  else {
@@ -2406,10 +3620,7 @@ export const bitrix24Plugin = {
2406
3620
  return;
2407
3621
  }
2408
3622
  commandReplyDelivered = true;
2409
- await sendService.sendText(sendCtx, formattedPayload.text, {
2410
- keyboard,
2411
- convertMarkdown: formattedPayload.convertMarkdown,
2412
- });
3623
+ await sendService.sendText(sendCtx, formattedPayload.text, sendOptions);
2413
3624
  }
2414
3625
  },
2415
3626
  onReplyStart: async () => {