@ihazz/bitrix24 0.1.4 → 0.1.6
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 +12 -3
- package/index.ts +3 -2
- package/package.json +1 -1
- package/src/access-control.ts +61 -4
- package/src/api.ts +107 -0
- package/src/channel.ts +405 -12
- package/src/commands.ts +60 -0
- package/src/config-schema.ts +1 -2
- package/src/inbound-handler.ts +1 -9
- package/src/media-service.ts +186 -0
- package/src/message-utils.ts +21 -11
- package/src/runtime.ts +23 -0
- package/src/types.ts +11 -2
- package/tests/access-control.test.ts +178 -6
- package/tests/channel.test.ts +538 -0
- package/tests/inbound-handler.test.ts +4 -2
- package/tests/media-service.test.ts +224 -0
- package/tests/message-utils.test.ts +13 -16
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { MediaService } from '../src/media-service.js';
|
|
4
|
+
import { Bitrix24Api } from '../src/api.js';
|
|
5
|
+
|
|
6
|
+
const silentLogger = {
|
|
7
|
+
info: vi.fn(),
|
|
8
|
+
warn: vi.fn(),
|
|
9
|
+
error: vi.fn(),
|
|
10
|
+
debug: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Mock the API
|
|
14
|
+
function mockApi(): Bitrix24Api {
|
|
15
|
+
return {
|
|
16
|
+
getFileInfo: vi.fn().mockResolvedValue({
|
|
17
|
+
DOWNLOAD_URL: 'https://example.com/download/test.txt?token=abc',
|
|
18
|
+
}),
|
|
19
|
+
getChatFolder: vi.fn().mockResolvedValue(42),
|
|
20
|
+
uploadFile: vi.fn().mockResolvedValue(100),
|
|
21
|
+
commitFileToChat: vi.fn().mockResolvedValue(true),
|
|
22
|
+
} as unknown as Bitrix24Api;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('MediaService', () => {
|
|
26
|
+
let service: MediaService;
|
|
27
|
+
let api: Bitrix24Api;
|
|
28
|
+
const savedPaths: string[] = [];
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
api = mockApi();
|
|
32
|
+
service = new MediaService(api, silentLogger);
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
// Clean up any files created during tests
|
|
38
|
+
for (const p of savedPaths) {
|
|
39
|
+
try { unlinkSync(p); } catch { /* ignore */ }
|
|
40
|
+
}
|
|
41
|
+
savedPaths.length = 0;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('downloadMedia', () => {
|
|
45
|
+
it('downloads and saves a file', async () => {
|
|
46
|
+
const mockContent = Buffer.from('hello world');
|
|
47
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
48
|
+
new Response(mockContent, { status: 200 }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const result = await service.downloadMedia({
|
|
52
|
+
fileId: '123',
|
|
53
|
+
fileName: 'test.txt',
|
|
54
|
+
extension: 'txt',
|
|
55
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
56
|
+
userToken: 'user_token',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result).not.toBeNull();
|
|
60
|
+
expect(result!.contentType).toBe('text/plain');
|
|
61
|
+
expect(result!.name).toBe('test.txt');
|
|
62
|
+
expect(result!.path).toContain('test.txt');
|
|
63
|
+
expect(existsSync(result!.path)).toBe(true);
|
|
64
|
+
savedPaths.push(result!.path);
|
|
65
|
+
|
|
66
|
+
expect(api.getFileInfo).toHaveBeenCalledWith(
|
|
67
|
+
'https://test.bitrix24.com/rest/',
|
|
68
|
+
'user_token',
|
|
69
|
+
123,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns correct MIME for images', async () => {
|
|
74
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
75
|
+
new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { status: 200 }),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = await service.downloadMedia({
|
|
79
|
+
fileId: '456',
|
|
80
|
+
fileName: 'photo.jpg',
|
|
81
|
+
extension: 'jpg',
|
|
82
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
83
|
+
userToken: 'token',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result).not.toBeNull();
|
|
87
|
+
expect(result!.contentType).toBe('image/jpeg');
|
|
88
|
+
if (result) savedPaths.push(result.path);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null when API fails', async () => {
|
|
92
|
+
(api.getFileInfo as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
93
|
+
new Error('API error'),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const result = await service.downloadMedia({
|
|
97
|
+
fileId: '789',
|
|
98
|
+
fileName: 'fail.txt',
|
|
99
|
+
extension: 'txt',
|
|
100
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
101
|
+
userToken: 'token',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result).toBeNull();
|
|
105
|
+
expect(silentLogger.error).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns null when no DOWNLOAD_URL', async () => {
|
|
109
|
+
(api.getFileInfo as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
|
|
110
|
+
|
|
111
|
+
const result = await service.downloadMedia({
|
|
112
|
+
fileId: '789',
|
|
113
|
+
fileName: 'nourl.txt',
|
|
114
|
+
extension: 'txt',
|
|
115
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
116
|
+
userToken: 'token',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns null when download fails', async () => {
|
|
123
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
124
|
+
new Response(null, { status: 404 }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const result = await service.downloadMedia({
|
|
128
|
+
fileId: '789',
|
|
129
|
+
fileName: 'missing.txt',
|
|
130
|
+
extension: 'txt',
|
|
131
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
132
|
+
userToken: 'token',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(result).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('handles unknown extensions with octet-stream', async () => {
|
|
139
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
140
|
+
new Response(Buffer.from('data'), { status: 200 }),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const result = await service.downloadMedia({
|
|
144
|
+
fileId: '100',
|
|
145
|
+
fileName: 'file.xyz',
|
|
146
|
+
extension: 'xyz',
|
|
147
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
148
|
+
userToken: 'token',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result).not.toBeNull();
|
|
152
|
+
expect(result!.contentType).toBe('application/octet-stream');
|
|
153
|
+
if (result) savedPaths.push(result.path);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('uploadMediaToChat', () => {
|
|
158
|
+
it('uploads file through 3-step process', async () => {
|
|
159
|
+
// Create a temp file to upload
|
|
160
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
161
|
+
const { join } = await import('node:path');
|
|
162
|
+
const { tmpdir } = await import('node:os');
|
|
163
|
+
const testDir = join(tmpdir(), 'openclaw-b24-test');
|
|
164
|
+
mkdirSync(testDir, { recursive: true });
|
|
165
|
+
const testPath = join(testDir, 'upload-test.txt');
|
|
166
|
+
writeFileSync(testPath, 'upload content');
|
|
167
|
+
savedPaths.push(testPath);
|
|
168
|
+
|
|
169
|
+
const result = await service.uploadMediaToChat({
|
|
170
|
+
localPath: testPath,
|
|
171
|
+
fileName: 'upload-test.txt',
|
|
172
|
+
chatId: 40985,
|
|
173
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
174
|
+
botToken: 'bot_token',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(true);
|
|
178
|
+
expect(api.getChatFolder).toHaveBeenCalledWith(
|
|
179
|
+
'https://test.bitrix24.com/rest/',
|
|
180
|
+
'bot_token',
|
|
181
|
+
40985,
|
|
182
|
+
);
|
|
183
|
+
expect(api.uploadFile).toHaveBeenCalledWith(
|
|
184
|
+
'https://test.bitrix24.com/rest/',
|
|
185
|
+
'bot_token',
|
|
186
|
+
42,
|
|
187
|
+
'upload-test.txt',
|
|
188
|
+
expect.any(Buffer),
|
|
189
|
+
);
|
|
190
|
+
expect(api.commitFileToChat).toHaveBeenCalledWith(
|
|
191
|
+
'https://test.bitrix24.com/rest/',
|
|
192
|
+
'bot_token',
|
|
193
|
+
40985,
|
|
194
|
+
100,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns false when getChatFolder fails', async () => {
|
|
199
|
+
(api.getChatFolder as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
200
|
+
new Error('folder error'),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
204
|
+
const { join } = await import('node:path');
|
|
205
|
+
const { tmpdir } = await import('node:os');
|
|
206
|
+
const testDir = join(tmpdir(), 'openclaw-b24-test');
|
|
207
|
+
mkdirSync(testDir, { recursive: true });
|
|
208
|
+
const testPath = join(testDir, 'fail-upload.txt');
|
|
209
|
+
writeFileSync(testPath, 'content');
|
|
210
|
+
savedPaths.push(testPath);
|
|
211
|
+
|
|
212
|
+
const result = await service.uploadMediaToChat({
|
|
213
|
+
localPath: testPath,
|
|
214
|
+
fileName: 'fail-upload.txt',
|
|
215
|
+
chatId: 40985,
|
|
216
|
+
clientEndpoint: 'https://test.bitrix24.com/rest/',
|
|
217
|
+
botToken: 'bot_token',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result).toBe(false);
|
|
221
|
+
expect(silentLogger.error).toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -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
|
});
|