@ihazz/bitrix24 0.2.5 → 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,49 +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');
294
902
  }
295
903
  }
296
904
 
297
905
  // ─── Outbound adapter helpers ────────────────────────────────────────────────
298
906
 
299
- /**
300
- * Build a minimal SendContext from the delivery pipeline's outbound context.
301
- * The pipeline provides `cfg` (full OpenClaw config) and `to` (normalized dialog ID).
302
- * We resolve the account config to get `webhookUrl`.
303
- */
304
907
  function resolveOutboundSendCtx(params: {
305
908
  cfg: Record<string, unknown>;
306
909
  to: string;
307
910
  accountId?: string;
308
- }): { webhookUrl?: string; dialogId: string } {
911
+ }): SendContext | null {
309
912
  const { config } = resolveAccount(params.cfg, params.accountId);
913
+ if (!config.webhookUrl || !gatewayState) return null;
310
914
  return {
311
915
  webhookUrl: config.webhookUrl,
916
+ bot: gatewayState.bot,
312
917
  dialogId: params.to,
313
918
  };
314
919
  }
315
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);
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;
976
+ }
977
+
316
978
  /**
317
979
  * The Bitrix24 channel plugin object.
318
- *
319
- * Implements the OpenClaw ChannelPlugin interface.
320
980
  */
321
981
  export const bitrix24Plugin = {
322
982
  id: 'bitrix24',
@@ -327,34 +987,25 @@ export const bitrix24Plugin = {
327
987
  selectionLabel: 'Bitrix24 (Messenger)',
328
988
  docsPath: '/channels/bitrix24',
329
989
  docsLabel: 'bitrix24',
330
- blurb: 'Connect to Bitrix24 Messenger via chat bot REST API.',
990
+ blurb: 'Connect to Bitrix24 Messenger via chat bot REST API (V2).',
331
991
  aliases: ['b24', 'bx24'],
332
992
  },
333
993
 
334
994
  capabilities: {
335
- chatTypes: ['direct', 'group'] as const,
995
+ chatTypes: ['direct'] as const,
336
996
  media: true,
337
- reactions: false,
997
+ reactions: true,
338
998
  threads: false,
339
999
  nativeCommands: true,
340
1000
  inlineButtons: 'all',
341
1001
  },
342
1002
 
343
1003
  messaging: {
344
- /**
345
- * Normalize target ID by stripping the channel prefix.
346
- * Called by the delivery pipeline so that `to` in outbound context is a clean numeric ID.
347
- */
348
- normalizeTarget: (raw: string) => raw.trim().replace(/^(bitrix24|b24|bx24):/i, ''),
1004
+ normalizeTarget: (raw: string) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
349
1005
  targetResolver: {
350
1006
  hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
351
- /**
352
- * Recognize any numeric string as a valid Bitrix24 target ID.
353
- * B24 dialog IDs can be short (e.g. "1"), so the default 6+ digit check is too strict.
354
- */
355
1007
  looksLikeId: (raw: string, _normalized: string) => {
356
- // raw may include channel prefix like "bitrix24:1" — strip it first
357
- const stripped = raw.trim().replace(/^(bitrix24|b24|bx24):/i, '');
1008
+ const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
358
1009
  return /^\d+$/.test(stripped);
359
1010
  },
360
1011
  },
@@ -367,16 +1018,20 @@ export const bitrix24Plugin = {
367
1018
  },
368
1019
 
369
1020
  security: {
370
- resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => ({
371
- policy: (params.account?.config?.dmPolicy as string) ?? 'pairing',
372
- allowFrom: (params.account?.config?.allowFrom as string[]) ?? [],
373
- policyPath: 'channels.bitrix24.dmPolicy',
374
- allowFromPath: 'channels.bitrix24.',
375
- approveHint: 'openclaw pairing approve bitrix24 <CODE>',
376
- normalizeEntry: (raw: string) => raw.replace(/^(bitrix24|b24|bx24):/i, ''),
377
- }),
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
+ },
378
1033
  normalizeAllowFrom: (entry: string) =>
379
- entry.replace(/^(bitrix24|b24|bx24):/i, ''),
1034
+ entry.replace(CHANNEL_PREFIX_RE, ''),
380
1035
  },
381
1036
 
382
1037
  pairing: {
@@ -384,12 +1039,16 @@ export const bitrix24Plugin = {
384
1039
  normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
385
1040
  notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
386
1041
  const { config: acctCfg } = resolveAccount(params.cfg);
387
- if (!acctCfg.webhookUrl) return;
388
- 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
+ };
389
1048
  try {
390
- await api.sendMessage(acctCfg.webhookUrl, params.id, '\u2705 OpenClaw access approved.');
391
- } finally {
392
- 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);
393
1052
  }
394
1053
  },
