@ihazz/bitrix24 0.1.3

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.
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getConfig, isConfigured, listAccountIds, resolveAccount } from '../src/config.js';
3
+
4
+ const mockCfg = {
5
+ channels: {
6
+ bitrix24: {
7
+ webhookUrl: 'https://test.bitrix24.com/rest/1/abc123/',
8
+ botName: 'TestBot',
9
+ dmPolicy: 'open',
10
+ accounts: {
11
+ portal2: {
12
+ webhookUrl: 'https://portal2.bitrix24.com/rest/1/xyz789/',
13
+ botName: 'Portal2Bot',
14
+ },
15
+ },
16
+ },
17
+ },
18
+ };
19
+
20
+ describe('getConfig', () => {
21
+ it('returns default config', () => {
22
+ const cfg = getConfig(mockCfg);
23
+ expect(cfg.webhookUrl).toBe('https://test.bitrix24.com/rest/1/abc123/');
24
+ expect(cfg.botName).toBe('TestBot');
25
+ });
26
+
27
+ it('returns account-specific config', () => {
28
+ const cfg = getConfig(mockCfg, 'portal2');
29
+ expect(cfg.webhookUrl).toBe('https://portal2.bitrix24.com/rest/1/xyz789/');
30
+ expect(cfg.botName).toBe('Portal2Bot');
31
+ });
32
+
33
+ it('returns empty config when no bitrix24 section', () => {
34
+ const cfg = getConfig({});
35
+ expect(cfg).toEqual({});
36
+ });
37
+
38
+ it('returns default config when account not found', () => {
39
+ const cfg = getConfig(mockCfg, 'nonexistent');
40
+ expect(cfg.webhookUrl).toBe('https://test.bitrix24.com/rest/1/abc123/');
41
+ });
42
+ });
43
+
44
+ describe('isConfigured', () => {
45
+ it('returns true when webhookUrl is set', () => {
46
+ expect(isConfigured(mockCfg)).toBe(true);
47
+ });
48
+
49
+ it('returns false when no config', () => {
50
+ expect(isConfigured({})).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe('listAccountIds', () => {
55
+ it('lists default and named accounts', () => {
56
+ const ids = listAccountIds(mockCfg);
57
+ expect(ids).toContain('default');
58
+ expect(ids).toContain('portal2');
59
+ expect(ids).toHaveLength(2);
60
+ });
61
+
62
+ it('returns empty when no config', () => {
63
+ expect(listAccountIds({})).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe('resolveAccount', () => {
68
+ it('resolves default account', () => {
69
+ const acct = resolveAccount(mockCfg);
70
+ expect(acct.accountId).toBe('default');
71
+ expect(acct.configured).toBe(true);
72
+ expect(acct.enabled).toBe(true);
73
+ });
74
+
75
+ it('resolves named account', () => {
76
+ const acct = resolveAccount(mockCfg, 'portal2');
77
+ expect(acct.accountId).toBe('portal2');
78
+ expect(acct.configured).toBe(true);
79
+ expect(acct.config.webhookUrl).toBe('https://portal2.bitrix24.com/rest/1/xyz789/');
80
+ });
81
+
82
+ it('handles missing config gracefully', () => {
83
+ const acct = resolveAccount({}, 'missing');
84
+ expect(acct.configured).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Dedup } from '../src/dedup.js';
3
+
4
+ describe('Dedup', () => {
5
+ let dedup: Dedup;
6
+
7
+ afterEach(() => {
8
+ dedup?.destroy();
9
+ });
10
+
11
+ it('returns false for first occurrence', () => {
12
+ dedup = new Dedup();
13
+ expect(dedup.isDuplicate(123)).toBe(false);
14
+ });
15
+
16
+ it('returns true for second occurrence', () => {
17
+ dedup = new Dedup();
18
+ dedup.isDuplicate(123);
19
+ expect(dedup.isDuplicate(123)).toBe(true);
20
+ });
21
+
22
+ it('handles string IDs', () => {
23
+ dedup = new Dedup();
24
+ expect(dedup.isDuplicate('abc')).toBe(false);
25
+ expect(dedup.isDuplicate('abc')).toBe(true);
26
+ });
27
+
28
+ it('tracks different IDs independently', () => {
29
+ dedup = new Dedup();
30
+ expect(dedup.isDuplicate(1)).toBe(false);
31
+ expect(dedup.isDuplicate(2)).toBe(false);
32
+ expect(dedup.isDuplicate(1)).toBe(true);
33
+ expect(dedup.isDuplicate(2)).toBe(true);
34
+ });
35
+
36
+ it('reports size correctly', () => {
37
+ dedup = new Dedup();
38
+ expect(dedup.size).toBe(0);
39
+ dedup.isDuplicate(1);
40
+ dedup.isDuplicate(2);
41
+ expect(dedup.size).toBe(2);
42
+ });
43
+
44
+ it('cleans up on destroy', () => {
45
+ dedup = new Dedup();
46
+ dedup.isDuplicate(1);
47
+ dedup.destroy();
48
+ expect(dedup.size).toBe(0);
49
+ });
50
+ });
@@ -0,0 +1,48 @@
1
+ {
2
+ "event": "ONIMBOTJOINCHAT",
3
+ "event_handler_id": 27,
4
+ "data": {
5
+ "BOT": {
6
+ "2081": {
7
+ "access_token": "bot_token_test_abc123",
8
+ "expires": 1771448424,
9
+ "scope": "imbot,im,disk",
10
+ "domain": "test.bitrix24.com",
11
+ "client_endpoint": "https://test.bitrix24.com/rest/",
12
+ "member_id": "test_member_id_123",
13
+ "user_id": 2081,
14
+ "client_id": "local.test123.app",
15
+ "application_token": "app_token_test_xyz",
16
+ "BOT_ID": 2081,
17
+ "BOT_CODE": "openclaw"
18
+ }
19
+ },
20
+ "PARAMS": {
21
+ "CHAT_TYPE": "P",
22
+ "MESSAGE_TYPE": "P",
23
+ "BOT_ID": 2081,
24
+ "USER_ID": 1,
25
+ "DIALOG_ID": "1",
26
+ "LANGUAGE": "ru"
27
+ },
28
+ "USER": {
29
+ "ID": 1,
30
+ "NAME": "Test User",
31
+ "FIRST_NAME": "Test",
32
+ "LAST_NAME": "User",
33
+ "WORK_POSITION": "Developer",
34
+ "GENDER": "M"
35
+ }
36
+ },
37
+ "ts": 1771444000,
38
+ "auth": {
39
+ "access_token": "user_token_test_def456",
40
+ "expires": 1771448424,
41
+ "scope": "imbot",
42
+ "domain": "test.bitrix24.com",
43
+ "client_endpoint": "https://test.bitrix24.com/rest/",
44
+ "member_id": "test_member_id_123",
45
+ "user_id": 1,
46
+ "application_token": "app_token_test_xyz"
47
+ }
48
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "event": "ONIMBOTMESSAGEADD",
3
+ "event_handler_id": 27,
4
+ "data": {
5
+ "BOT": {
6
+ "2081": {
7
+ "access_token": "bot_token_test_abc123",
8
+ "expires": 1771448424,
9
+ "scope": "imbot,im,disk",
10
+ "domain": "test.bitrix24.com",
11
+ "client_endpoint": "https://test.bitrix24.com/rest/",
12
+ "member_id": "test_member_id_123",
13
+ "user_id": 2081,
14
+ "client_id": "local.test123.app",
15
+ "application_token": "app_token_test_xyz",
16
+ "BOT_ID": 2081,
17
+ "BOT_CODE": "openclaw"
18
+ }
19
+ },
20
+ "PARAMS": {
21
+ "MESSAGE": "",
22
+ "TEMPLATE_ID": "e9c8e294-test-5678",
23
+ "MESSAGE_TYPE": "P",
24
+ "FROM_USER_ID": 1,
25
+ "DIALOG_ID": "1",
26
+ "TO_CHAT_ID": 40985,
27
+ "TO_USER_ID": 2081,
28
+ "MESSAGE_ID": 492035,
29
+ "CHAT_TYPE": "P",
30
+ "LANGUAGE": "ru",
31
+ "PLATFORM_CONTEXT": "web",
32
+ "PARAMS": {
33
+ "FILE_ID": [94611]
34
+ },
35
+ "FILES": {
36
+ "94611": {
37
+ "id": 94611,
38
+ "chatId": 40985,
39
+ "date": "2026-02-21T10:27:08+03:00",
40
+ "type": "file",
41
+ "name": "document.txt",
42
+ "extension": "txt",
43
+ "size": 101,
44
+ "image": 0,
45
+ "status": "done",
46
+ "progress": 100,
47
+ "authorId": 1,
48
+ "authorName": "Test User",
49
+ "urlPreview": "",
50
+ "urlShow": "https://test.bitrix24.com/disk/showFile/94611/",
51
+ "urlDownload": "https://test.bitrix24.com/disk/downloadFile/94611/",
52
+ "viewerAttrs": {
53
+ "viewer": "",
54
+ "viewerType": "code",
55
+ "src": "https://test.bitrix24.com/disk/viewer/94611/",
56
+ "objectId": 94611,
57
+ "title": "document.txt"
58
+ }
59
+ }
60
+ }
61
+ },
62
+ "USER": {
63
+ "ID": 1,
64
+ "NAME": "Test User",
65
+ "FIRST_NAME": "Test",
66
+ "LAST_NAME": "User",
67
+ "WORK_POSITION": "Developer",
68
+ "GENDER": "M",
69
+ "IS_BOT": "N",
70
+ "IS_CONNECTOR": "N",
71
+ "IS_NETWORK": "N",
72
+ "IS_EXTRANET": "N"
73
+ }
74
+ },
75
+ "ts": 1771444900,
76
+ "auth": {
77
+ "access_token": "user_token_test_def456",
78
+ "expires": 1771448424,
79
+ "scope": "imbot",
80
+ "domain": "test.bitrix24.com",
81
+ "client_endpoint": "https://test.bitrix24.com/rest/",
82
+ "member_id": "test_member_id_123",
83
+ "user_id": 1,
84
+ "application_token": "app_token_test_xyz"
85
+ }
86
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "event": "ONIMBOTMESSAGEADD",
3
+ "event_handler_id": 27,
4
+ "data": {
5
+ "BOT": {
6
+ "2081": {
7
+ "access_token": "bot_token_test_abc123",
8
+ "expires": 1771448424,
9
+ "scope": "imbot,im,disk",
10
+ "domain": "test.bitrix24.com",
11
+ "client_endpoint": "https://test.bitrix24.com/rest/",
12
+ "member_id": "test_member_id_123",
13
+ "user_id": 2081,
14
+ "client_id": "local.test123.app",
15
+ "application_token": "app_token_test_xyz",
16
+ "BOT_ID": 2081,
17
+ "BOT_CODE": "openclaw"
18
+ }
19
+ },
20
+ "PARAMS": {
21
+ "MESSAGE": "Hello, how are you?",
22
+ "TEMPLATE_ID": "ea602c80-test-1234",
23
+ "MESSAGE_TYPE": "P",
24
+ "FROM_USER_ID": 1,
25
+ "DIALOG_ID": "1",
26
+ "TO_CHAT_ID": 40985,
27
+ "AUTHOR_ID": 1,
28
+ "TO_USER_ID": 2081,
29
+ "MESSAGE_ID": 490659,
30
+ "CHAT_TYPE": "P",
31
+ "LANGUAGE": "ru",
32
+ "PLATFORM_CONTEXT": "web",
33
+ "CHAT_USER_COUNT": 2
34
+ },
35
+ "USER": {
36
+ "ID": 1,
37
+ "NAME": "Test User",
38
+ "FIRST_NAME": "Test",
39
+ "LAST_NAME": "User",
40
+ "WORK_POSITION": "Developer",
41
+ "GENDER": "M",
42
+ "IS_BOT": "N",
43
+ "IS_CONNECTOR": "N",
44
+ "IS_NETWORK": "N",
45
+ "IS_EXTRANET": "N"
46
+ }
47
+ },
48
+ "ts": 1771444824,
49
+ "auth": {
50
+ "access_token": "user_token_test_def456",
51
+ "expires": 1771448424,
52
+ "scope": "imbot",
53
+ "domain": "test.bitrix24.com",
54
+ "client_endpoint": "https://test.bitrix24.com/rest/",
55
+ "member_id": "test_member_id_123",
56
+ "user_id": 1,
57
+ "application_token": "app_token_test_xyz"
58
+ }
59
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "event": "ONIMCOMMANDADD",
3
+ "event_handler_id": 27,
4
+ "data": {
5
+ "COMMAND": {
6
+ "53": {
7
+ "access_token": "bot_token_test_abc123",
8
+ "BOT_ID": 2081,
9
+ "BOT_CODE": "openclaw",
10
+ "COMMAND": "help",
11
+ "COMMAND_ID": 53,
12
+ "COMMAND_PARAMS": "",
13
+ "COMMAND_CONTEXT": "TEXTAREA",
14
+ "MESSAGE_ID": 490667
15
+ }
16
+ },
17
+ "PARAMS": {
18
+ "MESSAGE": "/help",
19
+ "FROM_USER_ID": 1,
20
+ "DIALOG_ID": "1",
21
+ "TO_CHAT_ID": 40985,
22
+ "MESSAGE_ID": 490667,
23
+ "CHAT_TYPE": "P"
24
+ },
25
+ "USER": {
26
+ "ID": 1,
27
+ "NAME": "Test User",
28
+ "FIRST_NAME": "Test",
29
+ "LAST_NAME": "User",
30
+ "WORK_POSITION": "Developer",
31
+ "GENDER": "M"
32
+ }
33
+ },
34
+ "ts": 1771445000,
35
+ "auth": {
36
+ "access_token": "user_token_test_def456",
37
+ "expires": 1771448424,
38
+ "scope": "imbot",
39
+ "domain": "test.bitrix24.com",
40
+ "client_endpoint": "https://test.bitrix24.com/rest/",
41
+ "member_id": "test_member_id_123",
42
+ "user_id": 1,
43
+ "application_token": "app_token_test_xyz"
44
+ }
45
+ }
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { InboundHandler, normalizeMessageEvent } from '../src/inbound-handler.js';
3
+ import type { B24MessageEvent, B24MsgContext, B24BotEntry } from '../src/types.js';
4
+
5
+ // Import fixtures
6
+ import textFixture from './fixtures/onimbotmessageadd-text.json';
7
+ import fileFixture from './fixtures/onimbotmessageadd-file.json';
8
+ import joinFixture from './fixtures/onimbotjoinchat.json';
9
+ import commandFixture from './fixtures/onimcommandadd.json';
10
+
11
+ const silentLogger = {
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ debug: () => {},
16
+ };
17
+
18
+ describe('InboundHandler', () => {
19
+ let handler: InboundHandler;
20
+
21
+ afterEach(() => {
22
+ handler?.destroy();
23
+ });
24
+
25
+ it('dispatches text message events', async () => {
26
+ const onMessage = vi.fn();
27
+ handler = new InboundHandler({
28
+ config: { dmPolicy: 'open' },
29
+ logger: silentLogger,
30
+ onMessage,
31
+ });
32
+
33
+ const result = await handler.handleWebhook(textFixture);
34
+ expect(result).toBe(true);
35
+ expect(onMessage).toHaveBeenCalledOnce();
36
+
37
+ const ctx = onMessage.mock.calls[0][0] as B24MsgContext;
38
+ expect(ctx.channel).toBe('bitrix24');
39
+ expect(ctx.text).toBe('Hello, how are you?');
40
+ expect(ctx.senderId).toBe('1');
41
+ expect(ctx.senderName).toBe('Test User');
42
+ expect(ctx.chatId).toBe('1');
43
+ expect(ctx.isDm).toBe(true);
44
+ expect(ctx.isGroup).toBe(false);
45
+ expect(ctx.botToken).toBe('bot_token_test_abc123');
46
+ expect(ctx.userToken).toBe('user_token_test_def456');
47
+ });
48
+
49
+ it('deduplicates messages', async () => {
50
+ const onMessage = vi.fn();
51
+ handler = new InboundHandler({
52
+ config: { dmPolicy: 'open' },
53
+ logger: silentLogger,
54
+ onMessage,
55
+ });
56
+
57
+ await handler.handleWebhook(textFixture);
58
+ await handler.handleWebhook(textFixture);
59
+
60
+ expect(onMessage).toHaveBeenCalledOnce();
61
+ });
62
+
63
+ it('respects access control', async () => {
64
+ const onMessage = vi.fn();
65
+ handler = new InboundHandler({
66
+ config: { dmPolicy: 'allowlist', allowFrom: ['99'] },
67
+ logger: silentLogger,
68
+ onMessage,
69
+ });
70
+
71
+ await handler.handleWebhook(textFixture);
72
+ expect(onMessage).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('dispatches join chat events', async () => {
76
+ const onJoinChat = vi.fn();
77
+ handler = new InboundHandler({
78
+ config: { dmPolicy: 'open' },
79
+ logger: silentLogger,
80
+ onJoinChat,
81
+ });
82
+
83
+ const result = await handler.handleWebhook(joinFixture);
84
+ expect(result).toBe(true);
85
+ expect(onJoinChat).toHaveBeenCalledOnce();
86
+ });
87
+
88
+ it('dispatches command events', async () => {
89
+ const onCommand = vi.fn();
90
+ handler = new InboundHandler({
91
+ config: { dmPolicy: 'open' },
92
+ logger: silentLogger,
93
+ onCommand,
94
+ });
95
+
96
+ const result = await handler.handleWebhook(commandFixture);
97
+ expect(result).toBe(true);
98
+ expect(onCommand).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('returns false for unknown event types', async () => {
102
+ handler = new InboundHandler({
103
+ config: { dmPolicy: 'open' },
104
+ logger: silentLogger,
105
+ });
106
+
107
+ const result = await handler.handleWebhook({ event: 'UNKNOWN_EVENT' });
108
+ expect(result).toBe(false);
109
+ });
110
+
111
+ it('returns false for missing event type', async () => {
112
+ handler = new InboundHandler({
113
+ config: { dmPolicy: 'open' },
114
+ logger: silentLogger,
115
+ });
116
+
117
+ const result = await handler.handleWebhook({});
118
+ expect(result).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe('normalizeMessageEvent', () => {
123
+ it('normalizes a text message event', () => {
124
+ const event = textFixture as unknown as B24MessageEvent;
125
+ const botEntry = Object.values(event.data.BOT)[0] as B24BotEntry;
126
+ const ctx = normalizeMessageEvent(event, botEntry);
127
+
128
+ expect(ctx.channel).toBe('bitrix24');
129
+ expect(ctx.senderId).toBe('1');
130
+ expect(ctx.senderName).toBe('Test User');
131
+ expect(ctx.senderFirstName).toBe('Test');
132
+ expect(ctx.chatId).toBe('1');
133
+ expect(ctx.chatInternalId).toBe('40985');
134
+ expect(ctx.messageId).toBe('490659');
135
+ expect(ctx.text).toBe('Hello, how are you?');
136
+ expect(ctx.isDm).toBe(true);
137
+ expect(ctx.isGroup).toBe(false);
138
+ expect(ctx.media).toEqual([]);
139
+ expect(ctx.platform).toBe('web');
140
+ expect(ctx.language).toBe('ru');
141
+ expect(ctx.botId).toBe(2081);
142
+ expect(ctx.memberId).toBe('test_member_id_123');
143
+ expect(ctx.clientEndpoint).toBe('https://test.bitrix24.com/rest/');
144
+ });
145
+
146
+ it('normalizes a file message event', () => {
147
+ const event = fileFixture as unknown as B24MessageEvent;
148
+ const botEntry = Object.values(event.data.BOT)[0] as B24BotEntry;
149
+ const ctx = normalizeMessageEvent(event, botEntry);
150
+
151
+ expect(ctx.text).toBe('');
152
+ expect(ctx.media).toHaveLength(1);
153
+ expect(ctx.media[0]).toEqual({
154
+ id: '94611',
155
+ name: 'document.txt',
156
+ extension: 'txt',
157
+ size: 101,
158
+ type: 'file',
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { markdownToBbCode, splitMessage, buildKeyboard } from '../src/message-utils.js';
3
+
4
+ describe('markdownToBbCode', () => {
5
+ it('converts bold **text**', () => {
6
+ expect(markdownToBbCode('This is **bold** text')).toBe('This is [B]bold[/B] text');
7
+ });
8
+
9
+ it('converts bold __text__', () => {
10
+ expect(markdownToBbCode('This is __bold__ text')).toBe('This is [B]bold[/B] text');
11
+ });
12
+
13
+ it('converts italic *text*', () => {
14
+ expect(markdownToBbCode('This is *italic* text')).toBe('This is [I]italic[/I] text');
15
+ });
16
+
17
+ it('converts italic _text_', () => {
18
+ expect(markdownToBbCode('This is _italic_ text')).toBe('This is [I]italic[/I] text');
19
+ });
20
+
21
+ it('converts strikethrough ~~text~~', () => {
22
+ expect(markdownToBbCode('This is ~~deleted~~ text')).toBe('This is [S]deleted[/S] text');
23
+ });
24
+
25
+ it('converts inline code', () => {
26
+ expect(markdownToBbCode('Use `console.log`')).toBe('Use [CODE]console.log[/CODE]');
27
+ });
28
+
29
+ it('converts code blocks', () => {
30
+ const md = '```js\nconst x = 1;\n```';
31
+ const expected = '[CODE]const x = 1;\n[/CODE]';
32
+ expect(markdownToBbCode(md)).toBe(expected);
33
+ });
34
+
35
+ it('converts links [text](url)', () => {
36
+ expect(markdownToBbCode('[Google](https://google.com)'))
37
+ .toBe('[URL=https://google.com]Google[/URL]');
38
+ });
39
+
40
+ it('converts blockquotes', () => {
41
+ expect(markdownToBbCode('> This is a quote')).toBe('>>This is a quote');
42
+ });
43
+
44
+ it('handles mixed formatting', () => {
45
+ const input = '**Bold** and *italic* with `code`';
46
+ const expected = '[B]Bold[/B] and [I]italic[/I] with [CODE]code[/CODE]';
47
+ expect(markdownToBbCode(input)).toBe(expected);
48
+ });
49
+
50
+ it('preserves plain text', () => {
51
+ expect(markdownToBbCode('Hello world')).toBe('Hello world');
52
+ });
53
+ });
54
+
55
+ describe('splitMessage', () => {
56
+ it('returns single chunk for short messages', () => {
57
+ expect(splitMessage('Hello')).toEqual(['Hello']);
58
+ });
59
+
60
+ it('splits long messages at newlines', () => {
61
+ const text = 'Line 1\nLine 2\nLine 3';
62
+ const chunks = splitMessage(text, 10);
63
+ expect(chunks.length).toBeGreaterThan(1);
64
+ expect(chunks.join('\n')).toBe(text);
65
+ });
66
+
67
+ it('splits at limit when no newline found', () => {
68
+ const text = 'A'.repeat(30);
69
+ const chunks = splitMessage(text, 10);
70
+ expect(chunks.length).toBe(3);
71
+ expect(chunks.join('')).toBe(text);
72
+ });
73
+
74
+ it('handles exactly maxLen text', () => {
75
+ const text = 'A'.repeat(100);
76
+ expect(splitMessage(text, 100)).toEqual([text]);
77
+ });
78
+ });
79
+
80
+ describe('buildKeyboard', () => {
81
+ it('builds a single row keyboard', () => {
82
+ const kb = buildKeyboard([
83
+ [{ text: 'Yes', command: 'answer', commandParams: 'yes' }],
84
+ ]);
85
+ 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');
90
+ });
91
+
92
+ it('builds multi-row keyboard', () => {
93
+ const kb = buildKeyboard([
94
+ [
95
+ { text: 'Yes', command: 'answer', commandParams: 'yes' },
96
+ { text: 'No', command: 'answer', commandParams: 'no' },
97
+ ],
98
+ [{ text: 'More info', link: 'https://example.com', fullWidth: true }],
99
+ ]);
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');
105
+ });
106
+
107
+ it('applies default colors', () => {
108
+ const kb = buildKeyboard([[{ text: 'Click' }]]);
109
+ expect(kb[0][0].BG_COLOR).toBe('#29619b');
110
+ expect(kb[0][0].TEXT_COLOR).toBe('#fff');
111
+ });
112
+
113
+ it('applies custom colors', () => {
114
+ 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');
117
+ });
118
+
119
+ it('sets BLOCK=Y when disableAfterClick is true', () => {
120
+ const kb = buildKeyboard([[{ text: 'Once', command: 'once', disableAfterClick: true }]]);
121
+ expect(kb[0][0].BLOCK).toBe('Y');
122
+ });
123
+ });