@ihazz/bitrix24 0.2.5 → 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.
@@ -2,9 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { Readable, PassThrough } from 'node:stream';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import {
5
+ __setGatewayStateForTests,
6
+ buildBotCodeCandidates,
7
+ canCoalesceDirectMessage,
5
8
  convertButtonsToKeyboard,
6
9
  extractKeyboardFromPayload,
7
10
  handleWebhookRequest,
11
+ mergeBufferedDirectMessages,
12
+ mergeForwardedMessageContext,
13
+ resolveDirectMessageCoalesceDelay,
14
+ shouldSkipJoinChatWelcome,
8
15
  bitrix24Plugin,
9
16
  } from '../src/channel.js';
10
17
  import type { KeyboardButton, KeyboardNewline, B24Keyboard } from '../src/types.js';
@@ -26,6 +33,26 @@ function isNewline(item: KeyboardButton | KeyboardNewline): item is KeyboardNewl
26
33
  return 'TYPE' in item && item.TYPE === 'NEWLINE';
27
34
  }
28
35
 
36
+ describe('buildBotCodeCandidates', () => {
37
+ it('uses explicit botCode when it is configured', () => {
38
+ expect(buildBotCodeCandidates({
39
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
40
+ botCode: 'custom_bot',
41
+ }, 5)).toEqual(['custom_bot']);
42
+ });
43
+
44
+ it('builds automatic codes from webhook user id', () => {
45
+ expect(buildBotCodeCandidates({
46
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
47
+ }, 4)).toEqual([
48
+ 'openclaw_42',
49
+ 'openclaw_42_2',
50
+ 'openclaw_42_3',
51
+ 'openclaw_42_4',
52
+ ]);
53
+ });
54
+ });
55
+
29
56
  // ─── convertButtonsToKeyboard ────────────────────────────────────────────────
30
57
 
31
58
  describe('convertButtonsToKeyboard', () => {
@@ -284,9 +311,124 @@ describe('extractKeyboardFromPayload', () => {
284
311
  });
285
312
  });
286
313
 
