@ihazz/bitrix24 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihazz/bitrix24",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Bitrix24 Messenger channel for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,11 +1,12 @@
1
1
  import type { Bitrix24AccountConfig } from './types.js';
2
+ import type { PluginRuntime, ChannelPairingAdapter } from './runtime.js';
2
3
 
3
4
  /**
4
5
  * Normalize an allowFrom entry — strip platform prefixes.
5
6
  * "bitrix24:42" → "42", "b24:42" → "42", "bx24:42" → "42", "42" → "42"
6
7
  */
7
8
  export function normalizeAllowEntry(entry: string): string {
8
- return entry.trim().replace(/^(bitrix24|b24|bx24):/, '');
9
+ return entry.trim().replace(/^(bitrix24|b24|bx24):/i, '');
9
10
  }
10
11
 
11
12
  /**
@@ -17,7 +18,7 @@ export function checkAccess(
17
18
  senderId: string,
18
19
  config: Bitrix24AccountConfig,
19
20
  ): boolean {
20
- const policy = config.dmPolicy ?? 'open';
21
+ const policy = config.dmPolicy ?? 'pairing';
21
22
 
22
23
  switch (policy) {
23
24
  case 'open':
@@ -31,10 +32,66 @@ export function checkAccess(
31
32
  }
32
33
 
33
34
  case 'pairing':
34
- // Pairing mode is post-MVP for now, treat as open
35
- return true;
35
+ // Pairing requires runtimeuse checkAccessWithPairing() instead
36
+ return false;
36
37
 
37
38
  default:
38
39
  return false;
39
40
  }
40
41
  }
42
+
43
+ export type AccessResult = 'allow' | 'deny' | 'pairing';
44
+
45
+ /**
46
+ * Pairing-aware access check.
47
+ * Merges config allowFrom with file-based allowFrom store.
48
+ * For pairing mode, upserts a pairing request and sends the reply.
49
+ */
50
+ export async function checkAccessWithPairing(params: {
51
+ senderId: string;
52
+ config: Bitrix24AccountConfig;
53
+ runtime: PluginRuntime;
54
+ accountId: string;
55
+ pairingAdapter: ChannelPairingAdapter;
56
+ sendReply: (text: string) => Promise<void>;
57
+ logger?: { debug: (...args: unknown[]) => void };
58
+ }): Promise<AccessResult> {
59
+ const { senderId, config, runtime, accountId, pairingAdapter, sendReply, logger } = params;
60
+ const policy = config.dmPolicy ?? 'pairing';
61
+
62
+ if (policy === 'open') return 'allow';
63
+
64
+ // Read file-based allowFrom store and merge with config
65
+ const storeAllowFrom = await runtime.channel.pairing.readAllowFromStore('bitrix24', '', accountId);
66
+ const configAllowFrom = (config.allowFrom ?? []).map(normalizeAllowEntry);
67
+ const merged = [...new Set([...configAllowFrom, ...storeAllowFrom])];
68
+ const normalizedSender = normalizeAllowEntry(String(senderId));
69
+
70
+ if (merged.includes(normalizedSender)) return 'allow';
71
+
72
+ if (policy === 'allowlist') {
73
+ logger?.debug('Access denied (allowlist)', { senderId });
74
+ return 'deny';
75
+ }
76
+
77
+ // policy === 'pairing'
78
+ const { code, created } = await runtime.channel.pairing.upsertPairingRequest({
79
+ channel: 'bitrix24',
80
+ id: senderId,
81
+ accountId,
82
+ meta: {},
83
+ pairingAdapter,
84
+ });
85
+
86
+ if (created) {
87
+ const reply = runtime.channel.pairing.buildPairingReply({
88
+ code,
89
+ channel: 'bitrix24',
90
+ accountId,
91
+ });
92
+ await sendReply(reply.text);
93
+ }
94
+
95
+ logger?.debug('Pairing request handled', { senderId, code, created });
96
+ return 'pairing';
97
+ }
package/src/api.ts CHANGED
@@ -287,6 +287,86 @@ export class Bitrix24Api {
287
287
  return result.result;
288
288
  }
289
289
 
