@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 +13 -2
- package/index.ts +6 -5
- package/package.json +1 -1
- package/src/api.ts +59 -2
- package/src/channel.ts +436 -2
- package/src/commands.ts +60 -0
- package/src/config-schema.ts +1 -1
- package/src/message-utils.ts +21 -11
- package/src/runtime.ts +61 -5
- package/src/types.ts +11 -2
- package/tests/message-utils.test.ts +13 -16
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
|
-
"
|
|
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
|
-
| `
|
|
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:
|
|
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
|
|
27
|
+
setBitrix24Runtime(api.runtime);
|
|
28
28
|
|
|
29
29
|
api.registerChannel({ plugin: bitrix24Plugin });
|
|
30
30
|
|
|
31
|
-
// Register HTTP webhook route
|
|
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
|
|
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
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(
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
];
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/message-utils.ts
CHANGED
|
@@ -90,15 +90,25 @@ export function buildKeyboard(
|
|
|
90
90
|
}>
|
|
91
91
|
>,
|
|
92
92
|
): B24Keyboard {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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]).
|
|
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
|
-
|
|
101
|
-
expect(kb
|
|
102
|
-
expect(kb[
|
|
103
|
-
expect(kb[1]
|
|
104
|
-
expect(kb[
|
|
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]
|
|
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]
|
|
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]
|
|
118
|
+
expect(kb[0]).toMatchObject({ BLOCK: 'Y' });
|
|
122
119
|
});
|
|
123
120
|
});
|