@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 +1 -1
- package/src/access-control.ts +61 -4
- package/src/api.ts +80 -0
- package/src/channel.ts +134 -25
- package/src/commands.ts +1 -1
- package/src/config-schema.ts +1 -1
- package/src/inbound-handler.ts +1 -9
- package/src/media-service.ts +186 -0
- package/src/runtime.ts +23 -0
- package/src/types.ts +1 -0
- package/tests/access-control.test.ts +178 -6
- package/tests/channel.test.ts +538 -0
- package/tests/inbound-handler.test.ts +4 -2
- package/tests/media-service.test.ts +224 -0
|
@@ -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
|
@@ -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
|
|
33
|
-
expect(checkAccess('1', {})).toBe(
|
|
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('
|
|
65
|
-
expect(checkAccess('1', { dmPolicy: 'pairing' })).toBe(
|
|
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
|
});
|