395
1054
  } satisfies ChannelPairingAdapter,
@@ -398,12 +1057,6 @@ export const bitrix24Plugin = {
398
1057
  deliveryMode: 'direct' as const,
399
1058
  textChunkLimit: 4000,
400
1059
 
401
- /**
402
- * Send a text message to B24 via the bot.
403
- * Called by the OpenClaw delivery pipeline (message tool path).
404
- *
405
- * Context shape: { cfg, to, accountId, text, replyToId?, threadId?, ... }
406
- */
407
1060
  sendText: async (ctx: {
408
1061
  cfg: Record<string, unknown>;
409
1062
  to: string;
@@ -411,20 +1064,12 @@ export const bitrix24Plugin = {
411
1064
  text: string;
412
1065
  [key: string]: unknown;
413
1066
  }) => {
414
- if (!gatewayState) throw new Error('Bitrix24 gateway not started');
415
1067
  const sendCtx = resolveOutboundSendCtx(ctx);
1068
+ if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
416
1069
  const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
417
1070
  return { messageId: String(result.messageId ?? '') };
418
1071
  },
419
1072
 
420
- /**
421
- * Send a media message to B24.
422
- * Called by the delivery pipeline when the agent sends media.
423
- *
424
- * Note: full media upload requires OAuth bot token (clientEndpoint + botToken)
425
- * which is only available in the reply path (inbound webhook events).
426
- * In the message tool path we only have the webhook URL, so we send the caption text.
427
- */
428
1073
  sendMedia: async (ctx: {
429
1074
  cfg: Record<string, unknown>;
430
1075
  to: string;
@@ -433,10 +1078,19 @@ export const bitrix24Plugin = {
433
1078
  mediaUrl?: string;
434
1079
  [key: string]: unknown;
435
1080
  }) => {
436
- if (!gatewayState) throw new Error('Bitrix24 gateway not started');
437
1081
  const sendCtx = resolveOutboundSendCtx(ctx);
438
- // Media upload via message tool path not supported (no OAuth token).
439
- // Send caption text only.
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 };
1092
+ }
1093
+
440
1094
  if (ctx.text) {
441
1095
  const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
442
1096
  return { messageId: String(result.messageId ?? '') };
@@ -444,12 +1098,6 @@ export const bitrix24Plugin = {
444
1098
  return { messageId: '' };
445
1099
  },
446
1100
 
447
- /**
448
- * Send a rich payload with optional channelData (keyboards, etc.) to B24.
449
- * Called by the delivery pipeline when payload includes channelData.
450
- *
451
- * Context shape: { cfg, to, accountId, text, mediaUrl?, payload, ... }
452
- */
453
1101
  sendPayload: async (ctx: {
454
1102
  cfg: Record<string, unknown>;
455
1103
  to: string;
@@ -459,15 +1107,35 @@ export const bitrix24Plugin = {
459
1107
  payload?: { channelData?: Record<string, unknown>; [key: string]: unknown };
460
1108
  [key: string]: unknown;
461
1109
  }) => {
462
- if (!gatewayState) throw new Error('Bitrix24 gateway not started');
463
1110
  const sendCtx = resolveOutboundSendCtx(ctx);
1111
+ if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
464
1112
 
465
- // Extract keyboard from channelData
466
1113
  const keyboard = ctx.payload?.channelData
467
1114
  ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
468
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
+ }
469
1138
 
470
- // Send text + keyboard
471
1139
  if (ctx.text) {
472
1140
  const result = await gatewayState.sendService.sendText(
473
1141
  sendCtx,
@@ -480,10 +1148,100 @@ export const bitrix24Plugin = {
480
1148
  },
481
1149
  },
482
1150
 
1151
+ // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
1152
+
1153
+ actions: {
1154
+ listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
1155
+ return ['react'];
1156
+ },
1157
+
1158
+ supportsAction: (params: { action: string }): boolean => {
1159
+ return params.action === 'react';
1160
+ },
1161
+
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.' });
1181
+ }
1182
+
1183
+ const bot = gatewayState.bot;
1184
+ const api = gatewayState.api;
1185
+ const params = ctx.params;
1186
+
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);
1192
+
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.' });
1195
+ }
1196
+
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
+ }
1214
+
1215
+ // Add reaction
1216
+ if (!emoji) {
1217
+ return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required for Bitrix24 reactions.' });
1218
+ }
1219
+
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
+ }
1241
+ },
1242
+ },
1243
+
483
1244
  gateway: {
484
- /**
485
- * Start a channel account. Called by OpenClaw for each configured account.
486
- */
487
1245
  startAccount: async (ctx: {
488
1246
  cfg: Record<string, unknown>;
489
1247
  accountId: string;
@@ -494,117 +1252,142 @@ export const bitrix24Plugin = {
494
1252
  setStatus?: (status: Record<string, unknown>) => void;
495
1253
  }) => {
496
1254
  const logger = ctx.log ?? defaultLogger;
497
- 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);
498
1265
 
499
1266
  if (!config.webhookUrl) {
500
1267
  logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
501
1268
  return;
502
1269
  }
1270
+ // Safe to use without ! after this point
1271
+ const webhookUrl: string = config.webhookUrl;
503
1272
 
504
1273
  logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
505
1274
 
506
- // Derive CLIENT_ID from webhookUrl (md5) stable and unique per portal
507
- const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
508
- const api = new Bitrix24Api({ logger, clientId });
509
- const sendService = new SendService(api, logger);
510
- const mediaService = new MediaService(api, logger);
511
-
512
- // Register or update bot on the B24 portal
513
- const botId = await ensureBotRegistered(api, config, logger);
514
-
515
- // Register slash commands (runs in background, doesn't block startup)
516
- if (botId) {
517
- ensureCommandsRegistered(api, config, botId, logger).catch((err) => {
518
- logger.warn('Command registration failed', err);
519
- });
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();
520
1292
  }
521
1293
 
522
- const inboundHandler = new InboundHandler({
523
- config,
524
- logger,
1294
+ // Determine event mode
1295
+ const eventMode = resolveEventMode(config);
1296
+ logger.info(`[${ctx.accountId}] event mode: ${eventMode}`);
525
1297
 
526
- onMessage: async (msgCtx: B24MsgContext) => {
527
- logger.info('Inbound message', {
528
- senderId: msgCtx.senderId,
529
- chatId: msgCtx.chatId,
530
- messageId: msgCtx.messageId,
531
- textLen: msgCtx.text.length,
532
- });
1298
+ // Register or update bot on the B24 portal (V2 API)
1299
+ const botRegistration = await ensureBotRegistered(api, config, botToken, eventMode, logger);
533
1300
 
534
- const runtime = getBitrix24Runtime();
535
- 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
+ }
536
1307
 
537
- // Pairing-aware access control
538
- const accessResult = await checkAccessWithPairing({
539
- 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,
540
1341
  config,
541
- runtime,
542
- accountId: ctx.accountId,
543
- pairingAdapter: bitrix24Plugin.pairing,
544
- sendReply: async (text: string) => {
545
- const replySendCtx = {
546
- webhookUrl: config.webhookUrl,
547
- clientEndpoint: msgCtx.clientEndpoint,
548
- botToken: msgCtx.botToken,
549
- dialogId: msgCtx.chatId,
550
- };
551
- await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
552
- },
553
- logger,
554
1342
  });
555
1343
 
556
- if (accessResult !== 'allow') {
557
- logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
558
- return;
559
- }
560
-
561
1344
  // Download media files if present
562
1345
  let mediaFields: Record<string, unknown> = {};
563
1346
  if (msgCtx.media.length > 0) {
564
- const downloaded = (await Promise.all(
565
- msgCtx.media.map((m) =>
566
- mediaService.downloadMedia({
567
- fileId: m.id,
568
- fileName: m.name,
569
- extension: m.extension,
570
- clientEndpoint: msgCtx.clientEndpoint,
571
- userToken: msgCtx.userToken,
572
- webhookUrl: config.webhookUrl,
573
- }),
574
- ),
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
+ }),
575
1361
  )).filter(Boolean) as DownloadedMedia[];
576
1362
 
577
- if (downloaded.length > 0) {
1363
+ if (downloadedMedia.length > 0) {
578
1364
  mediaFields = {
579
- MediaPath: downloaded[0].path,
580
- MediaType: downloaded[0].contentType,
581
- MediaUrl: downloaded[0].path,
582
- MediaPaths: downloaded.map((m) => m.path),
583
- MediaUrls: downloaded.map((m) => m.path),
584
- 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),
585
1371
  };
586
1372
  } else {
587
- // All file downloads failed notify the user
588
- const fileNames = msgCtx.media.map((m) => m.name).join(', ');
1373
+ const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
589
1374
  logger.warn('All media downloads failed, notifying user', { fileNames });
590
- const errSendCtx = {
591
- webhookUrl: config.webhookUrl,
592
- clientEndpoint: msgCtx.clientEndpoint,
593
- botToken: msgCtx.botToken,
594
- dialogId: msgCtx.chatId,
595
- };
596
1375
  await sendService.sendText(
597
- errSendCtx,
598
- mediaDownloadFailedMsg(msgCtx.language, fileNames),
1376
+ sendCtx,
1377
+ mediaDownloadFailed(msgCtx.language, fileNames),
599
1378
  );
600
1379
  return;
601
1380
  }
1381
+ } else {
1382
+ await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_ANALYZING');
1383
+ replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
602
1384
  }
603
1385
 
604
1386
  // Use placeholder body for media-only messages
605
1387
  let body = msgCtx.text;
606
1388
  if (!body && msgCtx.media.length > 0) {
607
- 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');
608
1391
  body = hasImage ? '<media:image>' : '<media:document>';
609
1392
  }
610
1393
 
@@ -625,7 +1408,6 @@ export const bitrix24Plugin = {
625
1408
  matchedBy: route.matchedBy,
626
1409
  });
627
1410
 
628
- // Build and finalize inbound context for OpenClaw agent
629
1411
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
630
1412
  Body: body,
631
1413
  BodyForAgent: body,
@@ -649,41 +1431,34 @@ export const bitrix24Plugin = {
649
1431
  ...mediaFields,
650
1432
  });
