@ihazz/bitrix24 1.0.3 → 1.1.1

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.
package/src/channel.ts CHANGED
@@ -17,19 +17,30 @@ import {
17
17
  checkAccessWithPairing,
18
18
  getWebhookUserId,
19
19
  } from './access-control.js';
20
+ import {
21
+ checkGroupAccessPassive,
22
+ checkGroupAccessWithPairing,
23
+ resolveAgentWatchRules,
24
+ resolveGroupAccess,
25
+ } from './group-access.js';
20
26
  import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
21
27
  import { Bitrix24ApiError, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
22
28
  import { getBitrix24Runtime } from './runtime.js';
23
29
  import type { ChannelPairingAdapter } from './runtime.js';
24
30
  import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply } from './commands.js';
25
31
  import {
26
- forwardedMessageUnsupported,
32
+ accessApproved,
33
+ accessDenied,
34
+ groupPairingPending,
27
35
  mediaDownloadFailed,
28
36
  groupChatUnsupported,
29
37
  onboardingMessage,
38
+ ownerAndAllowedUsersOnly,
30
39
  personalBotOwnerOnly,
31
- replyMessageUnsupported,
40
+ watchOwnerDmNotice,
32
41
  } from './i18n.js';
42
+ import { HistoryCache } from './history-cache.js';
43
+ import type { ConversationMeta } from './history-cache.js';
33
44
  import type {
34
45
  B24MsgContext,
35
46
  B24InputActionStatusCode,
@@ -37,6 +48,13 @@ import type {
37
48
  B24V2DeleteEventData,
38
49
  FetchContext,
39
50
  Bitrix24AccountConfig,
51
+ Bitrix24AgentWatchConfig,
52
+ Bitrix24DmPolicy,
53
+ Bitrix24GroupPolicy,
54
+ Bitrix24GroupWatchConfig,
55
+ B24V2GetMessageContextResult,
56
+ B24V2GetMessageResult,
57
+ B24V2User,
40
58
  B24Keyboard,
41
59
  KeyboardButton,
42
60
  Logger,
@@ -52,6 +70,13 @@ const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
52
70
  const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
53
71
  const MEDIA_DOWNLOAD_CONCURRENCY = 2;
54
72
  const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
73
+ const DEFAULT_HISTORY_LIMIT = 100;
74
+ const HISTORY_CACHE_MAX_KEYS = 1000;
75
+ const HISTORY_CONTEXT_MARKER = '[Chat messages since your last reply - for context]';
76
+ const CROSS_CHAT_HISTORY_LIMIT = 20;
77
+ const ACCESS_DENIED_REACTION = 'crossMark';
78
+ const FORWARDED_CONTEXT_RANGE = 5;
79
+ const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
55
80
 
56
81
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
57
82
  // B24 uses named reaction codes, not Unicode emoji.
@@ -127,6 +152,73 @@ function toMessageId(value: string | number | undefined): number | undefined {
127
152
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
128
153
  }
129
154
 
155
+ function escapeBbCodeText(value: string): string {
156
+ return value
157
+ .replace(/\[/g, '(')
158
+ .replace(/\]/g, ')');
159
+ }
160
+
161
+ function buildChatContextUrl(dialogId: string, messageId: string, chatName: string): string {
162
+ const dialogParam = encodeURIComponent(dialogId);
163
+ const messageParam = encodeURIComponent(messageId);
164
+ const label = escapeBbCodeText(chatName);
165
+ return `[URL=/online/?IM_DIALOG=${dialogParam}&IM_MESSAGE=${messageParam}]${label}[/URL]`;
166
+ }
167
+
168
+ function buildTopicsBbCode(topics: string[] | undefined): string | undefined {
169
+ if (!topics || topics.length === 0) {
170
+ return undefined;
171
+ }
172
+
173
+ return topics
174
+ .map((topic) => topic.trim())
175
+ .filter(Boolean)
176
+ .map((topic) => `[b]${escapeBbCodeText(topic)}[/b]`)
177
+ .join(', ');
178
+ }
179
+
180
+ function formatQuoteTimestamp(timestamp: number | undefined, language: string | undefined): string {
181
+ const locale = (language ?? 'ru').toLowerCase().slice(0, 2);
182
+ const value = timestamp ?? Date.now();
183
+
184
+ try {
185
+ return new Intl.DateTimeFormat(locale, {
186
+ year: 'numeric',
187
+ month: '2-digit',
188
+ day: '2-digit',
189
+ hour: '2-digit',
190
+ minute: '2-digit',
191
+ }).format(new Date(value)).replace(',', '');
192
+ } catch {
193
+ return new Intl.DateTimeFormat('ru', {
194
+ year: 'numeric',
195
+ month: '2-digit',
196
+ day: '2-digit',
197
+ hour: '2-digit',
198
+ minute: '2-digit',
199
+ }).format(new Date(value)).replace(',', '');
200
+ }
201
+ }
202
+
203
+ function buildWatchQuoteText(params: {
204
+ senderName: string;
205
+ language?: string;
206
+ timestamp?: number;
207
+ anchor: string;
208
+ body: string;
209
+ }): string {
210
+ const separator = '------------------------------------------------------';
211
+ const senderLine = `${escapeBbCodeText(params.senderName)} [${formatQuoteTimestamp(params.timestamp, params.language)}] ${params.anchor}`;
212
+ const body = escapeBbCodeText(params.body);
213
+
214
+ return [
215
+ separator,
216
+ senderLine,
217
+ body,
218
+ separator,
219
+ ].join('\n');
220
+ }
221
+
130
222
  async function notifyStatus(
131
223
  sendService: SendService,
132
224
  sendCtx: SendContext,
@@ -283,8 +375,10 @@ export function mergeForwardedMessageContext(
283
375
  const previousText = previousMsgCtx.text.trim();
284
376
  const forwardedText = forwardedMsgCtx.text.trim();
285
377
  const mergedText = [
378
+ previousText ? '[User question about the forwarded message]' : '',
286
379
  previousText,
287
- forwardedText ? `[Forwarded message]\n${forwardedText}` : '',
380
+ previousText ? '[/User question]' : '',
381
+ forwardedText,
288
382
  ].filter(Boolean).join('\n\n');
289
383
 
290
384
  return {
@@ -292,6 +386,7 @@ export function mergeForwardedMessageContext(
292
386
  text: mergedText,
293
387
  media: [...previousMsgCtx.media, ...forwardedMsgCtx.media],
294
388
  language: forwardedMsgCtx.language ?? previousMsgCtx.language,
389
+ replyToMessageId: undefined,
295
390
  isForwarded: false,
296
391
  };
297
392
  }
@@ -373,6 +468,316 @@ function resolveSecurityConfig(params: {
373
468
  return {};
374
469
  }
375
470
 
471
+ function resolveHistoryLimit(config: Bitrix24AccountConfig): number {
472
+ return Math.max(0, config.historyLimit ?? DEFAULT_HISTORY_LIMIT);
473
+ }
474
+
475
+ export interface Bitrix24ConversationRef {
476
+ dialogId: string;
477
+ address: string;
478
+ historyKey: string;
479
+ peer: {
480
+ kind: 'direct' | 'group';
481
+ id: string;
482
+ };
483
+ }
484
+
485
+ export function resolveConversationRef(params: {
486
+ accountId: string;
487
+ dialogId: string;
488
+ isDirect: boolean;
489
+ }): Bitrix24ConversationRef {
490
+ const dialogId = String(params.dialogId);
491
+ return {
492
+ dialogId,
493
+ address: `bitrix24:${dialogId}`,
494
+ historyKey: `${params.accountId}:${dialogId}`,
495
+ peer: {
496
+ kind: params.isDirect ? 'direct' : 'group',
497
+ id: dialogId,
498
+ },
499
+ };
500
+ }
501
+
502
+ export function buildConversationSessionKey(
503
+ routeSessionKey: string,
504
+ conversation: Pick<Bitrix24ConversationRef, 'address'>,
505
+ ): string {
506
+ return `${routeSessionKey}:${conversation.address}`;
507
+ }
508
+
509
+ function buildHistoryBody(msgCtx: B24MsgContext): string {
510
+ const text = msgCtx.text.trim();
511
+ if (text) {
512
+ return text;
513
+ }
514
+
515
+ if (msgCtx.media.length === 0) {
516
+ return '';
517
+ }
518
+
519
+ const hasImage = msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
520
+ return hasImage ? '<media:image>' : '<media:document>';
521
+ }
522
+
523
+ function buildConversationMeta(msgCtx: B24MsgContext): Omit<ConversationMeta, 'key'> {
524
+ return {
525
+ dialogId: msgCtx.chatId,
526
+ chatId: msgCtx.chatInternalId,
527
+ chatName: msgCtx.chatName,
528
+ chatType: msgCtx.chatType,
529
+ isGroup: msgCtx.isGroup,
530
+ lastActivityAt: msgCtx.timestamp ?? Date.now(),
531
+ };
532
+ }
533
+
534
+ function appendMessageToHistory(params: {
535
+ historyCache: HistoryCache;
536
+ historyKey: string;
537
+ historyLimit: number;
538
+ msgCtx: B24MsgContext;
539
+ body?: string;
540
+ }): void {
541
+ const historyBody = (params.body ?? buildHistoryBody(params.msgCtx)).trim();
542
+ if (!historyBody) {
543
+ return;
544
+ }
545
+
546
+ params.historyCache.append({
547
+ key: params.historyKey,
548
+ limit: params.historyLimit,
549
+ entry: {
550
+ messageId: params.msgCtx.messageId,
551
+ sender: params.msgCtx.senderName,
552
+ senderId: params.msgCtx.senderId,
553
+ body: historyBody,
554
+ timestamp: params.msgCtx.timestamp ?? Date.now(),
555
+ wasMentioned: params.msgCtx.wasMentioned,
556
+ eventScope: params.msgCtx.eventScope ?? 'bot',
557
+ },
558
+ meta: buildConversationMeta(params.msgCtx),
559
+ });
560
+ }
561
+
562
+ function formatHistoryEntry(params: {
563
+ sender: string;
564
+ body: string;
565
+ messageId?: string;
566
+ }): string {
567
+ const idSuffix = params.messageId ? ` [id:${params.messageId}]` : '';
568
+ return `[${params.sender}] ${params.body}${idSuffix}`;
569
+ }
570
+
571
+ function buildHistoryContext(params: {
572
+ entries: Array<{ sender: string; body: string; messageId?: string }>;
573
+ currentBody: string;
574
+ }): string {
575
+ if (params.entries.length === 0) {
576
+ return params.currentBody;
577
+ }
578
+
579
+ const historyText = params.entries
580
+ .map((entry) => formatHistoryEntry(entry))
581
+ .join('\n');
582
+
583
+ return [
584
+ HISTORY_CONTEXT_MARKER,
585
+ historyText,
586
+ '',
587
+ params.currentBody,
588
+ ].join('\n');
589
+ }
590
+
591
+ function formatReplyContext(params: {
592
+ body: string;
593
+ replyEntry?: { sender: string; body: string; messageId: string };
594
+ }): string {
595
+ if (!params.replyEntry) {
596
+ return params.body;
597
+ }
598
+
599
+ return [
600
+ params.body,
601
+ '',
602
+ `[Replying to ${params.replyEntry.sender} id:${params.replyEntry.messageId}]`,
603
+ params.replyEntry.body,
604
+ '[/Replying]',
605
+ ].filter(Boolean).join('\n');
606
+ }
607
+
608
+ function resolveFetchedUserName(usersById: Map<number, B24V2User>, authorId: number): string {
609
+ return usersById.get(authorId)?.name?.trim() || `User ${authorId}`;
610
+ }
611
+
612
+ function resolveFetchedMessageBody(text: string): string {
613
+ const trimmed = text.trim();
614
+ return trimmed.length > 0 ? trimmed : '<empty message>';
615
+ }
616
+
617
+ function formatFetchedForwardContext(params: {
618
+ context: B24V2GetMessageContextResult;
619
+ currentMessageId: number;
620
+ }): string {
621
+ const usersById = new Map(params.context.users.map((user) => [user.id, user]));
622
+ const lines = params.context.messages
623
+ .filter((message) => message.id !== params.currentMessageId)
624
+ .map((message) => {
625
+ const sender = resolveFetchedUserName(usersById, message.authorId);
626
+ const body = resolveFetchedMessageBody(message.text);
627
+ return `[${sender} id:${message.id}] ${body}`;
628
+ })
629
+ .filter(Boolean);
630
+
631
+ if (lines.length === 0) {
632
+ return '';
633
+ }
634
+
635
+ return [
636
+ '[Bitrix24 surrounding context around this forwarded message - not the forwarded message body]',
637
+ ...lines,
638
+ '[/Bitrix24 surrounding context]',
639
+ ].join('\n');
640
+ }
641
+
642
+ function extractMentionedChatIds(text: string): string[] {
643
+ const matches = [...text.matchAll(/\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]/gi)];
644
+ return [...new Set(matches.map((match) => match[1]).filter(Boolean))];
645
+ }
646
+
647
+ function formatReferencedGroupHistory(params: {
648
+ conversation: ConversationMeta;
649
+ entries: Array<{ sender: string; body: string; messageId?: string }>;
650
+ }): string {
651
+ const chatId = params.conversation.chatId ?? params.conversation.dialogId.replace(/^chat/i, '');
652
+ const chatName = params.conversation.chatName ?? params.conversation.dialogId;
653
+ const header = `[Visible group chat history: [CHAT=${chatId}]${chatName}[/CHAT]]`;
654
+
655
+ if (params.entries.length === 0) {
656
+ return [
657
+ header,
658
+ 'No messages from this chat are currently available in RAM memory.',
659
+ ].join('\n');
660
+ }
661
+
662
+ return [
663
+ header,
664
+ ...params.entries.map((entry) => formatHistoryEntry(entry)),
665
+ ].join('\n');
666
+ }
667
+
668
+ function buildCrossChatMemoryContext(params: {
669
+ query: string;
670
+ historyCache: HistoryCache;
671
+ }): string | undefined {
672
+ const chatMentions = extractMentionedChatIds(params.query);
673
+ if (chatMentions.length === 0) {
674
+ return undefined;
675
+ }
676
+
677
+ const visibleGroupChats = params.historyCache
678
+ .listConversations()
679
+ .filter((conversation) => conversation.isGroup)
680
+ .sort((left, right) => (right.lastActivityAt ?? 0) - (left.lastActivityAt ?? 0));
681
+
682
+ const referencedChats = visibleGroupChats.filter((conversation) => {
683
+ const chatId = conversation.chatId ?? '';
684
+ return chatMentions.includes(chatId);
685
+ });
686
+
687
+ const sections: string[] = [];
688
+
689
+ if (referencedChats.length > 0) {
690
+ sections.push(
691
+ ...referencedChats.map((conversation) => formatReferencedGroupHistory({
692
+ conversation,
693
+ entries: params.historyCache.get(conversation.key, CROSS_CHAT_HISTORY_LIMIT),
694
+ })),
695
+ );
696
+ } else if (chatMentions.length > 0) {
697
+ sections.push('[Referenced group chats]\nThe requested group chat mention is not available in RAM memory right now.');
698
+ }
699
+
700
+ return [
701
+ '[OpenClaw cross-chat memory]',
702
+ 'The following Bitrix24 group chat memory is already available to you from RAM history.',
703
+ 'Use it as trusted context for your answer.',
704
+ 'Do not say that you only see the current chat if chats or messages are listed below.',
705
+ 'Do not call tools to list chats when this memory block already contains the answer.',
706
+ '',
707
+ '[BEGIN OPENCLAW CROSS-CHAT MEMORY]',
708
+ sections.join('\n\n'),
709
+ '[END OPENCLAW CROSS-CHAT MEMORY]',
710
+ ].join('\n');
711
+ }
712
+
713
+ function buildAccessDeniedNotice(
714
+ lang: string | undefined,
715
+ policy: Bitrix24DmPolicy | Bitrix24GroupPolicy | undefined,
716
+ params?: { hasAllowList?: boolean },
717
+ ): string {
718
+ const effectivePolicy = policy ?? 'webhookUser';
719
+
720
+ if (effectivePolicy === 'webhookUser') {
721
+ return personalBotOwnerOnly(lang);
722
+ }
723
+
724
+ if (params?.hasAllowList) {
725
+ return ownerAndAllowedUsersOnly(lang);
726
+ }
727
+
728
+ return accessDenied(lang);
729
+ }
730
+
731
+ function normalizeTopicText(text: string): string {
732
+ return text.toLowerCase().replace(/\s+/g, ' ').trim();
733
+ }
734
+
735
+ function tokenizeTopicText(text: string): string[] {
736
+ return normalizeTopicText(text)
737
+ .split(/[^a-zа-яё0-9]+/i)
738
+ .filter(Boolean);
739
+ }
740
+
741
+ function matchesWatchTopic(messageText: string, topic: string): boolean {
742
+ const normalizedMessage = normalizeTopicText(messageText);
743
+ const normalizedTopic = normalizeTopicText(topic);
744
+ if (!normalizedMessage || !normalizedTopic) {
745
+ return false;
746
+ }
747
+
748
+ if (normalizedMessage.includes(normalizedTopic)) {
749
+ return true;
750
+ }
751
+
752
+ const messageTokens = tokenizeTopicText(messageText);
753
+ const topicTokens = tokenizeTopicText(topic);
754
+ return topicTokens.length > 0
755
+ && topicTokens.every((topicToken) => messageTokens.some((messageToken) => messageToken.startsWith(topicToken)));
756
+ }
757
+
758
+ function findMatchingWatchRule<T extends { userId: string; topics?: string[] }>(
759
+ msgCtx: B24MsgContext,
760
+ watchRules: T[] | undefined,
761
+ ): T | undefined {
762
+ if (!Array.isArray(watchRules) || watchRules.length === 0) {
763
+ return undefined;
764
+ }
765
+
766
+ const senderId = normalizeAllowEntry(msgCtx.senderId);
767
+ return watchRules.find((rule) => {
768
+ const ruleUserId = normalizeAllowEntry(rule.userId);
769
+ if (ruleUserId !== '*' && ruleUserId !== senderId) {
770
+ return false;
771
+ }
772
+
773
+ if (!Array.isArray(rule.topics) || rule.topics.length === 0) {
774
+ return true;
775
+ }
776
+
777
+ return rule.topics.some((topic) => matchesWatchTopic(msgCtx.text, topic));
778
+ });
779
+ }
780
+
376
781
  interface BufferedDirectMessageEntry {
377
782
  messages: B24MsgContext[];
378
783
  startedAt: number;
@@ -535,6 +940,25 @@ export interface ChannelButton {
535
940
  style?: string;
536
941
  }
537
942
 
943
+ function parseRegisteredCommandTrigger(callbackData: string): { command: string; commandParams?: string } | undefined {
944
+ const trimmed = callbackData.trim();
945
+ const isSlashCommand = trimmed.startsWith('/');
946
+ const normalized = trimmed.replace(/^\/+/, '');
947
+ if (!normalized) {
948
+ return undefined;
949
+ }
950
+
951
+ const [commandName, ...params] = normalized.split(/\s+/);
952
+ if (!isSlashCommand && !REGISTERED_COMMANDS.has(commandName)) {
953
+ return undefined;
954
+ }
955
+
956
+ return {
957
+ command: commandName,
958
+ ...(params.length > 0 ? { commandParams: params.join(' ') } : {}),
959
+ };
960
+ }
961
+
538
962
  /**
539
963
  * Convert OpenClaw button rows to B24 flat KEYBOARD array.
540
964
  */
@@ -550,11 +974,14 @@ export function convertButtonsToKeyboard(input: ChannelButton[][] | ChannelButto
550
974
  for (const btn of rows[i]) {
551
975
  const b24Btn: KeyboardButton = { TEXT: btn.text, DISPLAY: 'LINE' };
552
976
 
553
- if (btn.callback_data?.startsWith('/')) {
554
- const parts = btn.callback_data.substring(1).split(' ');
555
- b24Btn.COMMAND = parts[0];
556
- if (parts.length > 1) {
557
- b24Btn.COMMAND_PARAMS = parts.slice(1).join(' ');
977
+ const parsedCommand = btn.callback_data
978
+ ? parseRegisteredCommandTrigger(btn.callback_data)
979
+ : undefined;
980
+
981
+ if (parsedCommand) {
982
+ b24Btn.COMMAND = parsedCommand.command;
983
+ if (parsedCommand.commandParams) {
984
+ b24Btn.COMMAND_PARAMS = parsedCommand.commandParams;
558
985
  }
559
986
  } else if (btn.callback_data) {
560
987
  b24Btn.ACTION = 'SEND';
@@ -1032,7 +1459,7 @@ export const bitrix24Plugin = {
1032
1459
  },
1033
1460
 
1034
1461
  capabilities: {
1035
- chatTypes: ['direct'] as const,
1462
+ chatTypes: ['direct', 'group'] as const,
1036
1463
  media: true,
1037
1464
  reactions: true,
1038
1465
  threads: false,
@@ -1060,9 +1487,10 @@ export const bitrix24Plugin = {
1060
1487
  security: {
1061
1488
  resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => {
1062
1489
  const securityConfig = resolveSecurityConfig(params);
1490
+ const policy = securityConfig.dmPolicy ?? 'webhookUser';
1063
1491
 
1064
1492
  return {
1065
- policy: securityConfig.dmPolicy === 'pairing' ? 'pairing' : 'webhookUser',
1493
+ policy,
1066
1494
  allowFrom: normalizeAllowList(securityConfig.allowFrom),
1067
1495
  policyPath: 'channels.bitrix24.dmPolicy',
1068
1496
  allowFromPath: 'channels.bitrix24.allowFrom',
@@ -1086,7 +1514,7 @@ export const bitrix24Plugin = {
1086
1514
  dialogId: params.id,
1087
1515
  };
1088
1516
  try {
1089
- await gatewayState.sendService.sendText(sendCtx, '\u2705 OpenClaw access approved.');
1517
+ await gatewayState.sendService.sendText(sendCtx, `\u2705 ${accessApproved(undefined)}`);
1090
1518
  } catch (err) {
1091
1519
  defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
1092
1520
  }
@@ -1383,12 +1811,13 @@ export const bitrix24Plugin = {
1383
1811
  return;
1384
1812
  }
1385
1813
  const welcomedDialogs = new Set<string>();
1386
- const deniedDialogs = new Map<string, number>();
1814
+ const dialogNoticeTimestamps = new Map<string, number>();
1815
+ const historyCache = new HistoryCache({ maxKeys: HISTORY_CACHE_MAX_KEYS });
1387
1816
 
1388
1817
  // Cleanup stale denied dialog entries once per day
1389
1818
  const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
1390
1819
  const deniedCleanupTimer = setInterval(() => {
1391
- deniedDialogs.clear();
1820
+ dialogNoticeTimestamps.clear();
1392
1821
  }, DENIED_CLEANUP_INTERVAL_MS);
1393
1822
  if (deniedCleanupTimer && typeof deniedCleanupTimer === 'object' && 'unref' in deniedCleanupTimer) {
1394
1823
  deniedCleanupTimer.unref();
@@ -1409,6 +1838,7 @@ export const bitrix24Plugin = {
1409
1838
  }
1410
1839
 
1411
1840
  const bot: BotContext = { botId: botRegistration.botId, botToken };
1841
+ const webhookOwnerId = getWebhookUserId(config.webhookUrl);
1412
1842
 
1413
1843
  // Sync user event subscription with agent mode setting
1414
1844
  if (eventMode === 'fetch') {
@@ -1427,15 +1857,126 @@ export const bitrix24Plugin = {
1427
1857
 
1428
1858
  const sendService = new SendService(api, logger);
1429
1859
  const mediaService = new MediaService(api, logger);
1860
+ const hydrateReplyEntry = async (
1861
+ replyToMessageId: string | undefined,
1862
+ ): Promise<{ sender: string; body: string; messageId: string } | undefined> => {
1863
+ const bitrixMessageId = toMessageId(replyToMessageId);
1864
+ if (!bitrixMessageId) {
1865
+ return undefined;
1866
+ }
1867
+
1868
+ try {
1869
+ const result: B24V2GetMessageResult = await api.getMessage(
1870
+ webhookUrl,
1871
+ bot,
1872
+ bitrixMessageId,
1873
+ );
1874
+
1875
+ return {
1876
+ sender: result.user?.name?.trim() || `User ${result.message.authorId}`,
1877
+ body: resolveFetchedMessageBody(result.message.text),
1878
+ messageId: String(result.message.id),
1879
+ };
1880
+ } catch (err) {
1881
+ logger.debug('Failed to hydrate reply context from Bitrix24 API', {
1882
+ replyToMessageId,
1883
+ error: err,
1884
+ });
1885
+ return undefined;
1886
+ }
1887
+ };
1888
+ const hydrateForwardedMessageContext = async (
1889
+ msgCtx: B24MsgContext,
1890
+ ): Promise<B24MsgContext> => {
1891
+ const currentMessageId = toMessageId(msgCtx.messageId);
1892
+ if (!currentMessageId) {
1893
+ return {
1894
+ ...msgCtx,
1895
+ isForwarded: false,
1896
+ };
1897
+ }
1898
+
1899
+ try {
1900
+ const result = await api.getMessageContext(
1901
+ webhookUrl,
1902
+ bot,
1903
+ currentMessageId,
1904
+ FORWARDED_CONTEXT_RANGE,
1905
+ );
1906
+ const currentMessage = result.messages.find((message) => message.id === currentMessageId);
1907
+ const currentBody = resolveFetchedMessageBody(currentMessage?.text ?? msgCtx.text);
1908
+ const fetchedContext = formatFetchedForwardContext({
1909
+ context: result,
1910
+ currentMessageId,
1911
+ });
1912
+
1913
+ if (!currentBody && !fetchedContext) {
1914
+ return {
1915
+ ...msgCtx,
1916
+ isForwarded: false,
1917
+ };
1918
+ }
1919
+
1920
+ return {
1921
+ ...msgCtx,
1922
+ text: [
1923
+ currentBody ? '[Forwarded message body]' : '',
1924
+ currentBody,
1925
+ currentBody ? '[/Forwarded message body]' : '',
1926
+ fetchedContext,
1927
+ ].filter(Boolean).join('\n\n'),
1928
+ isForwarded: false,
1929
+ };
1930
+ } catch (err) {
1931
+ logger.debug('Failed to hydrate forwarded message context from Bitrix24 API', {
1932
+ messageId: msgCtx.messageId,
1933
+ chatId: msgCtx.chatId,
1934
+ error: err,
1935
+ });
1936
+ return {
1937
+ ...msgCtx,
1938
+ isForwarded: false,
1939
+ };
1940
+ }
1941
+ };
1430
1942
  const processAllowedMessage = async (msgCtx: B24MsgContext): Promise<void> => {
1431
1943
  const runtime = getBitrix24Runtime();
1432
1944
  const cfg = runtime.config.loadConfig();
1945
+ const conversation = resolveConversationRef({
1946
+ accountId: ctx.accountId,
1947
+ dialogId: msgCtx.chatId,
1948
+ isDirect: msgCtx.isDm,
1949
+ });
1433
1950
  const sendCtx: SendContext = {
1434
1951
  webhookUrl,
1435
1952
  bot,
1436
- dialogId: msgCtx.chatId,
1953
+ dialogId: conversation.dialogId,
1437
1954
  };
1955
+ const historyKey = conversation.historyKey;
1956
+ const historyLimit = resolveHistoryLimit(config);
1957
+ const fallbackHistoryBody = buildHistoryBody(msgCtx);
1438
1958
  let downloadedMedia: DownloadedMedia[] = [];
1959
+ let historyRecorded = false;
1960
+
1961
+ const recordHistory = (bodyOverride?: string): void => {
1962
+ if (historyRecorded) {
1963
+ return;
1964
+ }
1965
+
1966
+ const historyBody = (bodyOverride ?? fallbackHistoryBody).trim();
1967
+ if (!historyBody) {
1968
+ return;
1969
+ }
1970
+
1971
+ appendMessageToHistory({
1972
+ historyCache,
1973
+ historyKey,
1974
+ historyLimit,
1975
+ msgCtx,
1976
+ body: historyBody,
1977
+ });
1978
+ historyRecorded = true;
1979
+ };
1439
1980
 
1440
1981
  try {
1441
1982
  const replyStatusHeartbeat = createReplyStatusHeartbeat({
@@ -1459,7 +2000,7 @@ export const bitrix24Plugin = {
1459
2000
  extension: mediaItem.extension,
1460
2001
  webhookUrl,
1461
2002
  bot,
1462
- dialogId: msgCtx.chatId,
2003
+ dialogId: conversation.dialogId,
1463
2004
  }),
1464
2005
  )).filter(Boolean) as DownloadedMedia[];
1465
2006
 
@@ -1475,6 +2016,7 @@ export const bitrix24Plugin = {
1475
2016
  } else {
1476
2017
  const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
1477
2018
  logger.warn('All media downloads failed, notifying user', { fileNames });
2019
+ recordHistory();
1478
2020
  await sendService.sendText(
1479
2021
  sendCtx,
1480
2022
  mediaDownloadFailed(msgCtx.language, fileNames),
@@ -1494,15 +2036,35 @@ export const bitrix24Plugin = {
1494
2036
  body = hasImage ? '<media:image>' : '<media:document>';
1495
2037
  }
1496
2038
 
2039
+ const previousEntries = historyCache.get(historyKey, historyLimit);
2040
+ const replyEntry = historyCache.findByMessageId(historyKey, msgCtx.replyToMessageId)
2041
+ ?? await hydrateReplyEntry(msgCtx.replyToMessageId);
2042
+ const bodyWithReply = formatReplyContext({
2043
+ body,
2044
+ replyEntry,
2045
+ });
2046
+ const crossChatContext = buildCrossChatMemoryContext({
2047
+ query: bodyWithReply,
2048
+ historyCache,
2049
+ });
2050
+ const bodyForAgent = crossChatContext
2051
+ ? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
2052
+ : bodyWithReply;
2053
+ const combinedBody = msgCtx.isGroup
2054
+ ? buildHistoryContext({
2055
+ entries: previousEntries,
2056
+ currentBody: bodyForAgent,
2057
+ })
2058
+ : bodyForAgent;
2059
+
2060
+ recordHistory(body);
2061
+
1497
2062
  // Resolve which agent handles this conversation
1498
2063
  const route = runtime.channel.routing.resolveAgentRoute({
1499
2064
  cfg,
1500
2065
  channel: 'bitrix24',
1501
2066
  accountId: ctx.accountId,
1502
- peer: {
1503
- kind: msgCtx.isDm ? 'direct' : 'group',
1504
- id: msgCtx.chatId,
1505
- },
2067
+ peer: conversation.peer,
1506
2068
  });
1507
2069
 
1508
2070
  logger.debug('Resolved route', {
@@ -1512,12 +2074,19 @@ export const bitrix24Plugin = {
1512
2074
  });
1513
2075
 
1514
2076
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
1515
- Body: body,
1516
- BodyForAgent: body,
2077
+ Body: combinedBody,
2078
+ BodyForAgent: bodyForAgent,
2079
+ InboundHistory: msgCtx.isGroup && previousEntries.length > 0
2080
+ ? previousEntries.map((entry) => ({
2081
+ sender: entry.sender,
2082
+ body: entry.body,
2083
+ timestamp: entry.timestamp,
2084
+ }))
2085
+ : undefined,
1517
2086
  RawBody: body,
1518
- From: `bitrix24:${msgCtx.chatId}`,
1519
- To: `bitrix24:${msgCtx.chatId}`,
1520
- SessionKey: `${route.sessionKey}:bitrix24:${msgCtx.chatId}`,
2087
+ From: conversation.address,
2088
+ To: conversation.address,
2089
+ SessionKey: buildConversationSessionKey(route.sessionKey, conversation),
1521
2090
  AccountId: route.accountId,
1522
2091
  ChatType: msgCtx.isDm ? 'direct' : 'group',
1523
2092
  ConversationLabel: msgCtx.senderName,
@@ -1526,11 +2095,14 @@ export const bitrix24Plugin = {
1526
2095
  Provider: 'bitrix24',
1527
2096
  Surface: 'bitrix24',
1528
2097
  MessageSid: msgCtx.messageId,
1529
- Timestamp: Date.now(),
1530
- WasMentioned: false,
2098
+ Timestamp: msgCtx.timestamp ?? Date.now(),
2099
+ ReplyToId: replyEntry?.messageId ?? msgCtx.replyToMessageId,
2100
+ ReplyToBody: replyEntry?.body,
2101
+ ReplyToSender: replyEntry?.sender,
2102
+ WasMentioned: msgCtx.wasMentioned ?? false,
1531
2103
  CommandAuthorized: true,
1532
2104
  OriginatingChannel: 'bitrix24',
1533
- OriginatingTo: `bitrix24:${msgCtx.chatId}`,
2105
+ OriginatingTo: conversation.address,
1534
2106
  ...mediaFields,
1535
2107
  });
1536
2108
 
@@ -1548,7 +2120,7 @@ export const bitrix24Plugin = {
1548
2120
  fileName: basename(mediaUrl),
1549
2121
  webhookUrl,
1550
2122
  bot,
1551
- dialogId: msgCtx.chatId,
2123
+ dialogId: conversation.dialogId,
1552
2124
  });
1553
2125
 
1554
2126
  if (!uploadResult.ok) {
@@ -1594,26 +2166,113 @@ export const bitrix24Plugin = {
1594
2166
  onFlush: processAllowedMessage,
1595
2167
  logger,
1596
2168
  });
1597
- const maybeNotifyDeniedDialog = async (
1598
- dialogId: string,
1599
- language: string | undefined,
2169
+ const maybeSendDialogNotice = async (
2170
+ noticeKey: string,
1600
2171
  sendCtx: SendContext,
2172
+ text: string,
1601
2173
  ): Promise<void> => {
1602
2174
  const now = Date.now();
1603
- const lastSentAt = deniedDialogs.get(dialogId) ?? 0;
2175
+ const lastSentAt = dialogNoticeTimestamps.get(noticeKey) ?? 0;
1604
2176
  if ((now - lastSentAt) < ACCESS_DENIED_NOTICE_COOLDOWN_MS) {
1605
2177
  return;
1606
2178
  }
1607
2179
 
1608
- deniedDialogs.set(dialogId, now);
2180
+ dialogNoticeTimestamps.set(noticeKey, now);
1609
2181
  try {
1610
- await sendService.sendText(
1611
- sendCtx,
1612
- personalBotOwnerOnly(language),
1613
- { convertMarkdown: false },
2182
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
2183
+ } catch (err) {
2184
+ logger.warn('Failed to send dialog notice', err);
2185
+ }
2186
+ };
2187
+ const maybeReactToDeniedMention = async (msgCtx: B24MsgContext): Promise<void> => {
2188
+ if (!msgCtx.isGroup || !msgCtx.wasMentioned) {
2189
+ return;
2190
+ }
2191
+
2192
+ const messageId = toMessageId(msgCtx.messageId);
2193
+ if (!messageId) {
2194
+ return;
2195
+ }
2196
+
2197
+ try {
2198
+ await api.addReaction(
2199
+ webhookUrl,
2200
+ bot,
2201
+ messageId,
2202
+ ACCESS_DENIED_REACTION,
2203
+ );
2204
+ } catch (err) {
2205
+ logger.debug('Failed to add access-denied reaction', {
2206
+ chatId: msgCtx.chatId,
2207
+ messageId: msgCtx.messageId,
2208
+ error: err,
2209
+ });
2210
+ }
2211
+ };
2212
+ const notifyWebhookOwnerAboutWatchMatch = async (
2213
+ msgCtx: B24MsgContext,
2214
+ watchRule: Bitrix24GroupWatchConfig | Bitrix24AgentWatchConfig,
2215
+ ): Promise<boolean> => {
2216
+ const ownerId = webhookOwnerId;
2217
+ const forwardedMessageId = toMessageId(msgCtx.messageId);
2218
+ if (!ownerId || !forwardedMessageId) {
2219
+ logger.warn('Skipping owner watch notification: missing owner dialog or message id', {
2220
+ ownerId,
2221
+ messageId: msgCtx.messageId,
2222
+ chatId: msgCtx.chatId,
2223
+ });
2224
+ return false;
2225
+ }
2226
+
2227
+ const ownerSendCtx: SendContext = {
2228
+ webhookUrl,
2229
+ bot,
2230
+ dialogId: ownerId,
2231
+ };
2232
+
2233
+ const noticeText = watchOwnerDmNotice(msgCtx.language, {
2234
+ chatRef: buildChatContextUrl(
2235
+ msgCtx.chatId,
2236
+ msgCtx.messageId,
2237
+ msgCtx.isDm
2238
+ ? (msgCtx.senderName || msgCtx.chatName || msgCtx.chatId)
2239
+ : (msgCtx.chatName ?? msgCtx.chatId),
2240
+ ),
2241
+ topicsRef: buildTopicsBbCode(watchRule.topics),
2242
+ sourceKind: msgCtx.isDm ? 'dm' : 'chat',
2243
+ });
2244
+
2245
+ try {
2246
+ await sendService.sendText(ownerSendCtx, noticeText, {
2247
+ convertMarkdown: false,
2248
+ });
2249
+
2250
+ if (msgCtx.eventScope === 'user' && msgCtx.isDm) {
2251
+ const quoteText = buildWatchQuoteText({
2252
+ senderName: msgCtx.senderName || msgCtx.chatName || msgCtx.chatId,
2253
+ language: msgCtx.language,
2254
+ timestamp: msgCtx.timestamp,
2255
+ anchor: `#${msgCtx.chatId}:${ownerId}/${msgCtx.messageId}`,
2256
+ body: msgCtx.text.trim(),
2257
+ });
2258
+
2259
+ await sendService.sendText(ownerSendCtx, quoteText, {
2260
+ convertMarkdown: false,
2261
+ });
2262
+ return true;
2263
+ }
2264
+
2265
+ await api.sendMessage(
2266
+ webhookUrl,
2267
+ bot,
2268
+ ownerId,
2269
+ null,
2270
+ { forwardMessages: [forwardedMessageId] },
1614
2271
  );
2272
+ return true;
1615
2273
  } catch (err) {
1616
- logger.warn('Failed to send access denied notice', err);
2274
+ logger.warn('Failed to send owner watch notification with native forward', err);
2275
+ return false;
1617
2276
  }
1618
2277
  };
1619
2278
 
@@ -1649,20 +2308,6 @@ export const bitrix24Plugin = {
1649
2308
  ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
1650
2309
  : null;
1651
2310
 
1652
- if (msgCtx.isGroup) {
1653
- logger.warn('Group chat is not supported, leaving chat', {
1654
- chatId: msgCtx.chatId,
1655
- senderId: msgCtx.senderId,
1656
- });
1657
-
1658
- try {
1659
- await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
1660
- } catch (err) {
1661
- logger.error('Failed to leave group chat after message', err);
1662
- }
1663
- return;
1664
- }
1665
-
1666
2311
  const runtime = getBitrix24Runtime();
1667
2312
 
1668
2313
  // Pairing-aware access control
@@ -1671,73 +2316,233 @@ export const bitrix24Plugin = {
1671
2316
  bot,
1672
2317
  dialogId: msgCtx.chatId,
1673
2318
  };
1674
-
1675
- const accessResult = await checkAccessWithPairing({
1676
- senderId: msgCtx.senderId,
2319
+ const conversation = resolveConversationRef({
2320
+ accountId: ctx.accountId,
1677
2321
  dialogId: msgCtx.chatId,
1678
2322
  isDirect: msgCtx.isDm,
1679
- config,
1680
- runtime,
1681
- accountId: ctx.accountId,
1682
- pairingAdapter: bitrix24Plugin.pairing,
1683
- sendReply: async (text: string) => {
1684
- await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1685
- },
1686
- logger,
1687
2323
  });
2324
+ const historyKey = conversation.historyKey;
2325
+ const historyLimit = resolveHistoryLimit(config);
2326
+ const groupAccess = msgCtx.isGroup
2327
+ ? resolveGroupAccess({
2328
+ config,
2329
+ dialogId: msgCtx.chatId,
2330
+ chatId: msgCtx.chatInternalId,
2331
+ })
2332
+ : null;
2333
+ const agentWatchRules = config.agentMode && msgCtx.eventScope === 'user'
2334
+ ? resolveAgentWatchRules({
2335
+ config,
2336
+ dialogId: msgCtx.chatId,
2337
+ chatId: msgCtx.chatInternalId,
2338
+ })
2339
+ : [];
2340
+ const watchRule = msgCtx.isGroup && groupAccess?.groupAllowed
2341
+ ? findMatchingWatchRule(msgCtx, groupAccess?.watch)
2342
+ : undefined;
2343
+ const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
2344
+ && webhookOwnerId
2345
+ && msgCtx.senderId === webhookOwnerId
2346
+ ? undefined
2347
+ : watchRule;
2348
+ const agentWatchRule = msgCtx.eventScope === 'user'
2349
+ ? findMatchingWatchRule(msgCtx, agentWatchRules)
2350
+ : undefined;
2351
+
2352
+ if (msgCtx.eventScope === 'user') {
2353
+ const isBotDialogUserEvent = msgCtx.isDm && msgCtx.chatId === String(bot.botId);
2354
+ const isBotAuthoredUserEvent = msgCtx.senderId === String(bot.botId);
2355
+
2356
+ if (isBotDialogUserEvent || isBotAuthoredUserEvent) {
2357
+ logger.debug('Skipping agent-mode user event for bot-owned conversation', {
2358
+ senderId: msgCtx.senderId,
2359
+ chatId: msgCtx.chatId,
2360
+ messageId: msgCtx.messageId,
2361
+ isBotDialogUserEvent,
2362
+ isBotAuthoredUserEvent,
2363
+ });
2364
+ return;
2365
+ }
1688
2366
 
1689
- if (accessResult === 'deny') {
1690
- await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1691
- await maybeNotifyDeniedDialog(msgCtx.chatId, msgCtx.language, sendCtx);
1692
- logger.debug('Message blocked (deny)', { senderId: msgCtx.senderId, chatId: msgCtx.chatId });
2367
+ appendMessageToHistory({
2368
+ historyCache,
2369
+ historyKey,
2370
+ historyLimit,
2371
+ msgCtx,
2372
+ });
2373
+
2374
+ if (webhookOwnerId && msgCtx.senderId !== webhookOwnerId && agentWatchRule?.mode === 'notifyOwnerDm') {
2375
+ await notifyWebhookOwnerAboutWatchMatch(msgCtx, agentWatchRule);
2376
+ logger.debug('User-event watch matched and notified webhook owner in DM', {
2377
+ senderId: msgCtx.senderId,
2378
+ chatId: msgCtx.chatId,
2379
+ messageId: msgCtx.messageId,
2380
+ });
2381
+ }
2382
+ return;
2383
+ }
2384
+
2385
+ if (msgCtx.isGroup && groupAccess?.groupPolicy === 'disabled') {
2386
+ logger.info('Group chat is disabled by policy, leaving chat', {
2387
+ chatId: msgCtx.chatId,
2388
+ senderId: msgCtx.senderId,
2389
+ });
2390
+
2391
+ try {
2392
+ await sendService.sendText(sendCtx, groupChatUnsupported(msgCtx.language));
2393
+ await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
2394
+ } catch (err) {
2395
+ logger.error('Failed to leave disabled group chat', err);
2396
+ }
1693
2397
  return;
1694
2398
  }
1695
2399
 
1696
2400
  await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1697
- await sendService.sendTyping(sendCtx);
1698
2401
 
1699
- if (accessResult !== 'allow') {
1700
- logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
2402
+ if (
2403
+ msgCtx.isGroup
2404
+ && !activeWatchRule
2405
+ && groupAccess?.requireMention
2406
+ && !msgCtx.wasMentioned
2407
+ ) {
2408
+ appendMessageToHistory({
2409
+ historyCache,
2410
+ historyKey,
2411
+ historyLimit,
2412
+ msgCtx,
2413
+ });
2414
+
2415
+ logger.info('Skipping group message without mention', {
2416
+ chatId: msgCtx.chatId,
2417
+ senderId: msgCtx.senderId,
2418
+ messageId: msgCtx.messageId,
2419
+ });
1701
2420
  return;
1702
2421
  }
1703
2422
 
1704
- if (msgCtx.isForwarded) {
1705
- if (pendingForwardContext) {
1706
- logger.info('Merging forwarded message with buffered context', {
1707
- senderId: msgCtx.senderId,
1708
- chatId: msgCtx.chatId,
1709
- previousMessageId: pendingForwardContext.messageId,
1710
- forwardedMessageId: msgCtx.messageId,
1711
- });
1712
- await processAllowedMessage(mergeForwardedMessageContext(pendingForwardContext, msgCtx));
1713
- return;
1714
- }
2423
+ if (activeWatchRule?.mode === 'notifyOwnerDm') {
2424
+ appendMessageToHistory({
2425
+ historyCache,
2426
+ historyKey,
2427
+ historyLimit,
2428
+ msgCtx,
2429
+ });
1715
2430
 
1716
- logger.info('Forwarded message is not supported yet', {
2431
+ await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule);
2432
+ logger.debug('Group watch matched and notified webhook owner in DM', {
1717
2433
  senderId: msgCtx.senderId,
1718
2434
  chatId: msgCtx.chatId,
1719
2435
  messageId: msgCtx.messageId,
1720
2436
  });
1721
- await sendService.sendText(
2437
+ return;
2438
+ }
2439
+
2440
+ const accessResult = activeWatchRule
2441
+ ? 'allow'
2442
+ : msgCtx.isGroup
2443
+ ? await checkGroupAccessWithPairing({
2444
+ senderId: msgCtx.senderId,
2445
+ dialogId: msgCtx.chatId,
2446
+ chatId: msgCtx.chatInternalId,
2447
+ config,
2448
+ runtime,
2449
+ accountId: ctx.accountId,
2450
+ pairingAdapter: bitrix24Plugin.pairing,
2451
+ logger,
2452
+ })
2453
+ : await checkAccessWithPairing({
2454
+ senderId: msgCtx.senderId,
2455
+ dialogId: msgCtx.chatId,
2456
+ isDirect: msgCtx.isDm,
2457
+ config,
2458
+ runtime,
2459
+ accountId: ctx.accountId,
2460
+ pairingAdapter: bitrix24Plugin.pairing,
2461
+ sendReply: async (text: string) => {
2462
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
2463
+ },
2464
+ logger,
2465
+ });
2466
+
2467
+ if (accessResult === 'deny') {
2468
+ if (msgCtx.isGroup) {
2469
+ appendMessageToHistory({
2470
+ historyCache,
2471
+ historyKey,
2472
+ historyLimit,
2473
+ msgCtx,
2474
+ });
2475
+
2476
+ if (!msgCtx.wasMentioned) {
2477
+ logger.debug('Group message blocked silently without mention', {
2478
+ senderId: msgCtx.senderId,
2479
+ chatId: msgCtx.chatId,
2480
+ messageId: msgCtx.messageId,
2481
+ });
2482
+ return;
2483
+ }
2484
+ }
2485
+
2486
+ await maybeReactToDeniedMention(msgCtx);
2487
+
2488
+ const noticeKey = msgCtx.isDm
2489
+ ? `deny:${msgCtx.chatId}`
2490
+ : `group-deny:${msgCtx.chatId}:${msgCtx.senderId}`;
2491
+ const policy = msgCtx.isDm
2492
+ ? config.dmPolicy
2493
+ : groupAccess?.groupPolicy;
2494
+
2495
+ await maybeSendDialogNotice(
2496
+ noticeKey,
1722
2497
  sendCtx,
1723
- forwardedMessageUnsupported(msgCtx.language),
1724
- { convertMarkdown: false },
2498
+ buildAccessDeniedNotice(msgCtx.language, policy, {
2499
+ hasAllowList: msgCtx.isDm
2500
+ ? normalizeAllowList(config.allowFrom).length > 0
2501
+ : Boolean(groupAccess?.senderAllowFrom.length),
2502
+ }),
1725
2503
  );
2504
+ logger.debug('Message blocked (deny)', { senderId: msgCtx.senderId, chatId: msgCtx.chatId });
2505
+ return;
2506
+ }
2507
+
2508
+ if (accessResult === 'pairing') {
2509
+ if (msgCtx.isGroup) {
2510
+ appendMessageToHistory({
2511
+ historyCache,
2512
+ historyKey,
2513
+ historyLimit,
2514
+ msgCtx,
2515
+ });
2516
+
2517
+ await maybeSendDialogNotice(
2518
+ `group-pairing:${msgCtx.chatId}:${msgCtx.senderId}`,
2519
+ sendCtx,
2520
+ groupPairingPending(msgCtx.language),
2521
+ );
2522
+ }
2523
+ logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
1726
2524
  return;
1727
2525
  }
1728
2526
 
1729
- if (msgCtx.replyToMessageId) {
1730
- logger.info('Reply-to-message is not supported yet', {
2527
+ await sendService.sendTyping(sendCtx);
2528
+
2529
+ if (msgCtx.isForwarded) {
2530
+ if (pendingForwardContext) {
2531
+ const hydratedForwardContext = await hydrateForwardedMessageContext(msgCtx);
2532
+ const mergedForwardContext = mergeForwardedMessageContext(
2533
+ pendingForwardContext,
2534
+ hydratedForwardContext,
2535
+ );
2536
+ await processAllowedMessage(mergedForwardContext);
2537
+ return;
2538
+ }
2539
+
2540
+ logger.info('Hydrating forwarded message context from Bitrix24 API', {
1731
2541
  senderId: msgCtx.senderId,
1732
2542
  chatId: msgCtx.chatId,
1733
2543
  messageId: msgCtx.messageId,
1734
- replyToMessageId: msgCtx.replyToMessageId,
1735
2544
  });
1736
- await sendService.sendText(
1737
- sendCtx,
1738
- replyMessageUnsupported(msgCtx.language),
1739
- { convertMarkdown: false },
1740
- );
2545
+ await processAllowedMessage(await hydrateForwardedMessageContext(msgCtx));
1741
2546
  return;
1742
2547
  }
1743
2548
 
@@ -1758,11 +2563,16 @@ export const bitrix24Plugin = {
1758
2563
  commandText,
1759
2564
  senderId,
1760
2565
  dialogId,
2566
+ chatId,
1761
2567
  chatType,
1762
2568
  messageId,
1763
2569
  } = cmdCtx;
1764
2570
  const isDm = chatType === 'P';
1765
- const peerId = isDm ? senderId : dialogId;
2571
+ const conversation = resolveConversationRef({
2572
+ accountId: ctx.accountId,
2573
+ dialogId,
2574
+ isDirect: isDm,
2575
+ });
1766
2576
 
1767
2577
  logger.info('Inbound command', {
1768
2578
  commandId,
@@ -1771,13 +2581,14 @@ export const bitrix24Plugin = {
1771
2581
  commandText,
1772
2582
  senderId,
1773
2583
  dialogId,
1774
- peerId,
2584
+ chatId,
2585
+ conversationDialogId: conversation.dialogId,
1775
2586
  });
1776
2587
 
1777
2588
  const sendCtx: SendContext = {
1778
2589
  webhookUrl,
1779
2590
  bot,
1780
- dialogId: peerId,
2591
+ dialogId: conversation.dialogId,
1781
2592
  };
1782
2593
 
1783
2594
  let runtime;
@@ -1799,28 +2610,46 @@ export const bitrix24Plugin = {
1799
2610
  commandDialogId: dialogId,
1800
2611
  }
1801
2612
  : null;
2613
+ const groupAccess = !isDm
2614
+ ? resolveGroupAccess({
2615
+ config,
2616
+ dialogId,
2617
+ chatId,
2618
+ })
2619
+ : null;
1802
2620
 
1803
2621
  // Access control
1804
2622
  let accessResult;
1805
2623
  try {
1806
- accessResult = await checkAccessWithPairing({
1807
- senderId,
1808
- dialogId,
1809
- isDirect: isDm,
1810
- config,
1811
- runtime,
1812
- accountId: ctx.accountId,
1813
- pairingAdapter: bitrix24Plugin.pairing,
1814
- sendReply: async (text: string) => {
1815
- if (commandSendCtx) {
1816
- await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
1817
- return;
1818
- }
2624
+ accessResult = !isDm
2625
+ ? await checkGroupAccessWithPairing({
2626
+ senderId,
2627
+ dialogId,
2628
+ chatId,
2629
+ config,
2630
+ runtime,
2631
+ accountId: ctx.accountId,
2632
+ pairingAdapter: bitrix24Plugin.pairing,
2633
+ logger,
2634
+ })
2635
+ : await checkAccessWithPairing({
2636
+ senderId,
2637
+ dialogId,
2638
+ isDirect: isDm,
2639
+ config,
2640
+ runtime,
2641
+ accountId: ctx.accountId,
2642
+ pairingAdapter: bitrix24Plugin.pairing,
2643
+ sendReply: async (text: string) => {
2644
+ if (commandSendCtx) {
2645
+ await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
2646
+ return;
2647
+ }
1819
2648
 
1820
- await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1821
- },
1822
- logger,
1823
- });
2649
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
2650
+ },
2651
+ logger,
2652
+ });
1824
2653
  } catch (err) {
1825
2654
  logger.error('Access check failed for command', err);
1826
2655
  return;
@@ -1830,7 +2659,16 @@ export const bitrix24Plugin = {
1830
2659
  logger.warn('Command event has invalid messageId, skipping response', { commandId, messageId, dialogId });
1831
2660
  return;
1832
2661
  }
1833
- const canMarkRead = dialogId === peerId;
2662
+ const canMarkRead = true;
2663
+
2664
+ if (!isDm && groupAccess?.groupPolicy === 'disabled') {
2665
+ await sendService.answerCommandText(
2666
+ commandSendCtx,
2667
+ groupChatUnsupported(cmdCtx.language),
2668
+ { convertMarkdown: false },
2669
+ );
2670
+ return;
2671
+ }
1834
2672
 
1835
2673
  await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
1836
2674
 
@@ -1840,7 +2678,15 @@ export const bitrix24Plugin = {
1840
2678
  }
1841
2679
  await sendService.answerCommandText(
1842
2680
  commandSendCtx,
1843
- personalBotOwnerOnly(cmdCtx.language),
2681
+ buildAccessDeniedNotice(
2682
+ cmdCtx.language,
2683
+ isDm ? config.dmPolicy : groupAccess?.groupPolicy,
2684
+ {
2685
+ hasAllowList: isDm
2686
+ ? normalizeAllowList(config.allowFrom).length > 0
2687
+ : Boolean(groupAccess?.senderAllowFrom.length),
2688
+ },
2689
+ ),
1844
2690
  { convertMarkdown: false },
1845
2691
  );
1846
2692
  logger.debug('Command blocked (deny)', { senderId, dialogId });
@@ -1851,16 +2697,23 @@ export const bitrix24Plugin = {
1851
2697
  await sendService.markRead(sendCtx, commandMessageId);
1852
2698
  }
1853
2699
 
1854
- if (accessResult !== 'allow') {
2700
+ if (accessResult === 'pairing') {
2701
+ if (!isDm) {
2702
+ await sendService.answerCommandText(
2703
+ commandSendCtx,
2704
+ groupPairingPending(cmdCtx.language),
2705
+ { convertMarkdown: false },
2706
+ );
2707
+ }
1855
2708
  logger.debug(`Command blocked (${accessResult})`, { senderId });
1856
2709
  return;
1857
2710
  }
1858
2711
 
1859
- await directTextCoalescer.flush(ctx.accountId, peerId);
2712
+ await directTextCoalescer.flush(ctx.accountId, conversation.dialogId);
1860
2713
 
1861
2714
  if (commandName === 'help' || commandName === 'commands') {
1862
- await sendService.sendText(
1863
- sendCtx,
2715
+ await sendService.answerCommandText(
2716
+ commandSendCtx,
1864
2717
  buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' }),
1865
2718
  { keyboard: DEFAULT_COMMAND_KEYBOARD, convertMarkdown: false },
1866
2719
  );
@@ -1871,7 +2724,7 @@ export const bitrix24Plugin = {
1871
2724
  cfg,
1872
2725
  channel: 'bitrix24',
1873
2726
  accountId: ctx.accountId,
1874
- peer: { kind: isDm ? 'direct' : 'group', id: peerId },
2727
+ peer: conversation.peer,
1875
2728
  });
1876
2729
 
1877
2730
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
@@ -1883,8 +2736,8 @@ export const bitrix24Plugin = {
1883
2736
  CommandBody: commandText,
1884
2737
  CommandAuthorized: true,
1885
2738
  CommandSource: 'native',
1886
- CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${peerId}`,
1887
- From: `bitrix24:${peerId}`,
2739
+ CommandTargetSessionKey: buildConversationSessionKey(route.sessionKey, conversation),
2740
+ From: conversation.address,
1888
2741
  To: `slash:${senderId}`,
1889
2742
  SessionKey: slashSessionKey,
1890
2743
  AccountId: route.accountId,
@@ -1898,7 +2751,7 @@ export const bitrix24Plugin = {
1898
2751
  Timestamp: Date.now(),
1899
2752
  WasMentioned: true,
1900
2753
  OriginatingChannel: 'bitrix24',
1901
- OriginatingTo: `bitrix24:${peerId}`,
2754
+ OriginatingTo: conversation.address,
1902
2755
  });
1903
2756
 
1904
2757
  const replyStatusHeartbeat = createReplyStatusHeartbeat({
@@ -1926,14 +2779,6 @@ export const bitrix24Plugin = {
1926
2779
  });
1927
2780
  if (!commandReplyDelivered) {
1928
2781
  commandReplyDelivered = true;
1929
- if (isDm) {
1930
- await sendService.sendText(sendCtx, formattedPayload.text, {
1931
- keyboard,
1932
- convertMarkdown: formattedPayload.convertMarkdown,
1933
- });
1934
- return;
1935
- }
1936
-
1937
2782
  await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
1938
2783
  keyboard,
1939
2784
  convertMarkdown: formattedPayload.convertMarkdown,
@@ -1963,12 +2808,16 @@ export const bitrix24Plugin = {
1963
2808
  },
1964
2809
 
1965
2810
  onJoinChat: async (joinCtx: FetchJoinChatContext) => {
1966
- const { dialogId, chatType, language } = joinCtx;
1967
- logger.info('Bot joined chat', { dialogId, chatType });
2811
+ const { senderId, dialogId, chatId, chatType, language } = joinCtx;
2812
+ logger.info('Bot joined chat', { senderId, dialogId, chatId, chatType });
1968
2813
 
1969
2814
  if (!dialogId) return;
2815
+ const isGroupChat = chatType === 'chat' || chatType === 'open';
2816
+ const groupAccess = isGroupChat
2817
+ ? resolveGroupAccess({ config, dialogId, chatId })
2818
+ : null;
1970
2819
 
1971
- if (shouldSkipJoinChatWelcome({
2820
+ if (!isGroupChat && shouldSkipJoinChatWelcome({
1972
2821
  dialogId,
1973
2822
  chatType,
1974
2823
  webhookUrl,
@@ -1984,9 +2833,8 @@ export const bitrix24Plugin = {
1984
2833
  dialogId,
1985
2834
  };
1986
2835
 
1987
- // Reject group chats
1988
- if (chatType === 'chat' || chatType === 'open') {
1989
- logger.info('Group chat not supported, leaving', { dialogId });
2836
+ if (isGroupChat && (!groupAccess?.groupAllowed || groupAccess.groupPolicy === 'disabled')) {
2837
+ logger.info('Group chat blocked by policy, leaving', { dialogId });
1990
2838
  try {
1991
2839
  await sendService.sendText(sendCtx, groupChatUnsupported(language));
1992
2840
  await api.leaveChat(webhookUrl, bot, dialogId);
@@ -1996,6 +2844,45 @@ export const bitrix24Plugin = {
1996
2844
  return;
1997
2845
  }
1998
2846
 
2847
+ if (isGroupChat) {
2848
+ const runtime = getBitrix24Runtime();
2849
+ const inviterAccessResult = await checkGroupAccessPassive({
2850
+ senderId,
2851
+ dialogId,
2852
+ chatId,
2853
+ config,
2854
+ runtime,
2855
+ accountId: ctx.accountId,
2856
+ logger,
2857
+ });
2858
+
2859
+ if (inviterAccessResult !== 'allow') {
2860
+ const noticeText = inviterAccessResult === 'pairing'
2861
+ ? groupPairingPending(language)
2862
+ : buildAccessDeniedNotice(language, groupAccess?.groupPolicy, {
2863
+ hasAllowList: Boolean(groupAccess?.senderAllowFrom.length),
2864
+ });
2865
+
2866
+ logger.info('Leaving group chat invited by user without access', {
2867
+ dialogId,
2868
+ chatId,
2869
+ senderId,
2870
+ inviterAccessResult,
2871
+ });
2872
+
2873
+ try {
2874
+ await sendService.sendText(sendCtx, noticeText, { convertMarkdown: false });
2875
+ await api.leaveChat(webhookUrl, bot, dialogId);
2876
+ } catch (err) {
2877
+ logger.error('Failed to leave group chat after inviter access check', err);
2878
+ }
2879
+ return;
2880
+ }
2881
+
2882
+ logger.info('Group chat enabled by policy, skipping auto-welcome', { dialogId });
2883
+ return;
2884
+ }
2885
+
1999
2886
  if (welcomedDialogs.has(dialogId)) {
2000
2887
  logger.info('Skipping duplicate welcome for already welcomed dialog', { dialogId });
2001
2888
  return;
@@ -2037,6 +2924,7 @@ export const bitrix24Plugin = {
2037
2924
  accountId: ctx.accountId,
2038
2925
  pollingIntervalMs: config.pollingIntervalMs ?? 3000,
2039
2926
  pollingFastIntervalMs: config.pollingFastIntervalMs ?? 100,
2927
+ withUserEvents: Boolean(config.agentMode),
2040
2928
  onEvent: async (event: B24V2FetchEventItem) => {
2041
2929
  const fetchCtx: FetchContext = {
2042
2930
  webhookUrl,