@ihazz/bitrix24 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { writeFile, readFile, mkdir } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, basename } from 'node:path';
5
+ import { Bitrix24Api } from './api.js';
6
+ import { defaultLogger } from './utils.js';
7
+
8
+ interface Logger {
9
+ info: (...args: unknown[]) => void;
10
+ warn: (...args: unknown[]) => void;
11
+ error: (...args: unknown[]) => void;
12
+ debug: (...args: unknown[]) => void;
13
+ }
14
+
15
+ export interface DownloadedMedia {
16
+ path: string;
17
+ contentType: string;
18
+ name: string;
19
+ }
20
+
21
+ const MIME_MAP: Record<string, string> = {
22
+ jpg: 'image/jpeg',
23
+ jpeg: 'image/jpeg',
24
+ png: 'image/png',
25
+ gif: 'image/gif',
26
+ webp: 'image/webp',
27
+ svg: 'image/svg+xml',
28
+ bmp: 'image/bmp',
29
+ ico: 'image/x-icon',
30
+ tiff: 'image/tiff',
31
+ tif: 'image/tiff',
32
+ mp4: 'video/mp4',
33
+ webm: 'video/webm',
34
+ avi: 'video/x-msvideo',
35
+ mov: 'video/quicktime',
36
+ mkv: 'video/x-matroska',
37
+ mp3: 'audio/mpeg',
38
+ wav: 'audio/wav',
39
+ ogg: 'audio/ogg',
40
+ flac: 'audio/flac',
41
+ pdf: 'application/pdf',
42
+ doc: 'application/msword',
43
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
44
+ xls: 'application/vnd.ms-excel',
45
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
46
+ ppt: 'application/vnd.ms-powerpoint',
47
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
48
+ zip: 'application/zip',
49
+ rar: 'application/x-rar-compressed',
50
+ gz: 'application/gzip',
51
+ tar: 'application/x-tar',
52
+ txt: 'text/plain',
53
+ csv: 'text/csv',
54
+ html: 'text/html',
55
+ css: 'text/css',
56
+ js: 'application/javascript',
57
+ json: 'application/json',
58
+ xml: 'application/xml',
59
+ };
60
+
61
+ function mimeFromExtension(ext: string): string {
62
+ return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
63
+ }
64
+
65
+ const MEDIA_DIR = join(tmpdir(), 'openclaw-b24-media');
66
+
67
+ export class MediaService {
68
+ private api: Bitrix24Api;
69
+ private logger: Logger;
70
+ private dirReady = false;
71
+
72
+ constructor(api: Bitrix24Api, logger?: Logger) {
73
+ this.api = api;
74
+ this.logger = logger ?? defaultLogger;
75
+ }
76
+
77
+ private async ensureDir(): Promise<void> {
78
+ if (this.dirReady) return;
79
+ await mkdir(MEDIA_DIR, { recursive: true });
80
+ this.dirReady = true;
81
+ }
82
+
83
+ /**
84
+ * Download a file from B24 and save it locally.
85
+ * Uses the user's access token to call disk.file.get for the download URL.
86
+ */
87
+ async downloadMedia(params: {
88
+ fileId: string;
89
+ fileName: string;
90
+ extension: string;
91
+ clientEndpoint: string;
92
+ userToken: string;
93
+ }): Promise<DownloadedMedia | null> {
94
+ const { fileId, fileName, extension, clientEndpoint, userToken } = params;
95
+
96
+ try {
97
+ // Get download URL from B24 REST API
98
+ const fileInfo = await this.api.getFileInfo(
99
+ clientEndpoint,
100
+ userToken,
101
+ Number(fileId),
102
+ );
103
+
104
+ const downloadUrl = fileInfo.DOWNLOAD_URL;
105
+ if (!downloadUrl) {
106
+ this.logger.warn('No DOWNLOAD_URL for file', { fileId });
107
+ return null;
108
+ }
109
+
110
+ // Download the file
111
+ const response = await fetch(downloadUrl);
112
+ if (!response.ok) {
113
+ this.logger.warn('Failed to download file', {
114
+ fileId,
115
+ status: response.status,
116
+ });
117
+ return null;
118
+ }
119
+
120
+ const buffer = Buffer.from(await response.arrayBuffer());
121
+
122
+ // Save to temp directory with UUID prefix for uniqueness
123
+ await this.ensureDir();
124
+ const savePath = join(MEDIA_DIR, `${randomUUID()}_${fileName}`);
125
+ await writeFile(savePath, buffer);
126
+
127
+ const contentType = mimeFromExtension(extension);
128
+ this.logger.debug('Downloaded media', {
129
+ fileId,
130
+ fileName,
131
+ contentType,
132
+ size: buffer.length,
133
+ path: savePath,
134
+ });
135
+
136
+ return { path: savePath, contentType, name: fileName };
137
+ } catch (err) {
138
+ this.logger.error('Error downloading media', { fileId, error: err });
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Upload a local file to a B24 chat.
145
+ * 3-step process: get folder → upload → commit.
146
+ */
147
+ async uploadMediaToChat(params: {
148
+ localPath: string;
149
+ fileName: string;
150
+ chatId: number;
151
+ clientEndpoint: string;
152
+ botToken: string;
153
+ }): Promise<boolean> {
154
+ const { localPath, fileName, chatId, clientEndpoint, botToken } = params;
155
+
156
+ try {
157
+ // Read the file
158
+ const content = await readFile(localPath);
159
+
160
+ // Step 1: Get chat folder
161
+ const folderId = await this.api.getChatFolder(clientEndpoint, botToken, chatId);
162
+
163
+ // Step 2: Upload file (base64)
164
+ const diskId = await this.api.uploadFile(
165
+ clientEndpoint,
166
+ botToken,
167
+ folderId,
168
+ fileName,
169
+ content,
170
+ );
171
+
172
+ // Step 3: Publish to chat
173
+ await this.api.commitFileToChat(clientEndpoint, botToken, chatId, diskId);
174
+
175
+ this.logger.debug('Uploaded media to chat', { fileName, chatId, diskId });
176
+ return true;
177
+ } catch (err) {
178
+ this.logger.error('Error uploading media to chat', {
179
+ fileName,
180
+ chatId,
181
+ error: err,
182
+ });
183
+ return false;
184
+ }
185
+ }
186
+ }
@@ -90,15 +90,25 @@ export function buildKeyboard(
90
90
  }>
91
91
  >,
92
92
  ): B24Keyboard {
93
- return rows.map((row) =>
94
- row.map((btn): KeyboardButton => ({
95
- TEXT: btn.text,
96
- ...(btn.command ? { COMMAND: btn.command, COMMAND_PARAMS: btn.commandParams ?? '' } : {}),
97
- ...(btn.link ? { LINK: btn.link } : {}),
98
- BG_COLOR: btn.bgColor ?? '#29619b',
99
- TEXT_COLOR: btn.textColor ?? '#fff',
100
- DISPLAY: btn.fullWidth ? 'LINE' : 'BLOCK',
101
- BLOCK: btn.disableAfterClick ? 'Y' : 'N',
102
- })),
103
- );
93
+ const keyboard: B24Keyboard = [];
94
+
95
+ for (let i = 0; i < rows.length; i++) {
96
+ for (const btn of rows[i]) {
97
+ keyboard.push({
98
+ TEXT: btn.text,
99
+ ...(btn.command ? { COMMAND: btn.command, COMMAND_PARAMS: btn.commandParams ?? '' } : {}),
100
+ ...(btn.link ? { LINK: btn.link } : {}),
101
+ BG_COLOR: btn.bgColor ?? '#29619b',
102
+ TEXT_COLOR: btn.textColor ?? '#fff',
103
+ DISPLAY: btn.fullWidth ? 'LINE' : 'BLOCK',
104
+ BLOCK: btn.disableAfterClick ? 'Y' : 'N',
105
+ });
106
+ }
107
+
108
+ if (i < rows.length - 1) {
109
+ keyboard.push({ TYPE: 'NEWLINE' });
110
+ }
111
+ }
112
+
113
+ return keyboard;
104
114
  }
package/src/runtime.ts CHANGED
@@ -6,6 +6,13 @@ interface ReplyPayload {
6
6
  channelData?: Record<string, unknown>;
7
7
  }
8
8
 
9
+ /** Adapter provided by the channel plugin for pairing support */
10
+ export interface ChannelPairingAdapter {
11
+ idLabel: string;
12
+ normalizeAllowEntry?: (entry: string) => string;
13
+ notifyApproval?: (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => Promise<void>;
14
+ }
15
+
9
16
  export interface PluginRuntime {
10
17
  config: {
11
18
  loadConfig: () => Record<string, unknown>;
@@ -50,6 +57,22 @@ export interface PluginRuntime {
50
57
  recordInboundSession: (params: Record<string, unknown>) => Promise<void>;
51
58
  [key: string]: unknown;
52
59
  };
60
+ pairing: {
61
+ readAllowFromStore: (channel: string, env: string, accountId: string) => Promise<string[]>;
62
+ upsertPairingRequest: (params: {
63
+ channel: string;
64
+ id: string;
65
+ accountId: string;
66
+ meta?: Record<string, unknown>;
67
+ pairingAdapter: ChannelPairingAdapter;
68
+ }) => Promise<{ code: string; created: boolean }>;
69
+ buildPairingReply: (params: {
70
+ code: string;
71
+ channel: string;
72
+ accountId: string;
73
+ }) => { text: string };
74
+ [key: string]: unknown;
75
+ };
53
76
  [key: string]: unknown;
54
77
  };
55
78
  logging: {
package/src/types.ts CHANGED
@@ -211,14 +211,23 @@ export interface KeyboardButton {
211
211
  COMMAND?: string;
212
212
  COMMAND_PARAMS?: string;
213
213
  BG_COLOR?: string;
214
+ BG_COLOR_TOKEN?: 'primary' | 'secondary' | 'alert' | 'base';
214
215
  TEXT_COLOR?: string;
215
216
  DISPLAY?: 'LINE' | 'BLOCK';
216
217
  DISABLED?: 'Y' | 'N';
217
218
  BLOCK?: 'Y' | 'N';
218
219
  LINK?: string;
220
+ WIDTH?: number;
221
+ ACTION?: 'PUT' | 'SEND' | 'COPY' | 'CALL' | 'DIALOG';
222
+ ACTION_VALUE?: string;
219
223
  }
220
224
 
221
- export type B24Keyboard = KeyboardButton[][];
225
+ export interface KeyboardNewline {
226
+ TYPE: 'NEWLINE';
227
+ }
228
+
229
+ /** B24 keyboard: flat array with NEWLINE separators between rows */
230
+ export type B24Keyboard = (KeyboardButton | KeyboardNewline)[];
222
231
 
223
232
  export interface SendMessageOptions {
224
233
  ATTACH?: unknown;
@@ -242,7 +251,6 @@ export interface Bitrix24AccountConfig {
242
251
  botName?: string;
243
252
  botCode?: string;
244
253
  botAvatar?: string;
245
- callbackPath?: string;
246
254
  callbackUrl?: string;
247
255
  dmPolicy?: 'open' | 'allowlist' | 'pairing';
248
256
  allowFrom?: string[];
@@ -285,6 +293,7 @@ export interface B24MediaItem {
285
293
  extension: string;
286
294
  size: number;
287
295
  type: 'image' | 'file';
296
+ urlDownload?: string;
288
297
  }
289
298
 
290
299
  // ─── Portal State ────────────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { checkAccess, normalizeAllowEntry } from '../src/access-control.js';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { checkAccess, normalizeAllowEntry, checkAccessWithPairing } from '../src/access-control.js';
3
+ import type { PluginRuntime, ChannelPairingAdapter } from '../src/runtime.js';
3
4
 
4
5
  describe('normalizeAllowEntry', () => {
5
6
  it('strips bitrix24: prefix', () => {
@@ -14,6 +15,12 @@ describe('normalizeAllowEntry', () => {
14
15
  expect(normalizeAllowEntry('bx24:7')).toBe('7');
15
16
  });
16
17
 
18
+ it('strips prefixes case-insensitively', () => {
19
+ expect(normalizeAllowEntry('Bitrix24:42')).toBe('42');
20
+ expect(normalizeAllowEntry('B24:100')).toBe('100');
21
+ expect(normalizeAllowEntry('BX24:7')).toBe('7');
22
+ });
23
+
17
24
  it('returns plain ID as-is', () => {
18
25
  expect(normalizeAllowEntry('42')).toBe('42');
19
26
  });
@@ -29,8 +36,8 @@ describe('checkAccess', () => {
29
36
  expect(checkAccess('999', { dmPolicy: 'open' })).toBe(true);
30
37
  });
31
38
 
32
- it('defaults to open when no policy set', () => {
33
- expect(checkAccess('1', {})).toBe(true);
39
+ it('defaults to pairing (returns false without runtime)', () => {
40
+ expect(checkAccess('1', {})).toBe(false);
34
41
  });
35
42
 
36
43
  it('allows listed users in allowlist mode', () => {
@@ -61,7 +68,172 @@ describe('checkAccess', () => {
61
68
  expect(checkAccess('1', config)).toBe(false);
62
69
  });
63
70
 
64
- it('treats pairing as open (post-MVP)', () => {
65
- expect(checkAccess('1', { dmPolicy: 'pairing' })).toBe(true);
71
+ it('returns false for pairing mode (requires runtime)', () => {
72
+ expect(checkAccess('1', { dmPolicy: 'pairing' })).toBe(false);
73
+ });
74
+ });
75
+
76
+ // ─── checkAccessWithPairing ─────────────────────────────────────────────────
77
+
78
+ function makeMockRuntime(storeAllowFrom: string[] = []): PluginRuntime {
79
+ return {
80
+ config: { loadConfig: () => ({}) },
81
+ channel: {
82
+ routing: { resolveAgentRoute: vi.fn() },
83
+ reply: {
84
+ finalizeInboundContext: vi.fn(),
85
+ dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
86
+ },
87
+ session: { recordInboundSession: vi.fn() },
88
+ pairing: {
89
+ readAllowFromStore: vi.fn().mockResolvedValue(storeAllowFrom),
90
+ upsertPairingRequest: vi.fn().mockResolvedValue({ code: 'ABCD1234', created: true }),
91
+ buildPairingReply: vi.fn().mockReturnValue({ text: 'Your pairing code: ABCD1234' }),
92
+ },
93
+ },
94
+ logging: { getChildLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) },
95
+ } as unknown as PluginRuntime;
96
+ }
97
+
98
+ const mockAdapter: ChannelPairingAdapter = {
99
+ idLabel: 'bitrix24UserId',
100
+ normalizeAllowEntry: (entry) => entry.replace(/^(bitrix24|b24|bx24):/i, ''),
101
+ };
102
+
103
+ const silentLogger = { debug: () => {} };
104
+
105
+ describe('checkAccessWithPairing', () => {
106
+ it('returns allow for open policy', async () => {
107
+ const runtime = makeMockRuntime();
108
+ const result = await checkAccessWithPairing({
109
+ senderId: '1',
110
+ config: { dmPolicy: 'open' },
111
+ runtime,
112
+ accountId: 'default',
113
+ pairingAdapter: mockAdapter,
114
+ sendReply: vi.fn(),
115
+ logger: silentLogger,
116
+ });
117
+ expect(result).toBe('allow');
118
+ // Should NOT read store for open policy
119
+ expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('returns allow when sender is in config allowFrom', async () => {
123
+ const runtime = makeMockRuntime();
124
+ const result = await checkAccessWithPairing({
125
+ senderId: '42',
126
+ config: { dmPolicy: 'pairing', allowFrom: ['42'] },
127
+ runtime,
128
+ accountId: 'default',
129
+ pairingAdapter: mockAdapter,
130
+ sendReply: vi.fn(),
131
+ logger: silentLogger,
132
+ });
133
+ expect(result).toBe('allow');
134
+ });
135
+
136
+ it('returns allow when sender is in file store', async () => {
137
+ const runtime = makeMockRuntime(['42']);
138
+ const result = await checkAccessWithPairing({
139
+ senderId: '42',
140
+ config: { dmPolicy: 'pairing' },
141
+ runtime,
142
+ accountId: 'default',
143
+ pairingAdapter: mockAdapter,
144
+ sendReply: vi.fn(),
145
+ logger: silentLogger,
146
+ });
147
+ expect(result).toBe('allow');
148
+ });
149
+
150
+ it('merges config and store allowFrom (deduped)', async () => {
151
+ const runtime = makeMockRuntime(['42', '99']);
152
+ const result = await checkAccessWithPairing({
153
+ senderId: '99',
154
+ config: { dmPolicy: 'allowlist', allowFrom: ['42'] },
155
+ runtime,
156
+ accountId: 'default',
157
+ pairingAdapter: mockAdapter,
158
+ sendReply: vi.fn(),
159
+ logger: silentLogger,
160
+ });
161
+ expect(result).toBe('allow');
162
+ });
163
+
164
+ it('returns deny for allowlist when sender not in merged list', async () => {
165
+ const runtime = makeMockRuntime(['42']);
166
+ const result = await checkAccessWithPairing({
167
+ senderId: '999',
168
+ config: { dmPolicy: 'allowlist', allowFrom: ['1'] },
169
+ runtime,
170
+ accountId: 'default',
171
+ pairingAdapter: mockAdapter,
172
+ sendReply: vi.fn(),
173
+ logger: silentLogger,
174
+ });
175
+ expect(result).toBe('deny');
176
+ });
177
+
178
+ it('upserts pairing request and sends reply for new pairing', async () => {
179
+ const runtime = makeMockRuntime();
180
+ const sendReply = vi.fn();
181
+ const result = await checkAccessWithPairing({
182
+ senderId: '77',
183
+ config: { dmPolicy: 'pairing' },
184
+ runtime,
185
+ accountId: 'default',
186
+ pairingAdapter: mockAdapter,
187
+ sendReply,
188
+ logger: silentLogger,
189
+ });
190
+ expect(result).toBe('pairing');
191
+ expect(runtime.channel.pairing.upsertPairingRequest).toHaveBeenCalledWith({
192
+ channel: 'bitrix24',
193
+ id: '77',
194
+ accountId: 'default',
195
+ meta: {},
196
+ pairingAdapter: mockAdapter,
197
+ });
198
+ expect(runtime.channel.pairing.buildPairingReply).toHaveBeenCalledWith({
199
+ code: 'ABCD1234',
200
+ channel: 'bitrix24',
201
+ accountId: 'default',
202
+ });
203
+ expect(sendReply).toHaveBeenCalledWith('Your pairing code: ABCD1234');
204
+ });
205
+
206
+ it('does not send reply for duplicate pairing request', async () => {
207
+ const runtime = makeMockRuntime();
208
+ (runtime.channel.pairing.upsertPairingRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
209
+ code: 'ABCD1234',
210
+ created: false,
211
+ });
212
+ const sendReply = vi.fn();
213
+ const result = await checkAccessWithPairing({
214
+ senderId: '77',
215
+ config: { dmPolicy: 'pairing' },
216
+ runtime,
217
+ accountId: 'default',
218
+ pairingAdapter: mockAdapter,
219
+ sendReply,
220
+ logger: silentLogger,
221
+ });
222
+ expect(result).toBe('pairing');
223
+ expect(sendReply).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('normalizes config allowFrom entries with prefixes', async () => {
227
+ const runtime = makeMockRuntime();
228
+ const result = await checkAccessWithPairing({
229
+ senderId: '42',
230
+ config: { dmPolicy: 'pairing', allowFrom: ['b24:42'] },
231
+ runtime,
232
+ accountId: 'default',
233
+ pairingAdapter: mockAdapter,
234
+ sendReply: vi.fn(),
235
+ logger: silentLogger,
236
+ });
237
+ expect(result).toBe('allow');
66
238
  });
67
239
  });