651
1433
 
652
- const sendCtx = {
653
- webhookUrl: config.webhookUrl,
654
- clientEndpoint: msgCtx.clientEndpoint,
655
- botToken: msgCtx.botToken,
656
- dialogId: msgCtx.chatId,
657
- };
658
-
659
- // Dispatch to AI agent; deliver callback sends reply back to B24
660
1434
  try {
661
1435
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
662
1436
  ctx: inboundCtx,
663
1437
  cfg,
664
1438
  dispatcherOptions: {
665
1439
  deliver: async (payload) => {
666
- // Send media if present in reply
1440
+ await replyStatusHeartbeat.stopAndWait();
667
1441
  const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
668
1442
  for (const mediaUrl of mediaUrls) {
669
- await mediaService.uploadMediaToChat({
1443
+ const uploadResult = await mediaService.uploadMediaToChat({
670
1444
  localPath: mediaUrl,
671
1445
  fileName: basename(mediaUrl),
672
- chatId: Number(msgCtx.chatInternalId),
673
- clientEndpoint: msgCtx.clientEndpoint,
674
- botToken: msgCtx.botToken,
1446
+ webhookUrl,
1447
+ bot,
1448
+ dialogId: msgCtx.chatId,
675
1449
  });
1450
+
1451
+ if (!uploadResult.ok) {
1452
+ throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
1453
+ }
676
1454
  }
677
- // Send text if present
678
1455
  if (payload.text) {
679
1456
  const keyboard = extractKeyboardFromPayload(payload);
680
1457
  await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
681
1458
  }
682
1459
  },
683
1460
  onReplyStart: async () => {
684
- if (config.showTyping !== false) {
685
- await sendService.sendTyping(sendCtx);
686
- }
1461
+ await replyStatusHeartbeat.start();
687
1462
  },
688
1463
  onError: (err) => {
689
1464
  logger.error('Error delivering reply to B24', err);
@@ -691,29 +1466,205 @@ export const bitrix24Plugin = {
691
1466
  },
692
1467
  });
693
1468
  } catch (err) {
694
- 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();
695
1472
  }
696
- },
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
+ }
697
1493
 
