@ihazz/bitrix24 0.2.4 → 1.0.0
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 +118 -164
- package/index.ts +46 -11
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/bitrix24/SKILL.md +70 -0
- package/src/access-control.ts +102 -46
- package/src/api.ts +434 -232
- package/src/channel.ts +1486 -393
- package/src/commands.ts +169 -31
- package/src/config-schema.ts +8 -3
- package/src/config.ts +11 -0
- package/src/dedup.ts +4 -0
- package/src/i18n.ts +127 -0
- package/src/inbound-handler.ts +306 -110
- package/src/media-service.ts +218 -65
- package/src/message-utils.ts +252 -10
- package/src/polling-service.ts +240 -0
- package/src/rate-limiter.ts +11 -6
- package/src/send-service.ts +140 -60
- package/src/types.ts +279 -185
- package/src/utils.ts +54 -3
- package/tests/access-control.test.ts +174 -58
- package/tests/api.test.ts +95 -0
- package/tests/channel.test.ts +279 -61
- package/tests/commands.test.ts +57 -0
- package/tests/config.test.ts +5 -1
- package/tests/i18n.test.ts +47 -0
- package/tests/inbound-handler.test.ts +554 -69
- package/tests/index.test.ts +94 -0
- package/tests/media-service.test.ts +146 -51
- package/tests/message-utils.test.ts +64 -0
- package/tests/polling-service.test.ts +77 -0
- package/tests/rate-limiter.test.ts +2 -2
- package/tests/send-service.test.ts +145 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import bitrix24Module, { collectBitrix24CallbackPaths } from '../index.ts';
|
|
3
|
+
|
|
4
|
+
const runtime = {
|
|
5
|
+
config: { loadConfig: () => ({}) },
|
|
6
|
+
channel: {
|
|
7
|
+
routing: { resolveAgentRoute: vi.fn() },
|
|
8
|
+
reply: {
|
|
9
|
+
finalizeInboundContext: vi.fn(),
|
|
10
|
+
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
session: { recordInboundSession: vi.fn() },
|
|
13
|
+
pairing: {
|
|
14
|
+
readAllowFromStore: vi.fn(),
|
|
15
|
+
upsertPairingRequest: vi.fn(),
|
|
16
|
+
buildPairingReply: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
logging: { getChildLogger: vi.fn() },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('collectBitrix24CallbackPaths', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('collects unique callback paths from base config and accounts', () => {
|
|
28
|
+
const paths = collectBitrix24CallbackPaths({
|
|
29
|
+
channels: {
|
|
30
|
+
bitrix24: {
|
|
31
|
+
callbackUrl: 'https://example.com/hooks/bitrix24',
|
|
32
|
+
accounts: {
|
|
33
|
+
a: { callbackUrl: 'https://example.com/hooks/bitrix24-a' },
|
|
34
|
+
b: { callbackUrl: 'https://other.example.com/hooks/bitrix24-a' },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(paths).toEqual(['/hooks/bitrix24', '/hooks/bitrix24-a']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('skips invalid callback urls without dropping valid ones', () => {
|
|
44
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
45
|
+
|
|
46
|
+
const paths = collectBitrix24CallbackPaths({
|
|
47
|
+
channels: {
|
|
48
|
+
bitrix24: {
|
|
49
|
+
callbackUrl: 'not-a-url',
|
|
50
|
+
accounts: {
|
|
51
|
+
a: { callbackUrl: 'https://example.com/hooks/bitrix24-a' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(paths).toEqual(['/hooks/bitrix24-a']);
|
|
58
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('bitrix24 index module', () => {
|
|
63
|
+
it('registers HTTP routes for every configured callback path', () => {
|
|
64
|
+
const registerChannel = vi.fn();
|
|
65
|
+
const registerHttpRoute = vi.fn();
|
|
66
|
+
|
|
67
|
+
bitrix24Module.register({
|
|
68
|
+
config: {
|
|
69
|
+
channels: {
|
|
70
|
+
bitrix24: {
|
|
71
|
+
callbackUrl: 'https://example.com/hooks/bitrix24',
|
|
72
|
+
accounts: {
|
|
73
|
+
a: { callbackUrl: 'https://example.com/hooks/bitrix24-a' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
runtime: runtime as never,
|
|
79
|
+
registerChannel,
|
|
80
|
+
registerHttpRoute,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(registerChannel).toHaveBeenCalledOnce();
|
|
84
|
+
expect(registerHttpRoute).toHaveBeenCalledTimes(2);
|
|
85
|
+
expect(registerHttpRoute).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
86
|
+
path: '/hooks/bitrix24',
|
|
87
|
+
auth: 'plugin',
|
|
88
|
+
}));
|
|
89
|
+
expect(registerHttpRoute).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
|
90
|
+
path: '/hooks/bitrix24-a',
|
|
91
|
+
auth: 'plugin',
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { MediaService, resolveManagedMediaDir } from '../src/media-service.js';
|
|
4
5
|
import { Bitrix24Api } from '../src/api.js';
|
|
5
6
|
|
|
6
7
|
const silentLogger = {
|
|
@@ -13,12 +14,13 @@ const silentLogger = {
|
|
|
13
14
|
// Mock the API
|
|
14
15
|
function mockApi(): Bitrix24Api {
|
|
15
16
|
return {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
getFileDownloadUrl: vi.fn().mockResolvedValue('https://example.com/download/test.txt?token=abc'),
|
|
18
|
+
uploadFile: vi.fn().mockResolvedValue({
|
|
19
|
+
file: { id: 100, name: 'upload-test.txt' },
|
|
20
|
+
messageId: 100,
|
|
21
|
+
chatId: 42,
|
|
22
|
+
dialogId: 'chat40985',
|
|
18
23
|
}),
|
|
19
|
-
getChatFolder: vi.fn().mockResolvedValue(42),
|
|
20
|
-
uploadFile: vi.fn().mockResolvedValue(100),
|
|
21
|
-
commitFileToChat: vi.fn().mockResolvedValue(true),
|
|
22
24
|
} as unknown as Bitrix24Api;
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -26,8 +28,10 @@ describe('MediaService', () => {
|
|
|
26
28
|
let service: MediaService;
|
|
27
29
|
let api: Bitrix24Api;
|
|
28
30
|
const savedPaths: string[] = [];
|
|
31
|
+
const tempStateDir = '/tmp/openclaw-test-state';
|
|
29
32
|
|
|
30
33
|
beforeEach(() => {
|
|
34
|
+
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
|
31
35
|
api = mockApi();
|
|
32
36
|
service = new MediaService(api, silentLogger);
|
|
33
37
|
vi.clearAllMocks();
|
|
@@ -39,6 +43,27 @@ describe('MediaService', () => {
|
|
|
39
43
|
try { unlinkSync(p); } catch { /* ignore */ }
|
|
40
44
|
}
|
|
41
45
|
savedPaths.length = 0;
|
|
46
|
+
delete process.env.OPENCLAW_STATE_DIR;
|
|
47
|
+
delete process.env.CLAWDBOT_STATE_DIR;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('resolveManagedMediaDir', () => {
|
|
51
|
+
it('uses OpenClaw state media directory by default', () => {
|
|
52
|
+
const mediaDir = resolveManagedMediaDir({
|
|
53
|
+
HOME: '/tmp/test-home',
|
|
54
|
+
} as NodeJS.ProcessEnv);
|
|
55
|
+
|
|
56
|
+
expect(mediaDir).toBe('/tmp/test-home/.openclaw/media/bitrix24');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('prefers OPENCLAW_STATE_DIR when it is configured', () => {
|
|
60
|
+
const mediaDir = resolveManagedMediaDir({
|
|
61
|
+
OPENCLAW_STATE_DIR: '/srv/openclaw-state',
|
|
62
|
+
HOME: '/tmp/test-home',
|
|
63
|
+
} as NodeJS.ProcessEnv);
|
|
64
|
+
|
|
65
|
+
expect(mediaDir).toBe('/srv/openclaw-state/media/bitrix24');
|
|
66
|
+
});
|
|
42
67
|
});
|
|
43
68
|
|
|
44
69
|
describe('downloadMedia', () => {
|
|
@@ -52,20 +77,23 @@ describe('MediaService', () => {
|
|
|
52
77
|
fileId: '123',
|
|
53
78
|
fileName: 'test.txt',
|
|
54
79
|
extension: 'txt',
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
81
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
82
|
+
dialogId: 'chat40985',
|
|
57
83
|
});
|
|
58
84
|
|
|
59
85
|
expect(result).not.toBeNull();
|
|
60
86
|
expect(result!.contentType).toBe('text/plain');
|
|
61
87
|
expect(result!.name).toBe('test.txt');
|
|
62
88
|
expect(result!.path).toContain('test.txt');
|
|
89
|
+
expect(result!.path.startsWith(join(tempStateDir, 'media', 'bitrix24'))).toBe(true);
|
|
63
90
|
expect(existsSync(result!.path)).toBe(true);
|
|
64
91
|
savedPaths.push(result!.path);
|
|
65
92
|
|
|
66
|
-
expect(api.
|
|
67
|
-
'https://test.bitrix24.com/rest/',
|
|
68
|
-
'
|
|
93
|
+
expect(api.getFileDownloadUrl).toHaveBeenCalledWith(
|
|
94
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
95
|
+
{ botId: 7, botToken: 'bot_token' },
|
|
96
|
+
'chat40985',
|
|
69
97
|
123,
|
|
70
98
|
);
|
|
71
99
|
});
|
|
@@ -79,8 +107,9 @@ describe('MediaService', () => {
|
|
|
79
107
|
fileId: '456',
|
|
80
108
|
fileName: 'photo.jpg',
|
|
81
109
|
extension: 'jpg',
|
|
82
|
-
|
|
83
|
-
|
|
110
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
111
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
112
|
+
dialogId: 'chat40985',
|
|
84
113
|
});
|
|
85
114
|
|
|
86
115
|
expect(result).not.toBeNull();
|
|
@@ -88,8 +117,55 @@ describe('MediaService', () => {
|
|
|
88
117
|
if (result) savedPaths.push(result.path);
|
|
89
118
|
});
|
|
90
119
|
|
|
120
|
+
it('cleans up managed downloaded temp files', async () => {
|
|
121
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
122
|
+
new Response(Buffer.from('cleanup me'), { status: 200 }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const result = await service.downloadMedia({
|
|
126
|
+
fileId: '654',
|
|
127
|
+
fileName: 'cleanup.txt',
|
|
128
|
+
extension: 'txt',
|
|
129
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
130
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
131
|
+
dialogId: 'chat40985',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(existsSync(result!.path)).toBe(true);
|
|
136
|
+
|
|
137
|
+
await service.cleanupDownloadedMedia([result!.path]);
|
|
138
|
+
|
|
139
|
+
expect(existsSync(result!.path)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rewrites private Bitrix download URLs to webhook origin', async () => {
|
|
143
|
+
(api.getFileDownloadUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
144
|
+
'https://192.168.50.50/rest/1/token/download/?token=abc',
|
|
145
|
+
);
|
|
146
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
147
|
+
new Response(Buffer.from('hello world'), { status: 200 }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const result = await service.downloadMedia({
|
|
151
|
+
fileId: '777',
|
|
152
|
+
fileName: 'private.txt',
|
|
153
|
+
extension: 'txt',
|
|
154
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
155
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
156
|
+
dialogId: '1',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result).not.toBeNull();
|
|
160
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
161
|
+
'https://test.bitrix24.com/rest/1/token/download/?token=abc',
|
|
162
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
163
|
+
);
|
|
164
|
+
if (result) savedPaths.push(result.path);
|
|
165
|
+
});
|
|
166
|
+
|
|
91
167
|
it('returns null when API fails', async () => {
|
|
92
|
-
(api.
|
|
168
|
+
(api.getFileDownloadUrl as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
93
169
|
new Error('API error'),
|
|
94
170
|
);
|
|
95
171
|
|
|
@@ -97,8 +173,9 @@ describe('MediaService', () => {
|
|
|
97
173
|
fileId: '789',
|
|
98
174
|
fileName: 'fail.txt',
|
|
99
175
|
extension: 'txt',
|
|
100
|
-
|
|
101
|
-
|
|
176
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
177
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
178
|
+
dialogId: 'chat40985',
|
|
102
179
|
});
|
|
103
180
|
|
|
104
181
|
expect(result).toBeNull();
|
|
@@ -106,14 +183,15 @@ describe('MediaService', () => {
|
|
|
106
183
|
});
|
|
107
184
|
|
|
108
185
|
it('returns null when no DOWNLOAD_URL', async () => {
|
|
109
|
-
(api.
|
|
186
|
+
(api.getFileDownloadUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce('');
|
|
110
187
|
|
|
111
188
|
const result = await service.downloadMedia({
|
|
112
189
|
fileId: '789',
|
|
113
190
|
fileName: 'nourl.txt',
|
|
114
191
|
extension: 'txt',
|
|
115
|
-
|
|
116
|
-
|
|
192
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
193
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
194
|
+
dialogId: 'chat40985',
|
|
117
195
|
});
|
|
118
196
|
|
|
119
197
|
expect(result).toBeNull();
|
|
@@ -128,8 +206,9 @@ describe('MediaService', () => {
|
|
|
128
206
|
fileId: '789',
|
|
129
207
|
fileName: 'missing.txt',
|
|
130
208
|
extension: 'txt',
|
|
131
|
-
|
|
132
|
-
|
|
209
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
210
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
211
|
+
dialogId: 'chat40985',
|
|
133
212
|
});
|
|
134
213
|
|
|
135
214
|
expect(result).toBeNull();
|
|
@@ -144,18 +223,41 @@ describe('MediaService', () => {
|
|
|
144
223
|
fileId: '100',
|
|
145
224
|
fileName: 'file.xyz',
|
|
146
225
|
extension: 'xyz',
|
|
147
|
-
|
|
148
|
-
|
|
226
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
227
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
228
|
+
dialogId: 'chat40985',
|
|
149
229
|
});
|
|
150
230
|
|
|
151
231
|
expect(result).not.toBeNull();
|
|
152
232
|
expect(result!.contentType).toBe('application/octet-stream');
|
|
153
233
|
if (result) savedPaths.push(result.path);
|
|
154
234
|
});
|
|
235
|
+
|
|
236
|
+
it('prefers response content type when extension is unknown', async () => {
|
|
237
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
238
|
+
new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
|
239
|
+
status: 200,
|
|
240
|
+
headers: { 'content-type': 'image/jpeg; charset=binary' },
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const result = await service.downloadMedia({
|
|
245
|
+
fileId: '101',
|
|
246
|
+
fileName: 'forwarded-file',
|
|
247
|
+
extension: '',
|
|
248
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
249
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
250
|
+
dialogId: 'chat40985',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(result).not.toBeNull();
|
|
254
|
+
expect(result!.contentType).toBe('image/jpeg');
|
|
255
|
+
if (result) savedPaths.push(result.path);
|
|
256
|
+
});
|
|
155
257
|
});
|
|
156
258
|
|
|
157
259
|
describe('uploadMediaToChat', () => {
|
|
158
|
-
it('uploads file through
|
|
260
|
+
it('uploads file through imbot.v2.File.upload', async () => {
|
|
159
261
|
// Create a temp file to upload
|
|
160
262
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
161
263
|
const { join } = await import('node:path');
|
|
@@ -169,35 +271,28 @@ describe('MediaService', () => {
|
|
|
169
271
|
const result = await service.uploadMediaToChat({
|
|
170
272
|
localPath: testPath,
|
|
171
273
|
fileName: 'upload-test.txt',
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
274
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
275
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
276
|
+
dialogId: 'chat40985',
|
|
277
|
+
message: 'hello',
|
|
175
278
|
});
|
|
176
279
|
|
|
177
|
-
expect(result).
|
|
178
|
-
expect(api.getChatFolder).toHaveBeenCalledWith(
|
|
179
|
-
'https://test.bitrix24.com/rest/',
|
|
180
|
-
'bot_token',
|
|
181
|
-
40985,
|
|
182
|
-
);
|
|
280
|
+
expect(result).toEqual({ ok: true, messageId: 100 });
|
|
183
281
|
expect(api.uploadFile).toHaveBeenCalledWith(
|
|
184
|
-
'https://test.bitrix24.com/rest/',
|
|
185
|
-
'bot_token',
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
'bot_token',
|
|
193
|
-
40985,
|
|
194
|
-
100,
|
|
282
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
283
|
+
{ botId: 7, botToken: 'bot_token' },
|
|
284
|
+
'chat40985',
|
|
285
|
+
{
|
|
286
|
+
name: 'upload-test.txt',
|
|
287
|
+
content: Buffer.from('upload content').toString('base64'),
|
|
288
|
+
message: 'hello',
|
|
289
|
+
},
|
|
195
290
|
);
|
|
196
291
|
});
|
|
197
292
|
|
|
198
|
-
it('returns false when
|
|
199
|
-
(api.
|
|
200
|
-
new Error('
|
|
293
|
+
it('returns { ok: false } when upload fails', async () => {
|
|
294
|
+
(api.uploadFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
295
|
+
new Error('upload error'),
|
|
201
296
|
);
|
|
202
297
|
|
|
203
298
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
@@ -212,12 +307,12 @@ describe('MediaService', () => {
|
|
|
212
307
|
const result = await service.uploadMediaToChat({
|
|
213
308
|
localPath: testPath,
|
|
214
309
|
fileName: 'fail-upload.txt',
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
310
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
311
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
312
|
+
dialogId: 'chat40985',
|
|
218
313
|
});
|
|
219
314
|
|
|
220
|
-
expect(result).
|
|
315
|
+
expect(result).toEqual({ ok: false });
|
|
221
316
|
expect(silentLogger.error).toHaveBeenCalled();
|
|
222
317
|
});
|
|
223
318
|
});
|
|
@@ -26,6 +26,14 @@ describe('markdownToBbCode', () => {
|
|
|
26
26
|
expect(markdownToBbCode('Use `console.log`')).toBe('Use [CODE]console.log[/CODE]');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('renders slash commands as bold burgundy inline accents', () => {
|
|
30
|
+
expect(markdownToBbCode('Try `/status`')).toBe('Try [B][COLOR=#7A1F3D]/status[/COLOR][/B]');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders short acronyms as bold burgundy inline accents', () => {
|
|
34
|
+
expect(markdownToBbCode('Use `API` here')).toBe('Use [B][COLOR=#7A1F3D]API[/COLOR][/B] here');
|
|
35
|
+
});
|
|
36
|
+
|
|
29
37
|
it('converts code blocks', () => {
|
|
30
38
|
const md = '```js\nconst x = 1;\n```';
|
|
31
39
|
const expected = '[CODE]const x = 1;[/CODE]';
|
|
@@ -37,10 +45,66 @@ describe('markdownToBbCode', () => {
|
|
|
37
45
|
.toBe('[URL=https://google.com]Google[/URL]');
|
|
38
46
|
});
|
|
39
47
|
|
|
48
|
+
it('converts markdown images to lowercase img tag without title text', () => {
|
|
49
|
+
expect(markdownToBbCode(''))
|
|
50
|
+
.toBe('[img size=medium]https://via.placeholder.com/150#image.jpg [/img]');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps supported image extensions unchanged', () => {
|
|
54
|
+
expect(markdownToBbCode(''))
|
|
55
|
+
.toBe('[img size=medium]https://example.com/image.png [/img]');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('adds image.jpg suffix for unsupported image extensions', () => {
|
|
59
|
+
expect(markdownToBbCode(''))
|
|
60
|
+
.toBe('[img size=medium]https://mcgrp.ru/img/2017_05/img_1007484.avif#image.jpg [/img]');
|
|
61
|
+
});
|
|
62
|
+
|
|
40
63
|
it('converts blockquotes', () => {
|
|
41
64
|
expect(markdownToBbCode('> This is a quote')).toBe('>>This is a quote');
|
|
42
65
|
});
|
|
43
66
|
|
|
67
|
+
it('converts nested blockquotes to Bitrix-compatible quoted blocks', () => {
|
|
68
|
+
expect(markdownToBbCode([
|
|
69
|
+
'> цитата уровня 1',
|
|
70
|
+
'>> цитата уровня два',
|
|
71
|
+
].join('\n'))).toBe([
|
|
72
|
+
'>>цитата уровня 1',
|
|
73
|
+
'>>------------------------------------------------------',
|
|
74
|
+
'>>цитата уровня два',
|
|
75
|
+
'>>------------------------------------------------------',
|
|
76
|
+
].join('\n'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('skips empty spacer lines inside nested blockquotes', () => {
|
|
80
|
+
expect(markdownToBbCode([
|
|
81
|
+
'> Это первая цитата уровня 1.',
|
|
82
|
+
'>',
|
|
83
|
+
'>> Это вторая цитата уровня 2, вложенная в первую.',
|
|
84
|
+
].join('\n'))).toBe([
|
|
85
|
+
'>>Это первая цитата уровня 1.',
|
|
86
|
+
'>>------------------------------------------------------',
|
|
87
|
+
'>>Это вторая цитата уровня 2, вложенная в первую.',
|
|
88
|
+
'>>------------------------------------------------------',
|
|
89
|
+
].join('\n'));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('converts GFM tables to simplified plain text tables', () => {
|
|
93
|
+
const input = [
|
|
94
|
+
'| Заголовок 1 | Заголовок 2 |',
|
|
95
|
+
'| ----------- | ----------- |',
|
|
96
|
+
'| Ячейка 1 | Ячейка 2 |',
|
|
97
|
+
'| Ячейка 3 | Ячейка 4 |',
|
|
98
|
+
].join('\n');
|
|
99
|
+
|
|
100
|
+
expect(markdownToBbCode(input)).toBe([
|
|
101
|
+
'Заголовок 1 | Заголовок 2',
|
|
102
|
+
'_________________________',
|
|
103
|
+
'Ячейка 1 | Ячейка 2',
|
|
104
|
+
'Ячейка 3 | Ячейка 4',
|
|
105
|
+
].join('\n'));
|
|
106
|
+
});
|
|
107
|
+
|
|
44
108
|
it('handles mixed formatting', () => {
|
|
45
109
|
const input = '**Bold** and *italic* with `code`';
|
|
46
110
|
const expected = '[B]Bold[/B] and [I]italic[/I] with [CODE]code[/CODE]';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { PollingService } from '../src/polling-service.js';
|
|
6
|
+
import type { Bitrix24Api } from '../src/api.js';
|
|
7
|
+
|
|
8
|
+
const silentLogger = {
|
|
9
|
+
info: vi.fn(),
|
|
10
|
+
warn: vi.fn(),
|
|
11
|
+
error: vi.fn(),
|
|
12
|
+
debug: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('PollingService', () => {
|
|
16
|
+
let tempHome = '';
|
|
17
|
+
const originalHome = process.env.HOME;
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (tempHome) {
|
|
21
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
22
|
+
tempHome = '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (originalHome === undefined) {
|
|
26
|
+
delete process.env.HOME;
|
|
27
|
+
} else {
|
|
28
|
+
process.env.HOME = originalHome;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('persists offset only up to the last successfully processed event', async () => {
|
|
35
|
+
tempHome = mkdtempSync(join(tmpdir(), 'b24-polling-'));
|
|
36
|
+
process.env.HOME = tempHome;
|
|
37
|
+
|
|
38
|
+
const abortController = new AbortController();
|
|
39
|
+
const fetchEvents = vi.fn().mockResolvedValue({
|
|
40
|
+
events: [
|
|
41
|
+
{ eventId: 10, type: 'ONE', date: '', data: {} },
|
|
42
|
+
{ eventId: 11, type: 'TWO', date: '', data: {} },
|
|
43
|
+
],
|
|
44
|
+
lastEventId: 11,
|
|
45
|
+
hasMore: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const api = { fetchEvents } as unknown as Bitrix24Api;
|
|
49
|
+
const onEvent = vi.fn(async (event: { eventId: number }) => {
|
|
50
|
+
if (event.eventId === 11) {
|
|
51
|
+
abortController.abort();
|
|
52
|
+
throw new Error('boom');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const service = new PollingService({
|
|
57
|
+
api,
|
|
58
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
59
|
+
bot: { botId: 7, botToken: 'bot_token' },
|
|
60
|
+
accountId: 'default',
|
|
61
|
+
pollingIntervalMs: 10,
|
|
62
|
+
pollingFastIntervalMs: 10,
|
|
63
|
+
onEvent: onEvent as never,
|
|
64
|
+
abortSignal: abortController.signal,
|
|
65
|
+
logger: silentLogger,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await service.start();
|
|
69
|
+
|
|
70
|
+
const statePath = join(tempHome, '.openclaw', 'state', 'bitrix24', 'poll-offset-default.json');
|
|
71
|
+
const state = JSON.parse(readFileSync(statePath, 'utf-8')) as { offset: number };
|
|
72
|
+
|
|
73
|
+
expect(fetchEvents).toHaveBeenCalledOnce();
|
|
74
|
+
expect(onEvent).toHaveBeenCalledTimes(2);
|
|
75
|
+
expect(state.offset).toBe(11);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -42,11 +42,11 @@ describe('RateLimiter', () => {
|
|
|
42
42
|
expect(limiter.pending).toBe(0);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it('
|
|
45
|
+
it('rejects pending on destroy', async () => {
|
|
46
46
|
limiter = new RateLimiter({ maxPerSecond: 1 });
|
|
47
47
|
await limiter.acquire();
|
|
48
48
|
const p = limiter.acquire();
|
|
49
49
|
limiter.destroy();
|
|
50
|
-
await p;
|
|
50
|
+
await expect(p).rejects.toThrow('RateLimiter destroyed');
|
|
51
51
|
});
|
|
52
52
|
});
|