@ihazz/bitrix24 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,14 +47,25 @@ Add to your `openclaw.json`:
47
47
  "webhookUrl": "https://your-portal.bitrix24.com/rest/1/abc123xyz456/",
48
48
  "botName": "OpenClaw",
49
49
  "botCode": "openclaw",
50
- "callbackPath": "/hooks/bitrix24",
50
+ "callbackUrl": "https://your-server.com/hooks/bitrix24",
51
51
  "dmPolicy": "open",
52
+ "allowFrom": ["*"],
52
53
  "showTyping": true
53
54
  }
54
55
  }
55
56
  }
56
57
  ```
57
58
 
59
+ Set allow in plugin section:
60
+ ```json
61
+ {
62
+ "plugins": {
63
+ "allow": [
64
+ "bitrix24"
65
+ ],
66
+ }
67
+ }
68
+ ```
58
69
  Only `webhookUrl` is required. The gateway will not start without it.
59
70
 
60
71
  ### Configuration Options
@@ -64,7 +75,7 @@ Only `webhookUrl` is required. The gateway will not start without it.
64
75
  | `webhookUrl` | — | Bitrix24 REST webhook URL (**required**) |
65
76
  | `botName` | `"OpenClaw"` | Bot display name (shown in welcome message) |
66
77
  | `botCode` | `"openclaw"` | Unique bot code for `imbot.register` |
67
- | `callbackPath` | `"/hooks/bitrix24"` | Webhook endpoint path for incoming B24 events |
78
+ | `callbackUrl` | — | Full public URL for bot EVENT_HANDLER (e.g. `https://your-server.com/hooks/bitrix24`). Path is auto-extracted for route registration. |
68
79
  | `dmPolicy` | `"open"` | Access policy: `"open"` / `"allowlist"` / `"pairing"` |
69
80
  | `allowFrom` | — | Allowed B24 user IDs (when `dmPolicy: "allowlist"`) |
70
81
  | `showTyping` | `true` | Send typing indicator before responding |
package/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { bitrix24Plugin, handleWebhookRequest } from './src/channel.js';
2
- import { setBitrix24Runtime } from './src/runtime.js';
2
+ import { setBitrix24Runtime, type PluginRuntime } from './src/runtime.js';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
 
