@ihazz/bitrix24 1.0.2 → 1.1.0

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,21 +940,48 @@ 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
  */
541
- export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
965
+ export function convertButtonsToKeyboard(input: ChannelButton[][] | ChannelButton[]): B24Keyboard {
966
+ // Normalize: accept both [[btn, btn], [btn]] (rows) and [btn, btn] (flat)
967
+ const isNested = input.length > 0 && Array.isArray(input[0]);
968
+ const rows: ChannelButton[][] = isNested
969
+ ? (input as ChannelButton[][])
970
+ : [input as ChannelButton[]];
542
971
  const keyboard: B24Keyboard = [];
543
972
 
544
973
  for (let i = 0; i < rows.length; i++) {
545
974
  for (const btn of rows[i]) {
546
975
  const b24Btn: KeyboardButton = { TEXT: btn.text, DISPLAY: 'LINE' };
547
976
 
548
- if (btn.callback_data?.startsWith('/')) {
549
- const parts = btn.callback_data.substring(1).split(' ');
550
- b24Btn.COMMAND = parts[0];
551
- if (parts.length > 1) {
552
- 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;
553
985
  }
554
986
  } else if (btn.callback_data) {
555
987
  b24Btn.ACTION = 'SEND';
@@ -1027,7 +1459,7 @@ export const bitrix24Plugin = {
1027
1459
  },
1028
1460
 
1029
1461
  capabilities: {
1030
- chatTypes: ['direct'] as const,
1462
+ chatTypes: ['direct', 'group'] as const,
1031
1463
  media: true,
1032
1464
  reactions: true,
1033
1465
  threads: false,
@@ -1055,9 +1487,10 @@ export const bitrix24Plugin = {
1055
1487
  security: {
1056
1488
  resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => {
1057
1489
  const securityConfig = resolveSecurityConfig(params);
1490
+ const policy = securityConfig.dmPolicy ?? 'webhookUser';
1058
1491
 
1059
1492
  return {
1060
- policy: securityConfig.dmPolicy === 'pairing' ? 'pairing' : 'webhookUser',
1493
+ policy,
1061
1494
  allowFrom: normalizeAllowList(securityConfig.allowFrom),
1062
1495
  policyPath: 'channels.bitrix24.dmPolicy',
1063
1496
  allowFromPath: 'channels.bitrix24.allowFrom',
@@ -1081,7 +1514,7 @@ export const bitrix24Plugin = {
1081
1514
  dialogId: params.id,
1082
1515
  };
1083
1516
  try {
1084
- await gatewayState.sendService.sendText(sendCtx, '\u2705 OpenClaw access approved.');
1517
+ await gatewayState.sendService.sendText(sendCtx, `\u2705 ${accessApproved(undefined)}`);
1085
1518
  } catch (err) {
1086
1519
  defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
1087
1520
  }
@@ -1378,12 +1811,13 @@ export const bitrix24Plugin = {
1378
1811
  return;
1379
1812
  }
1380
1813
  const welcomedDialogs = new Set<string>();
1381
- const deniedDialogs = new Map<string, number>();
1814
+ const dialogNoticeTimestamps = new Map<string, number>();
1815
+ const historyCache = new HistoryCache({ maxKeys: HISTORY_CACHE_MAX_KEYS });
1382
1816
 
1383
1817
  // Cleanup stale denied dialog entries once per day
1384
1818
  const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
1385
1819
  const deniedCleanupTimer = setInterval(() => {
1386
- deniedDialogs.clear();
1820
+ dialogNoticeTimestamps.clear();
1387
1821
  }, DENIED_CLEANUP_INTERVAL_MS);
1388
1822
  if (deniedCleanupTimer && typeof deniedCleanupTimer === 'object' && 'unref' in deniedCleanupTimer) {
1389
1823
  deniedCleanupTimer.unref();
@@ -1404,6 +1838,7 @@ export const bitrix24Plugin = {
1404
1838
  }
1405
1839
 
1406
1840
  const bot: BotContext = { botId: botRegistration.botId, botToken };
1841
+ const webhookOwnerId = getWebhookUserId(config.webhookUrl);
1407
1842
 
1408
1843
  // Sync user event subscription with agent mode setting
1409
1844
  if (eventMode === 'fetch') {
@@ -1422,15 +1857,126 @@ export const bitrix24Plugin = {
1422
1857
 
1423
1858
  const sendService = new SendService(api, logger);
1424
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
+ };
1425
1942
  const processAllowedMessage = async (msgCtx: B24MsgContext): Promise<void> => {
1426
1943
  const runtime = getBitrix24Runtime();
1427
1944
  const cfg = runtime.config.loadConfig();
1945
+ const conversation = resolveConversationRef({
1946
+ accountId: ctx.accountId,
1947
+ dialogId: msgCtx.chatId,
1948
+ isDirect: msgCtx.isDm,
1949
+ });
1428
1950
  const sendCtx: SendContext = {
1429
1951
  webhookUrl,
1430
1952
  bot,
1431
- dialogId: msgCtx.chatId,
1953
+ dialogId: conversation.dialogId,
1432
1954
  };
1955
+ const historyKey = conversation.historyKey;
1956
+ const historyLimit = resolveHistoryLimit(config);
1957
+ const fallbackHistoryBody = buildHistoryBody(msgCtx);
1433
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
+ };
1434
1980
 
1435
1981
  try {
1436
1982
  const replyStatusHeartbeat = createReplyStatusHeartbeat({
@@ -1454,7 +2000,7 @@ export const bitrix24Plugin = {
1454
2000
  extension: mediaItem.extension,
1455
2001
  webhookUrl,
1456
2002
  bot,
1457
- dialogId: msgCtx.chatId,
2003
+ dialogId: conversation.dialogId,
1458
2004
  }),
1459
2005
  )).filter(Boolean) as DownloadedMedia[];
1460
2006
 
@@ -1470,6 +2016,7 @@ export const bitrix24Plugin = {
1470
2016
  } else {
1471
2017
  const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
1472
2018
  logger.warn('All media downloads failed, notifying user', { fileNames });
2019
+ recordHistory();
1473
2020
  await sendService.sendText(
1474
2021
  sendCtx,
1475
2022
  mediaDownloadFailed(msgCtx.language, fileNames),
@@ -1489,15 +2036,35 @@ export const bitrix24Plugin = {
1489
2036
  body = hasImage ? '<media:image>' : '<media:document>';
1490
2037
  }
1491
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
+
1492
2062
  // Resolve which agent handles this conversation
1493
2063
  const route = runtime.channel.routing.resolveAgentRoute({
1494
2064
  cfg,
1495
2065
  channel: 'bitrix24',
1496
2066
  accountId: ctx.accountId,
1497
- peer: {
1498
- kind: msgCtx.isDm ? 'direct' : 'group',
1499
- id: msgCtx.chatId,
1500
- },
2067
+ peer: conversation.peer,
1501
2068
  });
1502
2069
 
1503
2070
  logger.debug('Resolved route', {
@@ -1507,12 +2074,19 @@ export const bitrix24Plugin = {
1507
2074
  });
1508
2075
 
1509
2076
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
1510
- Body: body,
1511
- 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,
1512
2086
  RawBody: body,
1513
- From: `bitrix24:${msgCtx.chatId}`,
1514
- To: `bitrix24:${msgCtx.chatId}`,
1515
- SessionKey: `${route.sessionKey}:bitrix24:${msgCtx.chatId}`,
2087
+ From: conversation.address,
2088
+ To: conversation.address,
2089
+ SessionKey: buildConversationSessionKey(route.sessionKey, conversation),
1516
2090
  AccountId: route.accountId,
1517
2091
  ChatType: msgCtx.isDm ? 'direct' : 'group',
1518
2092
  ConversationLabel: msgCtx.senderName,
@@ -1521,11 +2095,14 @@ export const bitrix24Plugin = {
1521
2095
  Provider: 'bitrix24',
1522
2096
  Surface: 'bitrix24',
1523
2097
  MessageSid: msgCtx.messageId,
1524
- Timestamp: Date.now(),
1525
- 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,
1526
2103
  CommandAuthorized: true,
1527
2104
  OriginatingChannel: 'bitrix24',
1528
- OriginatingTo: `bitrix24:${msgCtx.chatId}`,
2105
+ OriginatingTo: conversation.address,
1529
2106
  ...mediaFields,
1530
2107
  });
1531
2108
 
@@ -1543,7 +2120,7 @@ export const bitrix24Plugin = {
1543
2120
  fileName: basename(mediaUrl),
1544
2121
  webhookUrl,
1545
2122
  bot,
1546
- dialogId: msgCtx.chatId,
2123
+ dialogId: conversation.dialogId,
1547
2124
  });
1548
2125
 
1549
2126
  if (!uploadResult.ok) {
@@ -1589,26 +2166,113 @@ export const bitrix24Plugin = {
1589
2166
  onFlush: processAllowedMessage,
1590
2167
  logger,
1591
2168
  });
1592
- const maybeNotifyDeniedDialog = async (
1593
- dialogId: string,
1594
- language: string | undefined,
2169
+ const maybeSendDialogNotice = async (
2170
+ noticeKey: string,
1595
2171
  sendCtx: SendContext,
2172
+ text: string,
1596
2173
  ): Promise<void> => {
1597
2174
  const now = Date.now();
1598
- const lastSentAt = deniedDialogs.get(dialogId) ?? 0;
2175
+ const lastSentAt = dialogNoticeTimestamps.get(noticeKey) ?? 0;
1599
2176
  if ((now - lastSentAt) < ACCESS_DENIED_NOTICE_COOLDOWN_MS) {
1600
2177
  return;
1601
2178
  }
1602
2179
 
1603
- deniedDialogs.set(dialogId, now);
2180
+ dialogNoticeTimestamps.set(noticeKey, now);
1604
2181
  try {
1605
- await sendService.sendText(
1606
- sendCtx,
1607
- personalBotOwnerOnly(language),
1608
- { 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] },
1609
2271
  );
2272
+ return true;
1610
2273
  } catch (err) {
1611
- 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;
1612
2276
  }
1613
2277
  };
1614
2278
 
@@ -1644,20 +2308,6 @@ export const bitrix24Plugin = {
1644
2308
  ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
1645
2309
  : null;
1646
2310
 
1647
- if (msgCtx.isGroup) {
1648
- logger.warn('Group chat is not supported, leaving chat', {
1649
- chatId: msgCtx.chatId,
1650
- senderId: msgCtx.senderId,
1651
- });
1652
-
1653
- try {
1654
- await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
1655
- } catch (err) {
1656
- logger.error('Failed to leave group chat after message', err);
1657
- }
1658
- return;
1659
- }
1660
-
1661
2311
  const runtime = getBitrix24Runtime();
1662
2312
 
1663
2313
  // Pairing-aware access control
@@ -1666,73 +2316,233 @@ export const bitrix24Plugin = {
1666
2316
  bot,
1667
2317
  dialogId: msgCtx.chatId,
1668
2318
  };
1669
-
1670
- const accessResult = await checkAccessWithPairing({
1671
- senderId: msgCtx.senderId,
2319
+ const conversation = resolveConversationRef({
2320
+ accountId: ctx.accountId,
1672
2321
  dialogId: msgCtx.chatId,
1673
2322
  isDirect: msgCtx.isDm,
1674
- config,
1675
- runtime,
1676
- accountId: ctx.accountId,
1677
- pairingAdapter: bitrix24Plugin.pairing,
1678
- sendReply: async (text: string) => {
1679
- await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1680
- },
1681
- logger,
1682
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
+ }
1683
2366
 
1684
- if (accessResult === 'deny') {
1685
- await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1686
- await maybeNotifyDeniedDialog(msgCtx.chatId, msgCtx.language, sendCtx);
1687
- 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
+ }
1688
2397
  return;
1689
2398
  }
1690
2399
 
1691
2400
  await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1692
- await sendService.sendTyping(sendCtx);
1693
2401
 
1694
- if (accessResult !== 'allow') {
1695
- 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
+ });
1696
2420
  return;
1697
2421
  }
1698
2422
 
1699
- if (msgCtx.isForwarded) {
1700
- if (pendingForwardContext) {
1701
- logger.info('Merging forwarded message with buffered context', {
1702
- senderId: msgCtx.senderId,
1703
- chatId: msgCtx.chatId,
1704
- previousMessageId: pendingForwardContext.messageId,
1705
- forwardedMessageId: msgCtx.messageId,
1706
- });
1707
- await processAllowedMessage(mergeForwardedMessageContext(pendingForwardContext, msgCtx));
1708
- return;
1709
- }
2423
+ if (activeWatchRule?.mode === 'notifyOwnerDm') {
2424
+ appendMessageToHistory({
2425
+ historyCache,
2426
+ historyKey,
2427
+ historyLimit,
2428
+ msgCtx,
2429
+ });
1710
2430
 
1711
- 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', {
1712
2433
  senderId: msgCtx.senderId,
1713
2434
  chatId: msgCtx.chatId,
1714
2435
  messageId: msgCtx.messageId,
1715
2436
  });
1716
- 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,
1717
2497
  sendCtx,
1718
- forwardedMessageUnsupported(msgCtx.language),
1719
- { convertMarkdown: false },
2498
+ buildAccessDeniedNotice(msgCtx.language, policy, {
2499
+ hasAllowList: msgCtx.isDm
2500
+ ? normalizeAllowList(config.allowFrom).length > 0
2501
+ : Boolean(groupAccess?.senderAllowFrom.length),
2502
+ }),
1720
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 });
1721
2524
  return;
1722
2525
  }
1723
2526
 
1724
- if (msgCtx.replyToMessageId) {
1725
- 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', {
1726
2541
  senderId: msgCtx.senderId,
1727
2542
  chatId: msgCtx.chatId,
1728
2543
  messageId: msgCtx.messageId,
1729
- replyToMessageId: msgCtx.replyToMessageId,
1730
2544
  });
1731
- await sendService.sendText(
1732
- sendCtx,
1733
- replyMessageUnsupported(msgCtx.language),
1734
- { convertMarkdown: false },
1735
- );
2545
+ await processAllowedMessage(await hydrateForwardedMessageContext(msgCtx));
1736
2546
  return;
1737
2547
  }
1738
2548
 
@@ -1753,11 +2563,16 @@ export const bitrix24Plugin = {
1753
2563
  commandText,
1754
2564
  senderId,
1755
2565
  dialogId,
2566
+ chatId,
1756
2567
  chatType,
1757
2568
  messageId,
1758
2569
  } = cmdCtx;
1759
2570
  const isDm = chatType === 'P';
1760
- const peerId = isDm ? senderId : dialogId;
2571
+ const conversation = resolveConversationRef({
2572
+ accountId: ctx.accountId,
2573
+ dialogId,
2574
+ isDirect: isDm,
2575
+ });
1761
2576
 
1762
2577
  logger.info('Inbound command', {
1763
2578
  commandId,
@@ -1766,13 +2581,14 @@ export const bitrix24Plugin = {
1766
2581
  commandText,
1767
2582
  senderId,
1768
2583
  dialogId,
1769
- peerId,
2584
+ chatId,
2585
+ conversationDialogId: conversation.dialogId,
1770
2586
  });
1771
2587
 
1772
2588
  const sendCtx: SendContext = {
1773
2589
  webhookUrl,
1774
2590
  bot,
1775
- dialogId: peerId,
2591
+ dialogId: conversation.dialogId,
1776
2592
  };
1777
2593
 
1778
2594
  let runtime;
@@ -1794,28 +2610,46 @@ export const bitrix24Plugin = {
1794
2610
  commandDialogId: dialogId,
1795
2611
  }
1796
2612
  : null;
2613
+ const groupAccess = !isDm
2614
+ ? resolveGroupAccess({
2615
+ config,
2616
+ dialogId,
2617
+ chatId,
2618
+ })
2619
+ : null;
1797
2620
 
1798
2621
  // Access control
1799
2622
  let accessResult;
1800
2623
  try {
1801
- accessResult = await checkAccessWithPairing({
1802
- senderId,
1803
- dialogId,
1804
- isDirect: isDm,
1805
- config,
1806
- runtime,
1807
- accountId: ctx.accountId,
1808
- pairingAdapter: bitrix24Plugin.pairing,
1809
- sendReply: async (text: string) => {
1810
- if (commandSendCtx) {
1811
- await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
1812
- return;
1813
- }
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
+ }
1814
2648
 
1815
- await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1816
- },
1817
- logger,
1818
- });
2649
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
2650
+ },
2651
+ logger,
2652
+ });
1819
2653
  } catch (err) {
1820
2654
  logger.error('Access check failed for command', err);
1821
2655
  return;
@@ -1825,7 +2659,16 @@ export const bitrix24Plugin = {
1825
2659
  logger.warn('Command event has invalid messageId, skipping response', { commandId, messageId, dialogId });
1826
2660
  return;
1827
2661
  }
1828
- 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
+ }
1829
2672
 
1830
2673
  await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
1831
2674
 
@@ -1835,7 +2678,15 @@ export const bitrix24Plugin = {
1835
2678
  }
1836
2679
  await sendService.answerCommandText(
1837
2680
  commandSendCtx,
1838
- 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
+ ),
1839
2690
  { convertMarkdown: false },
1840
2691
  );
1841
2692
  logger.debug('Command blocked (deny)', { senderId, dialogId });
@@ -1846,16 +2697,23 @@ export const bitrix24Plugin = {
1846
2697
  await sendService.markRead(sendCtx, commandMessageId);
1847
2698
  }
1848
2699
 
1849
- 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
+ }
1850
2708
  logger.debug(`Command blocked (${accessResult})`, { senderId });
1851
2709
  return;
1852
2710
  }
1853
2711
 
1854
- await directTextCoalescer.flush(ctx.accountId, peerId);
2712
+ await directTextCoalescer.flush(ctx.accountId, conversation.dialogId);
1855
2713
 
1856
2714
  if (commandName === 'help' || commandName === 'commands') {
1857
- await sendService.sendText(
1858
- sendCtx,
2715
+ await sendService.answerCommandText(
2716
+ commandSendCtx,
1859
2717
  buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' }),
1860
2718
  { keyboard: DEFAULT_COMMAND_KEYBOARD, convertMarkdown: false },
1861
2719
  );
@@ -1866,7 +2724,7 @@ export const bitrix24Plugin = {
1866
2724
  cfg,
1867
2725
  channel: 'bitrix24',
1868
2726
  accountId: ctx.accountId,
1869
- peer: { kind: isDm ? 'direct' : 'group', id: peerId },
2727
+ peer: conversation.peer,
1870
2728
  });
1871
2729
 
1872
2730
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
@@ -1878,8 +2736,8 @@ export const bitrix24Plugin = {
1878
2736
  CommandBody: commandText,
1879
2737
  CommandAuthorized: true,
1880
2738
  CommandSource: 'native',
1881
- CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${peerId}`,
1882
- From: `bitrix24:${peerId}`,
2739
+ CommandTargetSessionKey: buildConversationSessionKey(route.sessionKey, conversation),
2740
+ From: conversation.address,
1883
2741
  To: `slash:${senderId}`,
1884
2742
  SessionKey: slashSessionKey,
1885
2743
  AccountId: route.accountId,
@@ -1893,7 +2751,7 @@ export const bitrix24Plugin = {
1893
2751
  Timestamp: Date.now(),
1894
2752
  WasMentioned: true,
1895
2753
  OriginatingChannel: 'bitrix24',
1896
- OriginatingTo: `bitrix24:${peerId}`,
2754
+ OriginatingTo: conversation.address,
1897
2755
  });
1898
2756
 
1899
2757
  const replyStatusHeartbeat = createReplyStatusHeartbeat({
@@ -1921,14 +2779,6 @@ export const bitrix24Plugin = {
1921
2779
  });
1922
2780
  if (!commandReplyDelivered) {
1923
2781
  commandReplyDelivered = true;
1924
- if (isDm) {
1925
- await sendService.sendText(sendCtx, formattedPayload.text, {
1926
- keyboard,
1927
- convertMarkdown: formattedPayload.convertMarkdown,
1928
- });
1929
- return;
1930
- }
1931
-
1932
2782
  await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
1933
2783
  keyboard,
1934
2784
  convertMarkdown: formattedPayload.convertMarkdown,
@@ -1958,12 +2808,16 @@ export const bitrix24Plugin = {
1958
2808
  },
1959
2809
 
1960
2810
  onJoinChat: async (joinCtx: FetchJoinChatContext) => {
1961
- const { dialogId, chatType, language } = joinCtx;
1962
- 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 });
1963
2813
 
1964
2814
  if (!dialogId) return;
2815
+ const isGroupChat = chatType === 'chat' || chatType === 'open';
2816
+ const groupAccess = isGroupChat
2817
+ ? resolveGroupAccess({ config, dialogId, chatId })
2818
+ : null;
1965
2819
 
1966
- if (shouldSkipJoinChatWelcome({
2820
+ if (!isGroupChat && shouldSkipJoinChatWelcome({
1967
2821
  dialogId,
1968
2822
  chatType,
1969
2823
  webhookUrl,
@@ -1979,9 +2833,8 @@ export const bitrix24Plugin = {
1979
2833
  dialogId,
1980
2834
  };
1981
2835
 
1982
- // Reject group chats
1983
- if (chatType === 'chat' || chatType === 'open') {
1984
- 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 });
1985
2838
  try {
1986
2839
  await sendService.sendText(sendCtx, groupChatUnsupported(language));
1987
2840
  await api.leaveChat(webhookUrl, bot, dialogId);
@@ -1991,6 +2844,45 @@ export const bitrix24Plugin = {
1991
2844
  return;
1992
2845
  }
1993
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
+
1994
2886
  if (welcomedDialogs.has(dialogId)) {
1995
2887
  logger.info('Skipping duplicate welcome for already welcomed dialog', { dialogId });
1996
2888
  return;
@@ -2032,6 +2924,7 @@ export const bitrix24Plugin = {
2032
2924
  accountId: ctx.accountId,
2033
2925
  pollingIntervalMs: config.pollingIntervalMs ?? 3000,
2034
2926
  pollingFastIntervalMs: config.pollingFastIntervalMs ?? 100,
2927
+ withUserEvents: Boolean(config.agentMode),
2035
2928
  onEvent: async (event: B24V2FetchEventItem) => {
2036
2929
  const fetchCtx: FetchContext = {
2037
2930
  webhookUrl,