@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/README.md +236 -42
- package/package.json +1 -1
- package/src/access-control.ts +31 -8
- package/src/api.ts +76 -7
- package/src/channel.ts +1025 -137
- package/src/commands.ts +7 -7
- package/src/config-schema.ts +24 -1
- package/src/config.ts +4 -2
- package/src/group-access.ts +279 -0
- package/src/history-cache.ts +122 -0
- package/src/i18n.ts +140 -50
- package/src/inbound-handler.ts +91 -8
- package/src/polling-service.ts +4 -0
- package/src/send-service.ts +14 -5
- package/src/types.ts +67 -3
- package/tests/access-control.test.ts +43 -0
- package/tests/api.test.ts +131 -0
- package/tests/channel-flow.test.ts +1692 -0
- package/tests/channel.test.ts +88 -2
- package/tests/config.test.ts +120 -0
- package/tests/group-access.test.ts +340 -0
- package/tests/history-cache.test.ts +117 -0
- package/tests/i18n.test.ts +55 -12
- package/tests/inbound-handler.test.ts +388 -3
- package/tests/polling-service.test.ts +38 -0
- package/tests/send-service.test.ts +17 -0
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
|
-
|
|
32
|
+
accessApproved,
|
|
33
|
+
accessDenied,
|
|
34
|
+
groupPairingPending,
|
|
27
35
|
mediaDownloadFailed,
|
|
28
36
|
groupChatUnsupported,
|
|
29
37
|
onboardingMessage,
|
|
38
|
+
ownerAndAllowedUsersOnly,
|
|
30
39
|
personalBotOwnerOnly,
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
1516
|
-
BodyForAgent:
|
|
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:
|
|
1519
|
-
To:
|
|
1520
|
-
SessionKey:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1598
|
-
|
|
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 =
|
|
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
|
-
|
|
2180
|
+
dialogNoticeTimestamps.set(noticeKey, now);
|
|
1609
2181
|
try {
|
|
1610
|
-
await sendService.sendText(
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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 (
|
|
1700
|
-
|
|
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 (
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1730
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2584
|
+
chatId,
|
|
2585
|
+
conversationDialogId: conversation.dialogId,
|
|
1775
2586
|
});
|
|
1776
2587
|
|
|
1777
2588
|
const sendCtx: SendContext = {
|
|
1778
2589
|
webhookUrl,
|
|
1779
2590
|
bot,
|
|
1780
|
-
dialogId:
|
|
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 =
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|
|
2712
|
+
await directTextCoalescer.flush(ctx.accountId, conversation.dialogId);
|
|
1860
2713
|
|
|
1861
2714
|
if (commandName === 'help' || commandName === 'commands') {
|
|
1862
|
-
await sendService.
|
|
1863
|
-
|
|
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:
|
|
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:
|
|
1887
|
-
From:
|
|
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:
|
|
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
|
-
|
|
1988
|
-
|
|
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,
|