290
+ // ─── File / Disk methods ─────────────────────────────────────────────
291
+
292
+ /**
293
+ * Get file info including DOWNLOAD_URL.
294
+ * Uses the user's access token (file belongs to the user's disk).
295
+ */
296
+ async getFileInfo(
297
+ clientEndpoint: string,
298
+ accessToken: string,
299
+ fileId: number,
300
+ ): Promise<{ DOWNLOAD_URL: string; [key: string]: unknown }> {
301
+ const result = await this.callWithToken<{ DOWNLOAD_URL: string; [key: string]: unknown }>(
302
+ clientEndpoint,
303
+ 'disk.file.get',
304
+ accessToken,
305
+ { id: fileId },
306
+ );
307
+ return result.result;
308
+ }
309
+
310
+ /**
311
+ * Get the disk folder ID for a chat (needed for file uploads).
312
+ */
313
+ async getChatFolder(
314
+ clientEndpoint: string,
315
+ accessToken: string,
316
+ chatId: number,
317
+ ): Promise<number> {
318
+ const result = await this.callWithToken<{ ID: number; [key: string]: unknown }>(
319
+ clientEndpoint,
320
+ 'im.disk.folder.get',
321
+ accessToken,
322
+ { CHAT_ID: chatId },
323
+ );
324
+ return result.result.ID;
325
+ }
326
+
327
+ /**
328
+ * Upload a file to a disk folder (base64 encoded).
329
+ * Returns the disk file ID.
330
+ */
331
+ async uploadFile(
332
+ clientEndpoint: string,
333
+ accessToken: string,
334
+ folderId: number,
335
+ fileName: string,
336
+ content: Buffer,
337
+ ): Promise<number> {
338
+ const result = await this.callWithToken<{ ID: number; [key: string]: unknown }>(
339
+ clientEndpoint,
340
+ 'disk.folder.uploadfile',
341
+ accessToken,
342
+ {
343
+ id: folderId,
344
+ data: { NAME: fileName },
345
+ fileContent: [fileName, content.toString('base64')],
346
+ generateUniqueName: true,
347
+ },
348
+ );
349
+ return result.result.ID;
350
+ }
351
+
352
+ /**
353
+ * Publish an uploaded file to a chat.
354
+ */
355
+ async commitFileToChat(
356
+ clientEndpoint: string,
357
+ accessToken: string,
358
+ chatId: number,
359
+ diskId: number,
360
+ ): Promise<boolean> {
361
+ const result = await this.callWithToken<boolean>(
362
+ clientEndpoint,
363
+ 'im.disk.file.commit',
364
+ accessToken,
365
+ { CHAT_ID: chatId, DISK_ID: diskId },
366
+ );
367
+ return result.result;
368
+ }
369
+
290
370
  destroy(): void {
291
371
  this.rateLimiter.destroy();
292
372
  }
package/src/channel.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { basename } from 'node:path';
2
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
4
  import { listAccountIds, resolveAccount, getConfig } from './config.js';
4
5
  import { Bitrix24Api } from './api.js';
5
6
  import { SendService } from './send-service.js';
7
+ import { MediaService } from './media-service.js';
8
+ import type { DownloadedMedia } from './media-service.js';
6
9
  import { InboundHandler } from './inbound-handler.js';
7
- import { checkAccess } from './access-control.js';
10
+ import { normalizeAllowEntry, checkAccessWithPairing } from './access-control.js';
8
11
  import { defaultLogger } from './utils.js';
9
12
  import { getBitrix24Runtime } from './runtime.js';
13
+ import type { ChannelPairingAdapter } from './runtime.js';
10
14
  import { OPENCLAW_COMMANDS } from './commands.js';
