@ihazz/bitrix24 0.2.4 → 1.0.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
@@ -3,57 +3,515 @@ import { basename } from 'node:path';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import { listAccountIds, resolveAccount, getConfig } from './config.js';
5
5
  import { Bitrix24Api } from './api.js';
6
+ import type { BotContext } from './api.js';
6
7
  import { SendService } from './send-service.js';
8
+ import type { CommandSendContext, SendContext } from './send-service.js';
7
9
  import { MediaService } from './media-service.js';
8
10
  import type { DownloadedMedia } from './media-service.js';
9
11
  import { InboundHandler } from './inbound-handler.js';
10
- import { normalizeAllowEntry, checkAccessWithPairing } from './access-control.js';
12
+ import type { FetchCommandContext, FetchJoinChatContext } from './inbound-handler.js';
13
+ import { PollingService } from './polling-service.js';
14
+ import {
15
+ normalizeAllowEntry,
16
+ normalizeAllowList,
17
+ checkAccessWithPairing,
18
+ getWebhookUserId,
19
+ } from './access-control.js';
11
20
  import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
12
- import { defaultLogger } from './utils.js';
21
+ import { Bitrix24ApiError, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
13
22
  import { getBitrix24Runtime } from './runtime.js';
14
23
  import type { ChannelPairingAdapter } from './runtime.js';
15
- import { OPENCLAW_COMMANDS } from './commands.js';
24
+ import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply } from './commands.js';
25
+ import {
26
+ forwardedMessageUnsupported,
27
+ mediaDownloadFailed,
28
+ groupChatUnsupported,
29
+ onboardingMessage,
30
+ personalBotOwnerOnly,
31
+ replyMessageUnsupported,
32
+ } from './i18n.js';
16
33
  import type {
17
34
  B24MsgContext,
18
- B24JoinChatEvent,
19
- B24CommandEvent,
35
+ B24InputActionStatusCode,
36
+ B24V2FetchEventItem,
37
+ B24V2DeleteEventData,
38
+ FetchContext,
20
39
  Bitrix24AccountConfig,
21
40
  B24Keyboard,
22
41
  KeyboardButton,
42
+ Logger,
23
43
  } from './types.js';
24
44
 
