@ihazz/bitrix24 1.1.1 → 1.1.2

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,10 +1,18 @@
1
1
  {
2
2
  "name": "@ihazz/bitrix24",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Bitrix24 Messenger channel for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "index.ts",
10
+ "openclaw.plugin.json",
11
+ "README.md",
12
+ "LICENSE",
13
+ "src",
14
+ "skills"
15
+ ],
8
16
  "openclaw": {
9
17
  "extensions": [
10
18
  "./index.ts"
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isIP } from 'node:net';
3
3
  import { writeFile, readFile, mkdir, stat, unlink } from 'node:fs/promises';
4
- import { homedir } from 'node:os';
5
4
  import { join, basename, resolve as resolvePath, relative, sep } from 'node:path';
6
5
  import { Bitrix24Api } from './api.js';
7
6
  import type { BotContext } from './api.js';
8
7
  import type { Logger } from './types.js';
8
+ import { resolveManagedMediaDir } from './state-paths.js';
9
9
  import { defaultLogger, serializeError } from './utils.js';
10
10
 
11
11
  export interface DownloadedMedia {
@@ -111,27 +111,23 @@ function normalizeDownloadUrl(downloadUrl: string, webhookUrl: string): string {
111
111
  }
112
112
  }
113
113
 
114
- function resolveHomeDir(env: NodeJS.ProcessEnv = process.env): string {
115
- const homePath = env.HOME?.trim() || env.USERPROFILE?.trim();
116
- if (homePath) {
117
- return resolvePath(homePath);
118
- }
119
-
120
- return homedir();
121
- }
114
+ function replaceDownloadUrlOrigin(downloadUrl: string, webhookUrl: string): string | null {
115
+ try {
116
+ const sourceUrl = new URL(downloadUrl);
117
+ const webhook = new URL(webhookUrl);
118
+ if (sourceUrl.protocol === webhook.protocol && sourceUrl.host === webhook.host) {
119
+ return null;
120
+ }
122
121
 
123
- function resolveOpenClawStateDir(env: NodeJS.ProcessEnv = process.env): string {
124
- const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
125
- if (override) {
126
- return resolvePath(override);
122
+ sourceUrl.protocol = webhook.protocol;
123
+ sourceUrl.host = webhook.host;
124
+ return sourceUrl.toString();
125
+ } catch {
126
+ return null;
127
127
  }
128
-
129
- return join(resolveHomeDir(env), '.openclaw');
130
128
  }
131
129
 
132
- export function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): string {
133
- return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
134
- }
130
+ export { resolveManagedMediaDir } from './state-paths.js';
135
131
 
136
132
  /** Maximum file size for download/upload (100 MB). */
137
133
  const MAX_FILE_SIZE = 100 * 1024 * 1024;
@@ -155,6 +151,24 @@ export class MediaService {
155
151
  this.dirReady = true;
156
152
  }
157
153
 
154
+ private async fetchDownloadResponse(params: {
155
+ url: string;
156
+ fileId: string;
157
+ }): Promise<Response> {
158
+ try {
159
+ return await fetch(params.url, {
160
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
161
+ });
162
+ } catch (err) {
163
+ this.logger.warn('Bitrix file fetch failed', {
164
+ fileId: params.fileId,
165
+ url: params.url,
166
+ error: serializeError(err),
167
+ });
168
+ throw err;
169
+ }
170
+ }
171
+
158
172
  /**
159
173
  * Download a file from B24 using imbot.v2.File.download.
160
174
  * Single-step: get download URL, then fetch the file.
@@ -198,16 +212,55 @@ export class MediaService {
198
212
  });
199
213
  }
200
214
 
215
+ const webhookFallbackUrl = replaceDownloadUrlOrigin(downloadUrl, webhookUrl);
216
+ const canRetryWithWebhookOrigin = Boolean(
217
+ webhookFallbackUrl && webhookFallbackUrl !== safeDownloadUrl,
218
+ );
219
+
201
220
  // Download the file (with timeout)
202
- const response = await fetch(safeDownloadUrl, {
203
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
204
- });
205
- if (!response.ok) {
206
- this.logger.warn('Failed to download file', {
221
+ let response: Response;
222
+ try {
223
+ response = await this.fetchDownloadResponse({
224
+ url: safeDownloadUrl,
207
225
  fileId,
208
- status: response.status,
209
226
  });
210
- return null;
227
+ } catch (err) {
228
+ if (!canRetryWithWebhookOrigin || !webhookFallbackUrl) {
229
+ throw err;
230
+ }
231
+
232
+ this.logger.warn('Retrying Bitrix file download via webhook origin after fetch failure', {
233
+ fileId,
234
+ fromUrl: safeDownloadUrl,
235
+ toUrl: webhookFallbackUrl,
236
+ });
237
+ response = await this.fetchDownloadResponse({
238
+ url: webhookFallbackUrl,
239
+ fileId,
240
+ });
241
+ }
242
+
243
+ if (!response.ok) {
244
+ if (canRetryWithWebhookOrigin && webhookFallbackUrl) {
245
+ this.logger.warn('Retrying Bitrix file download via webhook origin after HTTP failure', {
246
+ fileId,
247
+ status: response.status,
248
+ fromUrl: safeDownloadUrl,
249
+ toUrl: webhookFallbackUrl,
250
+ });
251
+ response = await this.fetchDownloadResponse({
252
+ url: webhookFallbackUrl,
253
+ fileId,
254
+ });
255
+ }
256
+
257
+ if (!response.ok) {
258
+ this.logger.warn('Failed to download file', {
259
+ fileId,
260
+ status: response.status,
261
+ });
262
+ return null;
263
+ }
211
264
  }
212
265
 
213
266
  // Check file size before downloading into memory
@@ -0,0 +1,24 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve as resolvePath } from 'node:path';
3
+
4
+ function resolveHomeDir(env: NodeJS.ProcessEnv = process.env): string {
5
+ const homePath = env.HOME?.trim() || env.USERPROFILE?.trim();
6
+ if (homePath) {
7
+ return resolvePath(homePath);
8
+ }
9
+
10
+ return homedir();
11
+ }
12
+
13
+ function resolveOpenClawStateDir(env: NodeJS.ProcessEnv = process.env): string {
14
+ const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
15
+ if (override) {
16
+ return resolvePath(override);
17
+ }
18
+
19
+ return join(resolveHomeDir(env), '.openclaw');
20
+ }
21
+
22
+ export function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): string {
23
+ return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
24
+ }
@@ -1,398 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import {
3
- checkAccess,
4
- checkAccessWithPairing,
5
- getWebhookUserId,
6
- normalizeAllowEntry,
7
- normalizeAllowList,
8
- } from '../src/access-control.js';
9
- import type { PluginRuntime, ChannelPairingAdapter } from '../src/runtime.js';
10
-
11
- describe('normalizeAllowEntry', () => {
12
- it('strips bitrix24: prefix', () => {
13
- expect(normalizeAllowEntry('bitrix24:42')).toBe('42');
14
- });
15
-
16
- it('strips b24: prefix', () => {
17
- expect(normalizeAllowEntry('b24:100')).toBe('100');
18
- });
19
-
20
- it('strips bx24: prefix', () => {
21
- expect(normalizeAllowEntry('bx24:7')).toBe('7');
22
- });
23
-
24
- it('strips prefixes case-insensitively', () => {
25
- expect(normalizeAllowEntry('Bitrix24:42')).toBe('42');
26
- expect(normalizeAllowEntry('B24:100')).toBe('100');
27
- expect(normalizeAllowEntry('BX24:7')).toBe('7');
28
- });
29
-
30
- it('returns plain ID as-is', () => {
31
- expect(normalizeAllowEntry('42')).toBe('42');
32
- });
33
-
34
- it('trims whitespace', () => {
35
- expect(normalizeAllowEntry(' b24:42 ')).toBe('42');
36
- });
37
- });
38
-
39
- describe('normalizeAllowList', () => {
40
- it('normalizes, deduplicates and filters empty allowFrom entries', () => {
41
- expect(normalizeAllowList([' bitrix24:42 ', '42', 'b24:7', ''])).toEqual(['42', '7']);
42
- });
43
-
44
- it('returns empty list for missing allowFrom config', () => {
45
- expect(normalizeAllowList(undefined)).toEqual([]);
46
- });
47
- });
48
-
49
- describe('getWebhookUserId', () => {
50
- it('extracts owner ID from webhook URL', () => {
51
- expect(getWebhookUserId('https://test.bitrix24.com/rest/42/token/')).toBe('42');
52
- });
53
-
54
- it('normalizes prefixed user IDs', () => {
55
- expect(getWebhookUserId('https://test.bitrix24.com/rest/b24:42/token/')).toBe('42');
56
- });
57
-
58
- it('returns null for invalid URLs', () => {
59
- expect(getWebhookUserId('not-a-url')).toBeNull();
60
- });
61
-
62
- it('returns null when rest segment is missing', () => {
63
- expect(getWebhookUserId('https://test.bitrix24.com/api/42/token/')).toBeNull();
64
- });
65
- });
66
-
67
- describe('checkAccess', () => {
68
- it('defaults to webhookUser and denies without a valid webhook owner', () => {
69
- expect(checkAccess('1', {})).toBe(false);
70
- });
71
-
72
- it('allows only the webhook owner in webhookUser mode', () => {
73
- const config = {
74
- dmPolicy: 'webhookUser' as const,
75
- webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
76
- };
77
-
78
- expect(checkAccess('42', config)).toBe(true);
79
- expect(checkAccess('99', config)).toBe(false);
80
- });
81
-
82
- it('allows webhook owner when dmPolicy is omitted', () => {
83
- expect(checkAccess('42', { webhookUrl: 'https://test.bitrix24.com/rest/42/token/' })).toBe(true);
84
- });
85
-
86
- it('keeps senderId as the access identity even when direct dialogId differs', () => {
87
- const config = {
88
- dmPolicy: 'webhookUser' as const,
89
- webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
90
- };
91
-
92
- expect(checkAccess('42', config, { dialogId: '2386', isDirect: true })).toBe(true);
93
- expect(checkAccess('77', config, { dialogId: '42', isDirect: true })).toBe(false);
94
- });
95
-
96
- it('denies when webhookUser mode has no valid webhook owner', () => {
97
- expect(checkAccess('42', { dmPolicy: 'webhookUser', webhookUrl: 'invalid' })).toBe(false);
98
- });
99
-
100
- it('returns false for pairing mode without runtime state', () => {
101
- expect(checkAccess('1', { dmPolicy: 'pairing' })).toBe(false);
102
- });
103
-
104
- it('allows any sender in open mode', () => {
105
- expect(checkAccess('1', { dmPolicy: 'open' })).toBe(true);
106
- expect(checkAccess('77', { dmPolicy: 'open' })).toBe(true);
107
- });
108
-
109
- it('allows only configured identities in allowlist mode', () => {
110
- expect(checkAccess('42', { dmPolicy: 'allowlist', allowFrom: ['bitrix24:42'] })).toBe(true);
111
- expect(checkAccess('77', { dmPolicy: 'allowlist', allowFrom: ['bitrix24:42'] })).toBe(false);
112
- });
113
- });
114
-
115
- function makeMockRuntime(storeAllowFrom: string[] = []): PluginRuntime {
116
- return {
117
- config: { loadConfig: () => ({}) },
118
- channel: {
119
- routing: { resolveAgentRoute: vi.fn() },
120
- reply: {
121
- finalizeInboundContext: vi.fn(),
122
- dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
123
- },
124
- session: { recordInboundSession: vi.fn() },
125
- pairing: {
126
- readAllowFromStore: vi.fn().mockResolvedValue(storeAllowFrom),
127
- upsertPairingRequest: vi.fn().mockResolvedValue({ code: 'ABCD1234', created: true }),
128
- buildPairingReply: vi.fn().mockReturnValue('Your pairing code: ABCD1234'),
129
- },
130
- },
131
- logging: { getChildLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) },
132
- } as unknown as PluginRuntime;
133
- }
134
-
135
- const mockAdapter: ChannelPairingAdapter = {
136
- idLabel: 'bitrix24UserId',
137
- normalizeAllowEntry: (entry) => entry.replace(/^(bitrix24|b24|bx24):/i, ''),
138
- };
139
-
140
- const silentLogger = { debug: vi.fn() };
141
-
142
- describe('checkAccessWithPairing', () => {
143
- it('defaults to webhookUser when dmPolicy is omitted', async () => {
144
- const runtime = makeMockRuntime();
145
-
146
- const result = await checkAccessWithPairing({
147
- senderId: '42',
148
- config: { webhookUrl: 'https://test.bitrix24.com/rest/42/token/' },
149
- runtime,
150
- accountId: 'default',
151
- pairingAdapter: mockAdapter,
152
- sendReply: vi.fn(),
153
- logger: silentLogger,
154
- });
155
-
156
- expect(result).toBe('allow');
157
- expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
158
- });
159
-
160
- it('allows only the webhook owner in webhookUser mode', async () => {
161
- const runtime = makeMockRuntime();
162
- const sendReply = vi.fn();
163
-
164
- const result = await checkAccessWithPairing({
165
- senderId: '42',
166
- config: {
167
- dmPolicy: 'webhookUser',
168
- webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
169
- },
170
- runtime,
171
- accountId: 'default',
172
- pairingAdapter: mockAdapter,
173
- sendReply,
174
- logger: silentLogger,
175
- });
176
-
177
- expect(result).toBe('allow');
178
- expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
179
- expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
180
- expect(sendReply).not.toHaveBeenCalled();
181
- });
182
-
183
- it('keeps senderId as the identity in webhookUser mode even when dialogId differs', async () => {
184
- const runtime = makeMockRuntime();
185
-
186
- const allowed = await checkAccessWithPairing({
187
- senderId: '42',
188
- dialogId: '2386',
189
- isDirect: true,
190
- config: {
191
- dmPolicy: 'webhookUser',
192
- webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
193
- },
194
- runtime,
195
- accountId: 'default',
196
- pairingAdapter: mockAdapter,
197
- sendReply: vi.fn(),
198
- logger: silentLogger,
199
- });
200
-
201
- const denied = await checkAccessWithPairing({
202
- senderId: '77',
203
- dialogId: '42',
204
- isDirect: true,
205
- config: {
206
- dmPolicy: 'webhookUser',
207
- webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
208
- },
209
- runtime,
210
- accountId: 'default',
211
- pairingAdapter: mockAdapter,
212
- sendReply: vi.fn(),
213
- logger: silentLogger,
214
- });
215
-
216
- expect(allowed).toBe('allow');
217
- expect(denied).toBe('deny');
218
- });
219
-
220
- it('denies non-owner users in webhookUser mode', async () => {
221
- const runtime = makeMockRuntime();
222
- const sendReply = vi.fn();
223
-
224
- const result = await checkAccessWithPairing({
225
- senderId: '77',
226
- config: {
227
- dmPolicy: 'webhookUser',
228
- webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
229
- },
230
- runtime,
231
- accountId: 'default',
232
- pairingAdapter: mockAdapter,
233
- sendReply,
234
- logger: silentLogger,
235
- });
236
-
237
- expect(result).toBe('deny');
238
- expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
239
- expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
240
- expect(sendReply).not.toHaveBeenCalled();
241
- });
242
-
243
- it('returns allow when sender is in pairing store', async () => {
244
- const runtime = makeMockRuntime(['42']);
245
-
246
- const result = await checkAccessWithPairing({
247
- senderId: '42',
248
- config: { dmPolicy: 'pairing' },
249
- runtime,
250
- accountId: 'default',
251
- pairingAdapter: mockAdapter,
252
- sendReply: vi.fn(),
253
- logger: silentLogger,
254
- });
255
-
256
- expect(result).toBe('allow');
257
- });
258
-
259
- it('returns allow when sender is in config allowFrom', async () => {
260
- const runtime = makeMockRuntime();
261
-
262
- const result = await checkAccessWithPairing({
263
- senderId: '42',
264
- config: {
265
- dmPolicy: 'pairing',
266
- allowFrom: ['bitrix24:42'],
267
- },
268
- runtime,
269
- accountId: 'default',
270
- pairingAdapter: mockAdapter,
271
- sendReply: vi.fn(),
272
- logger: silentLogger,
273
- });
274
-
275
- expect(result).toBe('allow');
276
- expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
277
- });
278
-
279
- it('returns allow for any sender in open mode', async () => {
280
- const runtime = makeMockRuntime();
281
-
282
- const result = await checkAccessWithPairing({
283
- senderId: '77',
284
- config: { dmPolicy: 'open' },
285
- runtime,
286
- accountId: 'default',
287
- pairingAdapter: mockAdapter,
288
- sendReply: vi.fn(),
289
- logger: silentLogger,
290
- });
291
-
292
- expect(result).toBe('allow');
293
- });
294
-
295
- it('returns deny for non-allowlisted sender in allowlist mode', async () => {
296
- const runtime = makeMockRuntime();
297
-
298
- const result = await checkAccessWithPairing({
299
- senderId: '77',
300
- config: { dmPolicy: 'allowlist', allowFrom: ['bitrix24:42'] },
301
- runtime,
302
- accountId: 'default',
303
- pairingAdapter: mockAdapter,
304
- sendReply: vi.fn(),
305
- logger: silentLogger,
306
- });
307
-
308
- expect(result).toBe('deny');
309
- expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
310
- });
311
-
312
- it('uses senderId as the pairing identity even when direct dialogId differs', async () => {
313
- const runtime = makeMockRuntime(['42']);
314
-
315
- const result = await checkAccessWithPairing({
316
- senderId: '42',
317
- dialogId: '42',
318
- isDirect: true,
319
- config: { dmPolicy: 'pairing' },
320
- runtime,
321
- accountId: 'default',
322
- pairingAdapter: mockAdapter,
323
- sendReply: vi.fn(),
324
- logger: silentLogger,
325
- });
326
-
327
- expect(result).toBe('allow');
328
- });
329
-
330
- it('normalizes prefixed entries from pairing store', async () => {
331
- const runtime = makeMockRuntime(['b24:42']);
332
-
333
- const result = await checkAccessWithPairing({
334
- senderId: '42',
335
- config: { dmPolicy: 'pairing' },
336
- runtime,
337
- accountId: 'default',
338
- pairingAdapter: mockAdapter,
339
- sendReply: vi.fn(),
340
- logger: silentLogger,
341
- });
342
-
343
- expect(result).toBe('allow');
344
- });
345
-
346
- it('upserts pairing request and sends reply for new pairing', async () => {
347
- const runtime = makeMockRuntime();
348
- const sendReply = vi.fn();
349
-
350
- const result = await checkAccessWithPairing({
351
- senderId: '77',
352
- config: { dmPolicy: 'pairing' },
353
- runtime,
354
- accountId: 'default',
355
- pairingAdapter: mockAdapter,
356
- sendReply,
357
- logger: silentLogger,
358
- });
359
-
360
- expect(result).toBe('pairing');
361
- expect(runtime.channel.pairing.readAllowFromStore).toHaveBeenCalledWith('bitrix24', '', 'default');
362
- expect(runtime.channel.pairing.upsertPairingRequest).toHaveBeenCalledWith({
363
- channel: 'bitrix24',
364
- id: '77',
365
- accountId: 'default',
366
- meta: {},
367
- pairingAdapter: mockAdapter,
368
- });
369
- expect(runtime.channel.pairing.buildPairingReply).toHaveBeenCalledWith({
370
- code: 'ABCD1234',
371
- channel: 'bitrix24',
372
- accountId: 'default',
373
- });
374
- expect(sendReply).toHaveBeenCalledWith('Your pairing code: ABCD1234');
375
- });
376
-
377
- it('does not send reply for duplicate pairing request', async () => {
378
- const runtime = makeMockRuntime();
379
- (runtime.channel.pairing.upsertPairingRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
380
- code: 'ABCD1234',
381
- created: false,
382
- });
383
- const sendReply = vi.fn();
384
-
385
- const result = await checkAccessWithPairing({
386
- senderId: '77',
387
- config: { dmPolicy: 'pairing' },
388
- runtime,
389
- accountId: 'default',
390
- pairingAdapter: mockAdapter,
391
- sendReply,
392
- logger: silentLogger,
393
- });
394
-
395
- expect(result).toBe('pairing');
396
- expect(sendReply).not.toHaveBeenCalled();
397
- });
398
- });