5
5
  interface OpenClawPluginApi {
6
6
  config: Record<string, unknown>;
7
- runtime: unknown;
7
+ runtime: PluginRuntime;
8
8
  registerChannel: (opts: { plugin: typeof bitrix24Plugin }) => void;
9
9
  registerHttpRoute: (params: {
10
10
  path: string;
@@ -24,13 +24,14 @@ export default {
24
24
  description: 'Bitrix24 Messenger channel for OpenClaw',
25
25
 
26
26
  register(api: OpenClawPluginApi) {
27
- setBitrix24Runtime(api.runtime as { logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; debug: (...args: unknown[]) => void }; [key: string]: unknown });
27
+ setBitrix24Runtime(api.runtime);
28
28
 
29
29
  api.registerChannel({ plugin: bitrix24Plugin });
30
30
 
31
- // Register HTTP webhook route on the OpenClaw gateway
31
+ // Register HTTP webhook route derive path from callbackUrl
32
32
  const channels = api.config?.channels as Record<string, Record<string, unknown>> | undefined;
33
- const callbackPath = (channels?.bitrix24?.callbackPath as string) ?? '/hooks/bitrix24';
33
+ const callbackUrl = channels?.bitrix24?.callbackUrl as string | undefined;
34
+ const callbackPath = callbackUrl ? new URL(callbackUrl).pathname : '/hooks/bitrix24';
34
35
 
35
36
  api.registerHttpRoute({
36
37
  path: callbackPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihazz/bitrix24",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Bitrix24 Messenger channel for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/api.ts CHANGED
@@ -12,10 +12,12 @@ interface Logger {
12
12
  export class Bitrix24Api {
13
13
  private rateLimiter: RateLimiter;
14
14
  private logger: Logger;
15
+ private clientId?: string;
15
16
 
16
- constructor(opts: { maxPerSecond?: number; logger?: Logger } = {}) {
17
+ constructor(opts: { maxPerSecond?: number; logger?: Logger; clientId?: string } = {}) {
17
18
  this.rateLimiter = new RateLimiter({ maxPerSecond: opts.maxPerSecond ?? 2 });
18
19
  this.logger = opts.logger ?? defaultLogger;
20
+ this.clientId = opts.clientId;
19
21
  }
20
22
 
21
23
  /**
@@ -33,12 +35,18 @@ export class Bitrix24Api {
33
35
  const url = `${webhookUrl.replace(/\/+$/, '')}/${method}.json`;
34
36
  this.logger.debug(`API call: ${method}`, { url });
35
37
 
38
+ // In webhook mode, CLIENT_ID identifies the "app" that owns the bot
39
+ const payload = { ...(params ?? {}) };
40
+ if (this.clientId && !payload.CLIENT_ID) {
41
+ payload.CLIENT_ID = this.clientId;
42
+ }
43
+
36
44
  const result = await withRetry(
37
45
  async () => {
38
46
  const response = await fetch(url, {
39
47
  method: 'POST',
40
48
  headers: { 'Content-Type': 'application/json' },
41
- body: JSON.stringify(params ?? {}),
49
+ body: JSON.stringify(payload),
42
50
  });
43
51
 
44
52
  if (!response.ok) {
@@ -215,6 +223,16 @@ export class Bitrix24Api {
215
223
  return result.result;
216
224
  }
217
225
 
226
+ async listBots(
227
+ webhookUrl: string,
228
+ ): Promise<Array<{ ID: number; NAME: string; CODE: string; OPENLINE: string }>> {
229
+ // B24 returns an object keyed by bot ID, not an array
230
+ const result = await this.callWebhook<
231
+ Record<string, { ID: number; NAME: string; CODE: string; OPENLINE: string }>
232
+ >(webhookUrl, 'imbot.bot.list', {});
233
+ return Object.values(result.result ?? {});
234
+ }
235
+
218
236
  async registerBot(
219
237
  webhookUrl: string,
220
238
  params: Record<string, unknown>,
@@ -223,6 +241,45 @@ export class Bitrix24Api {
223
241
  return result.result;
224
242
  }
225
243
 
244
+ async updateBot(
245
+ webhookUrl: string,
246
+ botId: number,
247
+ fields: Record<string, unknown>,
248
+ ): Promise<boolean> {
249
+ const result = await this.callWebhook<boolean>(webhookUrl, 'imbot.update', {
250
+ BOT_ID: botId,
251
+ FIELDS: fields,
252
+ });
253
+ return result.result;
254
+ }
255
+
256
+ async registerCommand(
257
+ webhookUrl: string,
258
+ params: {
259
+ BOT_ID: number;
260
+ COMMAND: string;
261
+ COMMON?: 'Y' | 'N';
262
+ HIDDEN?: 'Y' | 'N';
263
+ EXTRANET_SUPPORT?: 'Y' | 'N';
264
+ LANG: Array<{ LANGUAGE_ID: string; TITLE: string; PARAMS?: string }>;
265
+ EVENT_COMMAND_ADD: string;
266
+ },
267
+ ): Promise<number> {
268
+ const result = await this.callWebhook<number>(
269
+ webhookUrl,
270
+ 'imbot.command.register',
271
+ params,
272
+ );
273
+ return result.result;
274
+ }
275
+
276
+ async unregisterCommand(webhookUrl: string, commandId: number): Promise<boolean> {
277
+ const result = await this.callWebhook<boolean>(webhookUrl, 'imbot.command.unregister', {
278
+ COMMAND_ID: commandId,
279
+ });
280
+ return result.result;
281
+ }
282
+
226
283
  async unregisterBot(webhookUrl: string, botId: number): Promise<boolean> {
227
284
  const result = await this.callWebhook<boolean>(webhookUrl, 'imbot.unregister', {
228
285
  BOT_ID: botId,
package/src/channel.ts CHANGED
@@ -1,10 +1,21 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
3
  import { listAccountIds, resolveAccount, getConfig } from './config.js';
3
4
  import { Bitrix24Api } from './api.js';
4
5
  import { SendService } from './send-service.js';
5
6
  import { InboundHandler } from './inbound-handler.js';
7
+ import { checkAccess } from './access-control.js';
6
8
  import { defaultLogger } from './utils.js';
7
- import type { B24MsgContext, B24JoinChatEvent } from './types.js';
9
+ import { getBitrix24Runtime } from './runtime.js';
10
+ import { OPENCLAW_COMMANDS } from './commands.js';
11
+ import type {
12
+ B24MsgContext,
13
+ B24JoinChatEvent,
14
+ B24CommandEvent,
15
+ Bitrix24AccountConfig,
16
+ B24Keyboard,
17
+ KeyboardButton,
18
+ } from './types.js';
8
19
 
9
20
  interface Logger {
10
21
  info: (...args: unknown[]) => void;
@@ -22,6 +33,185 @@ interface GatewayState {
22
33
 
23
34
  let gatewayState: GatewayState | null = null;
24
35
 
36
+ // ─── Keyboard / Button conversion ────────────────────────────────────────────
37
+
38
+ interface TelegramButton {
39
+ text: string;
40
+ callback_data?: string;
41
+ style?: string;
42
+ }
43
+
44
+ /**
45
+ * Convert Telegram-style button rows to B24 flat KEYBOARD array.
46
+ * Telegram format: Array<Array<{ text, callback_data, style }>>
47
+ * B24 format: flat array with { TYPE: 'NEWLINE' } separators between rows.
48
+ */
49
+ function convertButtonsToKeyboard(rows: TelegramButton[][]): B24Keyboard {
50
+ const keyboard: B24Keyboard = [];
51
+
52
+ for (let i = 0; i < rows.length; i++) {
53
+ for (const btn of rows[i]) {
54
+ const b24Btn: KeyboardButton = { TEXT: btn.text, DISPLAY: 'LINE' };
55
+
56
+ if (btn.callback_data?.startsWith('/')) {
57
+ // Slash command — use COMMAND + COMMAND_PARAMS
58
+ const parts = btn.callback_data.substring(1).split(' ');
59
+ b24Btn.COMMAND = parts[0];
60
+ if (parts.length > 1) {
61
+ b24Btn.COMMAND_PARAMS = parts.slice(1).join(' ');
62
+ }
63
+ } else if (btn.callback_data) {
64
+ // Non-slash data — insert text into input via PUT action
65
+ b24Btn.ACTION = 'PUT';
66
+ b24Btn.ACTION_VALUE = btn.callback_data;
67
+ }
68
+
69
+ if (btn.style === 'primary') {
70
+ b24Btn.BG_COLOR_TOKEN = 'primary';
71
+ } else if (btn.style === 'attention' || btn.style === 'danger') {
72
+ b24Btn.BG_COLOR_TOKEN = 'alert';
73
+ }
74
+
75
+ keyboard.push(b24Btn);
76
+ }
77
+
78
+ // Add NEWLINE separator between rows (not after last row)
79
+ if (i < rows.length - 1) {
80
+ keyboard.push({ TYPE: 'NEWLINE' });
81
+ }
82
+ }
83
+
84
+ return keyboard;
85
+ }
86
+
87
+ /**
88
+ * Extract B24 keyboard from a dispatcher payload's channelData.
89
+ * Checks bitrix24-specific data first, then falls back to Telegram button format.
90
+ */
91
+ function extractKeyboardFromPayload(
92
+ payload: { channelData?: Record<string, unknown> },
93
+ ): B24Keyboard | undefined {
94
+ const cd = payload.channelData;
95
+ if (!cd) return undefined;
96
+
97
+ // Direct B24 keyboard (future-proof: channelData.bitrix24.keyboard)
98
+ const b24Data = cd.bitrix24 as { keyboard?: B24Keyboard } | undefined;
99
+ if (b24Data?.keyboard?.length) {
100
+ return b24Data.keyboard;
101
+ }
102
+
103
+ // Translate from Telegram button format
104
+ const tgData = cd.telegram as { buttons?: TelegramButton[][] } | undefined;
105
+ if (tgData?.buttons?.length) {
106
+ return convertButtonsToKeyboard(tgData.buttons);
107
+ }
108
+
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * Register or update the bot on the Bitrix24 portal.
114
+ * Checks imbot.bot.list first; if a bot with the same CODE exists, updates it.
115
+ * Otherwise registers a new one.
116
+ */
117
+ async function ensureBotRegistered(
118
+ api: Bitrix24Api,
119
+ config: Bitrix24AccountConfig,
120
+ logger: Logger,
121
+ ): Promise<number | null> {
122
+ const { webhookUrl, callbackUrl, botCode, botName } = config;
123
+ if (!webhookUrl || !callbackUrl) {
124
+ if (!callbackUrl) {
125
+ logger.warn('callbackUrl not configured — skipping bot registration (bot must be registered manually)');
126
+ }
127
+ return null;
128
+ }
129
+
130
+ const code = botCode ?? 'openclaw';
131
+ const name = botName ?? 'OpenClaw';
132
+
133
+ // Check if bot already exists
134
+ try {
135
+ const bots = await api.listBots(webhookUrl);
136
+ const existing = bots.find((b) => b.CODE === code);
137
+
138
+ if (existing) {
139
+ logger.info(`Bot "${code}" already registered (ID=${existing.ID}), updating EVENT_HANDLER`);
140
+ await api.updateBot(webhookUrl, existing.ID, {
141
+ EVENT_HANDLER: callbackUrl,
142
+ PROPERTIES: { NAME: name },
143
+ });
144
+ return existing.ID;
145
+ }
146
+ } catch (err) {
147
+ logger.warn('Failed to list existing bots, will try to register', err);
148
+ }
149
+
150
+ // Register new bot
151
+ try {
152
+ const botId = await api.registerBot(webhookUrl, {
153
+ CODE: code,
154
+ TYPE: 'B',
155
+ EVENT_HANDLER: callbackUrl,
156
+ PROPERTIES: {
157
+ NAME: name,
158
+ WORK_POSITION: 'AI Assistant',
159
+ COLOR: 'AZURE',
160
+ },
161
+ });
162
+ logger.info(`Bot "${code}" registered (ID=${botId})`);
163
+ return botId;
164
+ } catch (err) {
165
+ logger.error('Failed to register bot', err);
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Register OpenClaw slash commands with the B24 bot.
172
+ * Runs in the background — errors are logged but don't block startup.
173
+ */
174
+ async function ensureCommandsRegistered(
175
+ api: Bitrix24Api,
176
+ config: Bitrix24AccountConfig,
177
+ botId: number,
178
+ logger: Logger,
179
+ ): Promise<void> {
180
+ const { webhookUrl, callbackUrl } = config;
181
+ if (!webhookUrl || !callbackUrl) return;
182
+
183
+ let registered = 0;
184
+ let skipped = 0;
185
+
186
+ for (const cmd of OPENCLAW_COMMANDS) {
187
+ try {
188
+ await api.registerCommand(webhookUrl, {
189
+ BOT_ID: botId,
190
+ COMMAND: cmd.command,
191
+ COMMON: 'N',
192
+ HIDDEN: 'N',
193
+ EXTRANET_SUPPORT: 'N',
194
+ LANG: [
195
+ { LANGUAGE_ID: 'en', TITLE: cmd.en, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
196
+ { LANGUAGE_ID: 'ru', TITLE: cmd.ru, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
197
+ ],
198
+ EVENT_COMMAND_ADD: callbackUrl,
199
+ });
200
+ registered++;
201
+ } catch (err: unknown) {
202
+ // "WRONG_REQUEST" typically means command already exists
203
+ const msg = err instanceof Error ? err.message : String(err);
204
+ if (msg.includes('WRONG_REQUEST') || msg.includes('already')) {
205
+ skipped++;
206
+ } else {
207
+ logger.warn(`Failed to register command /${cmd.command}`, err);
208
+ }
209
+ }
210
+ }
211
+
212
+ logger.info(`Commands sync: ${registered} registered, ${skipped} already existed (total ${OPENCLAW_COMMANDS.length})`);
213
+ }
214
+
25
215
  /**
26
216
  * Handle an incoming HTTP request on the webhook route.
27
217
  * Called by the HTTP route registered in index.ts.
@@ -141,6 +331,55 @@ export const bitrix24Plugin = {
141
331
  error: result.error,
142
332
  };
143
333
  },
334
+
335
+ /**
336
+ * Send a payload with optional channelData (keyboards, etc.) to B24.
337
+ * Called by OpenClaw when the response includes channelData.
338
+ */
339
+ sendPayload: async (params: {
340
+ text: string;
341
+ channelData?: Record<string, unknown>;
342
+ context: B24MsgContext;
343
+ account: { config: { webhookUrl?: string; showTyping?: boolean } };
344
+ }) => {
345
+ const { text, channelData, context, account } = params;
346
+
347
+ if (!gatewayState) {
348
+ return { ok: false, error: 'Gateway not started', channel: 'bitrix24' };
349
+ }
350
+
351
+ const { sendService } = gatewayState;
352
+
353
+ const sendCtx = {
354
+ webhookUrl: account.config.webhookUrl,
355
+ clientEndpoint: context.clientEndpoint,
356
+ botToken: context.botToken,
357
+ dialogId: context.chatId,
358
+ };
359
+
360
+ // Send typing indicator
361
+ if (account.config.showTyping !== false) {
362
+ await sendService.sendTyping(sendCtx);
363
+ }
364
+
365
+ // Extract keyboard from channelData
366
+ const keyboard = channelData
367
+ ? extractKeyboardFromPayload({ channelData })
368
+ : undefined;
369
+
370
+ const result = await sendService.sendText(
371
+ sendCtx,
372
+ text,
373
+ keyboard ? { keyboard } : undefined,
374
+ );
375
+
376
+ return {
377
+ ok: result.ok,
378
+ messageId: result.messageId,
379
+ channel: 'bitrix24' as const,
380
+ error: result.error,
381
+ };
382
+ },
144
383
  },
145
384
 
146
385
  gateway: {
@@ -166,12 +405,207 @@ export const bitrix24Plugin = {
166
405
 
167
406
  logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
168
407
 
169
- const api = new Bitrix24Api({ logger });
408
+ // Derive CLIENT_ID from webhookUrl (md5) stable and unique per portal
409
+ const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
410
+ const api = new Bitrix24Api({ logger, clientId });
170
411
  const sendService = new SendService(api, logger);
171
412
 
413
+ // Register or update bot on the B24 portal
414
+ const botId = await ensureBotRegistered(api, config, logger);
415
+
416
+ // Register slash commands (runs in background, doesn't block startup)
417
+ if (botId) {
418
+ ensureCommandsRegistered(api, config, botId, logger).catch((err) => {
419
+ logger.warn('Command registration failed', err);
420
+ });
421
+ }
422
+
172
423
  const inboundHandler = new InboundHandler({
173
424
  config,
174
425
  logger,
426
+
427
+ onMessage: async (msgCtx: B24MsgContext) => {
428
+ logger.info('Inbound message', {
429
+ senderId: msgCtx.senderId,
430
+ chatId: msgCtx.chatId,
431
+ messageId: msgCtx.messageId,
432
+ textLen: msgCtx.text.length,
433
+ });
434
+
435
+ const runtime = getBitrix24Runtime();
436
+ const cfg = runtime.config.loadConfig();
437
+
438
+ // Resolve which agent handles this conversation
439
+ const route = runtime.channel.routing.resolveAgentRoute({
440
+ cfg,
441
+ channel: 'bitrix24',
442
+ accountId: ctx.accountId,
443
+ peer: {
444
+ kind: msgCtx.isDm ? 'direct' : 'group',
445
+ id: msgCtx.chatId,
446
+ },
447
+ });
448
+
449
+ logger.debug('Resolved route', {
450
+ sessionKey: route.sessionKey,
451
+ agentId: route.agentId,
452
+ matchedBy: route.matchedBy,
453
+ });
454
+
455
+ // Build and finalize inbound context for OpenClaw agent
456
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
457
+ Body: msgCtx.text,
458
+ BodyForAgent: msgCtx.text,
459
+ RawBody: msgCtx.text,
460
+ From: `bitrix24:${msgCtx.chatId}`,
461
+ To: `bitrix24:${msgCtx.chatId}`,
462
+ SessionKey: route.sessionKey,
463
+ AccountId: route.accountId,
464
+ ChatType: msgCtx.isDm ? 'direct' : 'group',
465
+ ConversationLabel: msgCtx.senderName,
466
+ SenderName: msgCtx.senderName,
467
+ SenderId: msgCtx.senderId,
468
+ Provider: 'bitrix24',
469
+ Surface: 'bitrix24',
470
+ MessageSid: msgCtx.messageId,
471
+ Timestamp: Date.now(),
472
+ WasMentioned: false,
473
+ CommandAuthorized: true,
474
+ OriginatingChannel: 'bitrix24',
475
+ OriginatingTo: `bitrix24:${msgCtx.chatId}`,
476
+ });
477
+
478
+ const sendCtx = {
479
+ webhookUrl: config.webhookUrl,
480
+ clientEndpoint: msgCtx.clientEndpoint,
481
+ botToken: msgCtx.botToken,
482
+ dialogId: msgCtx.chatId,
483
+ };
484
+
485
+ // Dispatch to AI agent; deliver callback sends reply back to B24
486
+ try {
487
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
488
+ ctx: inboundCtx,
489
+ cfg,
490
+ dispatcherOptions: {
491
+ deliver: async (payload) => {
492
+ if (payload.text) {
493
+ const keyboard = extractKeyboardFromPayload(payload);
494
+ await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
495
+ }
496
+ },
497
+ onReplyStart: async () => {
498
+ if (config.showTyping !== false) {
499
+ await sendService.sendTyping(sendCtx);
500
+ }
501
+ },
502
+ onError: (err) => {
503
+ logger.error('Error delivering reply to B24', err);
504
+ },
505
+ },
506
+ });
507
+ } catch (err) {
508
+ logger.error('Error dispatching message to agent', err);
509
+ }
510
+ },
511
+
512
+ onCommand: async (event: B24CommandEvent) => {
513
+ const cmdEntry = Object.values(event.data.COMMAND)[0];
514
+ if (!cmdEntry) {
515
+ logger.warn('No command entry in ONIMCOMMANDADD event');
516
+ return;
517
+ }
518
+
519
+ const commandName = cmdEntry.COMMAND;
520
+ const commandParams = cmdEntry.COMMAND_PARAMS?.trim() ?? '';
521
+ const commandText = commandParams
522
+ ? `/${commandName} ${commandParams}`
523
+ : `/${commandName}`;
524
+
525
+ const senderId = String(event.data.PARAMS.FROM_USER_ID);
526
+ const dialogId = event.data.PARAMS.DIALOG_ID;
527
+ const isDm = event.data.PARAMS.CHAT_TYPE === 'P';
528
+ const user = event.data.USER;
529
+
530
+ logger.info('Inbound command', { commandName, commandParams, senderId, dialogId });
531
+
532
+ // Access control
533
+ if (!checkAccess(senderId, config)) {
534
+ logger.debug(`Access denied for command from user ${senderId}`);
535
+ return;
536
+ }
537
+
538
+ const runtime = getBitrix24Runtime();
539
+ const cfg = runtime.config.loadConfig();
540
+
541
+ const route = runtime.channel.routing.resolveAgentRoute({
542
+ cfg,
543
+ channel: 'bitrix24',
544
+ accountId: ctx.accountId,
545
+ peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
546
+ });
547
+
548
+ // Native commands use a separate slash-command session (like Telegram)
549
+ const slashSessionKey = `bitrix24:slash:${senderId}`;
550
+
551
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
552
+ Body: commandText,
553
+ BodyForAgent: commandText,
554
+ RawBody: commandText,
555
+ CommandBody: commandText,
556
+ CommandAuthorized: true,
557
+ CommandSource: 'native',
558
+ CommandTargetSessionKey: route.sessionKey,
559
+ From: `bitrix24:${dialogId}`,
560
+ To: `slash:${senderId}`,
561
+ SessionKey: slashSessionKey,
562
+ AccountId: route.accountId,
563
+ ChatType: isDm ? 'direct' : 'group',
564
+ ConversationLabel: user.NAME,
565
+ SenderName: user.NAME,
566
+ SenderId: senderId,
567
+ Provider: 'bitrix24',
568
+ Surface: 'bitrix24',
569
+ MessageSid: String(event.data.PARAMS.MESSAGE_ID),
570
+ Timestamp: Date.now(),
571
+ WasMentioned: true,
572
+ OriginatingChannel: 'bitrix24',
573
+ OriginatingTo: `bitrix24:${dialogId}`,
574
+ });
575
+
576
+ const sendCtx = {
577
+ webhookUrl: config.webhookUrl,
578
+ clientEndpoint: (cmdEntry.client_endpoint as string | undefined) ?? event.auth.client_endpoint,
579
+ botToken: cmdEntry.access_token,
580
+ dialogId,
581
+ };
582
+
583
+ try {
584
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
585
+ ctx: inboundCtx,
586
+ cfg,
587
+ dispatcherOptions: {
588
+ deliver: async (payload) => {
589
+ if (payload.text) {
590
+ const keyboard = extractKeyboardFromPayload(payload);
591
+ await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
592
+ }
593
+ },
594
+ onReplyStart: async () => {
595
+ if (config.showTyping !== false) {
596
+ await sendService.sendTyping(sendCtx);
597
+ }
598
+ },
599
+ onError: (err) => {
600
+ logger.error('Error delivering command reply to B24', err);
601
+ },
602
+ },
603
+ });
604
+ } catch (err) {
605
+ logger.error('Error dispatching command to agent', err);
606
+ }
607
+ },
608
+
175
609
  onJoinChat: async (event: B24JoinChatEvent) => {
176
610
  logger.info('Bot joined chat', {
177
611
  dialogId: event.data.PARAMS.DIALOG_ID,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * OpenClaw bot commands to register with Bitrix24.
3
+ *
4
+ * Mirrors the native commands registered by the Telegram plugin via setMyCommands.
5
+ */
6
+
7
+ export interface BotCommandDef {
8
+ command: string;
9
+ en: string;
10
+ ru: string;
11
+ params?: string;
12
+ }
13
+
14
+ /**
15
+ * Standard OpenClaw commands.
16
+ * Excludes: focus/unfocus/agents (Discord-specific), allowlist/bash (text-only scope).
17
+ */
18
+ export const OPENCLAW_COMMANDS: BotCommandDef[] = [
19
+ // ── Status ──
20
+ { command: 'help', en: 'Show available commands', ru: 'Показать доступные команды' },
21
+ { command: 'commands', en: 'List all slash commands', ru: 'Список всех команд' },
22
+ { command: 'status', en: 'Show current status', ru: 'Показать текущий статус' },
23
+ { command: 'context', en: 'Explain how context is built', ru: 'Объяснить построение контекста' },
24
+ { command: 'whoami', en: 'Show your sender ID', ru: 'Показать ваш ID' },
25
+ { command: 'usage', en: 'Usage and cost summary', ru: 'Использование и стоимость', params: 'off|tokens|full|cost' },
26
+
27
+ // ── Session ──
28
+ { command: 'new', en: 'Start a new session', ru: 'Начать новую сессию' },
29
+ { command: 'reset', en: 'Reset the current session', ru: 'Сбросить текущую сессию' },
30
+ { command: 'stop', en: 'Stop the current run', ru: 'Остановить текущий запуск' },
31
+ { command: 'compact', en: 'Compact the session context', ru: 'Сжать контекст сессии', params: 'instructions' },
32
+ { command: 'session', en: 'Manage session settings', ru: 'Настройки сессии', params: 'ttl|...' },
33
+
34
+ // ── Options ──
35
+ { command: 'model', en: 'Show or set the model', ru: 'Показать/сменить модель', params: 'model name' },
36
+ { command: 'models', en: 'List available models', ru: 'Список моделей', params: 'provider' },
37
+ { command: 'think', en: 'Set thinking level', ru: 'Уровень размышлений', params: 'off|low|medium|high' },
38
+ { command: 'verbose', en: 'Toggle verbose mode', ru: 'Подробный режим', params: 'on|off' },
39
+ { command: 'reasoning', en: 'Toggle reasoning visibility', ru: 'Видимость рассуждений', params: 'on|off|stream' },
40
+ { command: 'elevated', en: 'Toggle elevated mode', ru: 'Режим с расширенными правами', params: 'on|off|ask|full' },
41
+ { command: 'exec', en: 'Set exec defaults', ru: 'Настройки выполнения', params: 'host|security|ask|node' },
42
+ { command: 'queue', en: 'Adjust queue settings', ru: 'Настройки очереди', params: 'mode|debounce|cap|drop' },
43
+
44
+ // ── Management ──
45
+ { command: 'config', en: 'Show or set config values', ru: 'Показать/задать конфигурацию', params: 'show|get|set|unset' },
46
+ { command: 'debug', en: 'Set runtime debug overrides', ru: 'Отладочные настройки', params: 'show|reset|set|unset' },
47
+ { command: 'approve', en: 'Approve or deny exec requests', ru: 'Одобрить/отклонить запросы' },
48
+ { command: 'activation', en: 'Set group activation mode', ru: 'Режим активации в группах', params: 'mention|always' },
49
+ { command: 'send', en: 'Set send policy', ru: 'Политика отправки', params: 'on|off|inherit' },
50
+ { command: 'subagents', en: 'Manage subagent runs', ru: 'Управление субагентами' },
51
+ { command: 'kill', en: 'Kill a running subagent', ru: 'Остановить субагента', params: 'id|all' },
52
+ { command: 'steer', en: 'Send guidance to a subagent', ru: 'Направить субагента', params: 'message' },
53
+
54
+ // ── Tools ──
55
+ { command: 'skill', en: 'Run a skill by name', ru: 'Запустить навык', params: 'name' },
56
+ { command: 'restart', en: 'Restart OpenClaw', ru: 'Перезапустить OpenClaw' },
57
+
58
+ // ── Export ──
59
+ { command: 'export-session', en: 'Export session to HTML', ru: 'Экспорт сессии в HTML' },
60
+ ];
@@ -6,7 +6,7 @@ const AccountSchema = z.object({
6
6
  botName: z.string().optional().default('OpenClaw'),
7
7
  botCode: z.string().optional().default('openclaw'),
8
8
  botAvatar: z.string().optional(),
9
- callbackPath: z.string().optional().default('/hooks/bitrix24'),
9
+ callbackUrl: z.string().url().optional(),
10
10
  dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('open'),
11
11
  allowFrom: z.array(z.string()).optional(),
12
12
  showTyping: z.boolean().optional().default(true),
@@ -90,15 +90,25 @@ export function buildKeyboard(
90
90
  }>
91
91
  >,
92
92
  ): B24Keyboard {
93
- return rows.map((row) =>
94
- row.map((btn): KeyboardButton => ({
95
- TEXT: btn.text,
96
- ...(btn.command ? { COMMAND: btn.command, COMMAND_PARAMS: btn.commandParams ?? '' } : {}),
97
- ...(btn.link ? { LINK: btn.link } : {}),
98
- BG_COLOR: btn.bgColor ?? '#29619b',
99
- TEXT_COLOR: btn.textColor ?? '#fff',
100
- DISPLAY: btn.fullWidth ? 'LINE' : 'BLOCK',
101
- BLOCK: btn.disableAfterClick ? 'Y' : 'N',
102
- })),
103
- );
93
+ const keyboard: B24Keyboard = [];
94
+
95
+ for (let i = 0; i < rows.length; i++) {
96
+ for (const btn of rows[i]) {
97
+ keyboard.push({
98
+ TEXT: btn.text,
99
+ ...(btn.command ? { COMMAND: btn.command, COMMAND_PARAMS: btn.commandParams ?? '' } : {}),
100
+ ...(btn.link ? { LINK: btn.link } : {}),
101
+ BG_COLOR: btn.bgColor ?? '#29619b',
102
+ TEXT_COLOR: btn.textColor ?? '#fff',
103
+ DISPLAY: btn.fullWidth ? 'LINE' : 'BLOCK',
104
+ BLOCK: btn.disableAfterClick ? 'Y' : 'N',
105
+ });
106
+ }
107
+
108
+ if (i < rows.length - 1) {
109
+ keyboard.push({ TYPE: 'NEWLINE' });
110
+ }
111
+ }
112
+
113
+ return keyboard;
104
114
  }
package/src/runtime.ts CHANGED
@@ -1,9 +1,65 @@
1
+ interface ReplyPayload {
2
+ text?: string;
3
+ mediaUrl?: string;
4
+ mediaUrls?: string[];
5
+ isError?: boolean;
6
+ channelData?: Record<string, unknown>;
7
+ }
8
+
1
9
  export interface PluginRuntime {
2
- logger: {
3
- info: (...args: unknown[]) => void;
4
- warn: (...args: unknown[]) => void;
5
- error: (...args: unknown[]) => void;
6
- debug: (...args: unknown[]) => void;
10
+ config: {
11
+ loadConfig: () => Record<string, unknown>;
12
+ [key: string]: unknown;
13
+ };
14
+ channel: {
15
+ routing: {
16
+ resolveAgentRoute: (input: {
17
+ cfg: Record<string, unknown>;
18
+ channel: string;
19
+ accountId?: string | null;
20
+ peer?: { kind: string; id: string } | null;
21
+ }) => {
22
+ agentId: string;
23
+ channel: string;
24
+ accountId: string;
25
+ sessionKey: string;
26
+ mainSessionKey: string;
27
+ matchedBy: string;
28
+ };
29
+ };
30
+ reply: {
31
+ finalizeInboundContext: <T extends Record<string, unknown>>(
32
+ ctx: T,
33
+ opts?: Record<string, boolean>,
34
+ ) => T;
35
+ dispatchReplyWithBufferedBlockDispatcher: (params: {
36
+ ctx: Record<string, unknown>;
37
+ cfg: Record<string, unknown>;
38
+ dispatcherOptions: {
39
+ deliver: (payload: ReplyPayload, info: { kind: string }) => Promise<void>;
40
+ onReplyStart?: () => Promise<void> | void;
41
+ onIdle?: () => void;
42
+ onCleanup?: () => void;
43
+ onError?: (err: unknown, info: { kind: string }) => void;
44
+ };
45
+ replyOptions?: Record<string, unknown>;
46
+ }) => Promise<{ queuedFinal: boolean; counts: Record<string, number> }>;
47
+ [key: string]: unknown;
48
+ };
49
+ session: {
50
+ recordInboundSession: (params: Record<string, unknown>) => Promise<void>;
51
+ [key: string]: unknown;
52
+ };
53
+ [key: string]: unknown;
54
+ };
55
+ logging: {
56
+ getChildLogger: (bindings?: Record<string, unknown>) => {
57
+ info: (...args: unknown[]) => void;
58
+ warn: (...args: unknown[]) => void;
59
+ error: (...args: unknown[]) => void;
60
+ debug: (...args: unknown[]) => void;
61
+ };
62
+ [key: string]: unknown;
7
63
  };
8
64
  [key: string]: unknown;
9
65
  }
package/src/types.ts CHANGED
@@ -211,14 +211,23 @@ export interface KeyboardButton {
211
211
  COMMAND?: string;
212
212
  COMMAND_PARAMS?: string;
213
213
  BG_COLOR?: string;
214
+ BG_COLOR_TOKEN?: 'primary' | 'secondary' | 'alert' | 'base';
214
215
  TEXT_COLOR?: string;
215
216
  DISPLAY?: 'LINE' | 'BLOCK';
216
217
  DISABLED?: 'Y' | 'N';
217
218
  BLOCK?: 'Y' | 'N';
218
219
  LINK?: string;
220
+ WIDTH?: number;
221
+ ACTION?: 'PUT' | 'SEND' | 'COPY' | 'CALL' | 'DIALOG';
222
+ ACTION_VALUE?: string;
219
223
  }
220
224
 
221
- export type B24Keyboard = KeyboardButton[][];
225
+ export interface KeyboardNewline {
226
+ TYPE: 'NEWLINE';
227
+ }
228
+
229
+ /** B24 keyboard: flat array with NEWLINE separators between rows */
230
+ export type B24Keyboard = (KeyboardButton | KeyboardNewline)[];
222
231
 
223
232
  export interface SendMessageOptions {
224
233
  ATTACH?: unknown;
@@ -242,7 +251,7 @@ export interface Bitrix24AccountConfig {
242
251
  botName?: string;
243
252
  botCode?: string;
244
253
  botAvatar?: string;
245
- callbackPath?: string;
254
+ callbackUrl?: string;
246
255
  dmPolicy?: 'open' | 'allowlist' | 'pairing';
247
256
  allowFrom?: string[];
248
257
  showTyping?: boolean;
@@ -78,18 +78,16 @@ describe('splitMessage', () => {
78
78
  });
79
79
 
80
80
  describe('buildKeyboard', () => {
81
- it('builds a single row keyboard', () => {
81
+ it('builds a single row keyboard (flat array)', () => {
82
82
  const kb = buildKeyboard([
83
83
  [{ text: 'Yes', command: 'answer', commandParams: 'yes' }],
84
84
  ]);
85
+ // Flat array: 1 button, no NEWLINE
85
86
  expect(kb).toHaveLength(1);
86
- expect(kb[0]).toHaveLength(1);
87
- expect(kb[0][0].TEXT).toBe('Yes');
88
- expect(kb[0][0].COMMAND).toBe('answer');
89
- expect(kb[0][0].COMMAND_PARAMS).toBe('yes');
87
+ expect(kb[0]).toMatchObject({ TEXT: 'Yes', COMMAND: 'answer', COMMAND_PARAMS: 'yes' });
90
88
  });
91
89
 
92
- it('builds multi-row keyboard', () => {
90
+ it('builds multi-row keyboard with NEWLINE separators', () => {
93
91
  const kb = buildKeyboard([
94
92
  [
95
93
  { text: 'Yes', command: 'answer', commandParams: 'yes' },
@@ -97,27 +95,26 @@ describe('buildKeyboard', () => {
97
95
  ],
98
96
  [{ text: 'More info', link: 'https://example.com', fullWidth: true }],
99
97
  ]);
100
- expect(kb).toHaveLength(2);
101
- expect(kb[0]).toHaveLength(2);
102
- expect(kb[1]).toHaveLength(1);
103
- expect(kb[1][0].LINK).toBe('https://example.com');
104
- expect(kb[1][0].DISPLAY).toBe('LINE');
98
+ // Flat: [btn, btn, NEWLINE, btn] = 4 items
99
+ expect(kb).toHaveLength(4);
100
+ expect(kb[0]).toMatchObject({ TEXT: 'Yes' });
101
+ expect(kb[1]).toMatchObject({ TEXT: 'No' });
102
+ expect(kb[2]).toMatchObject({ TYPE: 'NEWLINE' });
103
+ expect(kb[3]).toMatchObject({ TEXT: 'More info', LINK: 'https://example.com', DISPLAY: 'LINE' });
105
104
  });
106
105
 
107
106
  it('applies default colors', () => {
108
107
  const kb = buildKeyboard([[{ text: 'Click' }]]);
109
- expect(kb[0][0].BG_COLOR).toBe('#29619b');
110
- expect(kb[0][0].TEXT_COLOR).toBe('#fff');
108
+ expect(kb[0]).toMatchObject({ BG_COLOR: '#29619b', TEXT_COLOR: '#fff' });
111
109
  });
112
110
 
113
111
  it('applies custom colors', () => {
114
112
  const kb = buildKeyboard([[{ text: 'Click', bgColor: '#333', textColor: '#eee' }]]);
115
- expect(kb[0][0].BG_COLOR).toBe('#333');
116
- expect(kb[0][0].TEXT_COLOR).toBe('#eee');
113
+ expect(kb[0]).toMatchObject({ BG_COLOR: '#333', TEXT_COLOR: '#eee' });
117
114
  });
118
115
 
119
116
  it('sets BLOCK=Y when disableAfterClick is true', () => {
120
117
  const kb = buildKeyboard([[{ text: 'Once', command: 'once', disableAfterClick: true }]]);
121
- expect(kb[0][0].BLOCK).toBe('Y');
118
+ expect(kb[0]).toMatchObject({ BLOCK: 'Y' });
122
119
  });
123
120
  });