25
- interface Logger {
26
- info: (...args: unknown[]) => void;
27
- warn: (...args: unknown[]) => void;
28
- error: (...args: unknown[]) => void;
29
- debug: (...args: unknown[]) => void;
45
+ const PHASE_STATUS_DURATION_SECONDS = 8;
46
+ const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
47
+ const THINKING_STATUS_DURATION_SECONDS = 30;
48
+ const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
49
+ const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
50
+ const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
51
+ const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
52
+ const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
53
+ const MEDIA_DOWNLOAD_CONCURRENCY = 2;
54
+ const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
55
+
56
+ // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
57
+ // B24 uses named reaction codes, not Unicode emoji.
58
+ // Map common Unicode emoji to their B24 equivalents.
59
+ const EMOJI_TO_B24_REACTION: Record<string, string> = {
60
+ '👍': 'like',
61
+ '👎': 'dislike',
62
+ '😂': 'faceWithTearsOfJoy',
63
+ '❤️': 'redHeart',
64
+ '❤': 'redHeart',
65
+ '😐': 'neutralFace',
66
+ '🔥': 'fire',
67
+ '😢': 'cry',
68
+ '🙂': 'slightlySmilingFace',
69
+ '😉': 'winkingFace',
70
+ '😆': 'laugh',
71
+ '😘': 'kiss',
72
+ '😲': 'wonder',
73
+ '🙁': 'slightlyFrowningFace',
74
+ '😭': 'loudlyCryingFace',
75
+ '😛': 'faceWithStuckOutTongue',
76
+ '😜': 'faceWithStuckOutTongueAndWinkingEye',
77
+ '😎': 'smilingFaceWithSunglasses',
78
+ '😕': 'confusedFace',
79
+ '😳': 'flushedFace',
80
+ '🤔': 'thinkingFace',
81
+ '😠': 'angry',
82
+ '😈': 'smilingFaceWithHorns',
83
+ '🤒': 'faceWithThermometer',
84
+ '🤦': 'facepalm',
85
+ '💩': 'poo',
86
+ '💪': 'flexedBiceps',
87
+ '👏': 'clappingHands',
88
+ '🖐️': 'raisedHand',
89
+ '🖐': 'raisedHand',
90
+ '😍': 'smilingFaceWithHeartEyes',
91
+ '🥰': 'smilingFaceWithHearts',
92
+ '🥺': 'pleadingFace',
93
+ '😌': 'relievedFace',
94
+ '🙏': 'foldedHands',
95
+ '👌': 'okHand',
96
+ '🤘': 'signHorns',
97
+ '🤟': 'loveYouGesture',
98
+ '🤡': 'clownFace',
99
+ '🥳': 'partyingFace',
100
+ '❓': 'questionMark',
101
+ '❗': 'exclamationMark',
102
+ '💡': 'lightBulb',
103
+ '💣': 'bomb',
104
+ '💤': 'sleepingSymbol',
105
+ '❌': 'crossMark',
106
+ '✅': 'whiteHeavyCheckMark',
107
+ '👀': 'eyes',
108
+ '🤝': 'handshake',
109
+ '💯': 'hundredPoints',
110
+ };
111
+
112
+ // All valid B24 reaction codes (for pass-through when code is used directly)
113
+ const B24_REACTION_CODES = new Set(Object.values(EMOJI_TO_B24_REACTION));
114
+
115
+ /**
116
+ * Resolve an emoji or B24 reaction code to a valid B24 reaction code.
117
+ * Returns null if the emoji/code is not supported.
118
+ */
119
+ function resolveB24Reaction(emojiOrCode: string): string | null {
120
+ const trimmed = emojiOrCode.trim();
121
+ if (B24_REACTION_CODES.has(trimmed)) return trimmed;
122
+ return EMOJI_TO_B24_REACTION[trimmed] ?? null;
123
+ }
124
+
125
+ function toMessageId(value: string | number | undefined): number | undefined {
126
+ const parsed = Number(value);
127
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
128
+ }
129
+
130
+ async function notifyStatus(
131
+ sendService: SendService,
132
+ sendCtx: SendContext,
133
+ config: Bitrix24AccountConfig,
134
+ statusMessageCode: B24InputActionStatusCode,
135
+ duration = PHASE_STATUS_DURATION_SECONDS,
136
+ ): Promise<void> {
137
+ if (config.showTyping === false) return;
138
+ await sendService.sendStatus(sendCtx, statusMessageCode, duration);
139
+ }
140
+
141
+ function createReplyStatusHeartbeat(params: {
142
+ sendService: SendService;
143
+ sendCtx: SendContext;
144
+ config: Bitrix24AccountConfig;
145
+ }): {
146
+ start: () => Promise<void>;
147
+ stop: () => void;
148
+ stopAndWait: () => Promise<void>;
149
+ holdFor: (durationSeconds: number, graceMs?: number) => void;
150
+ } {
151
+ const { sendService, sendCtx, config } = params;
152
+ let timer: ReturnType<typeof setTimeout> | null = null;
153
+ let stopped = false;
154
+ let inFlight = false;
155
+ let activeTick: Promise<void> | null = null;
156
+ let nextHeartbeatAt = Date.now();
157
+
158
+ const scheduleNext = (): void => {
159
+ if (stopped || config.showTyping === false) return;
160
+ if (timer) {
161
+ clearTimeout(timer);
162
+ timer = null;
163
+ }
164
+
165
+ const delay = Math.max(0, nextHeartbeatAt - Date.now());
166
+ timer = setTimeout(() => {
167
+ void runTick();
168
+ }, delay);
169
+ };
170
+
171
+ const holdFor = (
172
+ durationSeconds: number,
173
+ graceMs = PHASE_STATUS_REFRESH_GRACE_MS,
174
+ ): void => {
175
+ const holdMs = Math.max(0, (durationSeconds * 1000) - graceMs);
176
+ const holdUntil = Date.now() + holdMs;
177
+ if (holdUntil > nextHeartbeatAt) {
178
+ nextHeartbeatAt = holdUntil;
179
+ }
180
+ if (timer) {
181
+ scheduleNext();
182
+ }
183
+ };
184
+
185
+ const runTick = (): Promise<void> => {
186
+ const tick = (async (): Promise<void> => {
187
+ if (stopped || inFlight || config.showTyping === false) return;
188
+ if (Date.now() < nextHeartbeatAt) {
189
+ scheduleNext();
190
+ return;
191
+ }
192
+
193
+ inFlight = true;
194
+ try {
195
+ await notifyStatus(
196
+ sendService,
197
+ sendCtx,
198
+ config,
199
+ 'IMBOT_AGENT_ACTION_THINKING',
200
+ THINKING_STATUS_DURATION_SECONDS,
201
+ );
202
+ } finally {
203
+ inFlight = false;
204
+ nextHeartbeatAt = Date.now() + (
205
+ (THINKING_STATUS_DURATION_SECONDS * 1000) - THINKING_STATUS_REFRESH_GRACE_MS
206
+ );
207
+ scheduleNext();
208
+ }
209
+ })();
210
+
211
+ activeTick = tick;
212
+ void tick.finally(() => {
213
+ if (activeTick === tick) {
214
+ activeTick = null;
215
+ }
216
+ });
217
+
218
+ return tick;
219
+ };
220
+
221
+ return {
222
+ start: async () => {
223
+ if (stopped || timer || config.showTyping === false) return;
224
+ if (Date.now() >= nextHeartbeatAt) {
225
+ await runTick();
226
+ return;
227
+ }
228
+ scheduleNext();
229
+ },
230
+ stop: () => {
231
+ stopped = true;
232
+ if (!timer) return;
233
+ clearTimeout(timer);
234
+ timer = null;
235
+ },
236
+ stopAndWait: async () => {
237
+ stopped = true;
238
+ if (timer) {
239
+ clearTimeout(timer);
240
+ timer = null;
241
+ }
242
+ if (activeTick) {
243
+ await activeTick;
244
+ }
245
+ },
246
+ holdFor,
247
+ };
248
+ }
249
+
250
+ export function canCoalesceDirectMessage(
251
+ msgCtx: B24MsgContext,
252
+ config: Bitrix24AccountConfig,
253
+ ): boolean {
254
+ return msgCtx.isDm
255
+ && config.dmPolicy !== 'pairing'
256
+ && msgCtx.media.length === 0
257
+ && !msgCtx.replyToMessageId
258
+ && !msgCtx.isForwarded
259
+ && msgCtx.text.trim().length > 0
260
+ && !msgCtx.text.trim().startsWith('/');
261
+ }
262
+
263
+ export function mergeBufferedDirectMessages(messages: B24MsgContext[]): B24MsgContext {
264
+ const first = messages[0];
265
+ const last = messages[messages.length - 1];
266
+
267
+ return {
268
+ ...first,
269
+ text: messages
270
+ .map((message) => message.text.trim())
271
+ .filter(Boolean)
272
+ .join('\n'),
273
+ messageId: last.messageId,
274
+ language: last.language ?? first.language,
275
+ raw: last.raw,
276
+ };
277
+ }
278
+
279
+ export function mergeForwardedMessageContext(
280
+ previousMsgCtx: B24MsgContext,
281
+ forwardedMsgCtx: B24MsgContext,
282
+ ): B24MsgContext {
283
+ const previousText = previousMsgCtx.text.trim();
284
+ const forwardedText = forwardedMsgCtx.text.trim();
285
+ const mergedText = [
286
+ previousText,
287
+ forwardedText ? `[Forwarded message]\n${forwardedText}` : '',
288
+ ].filter(Boolean).join('\n\n');
289
+
290
+ return {
291
+ ...forwardedMsgCtx,
292
+ text: mergedText,
293
+ media: [...previousMsgCtx.media, ...forwardedMsgCtx.media],
294
+ language: forwardedMsgCtx.language ?? previousMsgCtx.language,
295
+ isForwarded: false,
296
+ };
297
+ }
298
+
299
+ export function resolveDirectMessageCoalesceDelay(params: {
300
+ startedAt: number;
301
+ now: number;
302
+ debounceMs?: number;
303
+ maxWaitMs?: number;
304
+ }): number {
305
+ const debounceMs = params.debounceMs ?? DIRECT_TEXT_COALESCE_DEBOUNCE_MS;
306
+ const maxWaitMs = params.maxWaitMs ?? DIRECT_TEXT_COALESCE_MAX_WAIT_MS;
307
+ const elapsedMs = Math.max(0, params.now - params.startedAt);
308
+ const remainingMs = Math.max(0, maxWaitMs - elapsedMs);
309
+
310
+ return Math.min(debounceMs, remainingMs);
311
+ }
312
+
313
+ export function shouldSkipJoinChatWelcome(params: {
314
+ dialogId?: string;
315
+ chatType?: string;
316
+ webhookUrl: string;
317
+ dmPolicy?: Bitrix24AccountConfig['dmPolicy'];
318
+ }): boolean {
319
+ if (!params.dialogId) {
320
+ return false;
321
+ }
322
+
323
+ if (params.chatType === 'chat' || params.chatType === 'open') {
324
+ return false;
325
+ }
326
+
327
+ const policy = params.dmPolicy ?? 'webhookUser';
328
+ const webhookUserId = getWebhookUserId(params.webhookUrl);
329
+
330
+ return policy === 'webhookUser'
331
+ && Boolean(webhookUserId)
332
+ && normalizeAllowEntry(params.dialogId) !== webhookUserId;
333
+ }
334
+
335
+ async function mapWithConcurrency<T, R>(
336
+ items: T[],
337
+ concurrency: number,
338
+ worker: (item: T, index: number) => Promise<R>,
339
+ ): Promise<R[]> {
340
+ if (items.length === 0) {
341
+ return [];
342
+ }
343
+
344
+ const results = new Array<R>(items.length);
345
+ let nextIndex = 0;
346
+ const poolSize = Math.max(1, Math.min(concurrency, items.length));
347
+
348
+ const runners = Array.from({ length: poolSize }, async () => {
349
+ while (nextIndex < items.length) {
350
+ const currentIndex = nextIndex;
351
+ nextIndex += 1;
352
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
353
+ }
354
+ });
355
+
356
+ await Promise.all(runners);
357
+ return results;
358
+ }
359
+
360
+ function resolveSecurityConfig(params: {
361
+ cfg?: Record<string, unknown>;
362
+ accountId?: string;
363
+ account?: { config?: Record<string, unknown> };
364
+ }): Bitrix24AccountConfig {
365
+ if (params.account?.config) {
366
+ return params.account.config as Bitrix24AccountConfig;
367
+ }
368
+
369
+ if (params.cfg) {
370
+ return resolveAccount(params.cfg, params.accountId).config;
371
+ }
372
+
373
+ return {};
374
+ }
375
+
376
+ interface BufferedDirectMessageEntry {
377
+ messages: B24MsgContext[];
378
+ startedAt: number;
379
+ timer: ReturnType<typeof setTimeout>;
380
+ }
381
+
382
+ class BufferedDirectMessageCoalescer {
383
+ private readonly entries = new Map<string, BufferedDirectMessageEntry>();
384
+ private readonly debounceMs: number;
385
+ private readonly maxWaitMs: number;
386
+ private readonly onFlush: (msgCtx: B24MsgContext) => Promise<void>;
387
+ private readonly logger: Logger;
388
+ private destroyed = false;
389
+
390
+ constructor(params: {
391
+ debounceMs: number;
392
+ maxWaitMs: number;
393
+ onFlush: (msgCtx: B24MsgContext) => Promise<void>;
394
+ logger: Logger;
395
+ }) {
396
+ this.debounceMs = params.debounceMs;
397
+ this.maxWaitMs = params.maxWaitMs;
398
+ this.onFlush = params.onFlush;
399
+ this.logger = params.logger;
400
+ }
401
+
402
+ enqueue(accountId: string, msgCtx: B24MsgContext): void {
403
+ const key = this.getKey(accountId, msgCtx.chatId);
404
+ const current = this.entries.get(key);
405
+
406
+ if (current) {
407
+ clearTimeout(current.timer);
408
+ current.messages.push(msgCtx);
409
+ current.timer = this.createTimer(key, current.startedAt);
410
+ this.logger.debug('Buffered direct message appended', {
411
+ chatId: msgCtx.chatId,
412
+ bufferedCount: current.messages.length,
413
+ });
414
+ return;
415
+ }
416
+
417
+ const startedAt = Date.now();
418
+ this.entries.set(key, {
419
+ messages: [msgCtx],
420
+ startedAt,
421
+ timer: this.createTimer(key, startedAt),
422
+ });
423
+ this.logger.debug('Buffered direct message started', { chatId: msgCtx.chatId });
424
+ }
425
+
426
+ async flush(accountId: string, dialogId: string): Promise<void> {
427
+ await this.flushKey(this.getKey(accountId, dialogId));
428
+ }
429
+
430
+ take(accountId: string, dialogId: string): B24MsgContext | null {
431
+ const key = this.getKey(accountId, dialogId);
432
+ const entry = this.entries.get(key);
433
+ if (!entry) return null;
434
+
435
+ clearTimeout(entry.timer);
436
+ this.entries.delete(key);
437
+ this.logger.debug('Taking buffered direct messages', {
438
+ chatId: dialogId,
439
+ bufferedCount: entry.messages.length,
440
+ });
441
+
442
+ return entry.messages.length === 1
443
+ ? entry.messages[0]
444
+ : mergeBufferedDirectMessages(entry.messages);
445
+ }
446
+
447
+ async flushAll(): Promise<void> {
448
+ for (const key of [...this.entries.keys()]) {
449
+ await this.flushKey(key);
450
+ }
451
+ }
452
+
453
+ destroy(): void {
454
+ this.destroyed = true;
455
+ for (const entry of this.entries.values()) {
456
+ clearTimeout(entry.timer);
457
+ }
458
+ this.entries.clear();
459
+ }
460
+
461
+ private getKey(accountId: string, dialogId: string): string {
462
+ return `${accountId}:${dialogId}`;
463
+ }
464
+
465
+ private createTimer(key: string, startedAt: number): ReturnType<typeof setTimeout> {
466
+ const delayMs = resolveDirectMessageCoalesceDelay({
467
+ startedAt,
468
+ now: Date.now(),
469
+ debounceMs: this.debounceMs,
470
+ maxWaitMs: this.maxWaitMs,
471
+ });
472
+ return setTimeout(() => {
473
+ void this.flushKey(key);
474
+ }, delayMs);
475
+ }
476
+
477
+ private async flushKey(key: string): Promise<void> {
478
+ const entry = this.entries.get(key);
479
+ if (!entry || this.destroyed) return;
480
+
481
+ clearTimeout(entry.timer);
482
+ this.entries.delete(key);
483
+
484
+ const msgCtx = entry.messages.length === 1
485
+ ? entry.messages[0]
486
+ : mergeBufferedDirectMessages(entry.messages);
487
+
488
+ try {
489
+ this.logger.debug('Flushing buffered direct messages', {
490
+ chatId: msgCtx.chatId,
491
+ bufferedCount: entry.messages.length,
492
+ });
493
+ await this.onFlush(msgCtx);
494
+ } catch (err) {
495
+ this.logger.error('Failed to flush buffered direct messages', err);
496
+ }
497
+ }
30
498
  }
31
499
 
32
500
  /** State held per running gateway instance */
33
501
  interface GatewayState {
502
+ accountId: string;
34
503
  api: Bitrix24Api;
504
+ bot: BotContext;
35
505
  sendService: SendService;
36
506
  mediaService: MediaService;
37
507
  inboundHandler: InboundHandler;
508
+ eventMode: 'fetch' | 'webhook';
38
509
  }
39
510
 
40
511
  let gatewayState: GatewayState | null = null;
41
512
 
42
- // ─── i18n helpers ────────────────────────────────────────────────────────────
43
-
44
- const I18N_MEDIA_DOWNLOAD_FAILED: Record<string, (files: string) => string> = {
45
- en: (f) => `⚠️ Could not download file(s): ${f}.\n\nFile processing is currently only available for the primary user (webhook owner). This limitation will be removed in a future release.`,
46
- ru: (f) => `⚠️ Не удалось загрузить файл(ы): ${f}.\n\nОбработка файлов пока доступна только для основного пользователя (автора вебхука). В будущих версиях это ограничение будет снято.`,
47
- de: (f) => `⚠️ Datei(en) konnten nicht heruntergeladen werden: ${f}.\n\nDateiverarbeitung ist derzeit nur für den Hauptbenutzer (Webhook-Besitzer) verfügbar. Diese Einschränkung wird in einer zukünftigen Version behoben.`,
48
- es: (f) => `⚠️ No se pudo descargar el/los archivo(s): ${f}.\n\nEl procesamiento de archivos actualmente solo está disponible para el usuario principal (propietario del webhook). Esta limitación se eliminará en una versión futura.`,
49
- fr: (f) => `⚠️ Impossible de télécharger le(s) fichier(s) : ${f}.\n\nLe traitement des fichiers est actuellement réservé à l'utilisateur principal (propriétaire du webhook). Cette limitation sera levée dans une prochaine version.`,
50
- pt: (f) => `⚠️ Não foi possível baixar o(s) arquivo(s): ${f}.\n\nO processamento de arquivos está disponível apenas para o usuário principal (dono do webhook). Essa limitação será removida em uma versão futura.`,
51
- };
52
-
53
- function mediaDownloadFailedMsg(lang: string | undefined, fileNames: string): string {
54
- const code = (lang ?? 'en').toLowerCase().slice(0, 2);
55
- const fn = I18N_MEDIA_DOWNLOAD_FAILED[code] ?? I18N_MEDIA_DOWNLOAD_FAILED.en;
56
- return fn(fileNames);
513
+ export function __setGatewayStateForTests(state: GatewayState | null): void {
514
+ gatewayState = state;
57
515
  }
58
516
 
59
517
  // ─── Default command keyboard ────────────────────────────────────────────────
@@ -79,8 +537,6 @@ export interface ChannelButton {
79
537
 
80
538
  /**
81
539
  * Convert OpenClaw button rows to B24 flat KEYBOARD array.
82
- * Input: Array<Array<{ text, callback_data, style }>>
83
- * Output: flat array with { TYPE: 'NEWLINE' } separators between rows.
84
540
  */
85
541
  export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
86
542
  const keyboard: B24Keyboard = [];
@@ -90,14 +546,12 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
90
546
  const b24Btn: KeyboardButton = { TEXT: btn.text, DISPLAY: 'LINE' };
91
547
 
92
548
  if (btn.callback_data?.startsWith('/')) {
93
- // Slash command — use COMMAND + COMMAND_PARAMS
94
549
  const parts = btn.callback_data.substring(1).split(' ');
95
550
  b24Btn.COMMAND = parts[0];
96
551
  if (parts.length > 1) {
97
552
  b24Btn.COMMAND_PARAMS = parts.slice(1).join(' ');
98
553
  }
99
554
  } else if (btn.callback_data) {
100
- // Non-slash data — insert text into input via PUT action
101
555
  b24Btn.ACTION = 'PUT';
102
556
  b24Btn.ACTION_VALUE = btn.callback_data;
103
557
  }
@@ -111,7 +565,6 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
111
565
  keyboard.push(b24Btn);
112
566
  }
113
567
 
114
- // Add NEWLINE separator between rows (not after last row)
115
568
  if (i < rows.length - 1) {
116
569
  keyboard.push({ TYPE: 'NEWLINE' });
117
570
  }
@@ -122,7 +575,6 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
122
575
 
123
576
  /**
124
577
  * Extract B24 keyboard from a dispatcher payload's channelData.
125
- * Checks bitrix24-specific data first, then falls back to OpenClaw generic button format.
126
578
  */
127
579
  export function extractKeyboardFromPayload(
128
580
  payload: { channelData?: Record<string, unknown> },
@@ -130,13 +582,11 @@ export function extractKeyboardFromPayload(
130
582
  const cd = payload.channelData;
131
583
  if (!cd) return undefined;
132
584
 
133
- // Direct B24 keyboard (future-proof: channelData.bitrix24.keyboard)
134
585
  const b24Data = cd.bitrix24 as { keyboard?: B24Keyboard } | undefined;
135
586
  if (b24Data?.keyboard?.length) {
136
587
  return b24Data.keyboard;
137
588
  }
138
589
 
139
- // Translate from OpenClaw generic button format (channelData.telegram key)
140
590
  const tgData = cd.telegram as { buttons?: ChannelButton[][] } | undefined;
141
591
  if (tgData?.buttons?.length) {
142
592
  return convertButtonsToKeyboard(tgData.buttons);
@@ -145,105 +595,245 @@ export function extractKeyboardFromPayload(
145
595
  return undefined;
146
596
  }
147
597
 
598
+ function normalizeCommandReplyPayload(params: {
599
+ commandName: string;
600
+ commandParams: string;
601
+ text: string;
602
+ language?: string;
603
+ }): { text: string; convertMarkdown?: boolean } {
604
+ const { commandName, commandParams, text, language } = params;
605
+
606
+ if (commandName === 'models' && commandParams.trim() === '') {
607
+ const formattedText = formatModelsCommandReply(text, language);
608
+ if (formattedText) {
609
+ return { text: formattedText, convertMarkdown: false };
610
+ }
611
+ }
612
+
613
+ return { text };
614
+ }
615
+
148
616
  /**
149
- * Register or update the bot on the Bitrix24 portal.
150
- * Checks imbot.bot.list first; if a bot with the same CODE exists, updates it.
151
- * Otherwise registers a new one.
617
+ * Determine effective event mode from config.
618
+ */
619
+ function resolveEventMode(config: Bitrix24AccountConfig): 'fetch' | 'webhook' {
620
+ return config.eventMode ?? (config.callbackUrl ? 'webhook' : 'fetch');
621
+ }
622
+
623
+ /**
624
+ * Generate a stable botToken from webhookUrl if not configured.
625
+ */
626
+ function resolveBotToken(config: Bitrix24AccountConfig): string | null {
627
+ if (config.botToken) return config.botToken;
628
+ if (!config.webhookUrl) return null;
629
+ // Derive a stable token from webhookUrl (md5, max 32 chars — platform limit)
630
+ return createHash('md5').update(config.webhookUrl).digest('hex');
631
+ }
632
+
633
+ export function buildBotCodeCandidates(
634
+ config: Pick<Bitrix24AccountConfig, 'webhookUrl' | 'botCode'>,
635
+ maxCandidates = AUTO_BOT_CODE_MAX_CANDIDATES,
636
+ ): string[] {
637
+ if (config.botCode) {
638
+ return [config.botCode];
639
+ }
640
+
641
+ const webhookUserId = getWebhookUserId(config.webhookUrl);
642
+ const baseCode = webhookUserId ? `openclaw_${webhookUserId}` : 'openclaw';
643
+ const safeMaxCandidates = Math.max(1, maxCandidates);
644
+
645
+ return Array.from({ length: safeMaxCandidates }, (_value, index) => {
646
+ return index === 0 ? baseCode : `${baseCode}_${index + 1}`;
647
+ });
648
+ }
649
+
650
+ function isBotCodeAlreadyTakenError(error: unknown): boolean {
651
+ return error instanceof Bitrix24ApiError && error.code === 'BOT_CODE_ALREADY_TAKEN';
652
+ }
653
+
654
+ interface BotRegistrationState {
655
+ botId: number;
656
+ language?: string;
657
+ isNew: boolean;
658
+ }
659
+
660
+ function isFreshBotRegistration(bot: {
661
+ countMessage?: number;
662
+ countCommand?: number;
663
+ countChat?: number;
664
+ countUser?: number;
665
+ }): boolean {
666
+ return (
667
+ (bot.countMessage ?? 0) === 0 &&
668
+ (bot.countCommand ?? 0) === 0 &&
669
+ (bot.countChat ?? 0) === 0 &&
670
+ (bot.countUser ?? 0) === 0
671
+ );
672
+ }
673
+
674
+ async function sendInitialWelcomeToWebhookOwner(params: {
675
+ config: Bitrix24AccountConfig;
676
+ bot: BotContext;
677
+ sendService: SendService;
678
+ language?: string;
679
+ welcomedDialogs: Set<string>;
680
+ logger: Logger;
681
+ }): Promise<void> {
682
+ const { config, bot, sendService, language, welcomedDialogs, logger } = params;
683
+ const ownerId = getWebhookUserId(config.webhookUrl);
684
+ if (!ownerId || !config.webhookUrl || welcomedDialogs.has(ownerId)) {
685
+ return;
686
+ }
687
+
688
+ const sendCtx: SendContext = {
689
+ webhookUrl: config.webhookUrl,
690
+ bot,
691
+ dialogId: ownerId,
692
+ };
693
+ const isPairing = config.dmPolicy === 'pairing';
694
+ const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
695
+ const options = isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD };
696
+
697
+ try {
698
+ await sendService.sendText(sendCtx, text, options);
699
+ welcomedDialogs.add(ownerId);
700
+ logger.info('Initial welcome sent to webhook owner', {
701
+ dialogId: ownerId,
702
+ language: language ?? 'en',
703
+ });
704
+ } catch (err) {
705
+ logger.warn('Failed to send initial welcome to webhook owner', err);
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Register or update the bot using imbot.v2.Bot.register / Bot.update.
152
711
  */
153
712
  async function ensureBotRegistered(
154
713
  api: Bitrix24Api,
155
714
  config: Bitrix24AccountConfig,
715
+ botToken: string,
716
+ eventMode: 'fetch' | 'webhook',
156
717
  logger: Logger,
157
- ): Promise<number | null> {
158
- const { webhookUrl, callbackUrl, botCode, botName } = config;
159
- if (!webhookUrl || !callbackUrl) {
160
- if (!callbackUrl) {
161
- logger.warn('callbackUrl not configured — skipping bot registration (bot must be registered manually)');
162
- }
718
+ ): Promise<BotRegistrationState | null> {
719
+ const { webhookUrl, callbackUrl, botName } = config;
720
+
721
+ if (!webhookUrl) return null;
722
+
723
+ if (eventMode === 'webhook' && !callbackUrl) {
724
+ logger.warn('callbackUrl not configured for webhook mode — skipping bot registration');
163
725
  return null;
164
726
  }
165
727
 
166
- const code = botCode ?? 'openclaw';
167
728
  const name = botName ?? 'OpenClaw';
729
+ const botCodeCandidates = buildBotCodeCandidates(config);
168
730
 
169
- // Check if bot already exists
731
+ // Check if bot already exists via imbot.v2.Bot.list
170
732
  try {
171
- const bots = await api.listBots(webhookUrl);
172
- const existing = bots.find((b) => b.CODE === code);
733
+ const listResult = await api.listBots(webhookUrl, botToken);
734
+ const existing = botCodeCandidates
735
+ .map((candidate) => listResult.bots.find((botItem) => botItem.code === candidate))
736
+ .find(Boolean);
173
737
 
174
738
  if (existing) {
175
- logger.info(`Bot "${code}" already registered (ID=${existing.ID}), updating EVENT_HANDLER`);
176
- const updateProps: Record<string, unknown> = {
177
- NAME: name,
178
- WORK_POSITION: 'AI Assistant',
179
- COLOR: 'RED',
739
+ logger.info(`Bot "${existing.code}" already registered (ID=${existing.id}), updating`);
740
+
741
+ const bot: BotContext = { botId: existing.id, botToken };
742
+ const updateFields: Record<string, unknown> = {
743
+ properties: {
744
+ name,
745
+ workPosition: 'AI Assistant',
746
+ avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
747
+ },
748
+ eventMode,
180
749
  };
181
- updateProps.PERSONAL_PHOTO = config.botAvatar || DEFAULT_AVATAR_BASE64;
182
750
 
183
- await api.updateBot(webhookUrl, existing.ID, {
184
- EVENT_HANDLER: callbackUrl,
185
- PROPERTIES: updateProps,
186
- });
187
- return existing.ID;
751
+ if (eventMode === 'webhook' && callbackUrl) {
752
+ updateFields.webhookUrl = callbackUrl;
753
+ }
754
+
755
+ await api.updateBot(webhookUrl, bot, updateFields);
756
+ return {
757
+ botId: existing.id,
758
+ language: existing.language,
759
+ isNew: false,
760
+ };
188
761
  }
189
762
  } catch (err) {
190
763
  logger.warn('Failed to list existing bots, will try to register', err);
191
764
  }
192
765
 
193
- // Register new bot
194
- try {
195
- const botId = await api.registerBot(webhookUrl, {
196
- CODE: code,
197
- TYPE: 'B',
198
- EVENT_HANDLER: callbackUrl,
199
- PROPERTIES: {
200
- NAME: name,
201
- WORK_POSITION: 'AI Assistant',
202
- COLOR: 'RED',
203
- PERSONAL_PHOTO: config.botAvatar || DEFAULT_AVATAR_BASE64,
204
- },
205
- });
206
- logger.info(`Bot "${code}" registered (ID=${botId})`);
207
- return botId;
208
- } catch (err) {
209
- logger.error('Failed to register bot', err);
210
- return null;
766
+ // Register new bot via imbot.v2.Bot.register
767
+ for (const code of botCodeCandidates) {
768
+ try {
769
+ const registerFields: {
770
+ code: string;
771
+ properties: { name: string; workPosition: string; avatar?: string };
772
+ type: string;
773
+ eventMode: 'fetch' | 'webhook';
774
+ webhookUrl?: string;
775
+ } = {
776
+ code,
777
+ properties: {
778
+ name,
779
+ workPosition: 'AI Assistant',
780
+ avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
781
+ },
782
+ type: 'personal',
783
+ eventMode,
784
+ };
785
+
786
+ if (eventMode === 'webhook' && callbackUrl) {
787
+ registerFields.webhookUrl = callbackUrl;
788
+ }
789
+
790
+ const result = await api.registerBot(webhookUrl, botToken, registerFields);
791
+ logger.info(`Bot "${code}" registered in ${eventMode} mode (ID=${result.bot.id})`);
792
+ return {
793
+ botId: result.bot.id,
794
+ language: result.bot.language,
795
+ isNew: isFreshBotRegistration(result.bot),
796
+ };
797
+ } catch (err) {
798
+ if (!config.botCode && isBotCodeAlreadyTakenError(err)) {
799
+ logger.warn(`Bot code "${code}" already taken, trying next candidate`);
800
+ continue;
801
+ }
802
+
803
+ logger.error('Failed to register bot', err);
804
+ return null;
805
+ }
211
806
  }
807
+
808
+ logger.error('Failed to register bot: exhausted automatic bot code candidates');
809
+ return null;
212
810
  }
213
811
 
214
812
  /**
215
- * Register OpenClaw slash commands with the B24 bot.
216
- * Runs in the background errors are logged but don't block startup.
813
+ * Register OpenClaw slash commands with imbot.v2.Command.register.
814
+ * V2 Command.register is idempotent and doesn't need EVENT_COMMAND_ADD URL.
217
815
  */
218
816
  async function ensureCommandsRegistered(
219
817
  api: Bitrix24Api,
220
818
  config: Bitrix24AccountConfig,
221
- botId: number,
819
+ bot: BotContext,
222
820
  logger: Logger,
223
821
  ): Promise<void> {
224
- const { webhookUrl, callbackUrl } = config;
225
- if (!webhookUrl || !callbackUrl) return;
822
+ const { webhookUrl } = config;
823
+ if (!webhookUrl) return;
226
824
 
227
825
  let registered = 0;
228
826
  let skipped = 0;
229
827
 
230
828
  for (const cmd of OPENCLAW_COMMANDS) {
231
829
  try {
232
- await api.registerCommand(webhookUrl, {
233
- BOT_ID: botId,
234
- COMMAND: cmd.command,
235
- COMMON: 'N',
236
- HIDDEN: 'N',
237
- EXTRANET_SUPPORT: 'N',
238
- LANG: [
239
- { LANGUAGE_ID: 'en', TITLE: cmd.en, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
240
- { LANGUAGE_ID: 'ru', TITLE: cmd.ru, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
241
- ],
242
- EVENT_COMMAND_ADD: callbackUrl,
830
+ await api.registerCommand(webhookUrl, bot, {
831
+ command: cmd.command,
832
+ title: { en: cmd.en, ru: cmd.ru },
833
+ ...(cmd.params ? { params: { en: cmd.params, ru: cmd.params } } : {}),
243
834
  });
244
835
  registered++;
245
836
  } catch (err: unknown) {
246
- // "WRONG_REQUEST" typically means command already exists
247
837
  const msg = err instanceof Error ? err.message : String(err);
248
838
  if (msg.includes('WRONG_REQUEST') || msg.includes('already')) {
249
839
  skipped++;
@@ -257,11 +847,9 @@ async function ensureCommandsRegistered(
257
847
  }
258
848
 
259
849
  /**
260
- * Handle an incoming HTTP request on the webhook route.
261
- * Called by the HTTP route registered in index.ts.
850
+ * Handle an incoming HTTP request on the webhook route (V2 webhook mode).
262
851
  */
263
852
  export async function handleWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
264
- // Always respond 200 quickly — B24 retries if it doesn't get a fast response
265
853
  if (req.method !== 'POST') {
266
854
  res.statusCode = 405;
267
855
  res.end('Method Not Allowed');
@@ -274,30 +862,121 @@ export async function handleWebhookRequest(req: IncomingMessage, res: ServerResp
274
862
  return;
275
863
  }
276
864
 
865
+ if (gatewayState.eventMode === 'fetch') {
866
+ res.statusCode = 200;
867
+ res.end('FETCH mode active');
868
+ return;
869
+ }
870
+
277
871
  // Read raw body
278
872
  const chunks: Buffer[] = [];
873
+ let bodySize = 0;
279
874
  for await (const chunk of req) {
280
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
875
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
876
+ chunks.push(buffer);
877
+ bodySize += buffer.length;
878
+ if (bodySize > MAX_WEBHOOK_BODY_BYTES) {
879
+ res.statusCode = 413;
880
+ res.end('Payload Too Large');
881
+ req.destroy();
882
+ return;
883
+ }
281
884
  }
282
885
  const body = Buffer.concat(chunks).toString('utf-8');
283
886
 
284
- // Respond immediately
285
- res.statusCode = 200;
286
- res.setHeader('Content-Type', 'text/plain');
287
- res.end('ok');
288
-
289
- // Process in background
290
887
  try {
291
- await gatewayState.inboundHandler.handleWebhook(body);
888
+ const handled = await gatewayState.inboundHandler.handleWebhook(body);
889
+ if (!handled) {
890
+ res.statusCode = 400;
891
+ res.end('Invalid webhook payload');
892
+ return;
893
+ }
894
+
895
+ res.statusCode = 200;
896
+ res.setHeader('Content-Type', 'application/json');
897
+ res.end(JSON.stringify({ status: 'ok' }));
292
898
  } catch (err) {
293
- defaultLogger.error('Error handling Bitrix24 webhook', err);
899
+ defaultLogger.error('Error handling Bitrix24 V2 webhook', err);
900
+ res.statusCode = 500;
901
+ res.end('Webhook processing failed');
902
+ }
903
+ }
904
+
905
+ // ─── Outbound adapter helpers ────────────────────────────────────────────────
906
+
907
+ function resolveOutboundSendCtx(params: {
908
+ cfg: Record<string, unknown>;
909
+ to: string;
910
+ accountId?: string;
911
+ }): SendContext | null {
912
+ const { config } = resolveAccount(params.cfg, params.accountId);
913
+ if (!config.webhookUrl || !gatewayState) return null;
914
+ return {
915
+ webhookUrl: config.webhookUrl,
916
+ bot: gatewayState.bot,
917
+ dialogId: params.to,
918
+ };
919
+ }
920
+
921
+ function collectOutboundMediaUrls(input: {
922
+ mediaUrl?: string;
923
+ payload?: { mediaUrl?: string; mediaUrls?: string[] };
924
+ }): string[] {
925
+ const mediaUrls: string[] = [];
926
+
927
+ if (input.mediaUrl) {
928
+ mediaUrls.push(input.mediaUrl);
929
+ }
930
+
931
+ if (input.payload?.mediaUrl) {
932
+ mediaUrls.push(input.payload.mediaUrl);
294
933
  }
934
+
935
+ if (Array.isArray(input.payload?.mediaUrls)) {
936
+ mediaUrls.push(...input.payload.mediaUrls.filter((item) => typeof item === 'string'));
937
+ }
938
+
939
+ return [...new Set(mediaUrls)];
940
+ }
941
+
942
+ async function uploadOutboundMedia(params: {
943
+ sendCtx: SendContext;
944
+ mediaUrls: string[];
945
+ text?: string;
946
+ }): Promise<string> {
947
+ if (!gatewayState) {
948
+ throw new Error('Bitrix24 gateway not started');
949
+ }
950
+
951
+ let lastMessageId = '';
952
+ let message = params.text;
953
+
954
+ for (const mediaUrl of params.mediaUrls) {
955
+ const result = await gatewayState.mediaService.uploadMediaToChat({
956
+ localPath: mediaUrl,
957
+ fileName: basename(mediaUrl),
958
+ webhookUrl: params.sendCtx.webhookUrl,
959
+ bot: params.sendCtx.bot,
960
+ dialogId: params.sendCtx.dialogId,
961
+ message: message || undefined,
962
+ });
963
+
964
+ if (!result.ok) {
965
+ throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
966
+ }
967
+
968
+ if (result.messageId) {
969
+ lastMessageId = String(result.messageId);
970
+ }
971
+
972
+ message = undefined;
973
+ }
974
+
975
+ return lastMessageId;
295
976
  }
296
977
 
297
978
  /**
298
979
  * The Bitrix24 channel plugin object.
299
- *
300
- * Implements the OpenClaw ChannelPlugin interface.
301
980
  */
302
981
  export const bitrix24Plugin = {
303
982
  id: 'bitrix24',
@@ -308,29 +987,25 @@ export const bitrix24Plugin = {
308
987
  selectionLabel: 'Bitrix24 (Messenger)',
309
988
  docsPath: '/channels/bitrix24',
310
989
  docsLabel: 'bitrix24',
311
- blurb: 'Connect to Bitrix24 Messenger via chat bot REST API.',
990
+ blurb: 'Connect to Bitrix24 Messenger via chat bot REST API (V2).',
312
991
  aliases: ['b24', 'bx24'],
313
992
  },
314
993
 
315
994
  capabilities: {
316
- chatTypes: ['direct', 'group'] as const,
995
+ chatTypes: ['direct'] as const,
317
996
  media: true,
318
- reactions: false,
997
+ reactions: true,
319
998
  threads: false,
320
999
  nativeCommands: true,
321
1000
  inlineButtons: 'all',
322
1001
  },
323
1002
 
324
1003
  messaging: {
1004
+ normalizeTarget: (raw: string) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
325
1005
  targetResolver: {
326
1006
  hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
327
- /**
328
- * Recognize any numeric string as a valid Bitrix24 target ID.
329
- * B24 dialog IDs can be short (e.g. "1"), so the default 6+ digit check is too strict.
330
- */
331
1007
  looksLikeId: (raw: string, _normalized: string) => {
332
- // raw may include channel prefix like "bitrix24:1" — strip it first
333
- const stripped = raw.trim().replace(/^(bitrix24|b24|bx24):/i, '');
1008
+ const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
334
1009
  return /^\d+$/.test(stripped);
335
1010
  },
336
1011
  },
@@ -343,16 +1018,20 @@ export const bitrix24Plugin = {
343
1018
  },
344
1019
 
345
1020
  security: {
346
- resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => ({
347
- policy: (params.account?.config?.dmPolicy as string) ?? 'pairing',
348
- allowFrom: (params.account?.config?.allowFrom as string[]) ?? [],
349
- policyPath: 'channels.bitrix24.dmPolicy',
350
- allowFromPath: 'channels.bitrix24.',
351
- approveHint: 'openclaw pairing approve bitrix24 <CODE>',
352
- normalizeEntry: (raw: string) => raw.replace(/^(bitrix24|b24|bx24):/i, ''),
353
- }),
1021
+ resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => {
1022
+ const securityConfig = resolveSecurityConfig(params);
1023
+
1024
+ return {
1025
+ policy: securityConfig.dmPolicy === 'pairing' ? 'pairing' : 'webhookUser',
1026
+ allowFrom: normalizeAllowList(securityConfig.allowFrom),
1027
+ policyPath: 'channels.bitrix24.dmPolicy',
1028
+ allowFromPath: 'channels.bitrix24.allowFrom',
1029
+ approveHint: 'openclaw pairing approve bitrix24 <CODE>',
1030
+ normalizeEntry: (raw: string) => raw.replace(CHANNEL_PREFIX_RE, ''),
1031
+ };
1032
+ },
354
1033
  normalizeAllowFrom: (entry: string) =>
355
- entry.replace(/^(bitrix24|b24|bx24):/i, ''),
1034
+ entry.replace(CHANNEL_PREFIX_RE, ''),
356
1035
  },
357
1036
 
358
1037
  pairing: {
@@ -360,113 +1039,209 @@ export const bitrix24Plugin = {
360
1039
  normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
361
1040
  notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
362
1041
  const { config: acctCfg } = resolveAccount(params.cfg);
363
- if (!acctCfg.webhookUrl) return;
364
- const api = new Bitrix24Api();
1042
+ if (!acctCfg.webhookUrl || !gatewayState) return;
1043
+ const sendCtx: SendContext = {
1044
+ webhookUrl: acctCfg.webhookUrl,
1045
+ bot: gatewayState.bot,
1046
+ dialogId: params.id,
1047
+ };
365
1048
  try {
366
- await api.sendMessage(acctCfg.webhookUrl, params.id, '\u2705 OpenClaw access approved.');
367
- } finally {
368
- api.destroy();
1049
+ await gatewayState.sendService.sendText(sendCtx, '\u2705 OpenClaw access approved.');
1050
+ } catch (err) {
1051
+ defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
369
1052
  }
370
1053
  },
371
1054
  } satisfies ChannelPairingAdapter,
372
1055
 
373
1056
  outbound: {
374
1057
  deliveryMode: 'direct' as const,
1058
+ textChunkLimit: 4000,
375
1059
 
376
- /**
377
- * Send a text message to B24 via the bot.
378
- * Called by OpenClaw when the agent produces a response.
379
- */
380
- sendText: async (params: {
1060
+ sendText: async (ctx: {
1061
+ cfg: Record<string, unknown>;
1062
+ to: string;
1063
+ accountId?: string;
381
1064
  text: string;
382
- context: B24MsgContext;
383
- account: { config: { webhookUrl?: string; showTyping?: boolean } };
1065
+ [key: string]: unknown;
384
1066
  }) => {
385
- const { text, context, account } = params;
1067
+ const sendCtx = resolveOutboundSendCtx(ctx);
1068
+ if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
1069
+ const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
1070
+ return { messageId: String(result.messageId ?? '') };
1071
+ },
386
1072
 
387
- if (!gatewayState) {
388
- return { ok: false, error: 'Gateway not started', channel: 'bitrix24' };
1073
+ sendMedia: async (ctx: {
1074
+ cfg: Record<string, unknown>;
1075
+ to: string;
1076
+ accountId?: string;
1077
+ text: string;
1078
+ mediaUrl?: string;
1079
+ [key: string]: unknown;
1080
+ }) => {
1081
+ const sendCtx = resolveOutboundSendCtx(ctx);
1082
+ if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
1083
+
1084
+ const mediaUrls = collectOutboundMediaUrls({ mediaUrl: ctx.mediaUrl });
1085
+ if (mediaUrls.length > 0) {
1086
+ const messageId = await uploadOutboundMedia({
1087
+ sendCtx,
1088
+ mediaUrls,
1089
+ text: ctx.text,
1090
+ });
1091
+ return { messageId };
389
1092
  }
390
1093
 
391
- const { sendService } = gatewayState;
1094
+ if (ctx.text) {
1095
+ const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
1096
+ return { messageId: String(result.messageId ?? '') };
1097
+ }
1098
+ return { messageId: '' };
1099
+ },
392
1100
 
393
- const sendCtx = {
394
- webhookUrl: account.config.webhookUrl,
395
- clientEndpoint: context.clientEndpoint,
396
- botToken: context.botToken,
397
- dialogId: context.chatId,
398
- };
1101
+ sendPayload: async (ctx: {
1102
+ cfg: Record<string, unknown>;
1103
+ to: string;
1104
+ accountId?: string;
1105
+ text: string;
1106
+ mediaUrl?: string;
1107
+ payload?: { channelData?: Record<string, unknown>; [key: string]: unknown };
1108
+ [key: string]: unknown;
1109
+ }) => {
1110
+ const sendCtx = resolveOutboundSendCtx(ctx);
1111
+ if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
1112
+
1113
+ const keyboard = ctx.payload?.channelData
1114
+ ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
1115
+ : undefined;
1116
+ const mediaUrls = collectOutboundMediaUrls({
1117
+ mediaUrl: ctx.mediaUrl,
1118
+ payload: ctx.payload as { mediaUrl?: string; mediaUrls?: string[] } | undefined,
1119
+ });
1120
+
1121
+ if (mediaUrls.length > 0) {
1122
+ const uploadedMessageId = await uploadOutboundMedia({
1123
+ sendCtx,
1124
+ mediaUrls,
1125
+ });
1126
+
1127
+ if (ctx.text) {
1128
+ const result = await gatewayState.sendService.sendText(
1129
+ sendCtx,
1130
+ ctx.text,
1131
+ keyboard ? { keyboard } : undefined,
1132
+ );
1133
+ return { messageId: String(result.messageId ?? uploadedMessageId) };
1134
+ }
1135
+
1136
+ return { messageId: uploadedMessageId };
1137
+ }
399
1138
 
400
- // Send typing indicator
401
- if (account.config.showTyping !== false) {
402
- await sendService.sendTyping(sendCtx);
1139
+ if (ctx.text) {
1140
+ const result = await gatewayState.sendService.sendText(
1141
+ sendCtx,
1142
+ ctx.text,
1143
+ keyboard ? { keyboard } : undefined,
1144
+ );
1145
+ return { messageId: String(result.messageId ?? '') };
403
1146
  }
1147
+ return { messageId: '' };
1148
+ },
1149
+ },
404
1150
 
405
- // Send the response
406
- const result = await sendService.sendText(sendCtx, text);
1151
+ // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
407
1152
 
408
- return {
409
- ok: result.ok,
410
- messageId: result.messageId,
411
- channel: 'bitrix24' as const,
412
- error: result.error,
413
- };
1153
+ actions: {
1154
+ listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
1155
+ return ['react'];
414
1156
  },
415
1157
 
416
- /**
417
- * Send a payload with optional channelData (keyboards, etc.) to B24.
418
- * Called by OpenClaw when the response includes channelData.
419
- */
420
- sendPayload: async (params: {
421
- text: string;
422
- channelData?: Record<string, unknown>;
423
- context: B24MsgContext;
424
- account: { config: { webhookUrl?: string; showTyping?: boolean } };
425
- }) => {
426
- const { text, channelData, context, account } = params;
1158
+ supportsAction: (params: { action: string }): boolean => {
1159
+ return params.action === 'react';
1160
+ },
427
1161
 
428
- if (!gatewayState) {
429
- return { ok: false, error: 'Gateway not started', channel: 'bitrix24' };
1162
+ handleAction: async (ctx: {
1163
+ action: string;
1164
+ channel: string;
1165
+ cfg: Record<string, unknown>;
1166
+ accountId?: string;
1167
+ params: Record<string, unknown>;
1168
+ [key: string]: unknown;
1169
+ }): Promise<Record<string, unknown> | null> => {
1170
+ if (ctx.action !== 'react') return null;
1171
+
1172
+ // Helper: wrap payload as gateway-compatible tool result
1173
+ const toolResult = (payload: Record<string, unknown>) => ({
1174
+ content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
1175
+ details: payload,
1176
+ });
1177
+
1178
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1179
+ if (!config.webhookUrl || !gatewayState) {
1180
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
430
1181
  }
431
1182
 
432
- const { sendService } = gatewayState;
1183
+ const bot = gatewayState.bot;
1184
+ const api = gatewayState.api;
1185
+ const params = ctx.params;
433
1186
 
434
- const sendCtx = {
435
- webhookUrl: account.config.webhookUrl,
436
- clientEndpoint: context.clientEndpoint,
437
- botToken: context.botToken,
438
- dialogId: context.chatId,
439
- };
1187
+ // Resolve messageId: explicit param → toolContext.currentMessageId fallback
1188
+ const toolContext = (ctx as Record<string, unknown>).toolContext as
1189
+ | { currentMessageId?: string | number } | undefined;
1190
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
1191
+ const messageId = Number(rawMessageId);
440
1192
 
441
- // Send typing indicator
442
- if (account.config.showTyping !== false) {
443
- await sendService.sendTyping(sendCtx);
1193
+ if (!Number.isFinite(messageId) || messageId <= 0) {
1194
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
444
1195
  }
445
1196
 
446
- // Extract keyboard from channelData
447
- const keyboard = channelData
448
- ? extractKeyboardFromPayload({ channelData })
449
- : undefined;
1197
+ const emoji = String(params.emoji ?? '').trim();
1198
+ const remove = params.remove === true || params.remove === 'true';
1199
+
1200
+ if (remove) {
1201
+ // Remove reaction — need to know which one
1202
+ const reactionCode = emoji ? resolveB24Reaction(emoji) : null;
1203
+ if (!reactionCode) {
1204
+ return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required to remove a Bitrix24 reaction.' });
1205
+ }
1206
+ try {
1207
+ await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
1208
+ return toolResult({ ok: true, removed: true });
1209
+ } catch (err) {
1210
+ const errMsg = err instanceof Error ? err.message : String(err);
1211
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to remove reaction: ${errMsg}. Do not retry.` });
1212
+ }
1213
+ }
450
1214
 
451
- const result = await sendService.sendText(
452
- sendCtx,
453
- text,
454
- keyboard ? { keyboard } : undefined,
455
- );
1215
+ // Add reaction
1216
+ if (!emoji) {
1217
+ return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required for Bitrix24 reactions.' });
1218
+ }
456
1219
 
457
- return {
458
- ok: result.ok,
459
- messageId: result.messageId,
460
- channel: 'bitrix24' as const,
461
- error: result.error,
462
- };
1220
+ const reactionCode = resolveB24Reaction(emoji);
1221
+ if (!reactionCode) {
1222
+ return toolResult({
1223
+ ok: false,
1224
+ reason: 'REACTION_NOT_FOUND',
1225
+ emoji,
1226
+ hint: `Emoji "${emoji}" is not supported for Bitrix24 reactions. Add it to your reaction disallow list so you do not try it again.`,
1227
+ });
1228
+ }
1229
+
1230
+ try {
1231
+ await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
1232
+ return toolResult({ ok: true, added: emoji });
1233
+ } catch (err) {
1234
+ const errMsg = err instanceof Error ? err.message : String(err);
1235
+ const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
1236
+ if (isAlreadySet) {
1237
+ return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
1238
+ }
1239
+ return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
1240
+ }
463
1241
  },
464
1242
  },
465
1243
 
466
1244
  gateway: {
467
- /**
468
- * Start a channel account. Called by OpenClaw for each configured account.
469
- */
470
1245
  startAccount: async (ctx: {
471
1246
  cfg: Record<string, unknown>;
472
1247
  accountId: string;
@@ -477,117 +1252,142 @@ export const bitrix24Plugin = {
477
1252
  setStatus?: (status: Record<string, unknown>) => void;
478
1253
  }) => {
479
1254
  const logger = ctx.log ?? defaultLogger;
480
- const config = getConfig(ctx.cfg);
1255
+
1256
+ // Guard: only one account can run at a time (singleton gateway)
1257
+ if (gatewayState !== null) {
1258
+ throw new Error(
1259
+ `Bitrix24 channel already started for account "${gatewayState.accountId}". ` +
1260
+ `Cannot start account "${ctx.accountId}" concurrently.`,
1261
+ );
1262
+ }
1263
+
1264
+ const config = getConfig(ctx.cfg, ctx.accountId);
481
1265
 
482
1266
  if (!config.webhookUrl) {
483
1267
  logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
484
1268
  return;
485
1269
  }
1270
+ // Safe to use without ! after this point
1271
+ const webhookUrl: string = config.webhookUrl;
486
1272
 
487
1273
  logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
488
1274
 
489
- // Derive CLIENT_ID from webhookUrl (md5) stable and unique per portal
490
- const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
491
- const api = new Bitrix24Api({ logger, clientId });
492
- const sendService = new SendService(api, logger);
493
- const mediaService = new MediaService(api, logger);
494
-
495
- // Register or update bot on the B24 portal
496
- const botId = await ensureBotRegistered(api, config, logger);
497
-
498
- // Register slash commands (runs in background, doesn't block startup)
499
- if (botId) {
500
- ensureCommandsRegistered(api, config, botId, logger).catch((err) => {
501
- logger.warn('Command registration failed', err);
502
- });
1275
+ const api = new Bitrix24Api({ logger });
1276
+ const botToken = resolveBotToken(config);
1277
+ if (!botToken) {
1278
+ logger.error(`[${ctx.accountId}] cannot derive botToken webhookUrl is missing`);
1279
+ api.destroy();
1280
+ return;
1281
+ }
1282
+ const welcomedDialogs = new Set<string>();
1283
+ const deniedDialogs = new Map<string, number>();
1284
+
1285
+ // Cleanup stale denied dialog entries once per day
1286
+ const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
1287
+ const deniedCleanupTimer = setInterval(() => {
1288
+ deniedDialogs.clear();
1289
+ }, DENIED_CLEANUP_INTERVAL_MS);
1290
+ if (deniedCleanupTimer && typeof deniedCleanupTimer === 'object' && 'unref' in deniedCleanupTimer) {
1291
+ deniedCleanupTimer.unref();
503
1292
  }
504
1293
 
505
- const inboundHandler = new InboundHandler({
506
- config,
507
- logger,
1294
+ // Determine event mode
1295
+ const eventMode = resolveEventMode(config);
1296
+ logger.info(`[${ctx.accountId}] event mode: ${eventMode}`);
508
1297
 
509
- onMessage: async (msgCtx: B24MsgContext) => {
510
- logger.info('Inbound message', {
511
- senderId: msgCtx.senderId,
512
- chatId: msgCtx.chatId,
513
- messageId: msgCtx.messageId,
514
- textLen: msgCtx.text.length,
515
- });
1298
+ // Register or update bot on the B24 portal (V2 API)
1299
+ const botRegistration = await ensureBotRegistered(api, config, botToken, eventMode, logger);
516
1300
 
517
- const runtime = getBitrix24Runtime();
518
- const cfg = runtime.config.loadConfig();
1301
+ if (!botRegistration) {
1302
+ logger.error(`[${ctx.accountId}] bot registration failed, cannot start`);
1303
+ clearInterval(deniedCleanupTimer);
1304
+ api.destroy();
1305
+ return;
1306
+ }
519
1307
 
520
- // Pairing-aware access control
521
- const accessResult = await checkAccessWithPairing({
522
- senderId: msgCtx.senderId,
1308
+ const bot: BotContext = { botId: botRegistration.botId, botToken };
1309
+
1310
+ // Sync user event subscription with agent mode setting
1311
+ if (eventMode === 'fetch') {
1312
+ try {
1313
+ if (config.agentMode) {
1314
+ await api.subscribeUserEvents(webhookUrl);
1315
+ logger.info('User events subscription active (agent mode)');
1316
+ } else {
1317
+ await api.unsubscribeUserEvents(webhookUrl);
1318
+ logger.debug('User events unsubscribed (agent mode off)');
1319
+ }
1320
+ } catch (err) {
1321
+ logger.warn('Failed to sync user events subscription', err);
1322
+ }
1323
+ }
1324
+
1325
+ const sendService = new SendService(api, logger);
1326
+ const mediaService = new MediaService(api, logger);
1327
+ const processAllowedMessage = async (msgCtx: B24MsgContext): Promise<void> => {
1328
+ const runtime = getBitrix24Runtime();
1329
+ const cfg = runtime.config.loadConfig();
1330
+ const sendCtx: SendContext = {
1331
+ webhookUrl,
1332
+ bot,
1333
+ dialogId: msgCtx.chatId,
1334
+ };
1335
+ let downloadedMedia: DownloadedMedia[] = [];
1336
+
1337
+ try {
1338
+ const replyStatusHeartbeat = createReplyStatusHeartbeat({
1339
+ sendService,
1340
+ sendCtx,
523
1341
  config,
524
- runtime,
525
- accountId: ctx.accountId,
526
- pairingAdapter: bitrix24Plugin.pairing,
527
- sendReply: async (text: string) => {
528
- const replySendCtx = {
529
- webhookUrl: config.webhookUrl,
530
- clientEndpoint: msgCtx.clientEndpoint,
531
- botToken: msgCtx.botToken,
532
- dialogId: msgCtx.chatId,
533
- };
534
- await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
535
- },
536
- logger,
537
1342
  });
538
1343
 
539
- if (accessResult !== 'allow') {
540
- logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
541
- return;
542
- }
543
-
544
1344
  // Download media files if present
545
1345
  let mediaFields: Record<string, unknown> = {};
546
1346
  if (msgCtx.media.length > 0) {
547
- const downloaded = (await Promise.all(
548
- msgCtx.media.map((m) =>
549
- mediaService.downloadMedia({
550
- fileId: m.id,
551
- fileName: m.name,
552
- extension: m.extension,
553
- clientEndpoint: msgCtx.clientEndpoint,
554
- userToken: msgCtx.userToken,
555
- webhookUrl: config.webhookUrl,
556
- }),
557
- ),
1347
+ await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_PROCESSING');
1348
+ replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
1349
+
1350
+ downloadedMedia = (await mapWithConcurrency(
1351
+ msgCtx.media,
1352
+ MEDIA_DOWNLOAD_CONCURRENCY,
1353
+ (mediaItem) => mediaService.downloadMedia({
1354
+ fileId: mediaItem.id,
1355
+ fileName: mediaItem.name,
1356
+ extension: mediaItem.extension,
1357
+ webhookUrl,
1358
+ bot,
1359
+ dialogId: msgCtx.chatId,
1360
+ }),
558
1361
  )).filter(Boolean) as DownloadedMedia[];
559
1362
 
560
- if (downloaded.length > 0) {
1363
+ if (downloadedMedia.length > 0) {
561
1364
  mediaFields = {
562
- MediaPath: downloaded[0].path,
563
- MediaType: downloaded[0].contentType,
564
- MediaUrl: downloaded[0].path,
565
- MediaPaths: downloaded.map((m) => m.path),
566
- MediaUrls: downloaded.map((m) => m.path),
567
- MediaTypes: downloaded.map((m) => m.contentType),
1365
+ MediaPath: downloadedMedia[0].path,
1366
+ MediaType: downloadedMedia[0].contentType,
1367
+ MediaUrl: downloadedMedia[0].path,
1368
+ MediaPaths: downloadedMedia.map((mediaItem) => mediaItem.path),
1369
+ MediaUrls: downloadedMedia.map((mediaItem) => mediaItem.path),
1370
+ MediaTypes: downloadedMedia.map((mediaItem) => mediaItem.contentType),
568
1371
  };
569
1372
  } else {
570
- // All file downloads failed notify the user
571
- const fileNames = msgCtx.media.map((m) => m.name).join(', ');
1373
+ const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
572
1374
  logger.warn('All media downloads failed, notifying user', { fileNames });
573
- const errSendCtx = {
574
- webhookUrl: config.webhookUrl,
575
- clientEndpoint: msgCtx.clientEndpoint,
576
- botToken: msgCtx.botToken,
577
- dialogId: msgCtx.chatId,
578
- };
579
1375
  await sendService.sendText(
580
- errSendCtx,
581
- mediaDownloadFailedMsg(msgCtx.language, fileNames),
1376
+ sendCtx,
1377
+ mediaDownloadFailed(msgCtx.language, fileNames),
582
1378
  );
583
1379
  return;
584
1380
  }
1381
+ } else {
1382
+ await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_ANALYZING');
1383
+ replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
585
1384
  }
586
1385
 
587
1386
  // Use placeholder body for media-only messages
588
1387
  let body = msgCtx.text;
589
1388
  if (!body && msgCtx.media.length > 0) {
590
- const hasImage = msgCtx.media.some((m) => m.type === 'image');
1389
+ const hasImage = downloadedMedia.some((mediaItem) => mediaItem.contentType.startsWith('image/'))
1390
+ || msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
591
1391
  body = hasImage ? '<media:image>' : '<media:document>';
592
1392
  }
593
1393
 
@@ -608,7 +1408,6 @@ export const bitrix24Plugin = {
608
1408
  matchedBy: route.matchedBy,
609
1409
  });
610
1410
 
611
- // Build and finalize inbound context for OpenClaw agent
612
1411
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
613
1412
  Body: body,
614
1413
  BodyForAgent: body,
@@ -632,41 +1431,34 @@ export const bitrix24Plugin = {
632
1431
  ...mediaFields,
633
1432
  });
634
1433
 
635
- const sendCtx = {
636
- webhookUrl: config.webhookUrl,
637
- clientEndpoint: msgCtx.clientEndpoint,
638
- botToken: msgCtx.botToken,
639
- dialogId: msgCtx.chatId,
640
- };
641
-
642
- // Dispatch to AI agent; deliver callback sends reply back to B24
643
1434
  try {
644
1435
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
645
1436
  ctx: inboundCtx,
646
1437
  cfg,
647
1438
  dispatcherOptions: {
648
1439
  deliver: async (payload) => {
649
- // Send media if present in reply
1440
+ await replyStatusHeartbeat.stopAndWait();
650
1441
  const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
651
1442
  for (const mediaUrl of mediaUrls) {
652
- await mediaService.uploadMediaToChat({
1443
+ const uploadResult = await mediaService.uploadMediaToChat({
653
1444
  localPath: mediaUrl,
654
1445
  fileName: basename(mediaUrl),
655
- chatId: Number(msgCtx.chatInternalId),
656
- clientEndpoint: msgCtx.clientEndpoint,
657
- botToken: msgCtx.botToken,
1446
+ webhookUrl,
1447
+ bot,
1448
+ dialogId: msgCtx.chatId,
658
1449
  });
1450
+
1451
+ if (!uploadResult.ok) {
1452
+ throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
1453
+ }
659
1454
  }
660
- // Send text if present
661
1455
  if (payload.text) {
662
1456
  const keyboard = extractKeyboardFromPayload(payload);
663
1457
  await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
664
1458
  }
665
1459
  },
666
1460
  onReplyStart: async () => {
667
- if (config.showTyping !== false) {
668
- await sendService.sendTyping(sendCtx);
669
- }
1461
+ await replyStatusHeartbeat.start();
670
1462
  },
671
1463
  onError: (err) => {
672
1464
  logger.error('Error delivering reply to B24', err);
@@ -674,29 +1466,205 @@ export const bitrix24Plugin = {
674
1466
  },
675
1467
  });
676
1468
  } catch (err) {
677
- logger.error('Error dispatching message to agent', err);
1469
+ logger.error('Error dispatching message to agent', { senderId: msgCtx.senderId, chatId: msgCtx.chatId, error: err });
1470
+ } finally {
1471
+ replyStatusHeartbeat.stop();
678
1472
  }
679
- },
1473
+ } finally {
1474
+ await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
1475
+ }
1476
+ };
1477
+ const directTextCoalescer = new BufferedDirectMessageCoalescer({
1478
+ debounceMs: DIRECT_TEXT_COALESCE_DEBOUNCE_MS,
1479
+ maxWaitMs: DIRECT_TEXT_COALESCE_MAX_WAIT_MS,
1480
+ onFlush: processAllowedMessage,
1481
+ logger,
1482
+ });
1483
+ const maybeNotifyDeniedDialog = async (
1484
+ dialogId: string,
1485
+ language: string | undefined,
1486
+ sendCtx: SendContext,
1487
+ ): Promise<void> => {
1488
+ const now = Date.now();
1489
+ const lastSentAt = deniedDialogs.get(dialogId) ?? 0;
1490
+ if ((now - lastSentAt) < ACCESS_DENIED_NOTICE_COOLDOWN_MS) {
1491
+ return;
1492
+ }
1493
+
1494
+ deniedDialogs.set(dialogId, now);
1495
+ try {
1496
+ await sendService.sendText(
1497
+ sendCtx,
1498
+ personalBotOwnerOnly(language),
1499
+ { convertMarkdown: false },
1500
+ );
1501
+ } catch (err) {
1502
+ logger.warn('Failed to send access denied notice', err);
1503
+ }
1504
+ };
1505
+
1506
+ // Register slash commands (runs in background)
1507
+ ensureCommandsRegistered(api, config, bot, logger).catch((err) => {
1508
+ logger.warn('Command registration failed', err);
1509
+ });
1510
+
1511
+ if (botRegistration.isNew) {
1512
+ await sendInitialWelcomeToWebhookOwner({
1513
+ config,
1514
+ bot,
1515
+ sendService,
1516
+ language: botRegistration.language,
1517
+ welcomedDialogs,
1518
+ logger,
1519
+ });
1520
+ }
1521
+
1522
+ const inboundHandler = new InboundHandler({
1523
+ config,
1524
+ logger,
1525
+
1526
+ onMessage: async (msgCtx: B24MsgContext) => {
1527
+ logger.info('Inbound message', {
1528
+ senderId: msgCtx.senderId,
1529
+ chatId: msgCtx.chatId,
1530
+ messageId: msgCtx.messageId,
1531
+ textLen: msgCtx.text.length,
1532
+ });
1533
+
1534
+ const pendingForwardContext = msgCtx.isForwarded
1535
+ ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
1536
+ : null;
680
1537
 
681
- onCommand: async (event: B24CommandEvent) => {
682
- const cmdEntry = Object.values(event.data.COMMAND)[0];
683
- if (!cmdEntry) {
684
- logger.warn('No command entry in ONIMCOMMANDADD event');
1538
+ if (msgCtx.isGroup) {
1539
+ logger.warn('Group chat is not supported, leaving chat', {
1540
+ chatId: msgCtx.chatId,
1541
+ senderId: msgCtx.senderId,
1542
+ });
1543
+
1544
+ try {
1545
+ await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
1546
+ } catch (err) {
1547
+ logger.error('Failed to leave group chat after message', err);
1548
+ }
685
1549
  return;
686
1550
  }
687
1551
 
688
- const commandName = cmdEntry.COMMAND;
689
- const commandParams = cmdEntry.COMMAND_PARAMS?.trim() ?? '';
690
- const commandText = commandParams
691
- ? `/${commandName} ${commandParams}`
692
- : `/${commandName}`;
1552
+ const runtime = getBitrix24Runtime();
1553
+
1554
+ // Pairing-aware access control
1555
+ const sendCtx: SendContext = {
1556
+ webhookUrl,
1557
+ bot,
1558
+ dialogId: msgCtx.chatId,
1559
+ };
1560
+
1561
+ const accessResult = await checkAccessWithPairing({
1562
+ senderId: msgCtx.senderId,
1563
+ dialogId: msgCtx.chatId,
1564
+ isDirect: msgCtx.isDm,
1565
+ config,
1566
+ runtime,
1567
+ accountId: ctx.accountId,
1568
+ pairingAdapter: bitrix24Plugin.pairing,
1569
+ sendReply: async (text: string) => {
1570
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1571
+ },
1572
+ logger,
1573
+ });
693
1574
 
694
- const senderId = String(event.data.PARAMS.FROM_USER_ID);
695
- const dialogId = event.data.PARAMS.DIALOG_ID;
696
- const isDm = event.data.PARAMS.CHAT_TYPE === 'P';
697
- const user = event.data.USER;
1575
+ if (accessResult === 'deny') {
1576
+ await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1577
+ await maybeNotifyDeniedDialog(msgCtx.chatId, msgCtx.language, sendCtx);
1578
+ logger.debug('Message blocked (deny)', { senderId: msgCtx.senderId, chatId: msgCtx.chatId });
1579
+ return;
1580
+ }
698
1581
 
699
- logger.info('Inbound command', { commandName, commandParams, senderId, dialogId });
1582
+ await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1583
+ await sendService.sendTyping(sendCtx);
1584
+
1585
+ if (accessResult !== 'allow') {
1586
+ logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
1587
+ return;
1588
+ }
1589
+
1590
+ if (msgCtx.isForwarded) {
1591
+ if (pendingForwardContext) {
1592
+ logger.info('Merging forwarded message with buffered context', {
1593
+ senderId: msgCtx.senderId,
1594
+ chatId: msgCtx.chatId,
1595
+ previousMessageId: pendingForwardContext.messageId,
1596
+ forwardedMessageId: msgCtx.messageId,
1597
+ });
1598
+ await processAllowedMessage(mergeForwardedMessageContext(pendingForwardContext, msgCtx));
1599
+ return;
1600
+ }
1601
+
1602
+ logger.info('Forwarded message is not supported yet', {
1603
+ senderId: msgCtx.senderId,
1604
+ chatId: msgCtx.chatId,
1605
+ messageId: msgCtx.messageId,
1606
+ });
1607
+ await sendService.sendText(
1608
+ sendCtx,
1609
+ forwardedMessageUnsupported(msgCtx.language),
1610
+ { convertMarkdown: false },
1611
+ );
1612
+ return;
1613
+ }
1614
+
1615
+ if (msgCtx.replyToMessageId) {
1616
+ logger.info('Reply-to-message is not supported yet', {
1617
+ senderId: msgCtx.senderId,
1618
+ chatId: msgCtx.chatId,
1619
+ messageId: msgCtx.messageId,
1620
+ replyToMessageId: msgCtx.replyToMessageId,
1621
+ });
1622
+ await sendService.sendText(
1623
+ sendCtx,
1624
+ replyMessageUnsupported(msgCtx.language),
1625
+ { convertMarkdown: false },
1626
+ );
1627
+ return;
1628
+ }
1629
+
1630
+ if (canCoalesceDirectMessage(msgCtx, config)) {
1631
+ directTextCoalescer.enqueue(ctx.accountId, msgCtx);
1632
+ return;
1633
+ }
1634
+
1635
+ await directTextCoalescer.flush(ctx.accountId, msgCtx.chatId);
1636
+ await processAllowedMessage(msgCtx);
1637
+ },
1638
+
1639
+ onCommand: async (cmdCtx: FetchCommandContext) => {
1640
+ const {
1641
+ commandId,
1642
+ commandName,
1643
+ commandParams,
1644
+ commandText,
1645
+ senderId,
1646
+ dialogId,
1647
+ chatType,
1648
+ messageId,
1649
+ } = cmdCtx;
1650
+ const isDm = chatType === 'P';
1651
+ const peerId = isDm ? senderId : dialogId;
1652
+
1653
+ logger.info('Inbound command', {
1654
+ commandId,
1655
+ commandName,
1656
+ commandParams,
1657
+ commandText,
1658
+ senderId,
1659
+ dialogId,
1660
+ peerId,
1661
+ });
1662
+
1663
+ const sendCtx: SendContext = {
1664
+ webhookUrl,
1665
+ bot,
1666
+ dialogId: peerId,
1667
+ };
700
1668
 
701
1669
  let runtime;
702
1670
  let cfg;
@@ -708,16 +1676,35 @@ export const bitrix24Plugin = {
708
1676
  return;
709
1677
  }
710
1678
 
711
- // Pairing-aware access control (commands don't send pairing replies)
1679
+ const commandMessageId = toMessageId(messageId);
1680
+ const commandSendCtx: CommandSendContext | null = commandMessageId
1681
+ ? {
1682
+ ...sendCtx,
1683
+ commandId,
1684
+ messageId: commandMessageId,
1685
+ commandDialogId: dialogId,
1686
+ }
1687
+ : null;
1688
+
1689
+ // Access control
712
1690
  let accessResult;
713
1691
  try {
714
1692
  accessResult = await checkAccessWithPairing({
715
1693
  senderId,
1694
+ dialogId,
1695
+ isDirect: isDm,
716
1696
  config,
717
1697
  runtime,
718
1698
  accountId: ctx.accountId,
719
1699
  pairingAdapter: bitrix24Plugin.pairing,
720
- sendReply: async () => {},
1700
+ sendReply: async (text: string) => {
1701
+ if (commandSendCtx) {
1702
+ await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
1703
+ return;
1704
+ }
1705
+
1706
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1707
+ },
721
1708
  logger,
722
1709
  });
723
1710
  } catch (err) {
@@ -725,24 +1712,54 @@ export const bitrix24Plugin = {
725
1712
  return;
726
1713
  }
727
1714
 
1715
+ if (!commandMessageId || !commandSendCtx) {
1716
+ logger.warn('Command event has invalid messageId, skipping response', { commandId, messageId, dialogId });
1717
+ return;
1718
+ }
1719
+ const canMarkRead = dialogId === peerId;
1720
+
1721
+ await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
1722
+
1723
+ if (accessResult === 'deny') {
1724
+ if (canMarkRead) {
1725
+ await sendService.markRead(sendCtx, commandMessageId);
1726
+ }
1727
+ await sendService.answerCommandText(
1728
+ commandSendCtx,
1729
+ personalBotOwnerOnly(cmdCtx.language),
1730
+ { convertMarkdown: false },
1731
+ );
1732
+ logger.debug('Command blocked (deny)', { senderId, dialogId });
1733
+ return;
1734
+ }
1735
+
1736
+ if (canMarkRead) {
1737
+ await sendService.markRead(sendCtx, commandMessageId);
1738
+ }
1739
+
728
1740
  if (accessResult !== 'allow') {
729
1741
  logger.debug(`Command blocked (${accessResult})`, { senderId });
730
1742
  return;
731
1743
  }
732
1744
 
733
- logger.debug('Command access allowed, resolving route', { commandText });
1745
+ await directTextCoalescer.flush(ctx.accountId, peerId);
1746
+
1747
+ if (commandName === 'help' || commandName === 'commands') {
1748
+ await sendService.sendText(
1749
+ sendCtx,
1750
+ buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' }),
1751
+ { keyboard: DEFAULT_COMMAND_KEYBOARD, convertMarkdown: false },
1752
+ );
1753
+ return;
1754
+ }
734
1755
 
735
1756
  const route = runtime.channel.routing.resolveAgentRoute({
736
1757
  cfg,
737
1758
  channel: 'bitrix24',
738
1759
  accountId: ctx.accountId,
739
- peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
1760
+ peer: { kind: isDm ? 'direct' : 'group', id: peerId },
740
1761
  });
741
1762
 
742
- logger.debug('Command route resolved', { sessionKey: route.sessionKey });
743
-
744
- // Each command invocation gets a unique ephemeral session
745
- // so the gateway doesn't treat it as "already handled".
746
1763
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
747
1764
 
748
1765
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
@@ -752,130 +1769,206 @@ export const bitrix24Plugin = {
752
1769
  CommandBody: commandText,
753
1770
  CommandAuthorized: true,
754
1771
  CommandSource: 'native',
755
- CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${dialogId}`,
756
- From: `bitrix24:${dialogId}`,
1772
+ CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${peerId}`,
1773
+ From: `bitrix24:${peerId}`,
757
1774
  To: `slash:${senderId}`,
758
1775
  SessionKey: slashSessionKey,
759
1776
  AccountId: route.accountId,
760
1777
  ChatType: isDm ? 'direct' : 'group',
761
- ConversationLabel: user.NAME,
762
- SenderName: user.NAME,
1778
+ ConversationLabel: senderId,
1779
+ SenderName: senderId,
763
1780
  SenderId: senderId,
764
1781
  Provider: 'bitrix24',
765
1782
  Surface: 'bitrix24',
766
- MessageSid: String(event.data.PARAMS.MESSAGE_ID),
1783
+ MessageSid: messageId,
767
1784
  Timestamp: Date.now(),
768
1785
  WasMentioned: true,
769
1786
  OriginatingChannel: 'bitrix24',
770
- OriginatingTo: `bitrix24:${dialogId}`,
1787
+ OriginatingTo: `bitrix24:${peerId}`,
771
1788
  });
772
1789
 
773
- const sendCtx = {
774
- webhookUrl: config.webhookUrl,
775
- clientEndpoint: (cmdEntry.client_endpoint as string | undefined) ?? event.auth.client_endpoint,
776
- botToken: cmdEntry.access_token,
777
- dialogId,
778
- };
779
-
780
- logger.debug('Dispatching command to agent', { commandText, slashSessionKey });
1790
+ const replyStatusHeartbeat = createReplyStatusHeartbeat({
1791
+ sendService,
1792
+ sendCtx,
1793
+ config,
1794
+ });
1795
+ let commandReplyDelivered = false;
781
1796
 
782
1797
  try {
1798
+ await replyStatusHeartbeat.start();
783
1799
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
784
1800
  ctx: inboundCtx,
785
1801
  cfg,
786
1802
  dispatcherOptions: {
787
1803
  deliver: async (payload) => {
788
- logger.debug('Command deliver callback', {
789
- hasText: !!payload.text,
790
- textLen: payload.text?.length ?? 0,
791
- hasMedia: !!(payload.mediaUrl || payload.mediaUrls?.length),
792
- });
1804
+ await replyStatusHeartbeat.stopAndWait();
793
1805
  if (payload.text) {
794
- // Use agent-provided keyboard if any, otherwise re-attach default command keyboard
795
1806
  const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
796
- await sendService.sendText(sendCtx, payload.text, { keyboard });
1807
+ const formattedPayload = normalizeCommandReplyPayload({
1808
+ commandName,
1809
+ commandParams,
1810
+ text: payload.text,
1811
+ language: cmdCtx.language,
1812
+ });
1813
+ if (!commandReplyDelivered) {
1814
+ commandReplyDelivered = true;
1815
+ if (isDm) {
1816
+ await sendService.sendText(sendCtx, formattedPayload.text, {
1817
+ keyboard,
1818
+ convertMarkdown: formattedPayload.convertMarkdown,
1819
+ });
1820
+ return;
1821
+ }
1822
+
1823
+ await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
1824
+ keyboard,
1825
+ convertMarkdown: formattedPayload.convertMarkdown,
1826
+ });
1827
+ return;
1828
+ }
1829
+
1830
+ await sendService.sendText(sendCtx, formattedPayload.text, {
1831
+ keyboard,
1832
+ convertMarkdown: formattedPayload.convertMarkdown,
1833
+ });
797
1834
  }
798
1835
  },
799
1836
  onReplyStart: async () => {
800
- if (config.showTyping !== false) {
801
- await sendService.sendTyping(sendCtx);
802
- }
1837
+ await replyStatusHeartbeat.start();
803
1838
  },
804
1839
  onError: (err) => {
805
1840
  logger.error('Error delivering command reply to B24', err);
806
1841
  },
807
1842
  },
808
1843
  });
809
- logger.debug('Command dispatch completed', { commandText });
810
1844
  } catch (err) {
811
- logger.error('Error dispatching command to agent', err);
1845
+ logger.error('Error dispatching command to agent', { commandName, senderId, dialogId, error: err });
1846
+ } finally {
1847
+ replyStatusHeartbeat.stop();
812
1848
  }
813
1849
  },
814
1850
 
815
- onJoinChat: async (event: B24JoinChatEvent) => {
816
- const dialogId = event.data.PARAMS.DIALOG_ID;
817
- const botEntry = Object.values(event.data.BOT)[0];
818
- logger.info('Bot joined chat', {
819
- dialogId,
820
- userId: event.data.PARAMS.USER_ID,
821
- hasBotEntry: !!botEntry,
822
- botId: botEntry?.BOT_ID,
823
- hasEndpoint: !!botEntry?.client_endpoint,
824
- hasToken: !!botEntry?.access_token,
825
- });
1851
+ onJoinChat: async (joinCtx: FetchJoinChatContext) => {
1852
+ const { dialogId, chatType, language } = joinCtx;
1853
+ logger.info('Bot joined chat', { dialogId, chatType });
1854
+
826
1855
  if (!dialogId) return;
827
1856
 
828
- const welcomeText = `${config.botName ?? 'OpenClaw'} ready. Send me a message or pick a command below.`;
829
- const welcomeOpts = { KEYBOARD: DEFAULT_COMMAND_KEYBOARD };
1857
+ if (shouldSkipJoinChatWelcome({
1858
+ dialogId,
1859
+ chatType,
1860
+ webhookUrl,
1861
+ dmPolicy: config.dmPolicy,
1862
+ })) {
1863
+ logger.info('Skipping welcome for non-owner dialog in webhookUser mode', { dialogId });
1864
+ return;
1865
+ }
830
1866
 
831
- try {
832
- // Prefer token-based call; fall back to webhook URL
833
- if (botEntry?.client_endpoint && botEntry?.access_token) {
834
- await api.sendMessageWithToken(
835
- botEntry.client_endpoint,
836
- botEntry.access_token,
837
- dialogId,
838
- welcomeText,
839
- welcomeOpts,
840
- );
841
- } else if (config.webhookUrl) {
842
- await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
843
- } else {
844
- logger.warn('No way to send welcome message — no token and no webhookUrl');
845
- return;
1867
+ const sendCtx: SendContext = {
1868
+ webhookUrl,
1869
+ bot,
1870
+ dialogId,
1871
+ };
1872
+
1873
+ // Reject group chats
1874
+ if (chatType === 'chat' || chatType === 'open') {
1875
+ logger.info('Group chat not supported, leaving', { dialogId });
1876
+ try {
1877
+ await sendService.sendText(sendCtx, groupChatUnsupported(language));
1878
+ await api.leaveChat(webhookUrl, bot, dialogId);
1879
+ } catch (err) {
1880
+ logger.error('Failed to leave group chat', err);
846
1881
  }
1882
+ return;
1883
+ }
1884
+
1885
+ if (welcomedDialogs.has(dialogId)) {
1886
+ logger.info('Skipping duplicate welcome for already welcomed dialog', { dialogId });
1887
+ return;
1888
+ }
1889
+
1890
+ // Send welcome message
1891
+ const isPairing = config.dmPolicy === 'pairing';
1892
+ const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
1893
+ try {
1894
+ await sendService.sendText(
1895
+ sendCtx,
1896
+ text,
1897
+ isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD },
1898
+ );
1899
+ welcomedDialogs.add(dialogId);
847
1900
  logger.info('Welcome message sent', { dialogId });
848
- } catch (err: unknown) {
849
- const errMsg = err instanceof Error ? err.message : String(err);
850
- logger.error('Failed to send welcome message', { error: errMsg, dialogId });
851
- // Retry via webhook if token-based call failed
852
- if (botEntry?.client_endpoint && config.webhookUrl) {
853
- try {
854
- await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
855
- logger.info('Welcome message sent via webhook fallback', { dialogId });
856
- } catch (err2: unknown) {
857
- const errMsg2 = err2 instanceof Error ? err2.message : String(err2);
858
- logger.error('Welcome message webhook fallback also failed', { error: errMsg2, dialogId });
859
- }
860
- }
1901
+ } catch (err) {
1902
+ logger.error('Failed to send welcome message', err);
861
1903
  }
862
1904
  },
863
- });
864
1905
 
865
- gatewayState = { api, sendService, mediaService, inboundHandler };
1906
+ onBotDelete: async (_data: B24V2DeleteEventData) => {
1907
+ logger.info('Bot deleted from portal');
1908
+ },
1909
+ });
866
1910
 
867
- logger.info(`[${ctx.accountId}] Bitrix24 channel started`);
1911
+ gatewayState = { accountId: ctx.accountId, api, bot, sendService, mediaService, inboundHandler, eventMode };
1912
+
1913
+ logger.info(`[${ctx.accountId}] Bitrix24 channel started (${eventMode} mode)`);
1914
+
1915
+ // ─── Mode-specific lifecycle ──────────────────────────────────
1916
+
1917
+ if (eventMode === 'fetch') {
1918
+ // FETCH mode: start polling loop (blocks until abort)
1919
+ const pollingService = new PollingService({
1920
+ api,
1921
+ webhookUrl,
1922
+ bot,
1923
+ accountId: ctx.accountId,
1924
+ pollingIntervalMs: config.pollingIntervalMs ?? 3000,
1925
+ pollingFastIntervalMs: config.pollingFastIntervalMs ?? 100,
1926
+ onEvent: async (event: B24V2FetchEventItem) => {
1927
+ const fetchCtx: FetchContext = {
1928
+ webhookUrl,
1929
+ botId: bot.botId,
1930
+ botToken: bot.botToken,
1931
+ };
1932
+ await inboundHandler.handleFetchEvent(event, fetchCtx);
1933
+ },
1934
+ abortSignal: ctx.abortSignal,
1935
+ logger,
1936
+ });
868
1937
 
869
- // Keep alive until abort signal
870
- return new Promise<void>((resolve) => {
871
- ctx.abortSignal.addEventListener('abort', () => {
872
- logger.info(`[${ctx.accountId}] Bitrix24 channel stopping`);
1938
+ try {
1939
+ await pollingService.start();
1940
+ } finally {
1941
+ logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (fetch)`);
1942
+ clearInterval(deniedCleanupTimer);
1943
+ await directTextCoalescer.flushAll();
1944
+ directTextCoalescer.destroy();
873
1945
  inboundHandler.destroy();
874
1946
  api.destroy();
875
1947
  gatewayState = null;
876
- resolve();
1948
+ }
1949
+ } else {
1950
+ // WEBHOOK mode: keep alive until abort signal
1951
+ return new Promise<void>((resolve) => {
1952
+ ctx.abortSignal.addEventListener('abort', () => {
1953
+ const cleanup = async (): Promise<void> => {
1954
+ logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (webhook)`);
1955
+ clearInterval(deniedCleanupTimer);
1956
+ // Flush with timeout to prevent hanging
1957
+ await Promise.race([
1958
+ directTextCoalescer.flushAll(),
1959
+ new Promise<void>((r) => setTimeout(r, 5000)),
1960
+ ]);
1961
+ directTextCoalescer.destroy();
1962
+ inboundHandler.destroy();
1963
+ api.destroy();
1964
+ gatewayState = null;
1965
+ };
1966
+ cleanup().catch((err) => {
1967
+ logger.error('Webhook cleanup error', err);
1968
+ }).finally(() => resolve());
1969
+ });
877
1970
  });
878
- });
1971
+ }
879
1972
  },
880
1973
  },
881
1974
  };