@ihazz/bitrix24 0.1.4 → 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,15 +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",
51
50
  "callbackUrl": "https://your-server.com/hooks/bitrix24",
52
51
  "dmPolicy": "open",
52
+ "allowFrom": ["*"],
53
53
  "showTyping": true
54
54
  }
55
55
  }
56
56
  }
57
57
  ```
58
58
 
59
+ Set allow in plugin section:
60
+ ```json
61
+ {
62
+ "plugins": {
63
+ "allow": [
64
+ "bitrix24"
65
+ ],
66
+ }
67
+ }
68
+ ```
59
69
  Only `webhookUrl` is required. The gateway will not start without it.
60
70
 
61
71
  ### Configuration Options
@@ -65,8 +75,7 @@ Only `webhookUrl` is required. The gateway will not start without it.
65
75
  | `webhookUrl` | — | Bitrix24 REST webhook URL (**required**) |
66
76
  | `botName` | `"OpenClaw"` | Bot display name (shown in welcome message) |
67
77
  | `botCode` | `"openclaw"` | Unique bot code for `imbot.register` |
68
- | `callbackPath` | `"/hooks/bitrix24"` | Webhook endpoint path for incoming B24 events |
69
- | `callbackUrl` | — | Full public URL for bot registration (e.g. `https://your-server.com/hooks/bitrix24`).|
78
+ | `callbackUrl` | — | Full public URL for bot EVENT_HANDLER (e.g. `https://your-server.com/hooks/bitrix24`). Path is auto-extracted for route registration. |
70
79
  | `dmPolicy` | `"open"` | Access policy: `"open"` / `"allowlist"` / `"pairing"` |
71
80
  | `allowFrom` | — | Allowed B24 user IDs (when `dmPolicy: "allowlist"`) |
72
81
  | `showTyping` | `true` | Send typing indicator before responding |
package/index.ts CHANGED
@@ -28,9 +28,10 @@ export default {
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.4",
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
@@ -253,6 +253,33 @@ export class Bitrix24Api {
253
253
  return result.result;
254
254
  }
255
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
+
256
283
  async unregisterBot(webhookUrl: string, botId: number): Promise<boolean> {
257
284
  const result = await this.callWebhook<boolean>(webhookUrl, 'imbot.unregister', {
258
285
  BOT_ID: botId,
package/src/channel.ts CHANGED
@@ -4,9 +4,18 @@ import { listAccountIds, resolveAccount, getConfig } from './config.js';
4
4
  import { Bitrix24Api } from './api.js';
5
5
  import { SendService } from './send-service.js';
6
6
  import { InboundHandler } from './inbound-handler.js';
7
+ import { checkAccess } from './access-control.js';
7
8
  import { defaultLogger } from './utils.js';
8
9
  import { getBitrix24Runtime } from './runtime.js';
9
- import type { B24MsgContext, B24JoinChatEvent, Bitrix24AccountConfig } from './types.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';
10
19
 
11
20
  interface Logger {
12
21
  info: (...args: unknown[]) => void;
@@ -24,6 +33,82 @@ interface GatewayState {
24
33
 
25
34
  let gatewayState: GatewayState | null = null;
26
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
+
27
112
  /**
28
113
  * Register or update the bot on the Bitrix24 portal.
29
114
  * Checks imbot.bot.list first; if a bot with the same CODE exists, updates it.
@@ -82,6 +167,51 @@ async function ensureBotRegistered(
82
167
  }
83
168
  }
84
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
+
85
215
  /**
86
216
  * Handle an incoming HTTP request on the webhook route.
87
217
  * Called by the HTTP route registered in index.ts.
@@ -201,6 +331,55 @@ export const bitrix24Plugin = {
201
331
  error: result.error,
202
332
  };
203
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
+ },
204
383
  },
205
384
 
206
385
  gateway: {
@@ -232,7 +411,14 @@ export const bitrix24Plugin = {
232
411
  const sendService = new SendService(api, logger);
233
412
 
234
413
  // Register or update bot on the B24 portal
235
- await ensureBotRegistered(api, config, logger);
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
+ }
236
422
 
237
423
  const inboundHandler = new InboundHandler({
238
424
  config,
@@ -284,7 +470,7 @@ export const bitrix24Plugin = {
284
470
  MessageSid: msgCtx.messageId,
285
471
  Timestamp: Date.now(),
286
472
  WasMentioned: false,
287
- CommandAuthorized: false,
473
+ CommandAuthorized: true,
288
474
  OriginatingChannel: 'bitrix24',
289
475
  OriginatingTo: `bitrix24:${msgCtx.chatId}`,
290
476
  });
@@ -304,7 +490,8 @@ export const bitrix24Plugin = {
304
490
  dispatcherOptions: {
305
491
  deliver: async (payload) => {
306
492
  if (payload.text) {
307
- await sendService.sendText(sendCtx, payload.text);
493
+ const keyboard = extractKeyboardFromPayload(payload);
494
+ await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
308
495
  }
309
496
  },
310
497
  onReplyStart: async () => {
@@ -322,6 +509,103 @@ export const bitrix24Plugin = {
322
509
  }
323
510
  },
324
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
+
325
609
  onJoinChat: async (event: B24JoinChatEvent) => {
326
610
  logger.info('Bot joined chat', {
327
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,6 @@ 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'),
10
9
  callbackUrl: z.string().url().optional(),
11
10
  dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('open'),
12
11
  allowFrom: z.array(z.string()).optional(),
@@ -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/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,6 @@ export interface Bitrix24AccountConfig {
242
251
  botName?: string;
243
252
  botCode?: string;
244
253
  botAvatar?: string;
245
- callbackPath?: string;
246
254
  callbackUrl?: string;
247
255
  dmPolicy?: 'open' | 'allowlist' | 'pairing';
248
256
  allowFrom?: string[];
@@ -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
  });