314
+ describe('direct message coalescing helpers', () => {
315
+ const baseMsgCtx = {
316
+ channel: 'bitrix24' as const,
317
+ senderId: '1',
318
+ senderName: 'Test User',
319
+ senderFirstName: 'Test',
320
+ chatId: '1',
321
+ chatInternalId: '40985',
322
+ messageId: '100',
323
+ text: 'hello',
324
+ isDm: true,
325
+ isGroup: false,
326
+ media: [],
327
+ language: 'ru',
328
+ raw: { type: 'ONIMBOTV2MESSAGEADD' },
329
+ botId: 295,
330
+ memberId: '',
331
+ };
332
+
333
+ it('coalesces only plain direct text messages', () => {
334
+ expect(canCoalesceDirectMessage(baseMsgCtx, { dmPolicy: 'webhookUser' })).toBe(true);
335
+ expect(canCoalesceDirectMessage({ ...baseMsgCtx, text: '/status' }, { dmPolicy: 'webhookUser' })).toBe(false);
336
+ expect(canCoalesceDirectMessage({ ...baseMsgCtx, media: [{ id: '1', name: 'x', extension: '', size: 1, type: 'file' }] }, { dmPolicy: 'webhookUser' })).toBe(false);
337
+ expect(canCoalesceDirectMessage({ ...baseMsgCtx, replyToMessageId: '77' }, { dmPolicy: 'webhookUser' })).toBe(false);
338
+ expect(canCoalesceDirectMessage({ ...baseMsgCtx, isForwarded: true }, { dmPolicy: 'webhookUser' })).toBe(false);
339
+ expect(canCoalesceDirectMessage({ ...baseMsgCtx, isDm: false, isGroup: true }, { dmPolicy: 'webhookUser' })).toBe(false);
340
+ expect(canCoalesceDirectMessage(baseMsgCtx, { dmPolicy: 'pairing' })).toBe(false);
341
+ });
342
+
343
+ it('merges buffered direct messages with newline separators and last message id', () => {
344
+ const merged = mergeBufferedDirectMessages([
345
+ baseMsgCtx,
346
+ { ...baseMsgCtx, messageId: '101', text: 'second' },
347
+ { ...baseMsgCtx, messageId: '102', text: 'third', language: 'en' },
348
+ ]);
349
+
350
+ expect(merged.text).toBe('hello\nsecond\nthird');
351
+ expect(merged.messageId).toBe('102');
352
+ expect(merged.language).toBe('en');
353
+ });
354
+
355
+ it('merges a forwarded message with buffered question context', () => {
356
+ const merged = mergeForwardedMessageContext(
357
+ baseMsgCtx,
358
+ {
359
+ ...baseMsgCtx,
360
+ messageId: '103',
361
+ text: 'Forwarded body',
362
+ media: [{ id: '1', name: 'photo.jpg', extension: '', size: 42, type: 'file' }],
363
+ isForwarded: true,
364
+ },
365
+ );
366
+
367
+ expect(merged.text).toBe('hello\n\n[Forwarded message]\nForwarded body');
368
+ expect(merged.messageId).toBe('103');
369
+ expect(merged.media).toHaveLength(1);
370
+ expect(merged.isForwarded).toBe(false);
371
+ });
372
+
373
+ it('uses short debounce and extends the window until max wait', () => {
374
+ expect(resolveDirectMessageCoalesceDelay({
375
+ startedAt: 1000,
376
+ now: 1000,
377
+ })).toBe(200);
378
+
379
+ expect(resolveDirectMessageCoalesceDelay({
380
+ startedAt: 1000,
381
+ now: 1150,
382
+ })).toBe(200);
383
+
384
+ expect(resolveDirectMessageCoalesceDelay({
385
+ startedAt: 1000,
386
+ now: 5900,
387
+ })).toBe(100);
388
+
389
+ expect(resolveDirectMessageCoalesceDelay({
390
+ startedAt: 1000,
391
+ now: 6200,
392
+ })).toBe(0);
393
+ });
394
+ });
395
+
396
+ describe('join chat helpers', () => {
397
+ it('does not skip unsupported group chats even in webhookUser mode', () => {
398
+ expect(shouldSkipJoinChatWelcome({
399
+ dialogId: 'chat123',
400
+ chatType: 'chat',
401
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
402
+ dmPolicy: 'webhookUser',
403
+ })).toBe(false);
404
+ });
405
+
406
+ it('skips welcome for non-owner direct dialogs in webhookUser mode', () => {
407
+ expect(shouldSkipJoinChatWelcome({
408
+ dialogId: '42',
409
+ chatType: 'user',
410
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
411
+ dmPolicy: 'webhookUser',
412
+ })).toBe(true);
413
+ });
414
+
415
+ it('does not skip welcome for owner direct dialogs in webhookUser mode', () => {
416
+ expect(shouldSkipJoinChatWelcome({
417
+ dialogId: '1',
418
+ chatType: 'user',
419
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
420
+ dmPolicy: 'webhookUser',
421
+ })).toBe(false);
422
+ });
423
+ });
424
+
287
425
  // ─── handleWebhookRequest ────────────────────────────────────────────────────
288
426
 