11
15
  import type {
12
16
  B24MsgContext,
@@ -28,6 +32,7 @@ interface Logger {
28
32
  interface GatewayState {
29
33
  api: Bitrix24Api;
30
34
  sendService: SendService;
35
+ mediaService: MediaService;
31
36
  inboundHandler: InboundHandler;
32
37
  }
33
38
 
@@ -35,18 +40,19 @@ let gatewayState: GatewayState | null = null;
35
40
 
36
41
  // ─── Keyboard / Button conversion ────────────────────────────────────────────
37
42
 
38
- interface TelegramButton {
43
+ /** Generic button format used by OpenClaw channelData. */
44
+ export interface ChannelButton {
39
45
  text: string;
40
46
  callback_data?: string;
41
47
  style?: string;
42
48
  }
43
49
 
44
50
  /**
45
- * Convert Telegram-style button rows to B24 flat KEYBOARD array.
46
- * Telegram format: Array<Array<{ text, callback_data, style }>>
47
- * B24 format: flat array with { TYPE: 'NEWLINE' } separators between rows.
51
+ * Convert OpenClaw button rows to B24 flat KEYBOARD array.
52
+ * Input: Array<Array<{ text, callback_data, style }>>
53
+ * Output: flat array with { TYPE: 'NEWLINE' } separators between rows.
48
54
  */
49
- function convertButtonsToKeyboard(rows: TelegramButton[][]): B24Keyboard {
55
+ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
50
56
  const keyboard: B24Keyboard = [];
51
57
 
52
58
  for (let i = 0; i < rows.length; i++) {
@@ -86,9 +92,9 @@ function convertButtonsToKeyboard(rows: TelegramButton[][]): B24Keyboard {
86
92
 
87
93
  /**
88
94
  * Extract B24 keyboard from a dispatcher payload's channelData.
89
- * Checks bitrix24-specific data first, then falls back to Telegram button format.
95
+ * Checks bitrix24-specific data first, then falls back to OpenClaw generic button format.
90
96
  */
91
- function extractKeyboardFromPayload(
97
+ export function extractKeyboardFromPayload(
92
98
  payload: { channelData?: Record<string, unknown> },
93
99
  ): B24Keyboard | undefined {
94
100
  const cd = payload.channelData;
@@ -100,8 +106,8 @@ function extractKeyboardFromPayload(
100
106
  return b24Data.keyboard;
101
107
  }
102
108
 
103
- // Translate from Telegram button format
104
- const tgData = cd.telegram as { buttons?: TelegramButton[][] } | undefined;
109
+ // Translate from OpenClaw generic button format (channelData.telegram key)
110
+ const tgData = cd.telegram as { buttons?: ChannelButton[][] } | undefined;
105
111
  if (tgData?.buttons?.length) {
106
112
  return convertButtonsToKeyboard(tgData.buttons);
107
113
  }
@@ -270,7 +276,7 @@ export const bitrix24Plugin = {
270
276
 
271
277
  capabilities: {
272
278
  chatTypes: ['direct', 'group'] as const,
273
- media: false,
279
+ media: true,
274
280
  reactions: false,
275
281
  threads: false,
276
282
  nativeCommands: true,
@@ -283,12 +289,33 @@ export const bitrix24Plugin = {
283
289
  },
284
290
 
285
291
  security: {
286
- resolveDmPolicy: (account: { config?: { dmPolicy?: string } }) =>
287
- account.config?.dmPolicy ?? 'open',
292
+ resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => ({
293
+ policy: (params.account?.config?.dmPolicy as string) ?? 'pairing',
294
+ allowFrom: (params.account?.config?.allowFrom as string[]) ?? [],
295
+ policyPath: 'channels.bitrix24.dmPolicy',
296
+ allowFromPath: 'channels.bitrix24.',
297
+ approveHint: 'openclaw pairing approve bitrix24 <CODE>',
298
+ normalizeEntry: (raw: string) => raw.replace(/^(bitrix24|b24|bx24):/i, ''),
299
+ }),
288
300
  normalizeAllowFrom: (entry: string) =>
289
- entry.replace(/^(bitrix24|b24|bx24):/, ''),
301
+ entry.replace(/^(bitrix24|b24|bx24):/i, ''),
290
302
  },
291
303
 
304
+ pairing: {
305
+ idLabel: 'bitrix24UserId',
306
+ normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
307
+ notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
308
+ const { config: acctCfg } = resolveAccount(params.cfg);
309
+ if (!acctCfg.webhookUrl) return;
310
+ const api = new Bitrix24Api();
311
+ try {
312
+ await api.sendMessage(acctCfg.webhookUrl, params.id, '\u2705 OpenClaw access approved.');
313
+ } finally {
314
+ api.destroy();
315
+ }
316
+ },
317
+ } satisfies ChannelPairingAdapter,
318
+
292
319
  outbound: {
293
320
  deliveryMode: 'direct' as const,
294
321
 
@@ -409,6 +436,7 @@ export const bitrix24Plugin = {
409
436
  const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
410
437
  const api = new Bitrix24Api({ logger, clientId });
411
438
  const sendService = new SendService(api, logger);
439
+ const mediaService = new MediaService(api, logger);
412
440
 
413
441
  // Register or update bot on the B24 portal
414
442
  const botId = await ensureBotRegistered(api, config, logger);
@@ -435,6 +463,64 @@ export const bitrix24Plugin = {
435
463
  const runtime = getBitrix24Runtime();
436
464
  const cfg = runtime.config.loadConfig();
437
465
 
466
+ // Pairing-aware access control
467
+ const accessResult = await checkAccessWithPairing({
468
+ senderId: msgCtx.senderId,
469
+ config,
470
+ runtime,
471
+ accountId: ctx.accountId,
472
+ pairingAdapter: bitrix24Plugin.pairing,
473
+ sendReply: async (text: string) => {
474
+ const replySendCtx = {
475
+ webhookUrl: config.webhookUrl,
476
+ clientEndpoint: msgCtx.clientEndpoint,
477
+ botToken: msgCtx.botToken,
478
+ dialogId: msgCtx.chatId,
479
+ };
480
+ await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
481
+ },
482
+ logger,
483
+ });
484
+
485
+ if (accessResult !== 'allow') {
486
+ logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
487
+ return;
488
+ }
489
+
490
+ // Download media files if present
491
+ let mediaFields: Record<string, unknown> = {};
492
+ if (msgCtx.media.length > 0) {
493
+ const downloaded = (await Promise.all(
494
+ msgCtx.media.map((m) =>
495
+ mediaService.downloadMedia({
496
+ fileId: m.id,
497
+ fileName: m.name,
498
+ extension: m.extension,
499
+ clientEndpoint: msgCtx.clientEndpoint,
500
+ userToken: msgCtx.userToken,
501
+ }),
502
+ ),
503
+ )).filter(Boolean) as DownloadedMedia[];
504
+
505
+ if (downloaded.length > 0) {
506
+ mediaFields = {
507
+ MediaPath: downloaded[0].path,
508
+ MediaType: downloaded[0].contentType,
509
+ MediaUrl: downloaded[0].path,
510
+ MediaPaths: downloaded.map((m) => m.path),
511
+ MediaUrls: downloaded.map((m) => m.path),
512
+ MediaTypes: downloaded.map((m) => m.contentType),
513
+ };
514
+ }
515
+ }
516
+
517
+ // Use placeholder body for media-only messages
518
+ let body = msgCtx.text;
519
+ if (!body && msgCtx.media.length > 0) {
520
+ const hasImage = msgCtx.media.some((m) => m.type === 'image');
521
+ body = hasImage ? '<media:image>' : '<media:document>';
522
+ }
523
+
438
524
  // Resolve which agent handles this conversation
439
525
  const route = runtime.channel.routing.resolveAgentRoute({
440
526
  cfg,
@@ -454,9 +540,9 @@ export const bitrix24Plugin = {
454
540
 
455
541
  // Build and finalize inbound context for OpenClaw agent
456
542
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
457
- Body: msgCtx.text,
458
- BodyForAgent: msgCtx.text,
459
- RawBody: msgCtx.text,
543
+ Body: body,
544
+ BodyForAgent: body,
545
+ RawBody: body,
460
546
  From: `bitrix24:${msgCtx.chatId}`,
461
547
  To: `bitrix24:${msgCtx.chatId}`,
462
548
  SessionKey: route.sessionKey,
@@ -473,6 +559,7 @@ export const bitrix24Plugin = {
473
559
  CommandAuthorized: true,
474
560
  OriginatingChannel: 'bitrix24',
475
561
  OriginatingTo: `bitrix24:${msgCtx.chatId}`,
562
+ ...mediaFields,
476
563
  });
477
564
 
478
565
  const sendCtx = {
@@ -489,6 +576,18 @@ export const bitrix24Plugin = {
489
576
  cfg,
490
577
  dispatcherOptions: {
491
578
  deliver: async (payload) => {
579
+ // Send media if present in reply
580
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
581
+ for (const mediaUrl of mediaUrls) {
582
+ await mediaService.uploadMediaToChat({
583
+ localPath: mediaUrl,
584
+ fileName: basename(mediaUrl),
585
+ chatId: Number(msgCtx.chatInternalId),
586
+ clientEndpoint: msgCtx.clientEndpoint,
587
+ botToken: msgCtx.botToken,
588
+ });
589
+ }
590
+ // Send text if present
492
591
  if (payload.text) {
493
592
  const keyboard = extractKeyboardFromPayload(payload);
494
593
  await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
@@ -529,15 +628,25 @@ export const bitrix24Plugin = {
529
628
 
530
629
  logger.info('Inbound command', { commandName, commandParams, senderId, dialogId });
531
630
 
532
- // Access control
533
- if (!checkAccess(senderId, config)) {
534
- logger.debug(`Access denied for command from user ${senderId}`);
535
- return;
536
- }
537
-
538
631
  const runtime = getBitrix24Runtime();
539
632
  const cfg = runtime.config.loadConfig();
540
633
 
634
+ // Pairing-aware access control (commands don't send pairing replies)
635
+ const accessResult = await checkAccessWithPairing({
636
+ senderId,
637
+ config,
638
+ runtime,
639
+ accountId: ctx.accountId,
640
+ pairingAdapter: bitrix24Plugin.pairing,
641
+ sendReply: async () => {},
642
+ logger,
643
+ });
644
+
645
+ if (accessResult !== 'allow') {
646
+ logger.debug(`Command blocked (${accessResult})`, { senderId });
647
+ return;
648
+ }
649
+
541
650
  const route = runtime.channel.routing.resolveAgentRoute({
542
651
  cfg,
543
652
  channel: 'bitrix24',
@@ -545,7 +654,7 @@ export const bitrix24Plugin = {
545
654
  peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
546
655
  });
547
656
 
548
- // Native commands use a separate slash-command session (like Telegram)
657
+ // Native commands use a separate slash-command session
549
658
  const slashSessionKey = `bitrix24:slash:${senderId}`;
550
659
 
551
660
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
@@ -628,7 +737,7 @@ export const bitrix24Plugin = {
628
737
  },
629
738
  });
630
739
 
631
- gatewayState = { api, sendService, inboundHandler };
740
+ gatewayState = { api, sendService, mediaService, inboundHandler };
632
741
 
633
742
  logger.info(`[${ctx.accountId}] Bitrix24 channel started`);
634
743
 
package/src/commands.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenClaw bot commands to register with Bitrix24.
3
3
  *
4
- * Mirrors the native commands registered by the Telegram plugin via setMyCommands.
4
+ * Standard OpenClaw bot commands registered via imbot.command.register.
5
5
  */
6
6
 
7
7
  export interface BotCommandDef {
@@ -7,7 +7,7 @@ const AccountSchema = z.object({
7
7
  botCode: z.string().optional().default('openclaw'),
8
8
  botAvatar: z.string().optional(),
9
9
  callbackUrl: z.string().url().optional(),
10
- dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('open'),
10
+ dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('pairing'),
11
11
  allowFrom: z.array(z.string()).optional(),
12
12
  showTyping: z.boolean().optional().default(true),
13
13
  streamUpdates: z.boolean().optional().default(false),
@@ -11,7 +11,6 @@ import type {
11
11
  B24MediaItem,
12
12
  } from './types.js';
13
13
  import { Dedup } from './dedup.js';
14
- import { checkAccess } from './access-control.js';
15
14
  import type { Bitrix24AccountConfig } from './types.js';
16
15
  import { defaultLogger } from './utils.js';
17
16
 
@@ -109,14 +108,6 @@ export class InboundHandler {
109
108
  return true;
110
109
  }
111
110
 
112
- const senderId = String(params.FROM_USER_ID);
113
-
114
- // Access control
115
- if (!checkAccess(senderId, this.config)) {
116
- this.logger.debug(`Access denied for user ${senderId}`);
117
- return true;
118
- }
119
-
120
111
  // Extract bot entry
121
112
  const botEntry = extractBotEntry(event.data.BOT);
122
113
  if (!botEntry) {
@@ -183,5 +174,6 @@ function normalizeFiles(files?: Record<string, B24File>): B24MediaItem[] {
183
174
  extension: file.extension,
184
175
  size: file.size,
185
176
  type: file.image ? 'image' as const : 'file' as const,
177
+ urlDownload: file.urlDownload || undefined,
186
178
  }));
187
179
  }