@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.
@@ -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
+ }
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
@@ -293,6 +293,7 @@ export interface B24MediaItem {
293
293
  extension: string;
294
294
  size: number;
295
295
  type: 'image' | 'file';
296
+ urlDownload?: string;
296
297
  }
297
298
 
298
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
  });