@ihazz/bitrix24 1.1.1 → 1.1.2
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/package.json +9 -1
- package/src/media-service.ts +78 -25
- package/src/state-paths.ts +24 -0
- package/tests/access-control.test.ts +0 -398
- package/tests/api.test.ts +0 -226
- package/tests/channel-flow.test.ts +0 -1692
- package/tests/channel.test.ts +0 -842
- package/tests/commands.test.ts +0 -57
- package/tests/config.test.ts +0 -210
- package/tests/dedup.test.ts +0 -50
- package/tests/fixtures/onimbotjoinchat.json +0 -48
- package/tests/fixtures/onimbotmessageadd-file.json +0 -86
- package/tests/fixtures/onimbotmessageadd-text.json +0 -59
- package/tests/fixtures/onimcommandadd.json +0 -45
- package/tests/group-access.test.ts +0 -340
- package/tests/history-cache.test.ts +0 -117
- package/tests/i18n.test.ts +0 -90
- package/tests/inbound-handler.test.ts +0 -1033
- package/tests/index.test.ts +0 -94
- package/tests/media-service.test.ts +0 -319
- package/tests/message-utils.test.ts +0 -184
- package/tests/polling-service.test.ts +0 -115
- package/tests/rate-limiter.test.ts +0 -52
- package/tests/send-service.test.ts +0 -162
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -9
package/tests/channel.test.ts
DELETED
|
@@ -1,842 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { Readable, PassThrough } from 'node:stream';
|
|
3
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
-
import {
|
|
5
|
-
__setGatewayStateForTests,
|
|
6
|
-
buildBotCodeCandidates,
|
|
7
|
-
buildConversationSessionKey,
|
|
8
|
-
canCoalesceDirectMessage,
|
|
9
|
-
convertButtonsToKeyboard,
|
|
10
|
-
extractKeyboardFromPayload,
|
|
11
|
-
handleWebhookRequest,
|
|
12
|
-
mergeBufferedDirectMessages,
|
|
13
|
-
mergeForwardedMessageContext,
|
|
14
|
-
resolveConversationRef,
|
|
15
|
-
resolveDirectMessageCoalesceDelay,
|
|
16
|
-
shouldSkipJoinChatWelcome,
|
|
17
|
-
bitrix24Plugin,
|
|
18
|
-
} from '../src/channel.js';
|
|
19
|
-
import type { KeyboardButton, KeyboardNewline, B24Keyboard } from '../src/types.js';
|
|
20
|
-
|
|
21
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
const silentLogger = {
|
|
24
|
-
info: vi.fn(),
|
|
25
|
-
warn: vi.fn(),
|
|
26
|
-
error: vi.fn(),
|
|
27
|
-
debug: vi.fn(),
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
function isButton(item: KeyboardButton | KeyboardNewline): item is KeyboardButton {
|
|
31
|
-
return !('TYPE' in item);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function isNewline(item: KeyboardButton | KeyboardNewline): item is KeyboardNewline {
|
|
35
|
-
return 'TYPE' in item && item.TYPE === 'NEWLINE';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('buildBotCodeCandidates', () => {
|
|
39
|
-
it('uses explicit botCode when it is configured', () => {
|
|
40
|
-
expect(buildBotCodeCandidates({
|
|
41
|
-
webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
|
|
42
|
-
botCode: 'custom_bot',
|
|
43
|
-
}, 5)).toEqual(['custom_bot']);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('builds automatic codes from webhook user id', () => {
|
|
47
|
-
expect(buildBotCodeCandidates({
|
|
48
|
-
webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
|
|
49
|
-
}, 4)).toEqual([
|
|
50
|
-
'openclaw_42',
|
|
51
|
-
'openclaw_42_2',
|
|
52
|
-
'openclaw_42_3',
|
|
53
|
-
'openclaw_42_4',
|
|
54
|
-
]);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// ─── convertButtonsToKeyboard ────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
describe('convertButtonsToKeyboard', () => {
|
|
61
|
-
it('converts a single button with slash command', () => {
|
|
62
|
-
const kb = convertButtonsToKeyboard([
|
|
63
|
-
[{ text: 'Help', callback_data: '/help' }],
|
|
64
|
-
]);
|
|
65
|
-
|
|
66
|
-
expect(kb).toHaveLength(1);
|
|
67
|
-
const btn = kb[0];
|
|
68
|
-
expect(isButton(btn)).toBe(true);
|
|
69
|
-
if (isButton(btn)) {
|
|
70
|
-
expect(btn.TEXT).toBe('Help');
|
|
71
|
-
expect(btn.COMMAND).toBe('help');
|
|
72
|
-
expect(btn.COMMAND_PARAMS).toBeUndefined();
|
|
73
|
-
expect(btn.DISPLAY).toBe('LINE');
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('converts a registered command without leading slash', () => {
|
|
78
|
-
const kb = convertButtonsToKeyboard([
|
|
79
|
-
[{ text: 'Help', callback_data: 'help' }],
|
|
80
|
-
]);
|
|
81
|
-
|
|
82
|
-
const btn = kb[0];
|
|
83
|
-
expect(isButton(btn)).toBe(true);
|
|
84
|
-
if (isButton(btn)) {
|
|
85
|
-
expect(btn.COMMAND).toBe('help');
|
|
86
|
-
expect(btn.COMMAND_PARAMS).toBeUndefined();
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('converts slash command with params', () => {
|
|
91
|
-
const kb = convertButtonsToKeyboard([
|
|
92
|
-
[{ text: 'Set Model', callback_data: '/model gpt-4o' }],
|
|
93
|
-
]);
|
|
94
|
-
|
|
95
|
-
expect(kb).toHaveLength(1);
|
|
96
|
-
const btn = kb[0];
|
|
97
|
-
if (isButton(btn)) {
|
|
98
|
-
expect(btn.COMMAND).toBe('model');
|
|
99
|
-
expect(btn.COMMAND_PARAMS).toBe('gpt-4o');
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('converts a registered command with params without leading slash', () => {
|
|
104
|
-
const kb = convertButtonsToKeyboard([
|
|
105
|
-
[{ text: 'Set Model', callback_data: 'model gpt-4o' }],
|
|
106
|
-
]);
|
|
107
|
-
|
|
108
|
-
const btn = kb[0];
|
|
109
|
-
if (isButton(btn)) {
|
|
110
|
-
expect(btn.COMMAND).toBe('model');
|
|
111
|
-
expect(btn.COMMAND_PARAMS).toBe('gpt-4o');
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('converts slash command with multiple params', () => {
|
|
116
|
-
const kb = convertButtonsToKeyboard([
|
|
117
|
-
[{ text: 'Think', callback_data: '/think high extra' }],
|
|
118
|
-
]);
|
|
119
|
-
|
|
120
|
-
const btn = kb[0];
|
|
121
|
-
if (isButton(btn)) {
|
|
122
|
-
expect(btn.COMMAND).toBe('think');
|
|
123
|
-
expect(btn.COMMAND_PARAMS).toBe('high extra');
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('converts non-slash callback_data to ACTION SEND with text as value', () => {
|
|
128
|
-
const kb = convertButtonsToKeyboard([
|
|
129
|
-
[{ text: 'Click me', callback_data: 'some_value' }],
|
|
130
|
-
]);
|
|
131
|
-
|
|
132
|
-
const btn = kb[0];
|
|
133
|
-
if (isButton(btn)) {
|
|
134
|
-
expect(btn.COMMAND).toBeUndefined();
|
|
135
|
-
expect(btn.ACTION).toBe('SEND');
|
|
136
|
-
expect(btn.ACTION_VALUE).toBe('Click me');
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('handles button without callback_data', () => {
|
|
141
|
-
const kb = convertButtonsToKeyboard([
|
|
142
|
-
[{ text: 'Plain' }],
|
|
143
|
-
]);
|
|
144
|
-
|
|
145
|
-
const btn = kb[0];
|
|
146
|
-
if (isButton(btn)) {
|
|
147
|
-
expect(btn.TEXT).toBe('Plain');
|
|
148
|
-
expect(btn.COMMAND).toBeUndefined();
|
|
149
|
-
expect(btn.ACTION).toBeUndefined();
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('maps primary style to BG_COLOR_TOKEN', () => {
|
|
154
|
-
const kb = convertButtonsToKeyboard([
|
|
155
|
-
[{ text: 'Go', callback_data: '/start', style: 'primary' }],
|
|
156
|
-
]);
|
|
157
|
-
|
|
158
|
-
const btn = kb[0];
|
|
159
|
-
if (isButton(btn)) {
|
|
160
|
-
expect(btn.BG_COLOR_TOKEN).toBe('primary');
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('maps attention style to alert BG_COLOR_TOKEN', () => {
|
|
165
|
-
const kb = convertButtonsToKeyboard([
|
|
166
|
-
[{ text: 'Stop', callback_data: '/stop', style: 'attention' }],
|
|
167
|
-
]);
|
|
168
|
-
|
|
169
|
-
const btn = kb[0];
|
|
170
|
-
if (isButton(btn)) {
|
|
171
|
-
expect(btn.BG_COLOR_TOKEN).toBe('alert');
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('maps danger style to alert BG_COLOR_TOKEN', () => {
|
|
176
|
-
const kb = convertButtonsToKeyboard([
|
|
177
|
-
[{ text: 'Delete', callback_data: '/reset', style: 'danger' }],
|
|
178
|
-
]);
|
|
179
|
-
|
|
180
|
-
const btn = kb[0];
|
|
181
|
-
if (isButton(btn)) {
|
|
182
|
-
expect(btn.BG_COLOR_TOKEN).toBe('alert');
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('does not set BG_COLOR_TOKEN for unknown style', () => {
|
|
187
|
-
const kb = convertButtonsToKeyboard([
|
|
188
|
-
[{ text: 'Btn', callback_data: '/ok', style: 'custom' }],
|
|
189
|
-
]);
|
|
190
|
-
|
|
191
|
-
const btn = kb[0];
|
|
192
|
-
if (isButton(btn)) {
|
|
193
|
-
expect(btn.BG_COLOR_TOKEN).toBeUndefined();
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it('adds NEWLINE between rows', () => {
|
|
198
|
-
const kb = convertButtonsToKeyboard([
|
|
199
|
-
[{ text: 'A', callback_data: '/a' }],
|
|
200
|
-
[{ text: 'B', callback_data: '/b' }],
|
|
201
|
-
]);
|
|
202
|
-
|
|
203
|
-
// [btn, NEWLINE, btn] = 3 items
|
|
204
|
-
expect(kb).toHaveLength(3);
|
|
205
|
-
expect(isButton(kb[0])).toBe(true);
|
|
206
|
-
expect(isNewline(kb[1])).toBe(true);
|
|
207
|
-
expect(isButton(kb[2])).toBe(true);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('handles multiple buttons per row', () => {
|
|
211
|
-
const kb = convertButtonsToKeyboard([
|
|
212
|
-
[
|
|
213
|
-
{ text: 'Yes', callback_data: '/yes' },
|
|
214
|
-
{ text: 'No', callback_data: '/no' },
|
|
215
|
-
],
|
|
216
|
-
]);
|
|
217
|
-
|
|
218
|
-
// Single row with 2 buttons, no NEWLINE
|
|
219
|
-
expect(kb).toHaveLength(2);
|
|
220
|
-
expect(isButton(kb[0])).toBe(true);
|
|
221
|
-
expect(isButton(kb[1])).toBe(true);
|
|
222
|
-
if (isButton(kb[0])) expect(kb[0].COMMAND).toBe('yes');
|
|
223
|
-
if (isButton(kb[1])) expect(kb[1].COMMAND).toBe('no');
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('handles multiple rows with multiple buttons', () => {
|
|
227
|
-
const kb = convertButtonsToKeyboard([
|
|
228
|
-
[{ text: 'A' }, { text: 'B' }],
|
|
229
|
-
[{ text: 'C' }],
|
|
230
|
-
[{ text: 'D' }, { text: 'E' }, { text: 'F' }],
|
|
231
|
-
]);
|
|
232
|
-
|
|
233
|
-
// Row1(2) + NL + Row2(1) + NL + Row3(3) = 2+1+1+1+3 = 8
|
|
234
|
-
expect(kb).toHaveLength(8);
|
|
235
|
-
expect(isNewline(kb[2])).toBe(true);
|
|
236
|
-
expect(isNewline(kb[4])).toBe(true);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('handles empty rows array', () => {
|
|
240
|
-
const kb = convertButtonsToKeyboard([]);
|
|
241
|
-
expect(kb).toHaveLength(0);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('handles empty row inside rows', () => {
|
|
245
|
-
const kb = convertButtonsToKeyboard([[]]);
|
|
246
|
-
expect(kb).toHaveLength(0);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// ─── extractKeyboardFromPayload ──────────────────────────────────────────────
|
|
251
|
-
|
|
252
|
-
describe('extractKeyboardFromPayload', () => {
|
|
253
|
-
it('returns undefined when no channelData', () => {
|
|
254
|
-
expect(extractKeyboardFromPayload({})).toBeUndefined();
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('returns undefined when channelData is empty', () => {
|
|
258
|
-
expect(extractKeyboardFromPayload({ channelData: {} })).toBeUndefined();
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('returns undefined when channelData buttons is empty array', () => {
|
|
262
|
-
const result = extractKeyboardFromPayload({
|
|
263
|
-
channelData: { telegram: { buttons: [] } },
|
|
264
|
-
});
|
|
265
|
-
expect(result).toBeUndefined();
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('extracts direct B24 keyboard from channelData.bitrix24', () => {
|
|
269
|
-
const directKeyboard: B24Keyboard = [
|
|
270
|
-
{ TEXT: 'Direct', COMMAND: 'test' },
|
|
271
|
-
];
|
|
272
|
-
|
|
273
|
-
const result = extractKeyboardFromPayload({
|
|
274
|
-
channelData: { bitrix24: { keyboard: directKeyboard } },
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
expect(result).toEqual(directKeyboard);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('prefers B24 keyboard over generic channel buttons', () => {
|
|
281
|
-
const directKeyboard: B24Keyboard = [{ TEXT: 'B24' }];
|
|
282
|
-
|
|
283
|
-
const result = extractKeyboardFromPayload({
|
|
284
|
-
channelData: {
|
|
285
|
-
bitrix24: { keyboard: directKeyboard },
|
|
286
|
-
telegram: { buttons: [[{ text: 'TG', callback_data: '/tg' }]] },
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
expect(result).toEqual(directKeyboard);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('converts channel buttons to B24 keyboard', () => {
|
|
294
|
-
const result = extractKeyboardFromPayload({
|
|
295
|
-
channelData: {
|
|
296
|
-
telegram: {
|
|
297
|
-
buttons: [[{ text: 'Help', callback_data: '/help' }]],
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
expect(result).toBeDefined();
|
|
303
|
-
expect(result).toHaveLength(1);
|
|
304
|
-
if (result && isButton(result[0])) {
|
|
305
|
-
expect(result[0].TEXT).toBe('Help');
|
|
306
|
-
expect(result[0].COMMAND).toBe('help');
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it('converts multi-row channel buttons', () => {
|
|
311
|
-
const result = extractKeyboardFromPayload({
|
|
312
|
-
channelData: {
|
|
313
|
-
telegram: {
|
|
314
|
-
buttons: [
|
|
315
|
-
[{ text: 'A', callback_data: '/a' }],
|
|
316
|
-
[{ text: 'B', callback_data: '/b' }],
|
|
317
|
-
],
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
// [btn, NEWLINE, btn]
|
|
323
|
-
expect(result).toHaveLength(3);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it('returns undefined when bitrix24.keyboard is empty', () => {
|
|
327
|
-
const result = extractKeyboardFromPayload({
|
|
328
|
-
channelData: { bitrix24: { keyboard: [] } },
|
|
329
|
-
});
|
|
330
|
-
expect(result).toBeUndefined();
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('ignores unrelated channelData keys', () => {
|
|
334
|
-
const result = extractKeyboardFromPayload({
|
|
335
|
-
channelData: { discord: { something: true } },
|
|
336
|
-
});
|
|
337
|
-
expect(result).toBeUndefined();
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
describe('direct message coalescing helpers', () => {
|
|
342
|
-
const baseMsgCtx = {
|
|
343
|
-
channel: 'bitrix24' as const,
|
|
344
|
-
senderId: '1',
|
|
345
|
-
senderName: 'Test User',
|
|
346
|
-
senderFirstName: 'Test',
|
|
347
|
-
chatId: '1',
|
|
348
|
-
chatInternalId: '40985',
|
|
349
|
-
messageId: '100',
|
|
350
|
-
text: 'hello',
|
|
351
|
-
isDm: true,
|
|
352
|
-
isGroup: false,
|
|
353
|
-
media: [],
|
|
354
|
-
language: 'ru',
|
|
355
|
-
raw: { type: 'ONIMBOTV2MESSAGEADD' },
|
|
356
|
-
botId: 295,
|
|
357
|
-
memberId: '',
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
it('coalesces only plain direct text messages', () => {
|
|
361
|
-
expect(canCoalesceDirectMessage(baseMsgCtx, { dmPolicy: 'webhookUser' })).toBe(true);
|
|
362
|
-
expect(canCoalesceDirectMessage({ ...baseMsgCtx, text: '/status' }, { dmPolicy: 'webhookUser' })).toBe(false);
|
|
363
|
-
expect(canCoalesceDirectMessage({ ...baseMsgCtx, media: [{ id: '1', name: 'x', extension: '', size: 1, type: 'file' }] }, { dmPolicy: 'webhookUser' })).toBe(false);
|
|
364
|
-
expect(canCoalesceDirectMessage({ ...baseMsgCtx, replyToMessageId: '77' }, { dmPolicy: 'webhookUser' })).toBe(false);
|
|
365
|
-
expect(canCoalesceDirectMessage({ ...baseMsgCtx, isForwarded: true }, { dmPolicy: 'webhookUser' })).toBe(false);
|
|
366
|
-
expect(canCoalesceDirectMessage({ ...baseMsgCtx, isDm: false, isGroup: true }, { dmPolicy: 'webhookUser' })).toBe(false);
|
|
367
|
-
expect(canCoalesceDirectMessage(baseMsgCtx, { dmPolicy: 'pairing' })).toBe(false);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it('merges buffered direct messages with newline separators and last message id', () => {
|
|
371
|
-
const merged = mergeBufferedDirectMessages([
|
|
372
|
-
baseMsgCtx,
|
|
373
|
-
{ ...baseMsgCtx, messageId: '101', text: 'second' },
|
|
374
|
-
{ ...baseMsgCtx, messageId: '102', text: 'third', language: 'en' },
|
|
375
|
-
]);
|
|
376
|
-
|
|
377
|
-
expect(merged.text).toBe('hello\nsecond\nthird');
|
|
378
|
-
expect(merged.messageId).toBe('102');
|
|
379
|
-
expect(merged.language).toBe('en');
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it('merges a forwarded message with buffered question context', () => {
|
|
383
|
-
const merged = mergeForwardedMessageContext(
|
|
384
|
-
baseMsgCtx,
|
|
385
|
-
{
|
|
386
|
-
...baseMsgCtx,
|
|
387
|
-
messageId: '103',
|
|
388
|
-
text: 'Forwarded body',
|
|
389
|
-
replyToMessageId: '77',
|
|
390
|
-
media: [{ id: '1', name: 'photo.jpg', extension: '', size: 42, type: 'file' }],
|
|
391
|
-
isForwarded: true,
|
|
392
|
-
},
|
|
393
|
-
);
|
|
394
|
-
|
|
395
|
-
expect(merged.text).toBe('[User question about the forwarded message]\n\nhello\n\n[/User question]\n\nForwarded body');
|
|
396
|
-
expect(merged.messageId).toBe('103');
|
|
397
|
-
expect(merged.media).toHaveLength(1);
|
|
398
|
-
expect(merged.replyToMessageId).toBeUndefined();
|
|
399
|
-
expect(merged.isForwarded).toBe(false);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('uses short debounce and extends the window until max wait', () => {
|
|
403
|
-
expect(resolveDirectMessageCoalesceDelay({
|
|
404
|
-
startedAt: 1000,
|
|
405
|
-
now: 1000,
|
|
406
|
-
})).toBe(200);
|
|
407
|
-
|
|
408
|
-
expect(resolveDirectMessageCoalesceDelay({
|
|
409
|
-
startedAt: 1000,
|
|
410
|
-
now: 1150,
|
|
411
|
-
})).toBe(200);
|
|
412
|
-
|
|
413
|
-
expect(resolveDirectMessageCoalesceDelay({
|
|
414
|
-
startedAt: 1000,
|
|
415
|
-
now: 5900,
|
|
416
|
-
})).toBe(100);
|
|
417
|
-
|
|
418
|
-
expect(resolveDirectMessageCoalesceDelay({
|
|
419
|
-
startedAt: 1000,
|
|
420
|
-
now: 6200,
|
|
421
|
-
})).toBe(0);
|
|
422
|
-
});
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
describe('conversation helpers', () => {
|
|
426
|
-
it('uses direct dialog id as the stable conversation identity', () => {
|
|
427
|
-
const conversation = resolveConversationRef({
|
|
428
|
-
accountId: 'default',
|
|
429
|
-
dialogId: '2386',
|
|
430
|
-
isDirect: true,
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
expect(conversation.dialogId).toBe('2386');
|
|
434
|
-
expect(conversation.address).toBe('bitrix24:2386');
|
|
435
|
-
expect(conversation.historyKey).toBe('default:2386');
|
|
436
|
-
expect(conversation.peer).toEqual({ kind: 'direct', id: '2386' });
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('uses group dialog id as the stable conversation identity', () => {
|
|
440
|
-
const conversation = resolveConversationRef({
|
|
441
|
-
accountId: 'default',
|
|
442
|
-
dialogId: 'chat520',
|
|
443
|
-
isDirect: false,
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
expect(conversation.dialogId).toBe('chat520');
|
|
447
|
-
expect(conversation.address).toBe('bitrix24:chat520');
|
|
448
|
-
expect(conversation.historyKey).toBe('default:chat520');
|
|
449
|
-
expect(conversation.peer).toEqual({ kind: 'group', id: 'chat520' });
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it('builds session keys from the stable conversation address', () => {
|
|
453
|
-
const conversation = resolveConversationRef({
|
|
454
|
-
accountId: 'default',
|
|
455
|
-
dialogId: '2386',
|
|
456
|
-
isDirect: true,
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
expect(buildConversationSessionKey('route:session', conversation))
|
|
460
|
-
.toBe('route:session:bitrix24:2386');
|
|
461
|
-
});
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
describe('join chat helpers', () => {
|
|
465
|
-
it('does not skip unsupported group chats even in webhookUser mode', () => {
|
|
466
|
-
expect(shouldSkipJoinChatWelcome({
|
|
467
|
-
dialogId: 'chat123',
|
|
468
|
-
chatType: 'chat',
|
|
469
|
-
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
470
|
-
dmPolicy: 'webhookUser',
|
|
471
|
-
})).toBe(false);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it('skips welcome for non-owner direct dialogs in webhookUser mode', () => {
|
|
475
|
-
expect(shouldSkipJoinChatWelcome({
|
|
476
|
-
dialogId: '42',
|
|
477
|
-
chatType: 'user',
|
|
478
|
-
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
479
|
-
dmPolicy: 'webhookUser',
|
|
480
|
-
})).toBe(true);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
it('does not skip welcome for owner direct dialogs in webhookUser mode', () => {
|
|
484
|
-
expect(shouldSkipJoinChatWelcome({
|
|
485
|
-
dialogId: '1',
|
|
486
|
-
chatType: 'user',
|
|
487
|
-
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
488
|
-
dmPolicy: 'webhookUser',
|
|
489
|
-
})).toBe(false);
|
|
490
|
-
});
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
// ─── handleWebhookRequest ────────────────────────────────────────────────────
|
|
494
|
-
|
|
495
|
-
describe('handleWebhookRequest', () => {
|
|
496
|
-
afterEach(() => {
|
|
497
|
-
__setGatewayStateForTests(null);
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
function mockReq(method: string, body?: string): IncomingMessage {
|
|
501
|
-
const stream = new PassThrough();
|
|
502
|
-
if (body) {
|
|
503
|
-
stream.end(body);
|
|
504
|
-
} else {
|
|
505
|
-
stream.end();
|
|
506
|
-
}
|
|
507
|
-
(stream as unknown as Record<string, unknown>).method = method;
|
|
508
|
-
return stream as unknown as IncomingMessage;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function mockRes(): ServerResponse & { _statusCode: number; _body: string; _headers: Record<string, string> } {
|
|
512
|
-
const res = {
|
|
513
|
-
_statusCode: 200,
|
|
514
|
-
_body: '',
|
|
515
|
-
_headers: {} as Record<string, string>,
|
|
516
|
-
get statusCode() { return this._statusCode; },
|
|
517
|
-
set statusCode(v: number) { this._statusCode = v; },
|
|
518
|
-
setHeader(key: string, val: string) { this._headers[key] = val; },
|
|
519
|
-
end(body?: string) { this._body = body ?? ''; },
|
|
520
|
-
};
|
|
521
|
-
return res as unknown as ServerResponse & { _statusCode: number; _body: string; _headers: Record<string, string> };
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
it('rejects non-POST with 405', async () => {
|
|
525
|
-
const req = mockReq('GET');
|
|
526
|
-
const res = mockRes();
|
|
527
|
-
|
|
528
|
-
await handleWebhookRequest(req, res);
|
|
529
|
-
|
|
530
|
-
expect(res._statusCode).toBe(405);
|
|
531
|
-
expect(res._body).toBe('Method Not Allowed');
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
it('returns 503 when gateway not started', async () => {
|
|
535
|
-
const req = mockReq('POST', 'event=test');
|
|
536
|
-
const res = mockRes();
|
|
537
|
-
|
|
538
|
-
await handleWebhookRequest(req, res);
|
|
539
|
-
|
|
540
|
-
expect(res._statusCode).toBe(503);
|
|
541
|
-
expect(res._body).toBe('Channel not started');
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it('returns fetch mode acknowledgement when gateway runs in fetch mode', async () => {
|
|
545
|
-
__setGatewayStateForTests({
|
|
546
|
-
eventMode: 'fetch',
|
|
547
|
-
inboundHandler: { handleWebhook: vi.fn() },
|
|
548
|
-
} as never);
|
|
549
|
-
|
|
550
|
-
const req = mockReq('POST', '{"event":"test"}');
|
|
551
|
-
const res = mockRes();
|
|
552
|
-
|
|
553
|
-
await handleWebhookRequest(req, res);
|
|
554
|
-
|
|
555
|
-
expect(res._statusCode).toBe(200);
|
|
556
|
-
expect(res._body).toBe('FETCH mode active');
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
it('returns 400 when webhook payload is invalid', async () => {
|
|
560
|
-
const handleWebhook = vi.fn().mockResolvedValue(false);
|
|
561
|
-
__setGatewayStateForTests({
|
|
562
|
-
eventMode: 'webhook',
|
|
563
|
-
inboundHandler: { handleWebhook },
|
|
564
|
-
} as never);
|
|
565
|
-
|
|
566
|
-
const req = mockReq('POST', 'not-json');
|
|
567
|
-
const res = mockRes();
|
|
568
|
-
|
|
569
|
-
await handleWebhookRequest(req, res);
|
|
570
|
-
|
|
571
|
-
expect(handleWebhook).toHaveBeenCalledWith('not-json');
|
|
572
|
-
expect(res._statusCode).toBe(400);
|
|
573
|
-
expect(res._body).toBe('Invalid webhook payload');
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
it('returns 500 when webhook processing throws', async () => {
|
|
577
|
-
const handleWebhook = vi.fn().mockRejectedValue(new Error('boom'));
|
|
578
|
-
__setGatewayStateForTests({
|
|
579
|
-
eventMode: 'webhook',
|
|
580
|
-
inboundHandler: { handleWebhook },
|
|
581
|
-
} as never);
|
|
582
|
-
|
|
583
|
-
const req = mockReq('POST', '{"event":"test"}');
|
|
584
|
-
const res = mockRes();
|
|
585
|
-
|
|
586
|
-
await handleWebhookRequest(req, res);
|
|
587
|
-
|
|
588
|
-
expect(res._statusCode).toBe(500);
|
|
589
|
-
expect(res._body).toBe('Webhook processing failed');
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('rejects oversized webhook bodies with 413', async () => {
|
|
593
|
-
const handleWebhook = vi.fn();
|
|
594
|
-
__setGatewayStateForTests({
|
|
595
|
-
eventMode: 'webhook',
|
|
596
|
-
inboundHandler: { handleWebhook },
|
|
597
|
-
} as never);
|
|
598
|
-
|
|
599
|
-
const req = mockReq('POST', 'x'.repeat((1024 * 1024) + 1));
|
|
600
|
-
const res = mockRes();
|
|
601
|
-
|
|
602
|
-
await handleWebhookRequest(req, res);
|
|
603
|
-
|
|
604
|
-
expect(handleWebhook).not.toHaveBeenCalled();
|
|
605
|
-
expect(res._statusCode).toBe(413);
|
|
606
|
-
expect(res._body).toBe('Payload Too Large');
|
|
607
|
-
});
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
// ─── bitrix24Plugin structure ────────────────────────────────────────────────
|
|
611
|
-
|
|
612
|
-
describe('bitrix24Plugin', () => {
|
|
613
|
-
describe('metadata', () => {
|
|
614
|
-
it('has correct id', () => {
|
|
615
|
-
expect(bitrix24Plugin.id).toBe('bitrix24');
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
it('has correct meta', () => {
|
|
619
|
-
expect(bitrix24Plugin.meta.id).toBe('bitrix24');
|
|
620
|
-
expect(bitrix24Plugin.meta.label).toBe('Bitrix24');
|
|
621
|
-
expect(bitrix24Plugin.meta.aliases).toEqual(['b24', 'bx24']);
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it('declares capabilities', () => {
|
|
625
|
-
expect(bitrix24Plugin.capabilities.chatTypes).toEqual(['direct', 'group']);
|
|
626
|
-
expect(bitrix24Plugin.capabilities.media).toBe(true);
|
|
627
|
-
expect(bitrix24Plugin.capabilities.reactions).toBe(true);
|
|
628
|
-
expect(bitrix24Plugin.capabilities.threads).toBe(false);
|
|
629
|
-
expect(bitrix24Plugin.capabilities.nativeCommands).toBe(true);
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
describe('security', () => {
|
|
634
|
-
it('resolveDmPolicy returns webhookUser policy when configured', () => {
|
|
635
|
-
const result = bitrix24Plugin.security.resolveDmPolicy({
|
|
636
|
-
account: { config: { dmPolicy: 'webhookUser', allowFrom: ['bitrix24:42', '42', 'b24:7'] } },
|
|
637
|
-
});
|
|
638
|
-
expect(result.policy).toBe('webhookUser');
|
|
639
|
-
expect(result.allowFrom).toEqual(['42', '7']);
|
|
640
|
-
expect(result.policyPath).toBe('channels.bitrix24.dmPolicy');
|
|
641
|
-
expect(result.allowFromPath).toBe('channels.bitrix24.allowFrom');
|
|
642
|
-
expect(result.approveHint).toContain('openclaw pairing approve bitrix24');
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it('resolveDmPolicy returns allowlist policy when configured', () => {
|
|
646
|
-
const result = bitrix24Plugin.security.resolveDmPolicy({
|
|
647
|
-
account: { config: { dmPolicy: 'allowlist', allowFrom: ['bitrix24:42'] } },
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
expect(result.policy).toBe('allowlist');
|
|
651
|
-
expect(result.allowFrom).toEqual(['42']);
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
it('resolveDmPolicy returns open policy when configured', () => {
|
|
655
|
-
const result = bitrix24Plugin.security.resolveDmPolicy({
|
|
656
|
-
account: { config: { dmPolicy: 'open' } },
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
expect(result.policy).toBe('open');
|
|
660
|
-
expect(result.allowFrom).toEqual([]);
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('resolveDmPolicy defaults to webhookUser', () => {
|
|
664
|
-
const result = bitrix24Plugin.security.resolveDmPolicy({});
|
|
665
|
-
expect(result.policy).toBe('webhookUser');
|
|
666
|
-
expect(result.allowFrom).toEqual([]);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
it('resolveDmPolicy reads allowFrom from cfg when account is omitted', () => {
|
|
670
|
-
const result = bitrix24Plugin.security.resolveDmPolicy({
|
|
671
|
-
cfg: {
|
|
672
|
-
channels: {
|
|
673
|
-
bitrix24: {
|
|
674
|
-
allowFrom: ['bx24:100', '100', 'bitrix24:42'],
|
|
675
|
-
},
|
|
676
|
-
},
|
|
677
|
-
},
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
expect(result.allowFrom).toEqual(['100', '42']);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
it('resolveDmPolicy normalizeEntry strips prefixes', () => {
|
|
684
|
-
const result = bitrix24Plugin.security.resolveDmPolicy({});
|
|
685
|
-
expect(result.normalizeEntry('bitrix24:42')).toBe('42');
|
|
686
|
-
expect(result.normalizeEntry('b24:42')).toBe('42');
|
|
687
|
-
expect(result.normalizeEntry('BX24:7')).toBe('7');
|
|
688
|
-
expect(result.normalizeEntry('42')).toBe('42');
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
it('normalizeAllowFrom strips bitrix24: prefix', () => {
|
|
692
|
-
expect(bitrix24Plugin.security.normalizeAllowFrom('bitrix24:42')).toBe('42');
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
it('normalizeAllowFrom strips b24: prefix', () => {
|
|
696
|
-
expect(bitrix24Plugin.security.normalizeAllowFrom('b24:42')).toBe('42');
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
it('normalizeAllowFrom strips bx24: prefix', () => {
|
|
700
|
-
expect(bitrix24Plugin.security.normalizeAllowFrom('bx24:42')).toBe('42');
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
it('normalizeAllowFrom keeps plain id', () => {
|
|
704
|
-
expect(bitrix24Plugin.security.normalizeAllowFrom('42')).toBe('42');
|
|
705
|
-
});
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
describe('pairing adapter', () => {
|
|
709
|
-
it('has correct idLabel', () => {
|
|
710
|
-
expect(bitrix24Plugin.pairing.idLabel).toBe('bitrix24UserId');
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
it('normalizeAllowEntry strips prefixes', () => {
|
|
714
|
-
expect(bitrix24Plugin.pairing.normalizeAllowEntry!('bitrix24:42')).toBe('42');
|
|
715
|
-
expect(bitrix24Plugin.pairing.normalizeAllowEntry!('b24:42')).toBe('42');
|
|
716
|
-
expect(bitrix24Plugin.pairing.normalizeAllowEntry!('BX24:7')).toBe('7');
|
|
717
|
-
expect(bitrix24Plugin.pairing.normalizeAllowEntry!('42')).toBe('42');
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
it('has notifyApproval function', () => {
|
|
721
|
-
expect(typeof bitrix24Plugin.pairing.notifyApproval).toBe('function');
|
|
722
|
-
});
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
describe('messaging', () => {
|
|
726
|
-
it('normalizeTarget strips bitrix24: prefix', () => {
|
|
727
|
-
expect(bitrix24Plugin.messaging.normalizeTarget('bitrix24:1')).toBe('1');
|
|
728
|
-
expect(bitrix24Plugin.messaging.normalizeTarget('b24:42')).toBe('42');
|
|
729
|
-
expect(bitrix24Plugin.messaging.normalizeTarget('bx24:100')).toBe('100');
|
|
730
|
-
expect(bitrix24Plugin.messaging.normalizeTarget('123')).toBe('123');
|
|
731
|
-
expect(bitrix24Plugin.messaging.normalizeTarget(' bitrix24:5 ')).toBe('5');
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
it('looksLikeId recognizes numeric IDs', () => {
|
|
735
|
-
expect(bitrix24Plugin.messaging.targetResolver.looksLikeId('bitrix24:1', '1')).toBe(true);
|
|
736
|
-
expect(bitrix24Plugin.messaging.targetResolver.looksLikeId('42', '42')).toBe(true);
|
|
737
|
-
expect(bitrix24Plugin.messaging.targetResolver.looksLikeId('abc', 'abc')).toBe(false);
|
|
738
|
-
});
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
describe('outbound', () => {
|
|
742
|
-
it('has direct delivery mode', () => {
|
|
743
|
-
expect(bitrix24Plugin.outbound.deliveryMode).toBe('direct');
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
it('has textChunkLimit of 4000', () => {
|
|
747
|
-
expect(bitrix24Plugin.outbound.textChunkLimit).toBe(4000);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
it('sendText throws when gateway not started', async () => {
|
|
751
|
-
await expect(
|
|
752
|
-
bitrix24Plugin.outbound.sendText({
|
|
753
|
-
cfg: { channels: { bitrix24: { webhookUrl: 'https://test.bitrix24.com/rest/1/token/' } } },
|
|
754
|
-
to: '1',
|
|
755
|
-
accountId: 'default',
|
|
756
|
-
text: 'Hello',
|
|
757
|
-
}),
|
|
758
|
-
).rejects.toThrow('Bitrix24 gateway not started');
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
it('sendMedia throws when gateway not started', async () => {
|
|
762
|
-
await expect(
|
|
763
|
-
bitrix24Plugin.outbound.sendMedia({
|
|
764
|
-
cfg: { channels: { bitrix24: { webhookUrl: 'https://test.bitrix24.com/rest/1/token/' } } },
|
|
765
|
-
to: '1',
|
|
766
|
-
accountId: 'default',
|
|
767
|
-
text: 'Hello',
|
|
768
|
-
mediaUrl: '/tmp/test.jpg',
|
|
769
|
-
}),
|
|
770
|
-
).rejects.toThrow('Bitrix24 gateway not started');
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
it('sendPayload throws when gateway not started', async () => {
|
|
774
|
-
await expect(
|
|
775
|
-
bitrix24Plugin.outbound.sendPayload({
|
|
776
|
-
cfg: { channels: { bitrix24: { webhookUrl: 'https://test.bitrix24.com/rest/1/token/' } } },
|
|
777
|
-
to: '1',
|
|
778
|
-
accountId: 'default',
|
|
779
|
-
text: 'Hello',
|
|
780
|
-
payload: { channelData: { telegram: { buttons: [[{ text: 'OK' }]] } } },
|
|
781
|
-
}),
|
|
782
|
-
).rejects.toThrow('Bitrix24 gateway not started');
|
|
783
|
-
});
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
describe('config', () => {
|
|
787
|
-
it('listAccountIds returns empty for empty config', () => {
|
|
788
|
-
expect(bitrix24Plugin.config.listAccountIds({})).toEqual([]);
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
it('listAccountIds returns default when webhookUrl present', () => {
|
|
792
|
-
const cfg = {
|
|
793
|
-
channels: {
|
|
794
|
-
bitrix24: {
|
|
795
|
-
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
796
|
-
},
|
|
797
|
-
},
|
|
798
|
-
};
|
|
799
|
-
expect(bitrix24Plugin.config.listAccountIds(cfg)).toEqual(['default']);
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
it('resolveAccount returns config', () => {
|
|
803
|
-
const cfg = {
|
|
804
|
-
channels: {
|
|
805
|
-
bitrix24: {
|
|
806
|
-
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
807
|
-
botName: 'TestBot',
|
|
808
|
-
},
|
|
809
|
-
},
|
|
810
|
-
};
|
|
811
|
-
const account = bitrix24Plugin.config.resolveAccount(cfg);
|
|
812
|
-
expect(account.accountId).toBe('default');
|
|
813
|
-
expect(account.config.webhookUrl).toBe('https://test.bitrix24.com/rest/1/token/');
|
|
814
|
-
expect(account.config.botName).toBe('TestBot');
|
|
815
|
-
expect(account.configured).toBe(true);
|
|
816
|
-
expect(account.enabled).toBe(true);
|
|
817
|
-
});
|
|
818
|
-
});
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
// ─── gateway.startAccount ────────────────────────────────────────────────────
|
|
822
|
-
|
|
823
|
-
describe('gateway.startAccount', () => {
|
|
824
|
-
it('skips when webhookUrl not configured', async () => {
|
|
825
|
-
const logger = { ...silentLogger };
|
|
826
|
-
|
|
827
|
-
const result = await bitrix24Plugin.gateway.startAccount({
|
|
828
|
-
cfg: { channels: { bitrix24: {} } },
|
|
829
|
-
accountId: 'test',
|
|
830
|
-
account: { config: {} },
|
|
831
|
-
runtime: {},
|
|
832
|
-
abortSignal: new AbortController().signal,
|
|
833
|
-
log: logger,
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
// Should return undefined (early return, no promise)
|
|
837
|
-
expect(result).toBeUndefined();
|
|
838
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
839
|
-
expect.stringContaining('no webhookUrl configured'),
|
|
840
|
-
);
|
|
841
|
-
});
|
|
842
|
-
});
|