698
- onCommand: async (event: B24CommandEvent) => {
699
- const cmdEntry = Object.values(event.data.COMMAND)[0];
700
- if (!cmdEntry) {
701
- logger.warn('No command entry in ONIMCOMMANDADD event');
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;
1537
+
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
+ }
702
1549
  return;
703
1550
  }
704
1551
 
705
- const commandName = cmdEntry.COMMAND;
706
- const commandParams = cmdEntry.COMMAND_PARAMS?.trim() ?? '';
707
- const commandText = commandParams
708
- ? `/${commandName} ${commandParams}`
709
- : `/${commandName}`;
1552
+ const runtime = getBitrix24Runtime();
1553
+
1554
+ // Pairing-aware access control
1555
+ const sendCtx: SendContext = {
1556
+ webhookUrl,
1557
+ bot,
1558
+ dialogId: msgCtx.chatId,
1559
+ };
710
1560
 
711
- const senderId = String(event.data.PARAMS.FROM_USER_ID);
712
- const dialogId = event.data.PARAMS.DIALOG_ID;
713
- const isDm = event.data.PARAMS.CHAT_TYPE === 'P';
714
- const user = event.data.USER;
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
+ });
1574
+
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
+ }
715
1581
 
716
- 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
+ };
717
1668
 
718
1669
  let runtime;