289
427
  describe('handleWebhookRequest', () => {
428
+ afterEach(() => {
429
+ __setGatewayStateForTests(null);
430
+ });
431
+
290
432
  function mockReq(method: string, body?: string): IncomingMessage {
291
433
  const stream = new PassThrough();
292
434
  if (body) {
@@ -330,6 +472,71 @@ describe('handleWebhookRequest', () => {
330
472
  expect(res._statusCode).toBe(503);
331
473
  expect(res._body).toBe('Channel not started');
332
474
  });
475
+
476
+ it('returns fetch mode acknowledgement when gateway runs in fetch mode', async () => {
477
+ __setGatewayStateForTests({
478
+ eventMode: 'fetch',
479
+ inboundHandler: { handleWebhook: vi.fn() },
480
+ } as never);
481
+
482
+ const req = mockReq('POST', '{"event":"test"}');
483
+ const res = mockRes();
484
+
485
+ await handleWebhookRequest(req, res);
486
+
487
+ expect(res._statusCode).toBe(200);
488
+ expect(res._body).toBe('FETCH mode active');
489
+ });
490
+
491
+ it('returns 400 when webhook payload is invalid', async () => {
492
+ const handleWebhook = vi.fn().mockResolvedValue(false);
493
+ __setGatewayStateForTests({
494
+ eventMode: 'webhook',
495
+ inboundHandler: { handleWebhook },
496
+ } as never);
497
+
498
+ const req = mockReq('POST', 'not-json');
499
+ const res = mockRes();
500
+
501
+ await handleWebhookRequest(req, res);
502
+
503
+ expect(handleWebhook).toHaveBeenCalledWith('not-json');
504
+ expect(res._statusCode).toBe(400);
505
+ expect(res._body).toBe('Invalid webhook payload');
506
+ });
507
+
508
+ it('returns 500 when webhook processing throws', async () => {
509
+ const handleWebhook = vi.fn().mockRejectedValue(new Error('boom'));
510
+ __setGatewayStateForTests({
511
+ eventMode: 'webhook',
512
+ inboundHandler: { handleWebhook },
513
+ } as never);
514
+
515
+ const req = mockReq('POST', '{"event":"test"}');
516
+ const res = mockRes();
517
+
518
+ await handleWebhookRequest(req, res);
519
+
520
+ expect(res._statusCode).toBe(500);
521
+ expect(res._body).toBe('Webhook processing failed');
522
+ });
523
+
524
+ it('rejects oversized webhook bodies with 413', async () => {
525
+ const handleWebhook = vi.fn();
526
+ __setGatewayStateForTests({
527
+ eventMode: 'webhook',
528
+ inboundHandler: { handleWebhook },
529
+ } as never);
530
+
531
+ const req = mockReq('POST', 'x'.repeat((1024 * 1024) + 1));
532
+ const res = mockRes();
533
+
534
+ await handleWebhookRequest(req, res);
535
+
536
+ expect(handleWebhook).not.toHaveBeenCalled();
537
+ expect(res._statusCode).toBe(413);
538
+ expect(res._body).toBe('Payload Too Large');
539
+ });
333
540
  });
334
541
 
335
542
  // ─── bitrix24Plugin structure ────────────────────────────────────────────────
@@ -347,32 +554,46 @@ describe('bitrix24Plugin', () => {
347
554
  });
348
555
 
349
556
  it('declares capabilities', () => {
350
- expect(bitrix24Plugin.capabilities.chatTypes).toEqual(['direct', 'group']);
557
+ expect(bitrix24Plugin.capabilities.chatTypes).toEqual(['direct']);
351
558
  expect(bitrix24Plugin.capabilities.media).toBe(true);
352
- expect(bitrix24Plugin.capabilities.reactions).toBe(false);
559
+ expect(bitrix24Plugin.capabilities.reactions).toBe(true);
353
560
  expect(bitrix24Plugin.capabilities.threads).toBe(false);
354
561
  expect(bitrix24Plugin.capabilities.nativeCommands).toBe(true);
355
562
  });
356
563
  });
357
564
 
