@ihazz/bitrix24 1.1.6 → 1.1.8

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
@@ -1,5 +1,6 @@
1
- import { createHash } from 'node:crypto';
2
- import { basename } from 'node:path';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
3
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
5
  import { listAccountIds, resolveAccount, getConfig } from './config.js';
5
6
  import { Bitrix24Api } from './api.js';
@@ -11,6 +12,7 @@ import type { DownloadedMedia } from './media-service.js';
11
12
  import { InboundHandler } from './inbound-handler.js';
12
13
  import type { FetchCommandContext, FetchJoinChatContext, FetchReactionContext } from './inbound-handler.js';
13
14
  import { PollingService } from './polling-service.js';
15
+ import { resolvePollingStateDir } from './state-paths.js';
14
16
  import {
15
17
  normalizeAllowEntry,
16
18
  normalizeAllowList,
@@ -27,7 +29,12 @@ import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
27
29
  import { Bitrix24ApiError, createVerboseLogger, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
28
30
  import { getBitrix24Runtime } from './runtime.js';
29
31
  import type { ChannelPairingAdapter } from './runtime.js';
30
- import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply } from './commands.js';
32
+ import {
33
+ OPENCLAW_COMMANDS,
34
+ buildCommandsHelpText,
35
+ formatModelsCommandReply,
36
+ getCommandRegistrationPayload,
37
+ } from './commands.js';
31
38
  import {
32
39
  accessApproved,
33
40
  accessDenied,
@@ -35,9 +42,13 @@ import {
35
42
  groupPairingPending,
36
43
  mediaDownloadFailed,
37
44
  groupChatUnsupported,
45
+ newSessionReplyTexts,
38
46
  onboardingMessage,
47
+ normalizeNewSessionReply,
39
48
  ownerAndAllowedUsersOnly,
40
49
  personalBotOwnerOnly,
50
+ replyGenerationFailed,
51
+ welcomeKeyboardLabels,
41
52
  watchOwnerDmNotice,
42
53
  } from './i18n.js';
43
54
  import { HistoryCache } from './history-cache.js';
@@ -78,6 +89,9 @@ const CROSS_CHAT_HISTORY_LIMIT = 20;
78
89
  const ACCESS_DENIED_REACTION = 'crossMark';
79
90
  const BOT_MESSAGE_WATCH_REACTION = 'eyes';
80
91
  const FORWARDED_CONTEXT_RANGE = 5;
92
+ const ACTIVE_SESSION_NAMESPACE_TTL_MS = 180 * 24 * 60 * 60 * 1000;
93
+ const ACTIVE_SESSION_NAMESPACE_MAX_KEYS = 1000;
94
+ const ACTIVE_SESSION_NAMESPACE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
81
95
  const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
82
96
 
83
97
  // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
@@ -180,7 +194,7 @@ function buildTopicsBbCode(topics: string[] | undefined): string | undefined {
180
194
  }
181
195
 
182
196
  function formatQuoteTimestamp(timestamp: number | undefined, language: string | undefined): string {
183
- const locale = (language ?? 'ru').toLowerCase().slice(0, 2);
197
+ const locale = (language ?? 'en').toLowerCase().slice(0, 2);
184
198
  const value = timestamp ?? Date.now();
185
199
 
186
200
  try {
@@ -192,7 +206,7 @@ function formatQuoteTimestamp(timestamp: number | undefined, language: string |
192
206
  minute: '2-digit',
193
207
  }).format(new Date(value)).replace(',', '');
194
208
  } catch {
195
- return new Intl.DateTimeFormat('ru', {
209
+ return new Intl.DateTimeFormat('en', {
196
210
  year: 'numeric',
197
211
  month: '2-digit',
198
212
  day: '2-digit',
@@ -202,6 +216,14 @@ function formatQuoteTimestamp(timestamp: number | undefined, language: string |
202
216
  }
203
217
  }
204
218
 
219
+ function buildWatchQuoteAnchor(msgCtx: B24MsgContext, ownerId: string): string {
220
+ if (msgCtx.isGroup) {
221
+ return `#${msgCtx.chatId}/${msgCtx.messageId}`;
222
+ }
223
+
224
+ return `#${msgCtx.chatId}:${ownerId}/${msgCtx.messageId}`;
225
+ }
226
+
205
227
  function buildWatchQuoteText(params: {
206
228
  senderName: string;
207
229
  language?: string;
@@ -504,8 +526,116 @@ export function resolveConversationRef(params: {
504
526
  export function buildConversationSessionKey(
505
527
  routeSessionKey: string,
506
528
  conversation: Pick<Bitrix24ConversationRef, 'address'>,
529
+ sessionNamespace?: string,
507
530
  ): string {
508
- return `${routeSessionKey}:${conversation.address}`;
531
+ const baseKey = `${routeSessionKey}:${conversation.address}`;
532
+ return sessionNamespace
533
+ ? `${baseKey}:${sessionNamespace}`
534
+ : baseKey;
535
+ }
536
+
537
+ type ActiveSessionNamespaceEntry = {
538
+ namespace: string;
539
+ updatedAt: number;
540
+ };
541
+
542
+ function normalizeSessionNamespaceTimestamp(value: unknown, now: number, fallback: number): number {
543
+ let parsed: number | undefined;
544
+
545
+ if (typeof value === 'number') {
546
+ parsed = value;
547
+ } else if (typeof value === 'string') {
548
+ const asNumber = Number(value);
549
+ parsed = Number.isFinite(asNumber) ? asNumber : Date.parse(value);
550
+ }
551
+
552
+ if (!Number.isFinite(parsed) || parsed == null || parsed <= 0) {
553
+ return fallback;
554
+ }
555
+
556
+ return Math.min(parsed, now);
557
+ }
558
+
559
+ function isValidActiveSessionNamespace(value: unknown): value is string {
560
+ return typeof value === 'string' && ACTIVE_SESSION_NAMESPACE_RE.test(value);
561
+ }
562
+
563
+ export function normalizeActiveSessionNamespaceState(
564
+ state: unknown,
565
+ accountId: string,
566
+ now = Date.now(),
567
+ ): Record<string, ActiveSessionNamespaceEntry> {
568
+ if (!state || typeof state !== 'object') {
569
+ return {};
570
+ }
571
+
572
+ const rawState = state as {
573
+ updatedAt?: unknown;
574
+ sessions?: Record<string, unknown>;
575
+ };
576
+ const fallbackUpdatedAt = normalizeSessionNamespaceTimestamp(rawState.updatedAt, now, now);
577
+ const accountKeyPrefix = `${accountId}:`;
578
+ const entries: Array<[string, ActiveSessionNamespaceEntry]> = [];
579
+
580
+ for (const [key, value] of Object.entries(rawState.sessions ?? {})) {
581
+ if (!key.startsWith(accountKeyPrefix)) {
582
+ continue;
583
+ }
584
+
585
+ let namespace: unknown;
586
+ let updatedAt = fallbackUpdatedAt;
587
+
588
+ if (typeof value === 'string') {
589
+ namespace = value;
590
+ } else if (value && typeof value === 'object') {
591
+ const rawEntry = value as {
592
+ namespace?: unknown;
593
+ updatedAt?: unknown;
594
+ };
595
+ namespace = rawEntry.namespace;
596
+ updatedAt = normalizeSessionNamespaceTimestamp(rawEntry.updatedAt, now, fallbackUpdatedAt);
597
+ }
598
+
599
+ if (!isValidActiveSessionNamespace(namespace)) {
600
+ continue;
601
+ }
602
+
603
+ if (now - updatedAt > ACTIVE_SESSION_NAMESPACE_TTL_MS) {
604
+ continue;
605
+ }
606
+
607
+ entries.push([key, { namespace, updatedAt }]);
608
+ }
609
+
610
+ entries.sort((left, right) => left[1].updatedAt - right[1].updatedAt);
611
+ return Object.fromEntries(entries.slice(-ACTIVE_SESSION_NAMESPACE_MAX_KEYS));
612
+ }
613
+
614
+ function pruneActiveSessionNamespaceEntries(
615
+ entries: Map<string, ActiveSessionNamespaceEntry>,
616
+ now = Date.now(),
617
+ ): void {
618
+ for (const [key, value] of entries) {
619
+ if (!isValidActiveSessionNamespace(value?.namespace)
620
+ || !Number.isFinite(value?.updatedAt)
621
+ || value.updatedAt <= 0
622
+ || now - value.updatedAt > ACTIVE_SESSION_NAMESPACE_TTL_MS) {
623
+ entries.delete(key);
624
+ }
625
+ }
626
+
627
+ if (entries.size <= ACTIVE_SESSION_NAMESPACE_MAX_KEYS) {
628
+ return;
629
+ }
630
+
631
+ const overflow = entries.size - ACTIVE_SESSION_NAMESPACE_MAX_KEYS;
632
+ const staleFirst = [...entries.entries()]
633
+ .sort((left, right) => left[1].updatedAt - right[1].updatedAt)
634
+ .slice(0, overflow);
635
+
636
+ for (const [key] of staleFirst) {
637
+ entries.delete(key);
638
+ }
509
639
  }
510
640
 
511
641
  function buildHistoryBody(msgCtx: B24MsgContext): string {
@@ -736,7 +866,7 @@ function normalizeTopicText(text: string): string {
736
866
 
737
867
  function tokenizeTopicText(text: string): string[] {
738
868
  return normalizeTopicText(text)
739
- .split(/[^a-zа-яё0-9]+/i)
869
+ .split(/[^\p{L}\p{N}]+/u)
740
870
  .filter(Boolean);
741
871
  }
742
872
 
@@ -921,7 +1051,20 @@ export function __setGatewayStateForTests(state: GatewayState | null): void {
921
1051
  gatewayState = state;
922
1052
  }
923
1053
 
924
- // ─── Default command keyboard ────────────────────────────────────────────────
1054
+ // ─── Keyboard layouts ────────────────────────────────────────────────────────
1055
+
1056
+ export function buildWelcomeKeyboard(language?: string): B24Keyboard {
1057
+ const labels = welcomeKeyboardLabels(language);
1058
+
1059
+ return [
1060
+ { TEXT: labels.todayTasks, ACTION: 'SEND', ACTION_VALUE: labels.todayTasks, DISPLAY: 'LINE' },
1061
+ { TEXT: labels.stalledDeals, ACTION: 'SEND', ACTION_VALUE: labels.stalledDeals, DISPLAY: 'LINE' },
1062
+ { TYPE: 'NEWLINE' },
1063
+ { TEXT: labels.newSession, COMMAND: 'new', DISPLAY: 'LINE' },
1064
+ { TEXT: labels.commands, COMMAND: 'commands', DISPLAY: 'LINE' },
1065
+ { TEXT: labels.help, COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
1066
+ ];
1067
+ }
925
1068
 
926
1069
  /** Default keyboard shown with command responses and welcome messages. */
927
1070
  export function buildDefaultCommandKeyboard(language?: string): B24Keyboard {
@@ -938,6 +1081,7 @@ export function buildDefaultCommandKeyboard(language?: string): B24Keyboard {
938
1081
  }
939
1082
 
940
1083
  export const DEFAULT_COMMAND_KEYBOARD: B24Keyboard = buildDefaultCommandKeyboard();
1084
+ export const DEFAULT_WELCOME_KEYBOARD: B24Keyboard = buildWelcomeKeyboard();
941
1085
 
942
1086
  // ─── Keyboard / Button conversion ────────────────────────────────────────────
943
1087
 
@@ -1031,7 +1175,8 @@ export function extractKeyboardFromPayload(
1031
1175
 
1032
1176
  const tgData = cd.telegram as { buttons?: ChannelButton[][] } | undefined;
1033
1177
  if (tgData?.buttons?.length) {
1034
- return convertButtonsToKeyboard(tgData.buttons);
1178
+ const keyboard = convertButtonsToKeyboard(tgData.buttons);
1179
+ return keyboard.length > 0 ? keyboard : undefined;
1035
1180
  }
1036
1181
 
1037
1182
  return undefined;
@@ -1085,6 +1230,13 @@ function normalizeCommandReplyPayload(params: {
1085
1230
  }
1086
1231
  }
1087
1232
 
1233
+ if (commandName === 'new' && commandParams.trim() === '') {
1234
+ const normalizedText = normalizeNewSessionReply(language, text);
1235
+ if (normalizedText) {
1236
+ return { text: normalizedText, convertMarkdown: false };
1237
+ }
1238
+ }
1239
+
1088
1240
  return { text };
1089
1241
  }
1090
1242
 
@@ -1169,7 +1321,7 @@ async function sendInitialWelcomeToWebhookOwner(params: {
1169
1321
  const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
1170
1322
  const options = isPairing
1171
1323
  ? undefined
1172
- : { keyboard: buildDefaultCommandKeyboard(language) };
1324
+ : { keyboard: buildWelcomeKeyboard(language) };
1173
1325
 
1174
1326
  try {
1175
1327
  await sendService.sendText(sendCtx, text, options);
@@ -1306,8 +1458,7 @@ async function ensureCommandsRegistered(
1306
1458
  try {
1307
1459
  await api.registerCommand(webhookUrl, bot, {
1308
1460
  command: cmd.command,
1309
- title: { en: cmd.en, ru: cmd.ru },
1310
- ...(cmd.params ? { params: { en: cmd.params, ru: cmd.params } } : {}),
1461
+ ...getCommandRegistrationPayload(cmd),
1311
1462
  });
1312
1463
  registered++;
1313
1464
  } catch (err: unknown) {
@@ -1816,6 +1967,90 @@ export const bitrix24Plugin = {
1816
1967
  const welcomedDialogs = new Set<string>();
1817
1968
  const dialogNoticeTimestamps = new Map<string, number>();
1818
1969
  const historyCache = new HistoryCache({ maxKeys: HISTORY_CACHE_MAX_KEYS });
1970
+ const activeSessionNamespaces = new Map<string, ActiveSessionNamespaceEntry>();
1971
+ const sessionNamespaceStatePath = join(resolvePollingStateDir(), `session-namespaces-${ctx.accountId}.json`);
1972
+ let persistActiveSessionNamespacesTail: Promise<void> = Promise.resolve();
1973
+
1974
+ const buildActiveSessionNamespaceKey = (conversation: Pick<Bitrix24ConversationRef, 'address'>): string => {
1975
+ return `${ctx.accountId}:${conversation.address}`;
1976
+ };
1977
+
1978
+ const loadActiveSessionNamespaces = async (): Promise<void> => {
1979
+ try {
1980
+ const raw = await readFile(sessionNamespaceStatePath, 'utf-8');
1981
+ const state = JSON.parse(raw) as {
1982
+ sessions?: Record<string, unknown>;
1983
+ };
1984
+ const entries = normalizeActiveSessionNamespaceState(state, ctx.accountId);
1985
+ const persistedSessions = state.sessions ?? {};
1986
+ const needsCompaction = Object.keys(entries).length !== Object.keys(persistedSessions).length
1987
+ || Object.values(persistedSessions).some((value) => typeof value === 'string');
1988
+
1989
+ activeSessionNamespaces.clear();
1990
+ for (const [key, value] of Object.entries(entries)) {
1991
+ activeSessionNamespaces.set(key, value);
1992
+ }
1993
+
1994
+ if (needsCompaction) {
1995
+ void persistActiveSessionNamespaces();
1996
+ }
1997
+ } catch (err) {
1998
+ logger.debug('Failed to load active session namespaces, starting fresh', err);
1999
+ }
2000
+ };
2001
+
2002
+ const persistActiveSessionNamespaces = (): Promise<void> => {
2003
+ persistActiveSessionNamespacesTail = persistActiveSessionNamespacesTail
2004
+ .catch(() => undefined)
2005
+ .then(async () => {
2006
+ try {
2007
+ pruneActiveSessionNamespaceEntries(activeSessionNamespaces);
2008
+ await mkdir(dirname(sessionNamespaceStatePath), { recursive: true });
2009
+ const data = JSON.stringify({
2010
+ updatedAt: new Date().toISOString(),
2011
+ sessions: Object.fromEntries(activeSessionNamespaces),
2012
+ }, null, 2);
2013
+ const tmpPath = `${sessionNamespaceStatePath}.${randomUUID()}.tmp`;
2014
+ await writeFile(tmpPath, data, 'utf-8');
2015
+ await rename(tmpPath, sessionNamespaceStatePath);
2016
+ } catch (err) {
2017
+ logger.warn('Failed to persist active session namespaces', err);
2018
+ }
2019
+ });
2020
+
2021
+ return persistActiveSessionNamespacesTail;
2022
+ };
2023
+
2024
+ const resolveActiveConversationSessionKey = (
2025
+ routeSessionKey: string,
2026
+ conversation: Pick<Bitrix24ConversationRef, 'address'>,
2027
+ ): string => {
2028
+ return buildConversationSessionKey(
2029
+ routeSessionKey,
2030
+ conversation,
2031
+ activeSessionNamespaces.get(buildActiveSessionNamespaceKey(conversation))?.namespace,
2032
+ );
2033
+ };
2034
+
2035
+ const startNewConversationSession = async (
2036
+ conversation: Pick<Bitrix24ConversationRef, 'address' | 'historyKey' | 'dialogId'>,
2037
+ ): Promise<string> => {
2038
+ const sessionNamespace = randomUUID();
2039
+ pruneActiveSessionNamespaceEntries(activeSessionNamespaces);
2040
+ historyCache.clear(conversation.historyKey);
2041
+ activeSessionNamespaces.set(buildActiveSessionNamespaceKey(conversation), {
2042
+ namespace: sessionNamespace,
2043
+ updatedAt: Date.now(),
2044
+ });
2045
+ await persistActiveSessionNamespaces();
2046
+ logger.info('Started new local conversation session', {
2047
+ dialogId: conversation.dialogId,
2048
+ sessionNamespace,
2049
+ });
2050
+ return sessionNamespace;
2051
+ };
2052
+
2053
+ await loadActiveSessionNamespaces();
1819
2054
 
1820
2055
  // Cleanup stale denied dialog entries once per day
1821
2056
  const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -1987,6 +2222,8 @@ export const bitrix24Plugin = {
1987
2222
  sendCtx,
1988
2223
  config,
1989
2224
  });
2225
+ let replyDelivered = false;
2226
+ let dispatchFailed = false;
1990
2227
 
1991
2228
  // Download media files if present
1992
2229
  let mediaFields: Record<string, unknown> = {};
@@ -2089,7 +2326,7 @@ export const bitrix24Plugin = {
2089
2326
  RawBody: body,
2090
2327
  From: conversation.address,
2091
2328
  To: conversation.address,
2092
- SessionKey: buildConversationSessionKey(route.sessionKey, conversation),
2329
+ SessionKey: resolveActiveConversationSessionKey(route.sessionKey, conversation),
2093
2330
  AccountId: route.accountId,
2094
2331
  ChatType: msgCtx.isDm ? 'direct' : 'group',
2095
2332
  ConversationLabel: msgCtx.senderName,
@@ -2110,7 +2347,7 @@ export const bitrix24Plugin = {
2110
2347
  });
2111
2348
 
2112
2349
  try {
2113
- await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2350
+ const dispatchResult = await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2114
2351
  ctx: inboundCtx,
2115
2352
  cfg,
2116
2353
  dispatcherOptions: {
@@ -2137,6 +2374,7 @@ export const bitrix24Plugin = {
2137
2374
  }
2138
2375
  }
2139
2376
 
2377
+ replyDelivered = true;
2140
2378
  await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
2141
2379
  }
2142
2380
  },
@@ -2148,11 +2386,28 @@ export const bitrix24Plugin = {
2148
2386
  },
2149
2387
  },
2150
2388
  });
2389
+
2390
+ if (!replyDelivered && dispatchResult?.queuedFinal === false) {
2391
+ await sendService.sendText(
2392
+ sendCtx,
2393
+ replyGenerationFailed(msgCtx.language),
2394
+ { convertMarkdown: false },
2395
+ );
2396
+ }
2151
2397
  } catch (err) {
2398
+ dispatchFailed = true;
2152
2399
  logger.error('Error dispatching message to agent', { senderId: msgCtx.senderId, chatId: msgCtx.chatId, error: err });
2153
2400
  } finally {
2154
2401
  replyStatusHeartbeat.stop();
2155
2402
  }
2403
+
2404
+ if (!replyDelivered && dispatchFailed) {
2405
+ await sendService.sendText(
2406
+ sendCtx,
2407
+ replyGenerationFailed(msgCtx.language),
2408
+ { convertMarkdown: false },
2409
+ );
2410
+ }
2156
2411
  } finally {
2157
2412
  await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
2158
2413
  }
@@ -2265,6 +2520,19 @@ export const bitrix24Plugin = {
2265
2520
  bot,
2266
2521
  dialogId: ownerId,
2267
2522
  };
2523
+ const sendQuotedWatchMessage = async (): Promise<void> => {
2524
+ const quoteText = buildWatchQuoteText({
2525
+ senderName: msgCtx.senderName || msgCtx.chatName || msgCtx.chatId,
2526
+ language: msgCtx.language,
2527
+ timestamp: msgCtx.timestamp,
2528
+ anchor: buildWatchQuoteAnchor(msgCtx, ownerId),
2529
+ body: msgCtx.text.trim(),
2530
+ });
2531
+
2532
+ await sendService.sendText(ownerSendCtx, quoteText, {
2533
+ convertMarkdown: false,
2534
+ });
2535
+ };
2268
2536
 
2269
2537
  const noticeText = watchOwnerDmNotice(msgCtx.language, {
2270
2538
  chatRef: buildChatContextUrl(
@@ -2283,31 +2551,21 @@ export const bitrix24Plugin = {
2283
2551
  convertMarkdown: false,
2284
2552
  });
2285
2553
 
2286
- if (msgCtx.eventScope === 'user' && msgCtx.isDm) {
2287
- const quoteText = buildWatchQuoteText({
2288
- senderName: msgCtx.senderName || msgCtx.chatName || msgCtx.chatId,
2289
- language: msgCtx.language,
2290
- timestamp: msgCtx.timestamp,
2291
- anchor: `#${msgCtx.chatId}:${ownerId}/${msgCtx.messageId}`,
2292
- body: msgCtx.text.trim(),
2293
- });
2294
-
2295
- await sendService.sendText(ownerSendCtx, quoteText, {
2296
- convertMarkdown: false,
2297
- });
2298
- return true;
2554
+ try {
2555
+ await api.sendMessage(
2556
+ webhookUrl,
2557
+ bot,
2558
+ ownerId,
2559
+ null,
2560
+ { forwardMessages: [forwardedMessageId] },
2561
+ );
2562
+ } catch (err) {
2563
+ logger.warn('Failed to send owner watch notification with native forward, falling back to quote', err);
2564
+ await sendQuotedWatchMessage();
2299
2565
  }
2300
-
2301
- await api.sendMessage(
2302
- webhookUrl,
2303
- bot,
2304
- ownerId,
2305
- null,
2306
- { forwardMessages: [forwardedMessageId] },
2307
- );
2308
2566
  return true;
2309
2567
  } catch (err) {
2310
- logger.warn('Failed to send owner watch notification with native forward', err);
2568
+ logger.warn('Failed to send owner watch notification', err);
2311
2569
  return false;
2312
2570
  }
2313
2571
  };
@@ -2586,9 +2844,10 @@ export const bitrix24Plugin = {
2586
2844
  messageId,
2587
2845
  } = cmdCtx;
2588
2846
  const isDm = chatType === 'P';
2847
+ const replyDialogId = isDm ? senderId : dialogId;
2589
2848
  const conversation = resolveConversationRef({
2590
2849
  accountId: ctx.accountId,
2591
- dialogId,
2850
+ dialogId: replyDialogId,
2592
2851
  isDirect: isDm,
2593
2852
  });
2594
2853
 
@@ -2606,7 +2865,7 @@ export const bitrix24Plugin = {
2606
2865
  const sendCtx: SendContext = {
2607
2866
  webhookUrl,
2608
2867
  bot,
2609
- dialogId: conversation.dialogId,
2868
+ dialogId: replyDialogId,
2610
2869
  };
2611
2870
 
2612
2871
  let runtime;
@@ -2686,8 +2945,6 @@ export const bitrix24Plugin = {
2686
2945
  return;
2687
2946
  }
2688
2947
 
2689
- await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
2690
-
2691
2948
  if (accessResult === 'deny') {
2692
2949
  await sendService.markRead(sendCtx, commandMessageId);
2693
2950
  await sendService.answerCommandText(
@@ -2724,6 +2981,9 @@ export const bitrix24Plugin = {
2724
2981
  await directTextCoalescer.flush(ctx.accountId, conversation.dialogId);
2725
2982
 
2726
2983
  const defaultCommandKeyboard = buildDefaultCommandKeyboard(cmdCtx.language);
2984
+ const defaultSessionKeyboard = commandName === 'new' && commandParams.trim() === ''
2985
+ ? buildWelcomeKeyboard(cmdCtx.language)
2986
+ : defaultCommandKeyboard;
2727
2987
 
2728
2988
  if (commandName === 'help' || commandName === 'commands') {
2729
2989
  const helpText = buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' });
@@ -2744,6 +3004,28 @@ export const bitrix24Plugin = {
2744
3004
  return;
2745
3005
  }
2746
3006
 
3007
+ if (commandName === 'new' && commandParams.trim() === '') {
3008
+ await startNewConversationSession(conversation);
3009
+ const startedText = newSessionReplyTexts(cmdCtx.language).started;
3010
+
3011
+ if (isDm) {
3012
+ await sendService.sendText(
3013
+ sendCtx,
3014
+ startedText,
3015
+ { keyboard: defaultSessionKeyboard, convertMarkdown: false },
3016
+ );
3017
+ } else {
3018
+ await sendService.answerCommandText(
3019
+ commandSendCtx,
3020
+ startedText,
3021
+ { keyboard: defaultSessionKeyboard, convertMarkdown: false },
3022
+ );
3023
+ }
3024
+ return;
3025
+ }
3026
+
3027
+ await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
3028
+
2747
3029
  const route = runtime.channel.routing.resolveAgentRoute({
2748
3030
  cfg,
2749
3031
  channel: 'bitrix24',
@@ -2760,7 +3042,7 @@ export const bitrix24Plugin = {
2760
3042
  CommandBody: commandText,
2761
3043
  CommandAuthorized: true,
2762
3044
  CommandSource: 'native',
2763
- CommandTargetSessionKey: buildConversationSessionKey(route.sessionKey, conversation),
3045
+ CommandTargetSessionKey: resolveActiveConversationSessionKey(route.sessionKey, conversation),
2764
3046
  From: conversation.address,
2765
3047
  To: `slash:${senderId}`,
2766
3048
  SessionKey: slashSessionKey,
@@ -2784,10 +3066,11 @@ export const bitrix24Plugin = {
2784
3066
  config,
2785
3067
  });
2786
3068
  let commandReplyDelivered = false;
3069
+ let commandDispatchFailed = false;
2787
3070
 
2788
3071
  try {
2789
3072
  await replyStatusHeartbeat.start();
2790
- await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
3073
+ const dispatchResult = await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2791
3074
  ctx: inboundCtx,
2792
3075
  cfg,
2793
3076
  dispatcherOptions: {
@@ -2795,7 +3078,7 @@ export const bitrix24Plugin = {
2795
3078
  await replyStatusHeartbeat.stopAndWait();
2796
3079
  if (payload.text) {
2797
3080
  const keyboard = extractKeyboardFromPayload(payload)
2798
- ?? defaultCommandKeyboard;
3081
+ ?? defaultSessionKeyboard;
2799
3082
  const formattedPayload = normalizeCommandReplyPayload({
2800
3083
  commandName,
2801
3084
  commandParams,
@@ -2803,13 +3086,14 @@ export const bitrix24Plugin = {
2803
3086
  language: cmdCtx.language,
2804
3087
  });
2805
3088
  if (!commandReplyDelivered) {
2806
- commandReplyDelivered = true;
2807
3089
  if (isDm) {
3090
+ commandReplyDelivered = true;
2808
3091
  await sendService.sendText(sendCtx, formattedPayload.text, {
2809
3092
  keyboard,
2810
3093
  convertMarkdown: formattedPayload.convertMarkdown,
2811
3094
  });
2812
3095
  } else {
3096
+ commandReplyDelivered = true;
2813
3097
  await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
2814
3098
  keyboard,
2815
3099
  convertMarkdown: formattedPayload.convertMarkdown,
@@ -2818,6 +3102,7 @@ export const bitrix24Plugin = {
2818
3102
  return;
2819
3103
  }
2820
3104
 
3105
+ commandReplyDelivered = true;
2821
3106
  await sendService.sendText(sendCtx, formattedPayload.text, {
2822
3107
  keyboard,
2823
3108
  convertMarkdown: formattedPayload.convertMarkdown,
@@ -2832,11 +3117,42 @@ export const bitrix24Plugin = {
2832
3117
  },
2833
3118
  },
2834
3119
  });
3120
+
3121
+ if (!commandReplyDelivered && dispatchResult?.queuedFinal === false) {
3122
+ const fallbackText = replyGenerationFailed(cmdCtx.language);
3123
+ if (isDm) {
3124
+ await sendService.sendText(sendCtx, fallbackText, {
3125
+ keyboard: defaultSessionKeyboard,
3126
+ convertMarkdown: false,
3127
+ });
3128
+ } else {
3129
+ await sendService.answerCommandText(commandSendCtx, fallbackText, {
3130
+ keyboard: defaultSessionKeyboard,
3131
+ convertMarkdown: false,
3132
+ });
3133
+ }
3134
+ }
2835
3135
  } catch (err) {
3136
+ commandDispatchFailed = true;
2836
3137
  logger.error('Error dispatching command to agent', { commandName, senderId, dialogId, error: err });
2837
3138
  } finally {
2838
3139
  replyStatusHeartbeat.stop();
2839
3140
  }
3141
+
3142
+ if (!commandReplyDelivered && commandDispatchFailed) {
3143
+ const fallbackText = replyGenerationFailed(cmdCtx.language);
3144
+ if (isDm) {
3145
+ await sendService.sendText(sendCtx, fallbackText, {
3146
+ keyboard: defaultSessionKeyboard,
3147
+ convertMarkdown: false,
3148
+ });
3149
+ } else {
3150
+ await sendService.answerCommandText(commandSendCtx, fallbackText, {
3151
+ keyboard: defaultSessionKeyboard,
3152
+ convertMarkdown: false,
3153
+ });
3154
+ }
3155
+ }
2840
3156
  },
2841
3157
 
2842
3158
  onJoinChat: async (joinCtx: FetchJoinChatContext) => {
@@ -2927,7 +3243,7 @@ export const bitrix24Plugin = {
2927
3243
  await sendService.sendText(
2928
3244
  sendCtx,
2929
3245
  text,
2930
- isPairing ? undefined : { keyboard: buildDefaultCommandKeyboard(language) },
3246
+ isPairing ? undefined : { keyboard: buildWelcomeKeyboard(language) },
2931
3247
  );
2932
3248
  welcomedDialogs.add(dialogId);
2933
3249
  logger.info('Welcome message sent', { dialogId });