719
1670
  let cfg;
@@ -725,16 +1676,35 @@ export const bitrix24Plugin = {
725
1676
  return;
726
1677
  }
727
1678
 
728
- // 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
729
1690
  let accessResult;
730
1691
  try {
731
1692
  accessResult = await checkAccessWithPairing({
732
1693
  senderId,
1694
+ dialogId,
1695
+ isDirect: isDm,
733
1696
  config,
734
1697
  runtime,
735
1698
  accountId: ctx.accountId,
736
1699
  pairingAdapter: bitrix24Plugin.pairing,
737
- 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
+ },
738
1708
  logger,
739
1709
  });
740
1710
  } catch (err) {
@@ -742,24 +1712,54 @@ export const bitrix24Plugin = {
742
1712
  return;
743
1713
  }
744
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
+
745
1740
  if (accessResult !== 'allow') {
746
1741
  logger.debug(`Command blocked (${accessResult})`, { senderId });
747
1742
  return;
748
1743
  }
749
1744
 
750
- 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
+ }
751
1755
 
752
1756
  const route = runtime.channel.routing.resolveAgentRoute({
753
1757
  cfg,
754
1758
  channel: 'bitrix24',
755
1759
  accountId: ctx.accountId,
756
- peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
1760
+ peer: { kind: isDm ? 'direct' : 'group', id: peerId },
757
1761
  });
758
1762
 
759
- logger.debug('Command route resolved', { sessionKey: route.sessionKey });
760
-
761
- // Each command invocation gets a unique ephemeral session
762
- // so the gateway doesn't treat it as "already handled".
763
1763
  const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
764
1764
 
765
1765
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
@@ -769,130 +1769,206 @@ export const bitrix24Plugin = {
769
1769
  CommandBody: commandText,
770
1770
  CommandAuthorized: true,
771
1771
  CommandSource: 'native',
772
- CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${dialogId}`,
773
- From: `bitrix24:${dialogId}`,
1772
+ CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${peerId}`,
1773
+ From: `bitrix24:${peerId}`,
774
1774
  To: `slash:${senderId}`,
775
1775
  SessionKey: slashSessionKey,
776
1776
  AccountId: route.accountId,
777
1777
  ChatType: isDm ? 'direct' : 'group',
778
- ConversationLabel: user.NAME,
779
- SenderName: user.NAME,
1778
+ ConversationLabel: senderId,
1779
+ SenderName: senderId,
780
1780
  SenderId: senderId,
781
1781
  Provider: 'bitrix24',
782
1782
  Surface: 'bitrix24',
783
- MessageSid: String(event.data.PARAMS.MESSAGE_ID),
1783
+ MessageSid: messageId,
784
1784
  Timestamp: Date.now(),
785
1785
  WasMentioned: true,
786
1786
  OriginatingChannel: 'bitrix24',
787
- OriginatingTo: `bitrix24:${dialogId}`,
1787
+ OriginatingTo: `bitrix24:${peerId}`,
788
1788
  });
789
1789
 
790
- const sendCtx = {
791
- webhookUrl: config.webhookUrl,
792
- clientEndpoint: (cmdEntry.client_endpoint as string | undefined) ?? event.auth.client_endpoint,
793
- botToken: cmdEntry.access_token,
794
- dialogId,
795
- };
796
-
797
- logger.debug('Dispatching command to agent', { commandText, slashSessionKey });
1790
+ const replyStatusHeartbeat = createReplyStatusHeartbeat({
1791
+ sendService,
1792
+ sendCtx,
1793
+ config,
1794
+ });
1795
+ let commandReplyDelivered = false;
798
1796
 