358
565
  describe('security', () => {
359
- it('resolveDmPolicy returns policy object with configured policy', () => {
566
+ it('resolveDmPolicy returns webhookUser policy when configured', () => {
360
567
  const result = bitrix24Plugin.security.resolveDmPolicy({
361
- account: { config: { dmPolicy: 'allowlist', allowFrom: ['1'] } },
568
+ account: { config: { dmPolicy: 'webhookUser', allowFrom: ['bitrix24:42', '42', 'b24:7'] } },
362
569
  });
363
- expect(result.policy).toBe('allowlist');
364
- expect(result.allowFrom).toEqual(['1']);
570
+ expect(result.policy).toBe('webhookUser');
571
+ expect(result.allowFrom).toEqual(['42', '7']);
365
572
  expect(result.policyPath).toBe('channels.bitrix24.dmPolicy');
366
- expect(result.allowFromPath).toBe('channels.bitrix24.');
573
+ expect(result.allowFromPath).toBe('channels.bitrix24.allowFrom');
367
574
  expect(result.approveHint).toContain('openclaw pairing approve bitrix24');
368
575
  });
369
576
 
370
- it('resolveDmPolicy defaults to pairing', () => {
577
+ it('resolveDmPolicy defaults to webhookUser', () => {
371
578
  const result = bitrix24Plugin.security.resolveDmPolicy({});
372
- expect(result.policy).toBe('pairing');
579
+ expect(result.policy).toBe('webhookUser');
373
580
  expect(result.allowFrom).toEqual([]);
374
581
  });
375
582
 
583
+ it('resolveDmPolicy reads allowFrom from cfg when account is omitted', () => {
584
+ const result = bitrix24Plugin.security.resolveDmPolicy({
585
+ cfg: {
586
+ channels: {
587
+ bitrix24: {
588
+ allowFrom: ['bx24:100', '100', 'bitrix24:42'],
589
+ },
590
+ },
591
+ },
592
+ });
593
+
594
+ expect(result.allowFrom).toEqual(['100', '42']);
595
+ });
596
+
376
597
  it('resolveDmPolicy normalizeEntry strips prefixes', () => {
377
598
  const result = bitrix24Plugin.security.resolveDmPolicy({});
378
599
  expect(result.normalizeEntry('bitrix24:42')).toBe('42');
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildCommandsHelpText, formatModelsCommandReply } from '../src/commands.js';
3
+
4
+ describe('buildCommandsHelpText', () => {
5
+ it('builds concise Russian help for keyboard commands', () => {
6
+ const text = buildCommandsHelpText('ru', { concise: true });
7
+
8
+ expect(text).toContain('[B]Основные команды[/B]');
9
+ expect(text).toContain('[send=/status]/status[/send]');
10
+ expect(text).toContain('[send=/commands]/commands[/send]');
11
+ expect(text).toContain('[put=/models ]/models[/put]');
12
+ expect(text).toContain('[COLOR=#6C788A]Полный список:[/COLOR] [send=/commands]');
13
+ });
14
+
15
+ it('builds full Russian commands list', () => {
16
+ const text = buildCommandsHelpText('ru');
17
+
18
+ expect(text).toContain('[B]Доступные команды[/B]');
19
+ expect(text).toContain('[B]Справка и статус[/B]');
20
+ expect(text).toContain('[send=/help]/help[/send]');
21
+ expect(text).toContain('[send=/export-session]/export-session[/send]');
22
+ expect(text).toContain('[COLOR=#6C788A]Параметры: off | tokens | full | cost[/COLOR]');
23
+ });
24
+
25
+ it('falls back to English text', () => {
26
+ const text = buildCommandsHelpText('en', { concise: true });
27
+
28
+ expect(text).toContain('[B]Key commands[/B]');
29
+ expect(text).toContain('[COLOR=#6C788A]Full list:[/COLOR] [send=/commands]');
30
+ });
31
+ });
32
+
33
+ describe('formatModelsCommandReply', () => {
34
+ it('formats provider summary into interactive Bitrix text', () => {
35
+ const text = formatModelsCommandReply(
36
+ [
37
+ 'Providers:',
38
+ '• openai (36)',
39
+ '• anthropic (23)',
40
+ '',
41
+ 'Use: /models <provider>',
42
+ 'Switch: /model <provider/model>',
43
+ ].join('\n'),
44
+ 'ru',
45
+ );
46
+
47
+ expect(text).toContain('[B]Провайдеры[/B]');
48
+ expect(text).toContain('[send=/models openai]openai[/send]');
49
+ expect(text).toContain('[send=/models anthropic]anthropic[/send]');
50
+ expect(text).toContain('[put=/models ]/models <provider>[/put]');
51
+ expect(text).toContain('[put=/model ]/model <provider/model>[/put]');
52
+ });
53
+
54
+ it('returns null for unrelated text', () => {
55
+ expect(formatModelsCommandReply('Status ok', 'ru')).toBeNull();
56
+ });
57
+ });
@@ -6,11 +6,13 @@ const mockCfg = {
6
6
  bitrix24: {
7
7
  webhookUrl: 'https://test.bitrix24.com/rest/1/abc123/',
8
8
  botName: 'TestBot',
9
- dmPolicy: 'open',
9
+ dmPolicy: 'pairing',
10
+ allowFrom: ['bitrix24:1'],
10
11
  accounts: {
11
12
  portal2: {
12
13
  webhookUrl: 'https://portal2.bitrix24.com/rest/1/xyz789/',
13
14
  botName: 'Portal2Bot',
15
+ allowFrom: ['bitrix24:2'],
14
16
  },
15
17
  },
16
18
  },
@@ -22,12 +24,14 @@ describe('getConfig', () => {
22
24
  const cfg = getConfig(mockCfg);
23
25
  expect(cfg.webhookUrl).toBe('https://test.bitrix24.com/rest/1/abc123/');
24
26
  expect(cfg.botName).toBe('TestBot');
27
+ expect(cfg.allowFrom).toEqual(['bitrix24:1']);
25
28
  });
26
29
 
27
30
  it('returns account-specific config', () => {
28
31
  const cfg = getConfig(mockCfg, 'portal2');
29
32
  expect(cfg.webhookUrl).toBe('https://portal2.bitrix24.com/rest/1/xyz789/');
30
33
  expect(cfg.botName).toBe('Portal2Bot');
34
+ expect(cfg.allowFrom).toEqual(['bitrix24:2']);
31
35
  });
32
36
 
33
37
  it('returns empty config when no bitrix24 section', () => {
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ resolveLocale,
4
+ welcomeMessage,
5
+ onboardingMessage,
6
+ personalBotOwnerOnly,
7
+ forwardedMessageUnsupported,
8
+ replyMessageUnsupported,
9
+ } from '../src/i18n.js';
10
+
11
+ describe('i18n welcome messages', () => {
12
+ it('resolves locale to two-letter lowercase code', () => {
13
+ expect(resolveLocale('RU')).toBe('ru');
14
+ expect(resolveLocale('en_US')).toBe('en');
15
+ expect(resolveLocale(undefined)).toBe('en');
16
+ });
17
+
18
+ it('returns localized Russian welcome message', () => {
19
+ expect(welcomeMessage('ru', 'OpenClaw')).toContain('OpenClaw готов');
20
+ });
21
+
22
+ it('falls back to English welcome message for unknown locale', () => {
23
+ expect(welcomeMessage('zz', 'OpenClaw')).toBe(
24
+ 'OpenClaw ready. Send me a message or pick a command below.',
25
+ );
26
+ });
27
+
28
+ it('returns pairing onboarding message when dmPolicy is pairing', () => {
29
+ expect(onboardingMessage('ru', 'OpenClaw', 'pairing')).toContain('код привязки');
30
+ expect(onboardingMessage('en', 'OpenClaw', 'pairing')).toContain('pairing code');
31
+ });
32
+
33
+ it('returns localized personal bot access denied message', () => {
34
+ expect(personalBotOwnerOnly('ru')).toContain('персональный');
35
+ expect(personalBotOwnerOnly('en')).toContain('personal');
36
+ });
37
+
38
+ it('returns localized reply unsupported message', () => {
39
+ expect(replyMessageUnsupported('ru')).toContain('пока не поддерживаются');
40
+ expect(replyMessageUnsupported('en')).toContain('not supported');
41
+ });
42
+
43
+ it('returns localized forwarded message unsupported message', () => {
44
+ expect(forwardedMessageUnsupported('ru')).toContain('Пересланные сообщения');
45
+ expect(forwardedMessageUnsupported('en')).toContain('Forwarded messages');
46
+ });
47
+ });