799
1797
  try {
1798
+ await replyStatusHeartbeat.start();
800
1799
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
801
1800
  ctx: inboundCtx,
802
1801
  cfg,
803
1802
  dispatcherOptions: {
804
1803
  deliver: async (payload) => {
805
- logger.debug('Command deliver callback', {
806
- hasText: !!payload.text,
807
- textLen: payload.text?.length ?? 0,
808
- hasMedia: !!(payload.mediaUrl || payload.mediaUrls?.length),
809
- });
1804
+ await replyStatusHeartbeat.stopAndWait();
810
1805
  if (payload.text) {
811
- // Use agent-provided keyboard if any, otherwise re-attach default command keyboard
812
1806
  const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
813
- 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
+ });
814
1834
  }
815
1835
  },
816
1836
  onReplyStart: async () => {
817
- if (config.showTyping !== false) {
818
- await sendService.sendTyping(sendCtx);
819
- }
1837
+ await replyStatusHeartbeat.start();
820
1838
  },
821
1839
  onError: (err) => {
822
1840
  logger.error('Error delivering command reply to B24', err);
823
1841
  },
824
1842
  },
825
1843
  });
826
- logger.debug('Command dispatch completed', { commandText });
827
1844
  } catch (err) {
828
- 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();
829
1848
  }
830
1849
  },
831
1850
 
832
- onJoinChat: async (event: B24JoinChatEvent) => {
833
- const dialogId = event.data.PARAMS.DIALOG_ID;
834
- const botEntry = Object.values(event.data.BOT)[0];
835
- logger.info('Bot joined chat', {
836
- dialogId,
837
- userId: event.data.PARAMS.USER_ID,
838
- hasBotEntry: !!botEntry,
839
- botId: botEntry?.BOT_ID,
840
- hasEndpoint: !!botEntry?.client_endpoint,
841
- hasToken: !!botEntry?.access_token,
842
- });
1851
+ onJoinChat: async (joinCtx: FetchJoinChatContext) => {
1852
+ const { dialogId, chatType, language } = joinCtx;
1853
+ logger.info('Bot joined chat', { dialogId, chatType });
1854
+
843
1855
  if (!dialogId) return;
844
1856
 
845
- const welcomeText = `${config.botName ?? 'OpenClaw'} ready. Send me a message or pick a command below.`;
846
- 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
+ }
847
1866
 
848
- try {
849
- // Prefer token-based call; fall back to webhook URL
850
- if (botEntry?.client_endpoint && botEntry?.access_token) {
851
- await api.sendMessageWithToken(
852
- botEntry.client_endpoint,
853
- botEntry.access_token,
854
- dialogId,
855
- welcomeText,
856
- welcomeOpts,
857
- );
858
- } else if (config.webhookUrl) {
859
- await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
860
- } else {
861
- logger.warn('No way to send welcome message — no token and no webhookUrl');
862
- 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);
863
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);
864
1900
  logger.info('Welcome message sent', { dialogId });
865
- } catch (err: unknown) {
866
- const errMsg = err instanceof Error ? err.message : String(err);
867
- logger.error('Failed to send welcome message', { error: errMsg, dialogId });
868
- // Retry via webhook if token-based call failed
869
- if (botEntry?.client_endpoint && config.webhookUrl) {
870
- try {
871
- await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
872
- logger.info('Welcome message sent via webhook fallback', { dialogId });
873
- } catch (err2: unknown) {
874
- const errMsg2 = err2 instanceof Error ? err2.message : String(err2);
875
- logger.error('Welcome message webhook fallback also failed', { error: errMsg2, dialogId });
876
- }
877
- }
1901
+ } catch (err) {
1902
+ logger.error('Failed to send welcome message', err);
878
1903
  }
879
1904
  },
880
- });
881
1905
 
882
- gatewayState = { api, sendService, mediaService, inboundHandler };
1906
+ onBotDelete: async (_data: B24V2DeleteEventData) => {
1907
+ logger.info('Bot deleted from portal');
1908
+ },
1909
+ });
883
1910
 
884
- 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
+ });
885
1937
 
886
- // Keep alive until abort signal
887
- return new Promise<void>((resolve) => {
888
- ctx.abortSignal.addEventListener('abort', () => {
889
- 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();
890
1945
  inboundHandler.destroy();
891
1946
  api.destroy();
892
1947
  gatewayState = null;
893
- 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
+ });
894
1970
  });
895
- });
1971
+ }
896
1972
  },
897
1973
  },
898
1974
  };