@ihazz/bitrix24 1.1.1 → 1.1.3
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/README.md +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/src/access-control.d.ts +43 -0
- package/dist/src/access-control.d.ts.map +1 -0
- package/dist/src/access-control.js +128 -0
- package/dist/src/access-control.js.map +1 -0
- package/dist/src/api.d.ts +161 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +357 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/bot-avatar.d.ts +7 -0
- package/dist/src/bot-avatar.d.ts.map +1 -0
- package/dist/src/bot-avatar.js +7 -0
- package/dist/src/bot-avatar.js.map +1 -0
- package/dist/src/channel.d.ts +216 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/dist/src/channel.js +2324 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/commands.d.ts +22 -0
- package/dist/src/commands.d.ts.map +1 -0
- package/dist/src/commands.js +160 -0
- package/dist/src/commands.js.map +1 -0
- package/dist/src/config-schema.d.ts +356 -0
- package/dist/src/config-schema.d.ts.map +1 -0
- package/dist/src/config-schema.js +43 -0
- package/dist/src/config-schema.js.map +1 -0
- package/dist/src/config.d.ts +11 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +50 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/dedup.d.ts +22 -0
- package/dist/src/dedup.d.ts.map +1 -0
- package/dist/src/dedup.js +49 -0
- package/dist/src/dedup.js.map +1 -0
- package/dist/src/group-access.d.ts +52 -0
- package/dist/src/group-access.d.ts.map +1 -0
- package/dist/src/group-access.js +180 -0
- package/dist/src/group-access.js.map +1 -0
- package/dist/src/history-cache.d.ts +41 -0
- package/dist/src/history-cache.d.ts.map +1 -0
- package/dist/src/history-cache.js +82 -0
- package/dist/src/history-cache.js.map +1 -0
- package/dist/src/i18n.d.ts +22 -0
- package/dist/src/i18n.d.ts.map +1 -0
- package/dist/src/i18n.js +175 -0
- package/dist/src/i18n.js.map +1 -0
- package/dist/src/inbound-handler.d.ts +92 -0
- package/dist/src/inbound-handler.d.ts.map +1 -0
- package/dist/src/inbound-handler.js +417 -0
- package/dist/src/inbound-handler.js.map +1 -0
- package/dist/src/media-service.d.ts +52 -0
- package/dist/src/media-service.d.ts.map +1 -0
- package/dist/src/media-service.js +423 -0
- package/dist/src/media-service.js.map +1 -0
- package/dist/src/message-utils.d.ts +34 -0
- package/dist/src/message-utils.d.ts.map +1 -0
- package/dist/src/message-utils.js +392 -0
- package/dist/src/message-utils.js.map +1 -0
- package/dist/src/polling-service.d.ts +39 -0
- package/dist/src/polling-service.d.ts.map +1 -0
- package/dist/src/polling-service.js +204 -0
- package/dist/src/polling-service.js.map +1 -0
- package/dist/src/rate-limiter.d.ts +22 -0
- package/dist/src/rate-limiter.d.ts.map +1 -0
- package/dist/src/rate-limiter.js +72 -0
- package/dist/src/rate-limiter.js.map +1 -0
- package/dist/src/runtime.d.ts +106 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/send-service.d.ts +66 -0
- package/dist/src/send-service.d.ts.map +1 -0
- package/dist/src/send-service.js +177 -0
- package/dist/src/send-service.js.map +1 -0
- package/dist/src/state-paths.d.ts +3 -0
- package/dist/src/state-paths.d.ts.map +1 -0
- package/dist/src/state-paths.js +23 -0
- package/dist/src/state-paths.js.map +1 -0
- package/dist/src/types.d.ts +381 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils.d.ts +60 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +131 -0
- package/dist/src/utils.js.map +1 -0
- package/index.ts +1 -1
- package/openclaw.plugin.json +278 -1
- package/package.json +19 -2
- package/src/api.ts +0 -3
- package/src/channel.ts +76 -73
- package/src/config-schema.ts +1 -2
- package/src/config.ts +6 -8
- package/src/group-access.ts +1 -8
- package/src/inbound-handler.ts +128 -15
- package/src/media-service.ts +229 -61
- package/src/polling-service.ts +2 -3
- package/src/send-service.ts +4 -3
- package/src/state-paths.ts +28 -0
- package/src/types.ts +1 -2
- package/src/utils.ts +31 -4
- package/tests/access-control.test.ts +0 -398
- package/tests/api.test.ts +0 -226
- package/tests/channel-flow.test.ts +0 -1692
- package/tests/channel.test.ts +0 -842
- package/tests/commands.test.ts +0 -57
- package/tests/config.test.ts +0 -210
- package/tests/dedup.test.ts +0 -50
- package/tests/fixtures/onimbotjoinchat.json +0 -48
- package/tests/fixtures/onimbotmessageadd-file.json +0 -86
- package/tests/fixtures/onimbotmessageadd-text.json +0 -59
- package/tests/fixtures/onimcommandadd.json +0 -45
- package/tests/group-access.test.ts +0 -340
- package/tests/history-cache.test.ts +0 -117
- package/tests/i18n.test.ts +0 -90
- package/tests/inbound-handler.test.ts +0 -1033
- package/tests/index.test.ts +0 -94
- package/tests/media-service.test.ts +0 -319
- package/tests/message-utils.test.ts +0 -184
- package/tests/polling-service.test.ts +0 -115
- package/tests/rate-limiter.test.ts +0 -52
- package/tests/send-service.test.ts +0 -162
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -9
package/src/group-access.ts
CHANGED
|
@@ -37,14 +37,7 @@ function normalizeWatchRules<T extends { userId: string; topics?: string[]; mode
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function normalizeGroupEntry(entry: string): string {
|
|
40
|
-
|
|
41
|
-
if (/^\d+$/.test(normalized)) {
|
|
42
|
-
return normalized;
|
|
43
|
-
}
|
|
44
|
-
if (/^chat\d+$/.test(normalized)) {
|
|
45
|
-
return normalized;
|
|
46
|
-
}
|
|
47
|
-
return normalized;
|
|
40
|
+
return String(entry).trim().toLowerCase();
|
|
48
41
|
}
|
|
49
42
|
|
|
50
43
|
export function normalizeGroupAllowList(entries: string[] | undefined): string[] {
|
package/src/inbound-handler.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
B24V2JoinChatEventData,
|
|
8
8
|
B24V2CommandEventData,
|
|
9
9
|
B24V2DeleteEventData,
|
|
10
|
+
B24V2ReactionEventData,
|
|
10
11
|
B24V2Message,
|
|
11
12
|
B24V2MessageParams,
|
|
12
13
|
B24MsgContext,
|
|
@@ -16,7 +17,7 @@ import type {
|
|
|
16
17
|
Logger,
|
|
17
18
|
} from './types.js';
|
|
18
19
|
import { Dedup } from './dedup.js';
|
|
19
|
-
import { defaultLogger } from './utils.js';
|
|
20
|
+
import { createVerboseLogger, defaultLogger } from './utils.js';
|
|
20
21
|
|
|
21
22
|
/** Normalized fetch command context passed to onFetchCommand callback. */
|
|
22
23
|
export interface FetchCommandContext {
|
|
@@ -43,12 +44,28 @@ export interface FetchJoinChatContext {
|
|
|
43
44
|
fetchCtx: FetchContext;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** Normalized fetch reaction context. */
|
|
48
|
+
export interface FetchReactionContext {
|
|
49
|
+
eventScope: 'bot' | 'user';
|
|
50
|
+
senderId: string;
|
|
51
|
+
dialogId: string;
|
|
52
|
+
chatId: string;
|
|
53
|
+
messageId: string;
|
|
54
|
+
messageAuthorId: string;
|
|
55
|
+
reaction: string;
|
|
56
|
+
action: 'set' | 'delete';
|
|
57
|
+
language?: string;
|
|
58
|
+
fetchCtx: FetchContext;
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
export interface InboundHandlerOptions {
|
|
47
62
|
config: Bitrix24AccountConfig;
|
|
48
63
|
logger?: Logger;
|
|
49
64
|
onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
|
|
50
65
|
/** Called when bot is invited to a chat (FETCH or webhook). */
|
|
51
66
|
onJoinChat?: (ctx: FetchJoinChatContext) => void | Promise<void>;
|
|
67
|
+
/** Called when reaction changes on a message. */
|
|
68
|
+
onReactionChange?: (ctx: FetchReactionContext) => void | Promise<void>;
|
|
52
69
|
/** Called for a slash command. */
|
|
53
70
|
onCommand?: (cmdCtx: FetchCommandContext) => void | Promise<void>;
|
|
54
71
|
/** Called when bot is deleted. */
|
|
@@ -59,17 +76,21 @@ export class InboundHandler {
|
|
|
59
76
|
private dedup: Dedup;
|
|
60
77
|
private config: Bitrix24AccountConfig;
|
|
61
78
|
private logger: Logger;
|
|
79
|
+
private verboseLog: boolean;
|
|
62
80
|
private onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
|
|
63
81
|
private onJoinChat?: (ctx: FetchJoinChatContext) => void | Promise<void>;
|
|
82
|
+
private onReactionChange?: (ctx: FetchReactionContext) => void | Promise<void>;
|
|
64
83
|
private onCommand?: (cmdCtx: FetchCommandContext) => void | Promise<void>;
|
|
65
84
|
private onBotDelete?: (data: B24V2DeleteEventData) => void | Promise<void>;
|
|
66
85
|
|
|
67
86
|
constructor(opts: InboundHandlerOptions) {
|
|
68
87
|
this.dedup = new Dedup();
|
|
69
88
|
this.config = opts.config;
|
|
70
|
-
this.
|
|
89
|
+
this.verboseLog = Boolean(opts.config.verboseLog);
|
|
90
|
+
this.logger = createVerboseLogger(opts.logger ?? defaultLogger, this.verboseLog);
|
|
71
91
|
this.onMessage = opts.onMessage;
|
|
72
92
|
this.onJoinChat = opts.onJoinChat;
|
|
93
|
+
this.onReactionChange = opts.onReactionChange;
|
|
73
94
|
this.onCommand = opts.onCommand;
|
|
74
95
|
this.onBotDelete = opts.onBotDelete;
|
|
75
96
|
}
|
|
@@ -97,6 +118,12 @@ export class InboundHandler {
|
|
|
97
118
|
case 'ONIMBOTV2COMMANDADD':
|
|
98
119
|
return this.handleV2Command(item, fetchCtx);
|
|
99
120
|
|
|
121
|
+
case 'ONIMBOTV2REACTIONCHANGE':
|
|
122
|
+
return this.handleV2ReactionChange(item, fetchCtx, 'bot');
|
|
123
|
+
|
|
124
|
+
case 'ONIMV2REACTIONCHANGE':
|
|
125
|
+
return this.handleV2ReactionChange(item, fetchCtx, 'user');
|
|
126
|
+
|
|
100
127
|
case 'ONIMBOTV2DELETE':
|
|
101
128
|
await this.onBotDelete?.(item.data as B24V2DeleteEventData);
|
|
102
129
|
return true;
|
|
@@ -104,10 +131,8 @@ export class InboundHandler {
|
|
|
104
131
|
case 'ONIMBOTV2MESSAGEUPDATE':
|
|
105
132
|
case 'ONIMBOTV2MESSAGEDELETE':
|
|
106
133
|
case 'ONIMBOTV2CONTEXTGET':
|
|
107
|
-
case 'ONIMBOTV2REACTIONCHANGE':
|
|
108
134
|
case 'ONIMV2MESSAGEUPDATE':
|
|
109
135
|
case 'ONIMV2MESSAGEDELETE':
|
|
110
|
-
case 'ONIMV2REACTIONCHANGE':
|
|
111
136
|
case 'ONIMV2JOINCHAT':
|
|
112
137
|
this.logger.debug(`Fetch: skipping ${eventType} (not handled)`);
|
|
113
138
|
return true;
|
|
@@ -158,15 +183,23 @@ export class InboundHandler {
|
|
|
158
183
|
// Extract file attachments from message params
|
|
159
184
|
const media = extractFilesFromParams(data.message.params);
|
|
160
185
|
|
|
161
|
-
this.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
if (this.verboseLog) {
|
|
187
|
+
this.logger.info('Fetch: message payload', {
|
|
188
|
+
eventId: item.eventId,
|
|
189
|
+
messageId,
|
|
190
|
+
dialogId,
|
|
191
|
+
chatId: data.message.chatId,
|
|
192
|
+
forward: data.message.forward,
|
|
193
|
+
params: data.message.params,
|
|
194
|
+
text: data.message.text,
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
this.logger.info('Fetch: message payload', {
|
|
198
|
+
eventId: item.eventId,
|
|
199
|
+
messageId,
|
|
200
|
+
dialogId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
170
203
|
|
|
171
204
|
const ctx: B24MsgContext = {
|
|
172
205
|
channel: 'bitrix24',
|
|
@@ -202,6 +235,57 @@ export class InboundHandler {
|
|
|
202
235
|
return true;
|
|
203
236
|
}
|
|
204
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Handle V2 ONIMBOTV2REACTIONCHANGE / ONIMV2REACTIONCHANGE event.
|
|
240
|
+
*/
|
|
241
|
+
private async handleV2ReactionChange(
|
|
242
|
+
item: B24V2FetchEventItem,
|
|
243
|
+
fetchCtx: FetchContext,
|
|
244
|
+
eventScope: 'bot' | 'user',
|
|
245
|
+
): Promise<boolean> {
|
|
246
|
+
const data = item.data as B24V2ReactionEventData;
|
|
247
|
+
if (!data?.message || !data?.user) {
|
|
248
|
+
this.logger.warn('Fetch: REACTIONCHANGE event missing message or user data, skipping', { eventId: item.eventId });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const messageId = String(data.message.id ?? '');
|
|
253
|
+
if (!messageId) {
|
|
254
|
+
this.logger.warn('Fetch: reaction event has no message id, skipping', { eventId: item.eventId });
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const action = normalizeReactionAction(data.action);
|
|
259
|
+
if (!action) {
|
|
260
|
+
this.logger.warn('Fetch: reaction event has invalid action, skipping', {
|
|
261
|
+
eventId: item.eventId,
|
|
262
|
+
action: data.action,
|
|
263
|
+
});
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const dialogId = String(data.chat?.dialogId ?? data.message.chatId ?? data.user.id ?? '');
|
|
268
|
+
if (!dialogId) {
|
|
269
|
+
this.logger.warn('Fetch: reaction event has no dialogId, skipping', { eventId: item.eventId });
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await this.onReactionChange?.({
|
|
274
|
+
eventScope,
|
|
275
|
+
senderId: String(data.user.id ?? ''),
|
|
276
|
+
dialogId,
|
|
277
|
+
chatId: String(data.chat?.id ?? data.message.chatId ?? dialogId),
|
|
278
|
+
messageId,
|
|
279
|
+
messageAuthorId: String(data.message.authorId ?? ''),
|
|
280
|
+
reaction: String(data.reaction ?? ''),
|
|
281
|
+
action,
|
|
282
|
+
language: data.language,
|
|
283
|
+
fetchCtx,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
205
289
|
/**
|
|
206
290
|
* Handle V2 ONIMBOTV2JOINCHAT event.
|
|
207
291
|
*/
|
|
@@ -298,7 +382,8 @@ export class InboundHandler {
|
|
|
298
382
|
} catch {
|
|
299
383
|
const parsedBody = parseQueryString(rawBody, {
|
|
300
384
|
allowDots: true,
|
|
301
|
-
depth:
|
|
385
|
+
depth: 3,
|
|
386
|
+
parameterLimit: 200,
|
|
302
387
|
parseArrays: true,
|
|
303
388
|
});
|
|
304
389
|
|
|
@@ -318,7 +403,22 @@ export class InboundHandler {
|
|
|
318
403
|
|
|
319
404
|
const eventType = payload.event;
|
|
320
405
|
if (!eventType) {
|
|
321
|
-
|
|
406
|
+
const payloadPreview = this.verboseLog
|
|
407
|
+
? {
|
|
408
|
+
hasData: Boolean(payload.data),
|
|
409
|
+
dataKeys: payload.data && typeof payload.data === 'object'
|
|
410
|
+
? Object.keys(payload.data as unknown as Record<string, unknown>)
|
|
411
|
+
: [],
|
|
412
|
+
hasAuth: Boolean(payload.auth),
|
|
413
|
+
authKeys: payload.auth && typeof payload.auth === 'object'
|
|
414
|
+
? Object.keys(payload.auth)
|
|
415
|
+
: [],
|
|
416
|
+
}
|
|
417
|
+
: undefined;
|
|
418
|
+
this.logger.warn('Received webhook without event type', {
|
|
419
|
+
keys: Object.keys(payload),
|
|
420
|
+
...(payloadPreview ? { payloadPreview } : {}),
|
|
421
|
+
});
|
|
322
422
|
return false;
|
|
323
423
|
}
|
|
324
424
|
|
|
@@ -456,3 +556,16 @@ function isMessageMentioningBot(params: {
|
|
|
456
556
|
function escapeRegExp(value: string): string {
|
|
457
557
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
458
558
|
}
|
|
559
|
+
|
|
560
|
+
function normalizeReactionAction(value: unknown): 'set' | 'delete' | null {
|
|
561
|
+
const action = String(value ?? '').trim().toLowerCase();
|
|
562
|
+
if (action === 'set' || action === 'add') {
|
|
563
|
+
return 'set';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (action === 'delete' || action === 'remove') {
|
|
567
|
+
return 'delete';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return null;
|
|
571
|
+
}
|
package/src/media-service.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { join, basename,
|
|
3
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { writeFile, mkdir, stat, unlink, rename, realpath } from 'node:fs/promises';
|
|
5
|
+
import { join, basename, relative, sep } from 'node:path';
|
|
6
|
+
import { Readable, Transform } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
8
|
import { Bitrix24Api } from './api.js';
|
|
7
9
|
import type { BotContext } from './api.js';
|
|
8
10
|
import type { Logger } from './types.js';
|
|
9
|
-
import {
|
|
11
|
+
import { resolveManagedMediaDir } from './state-paths.js';
|
|
12
|
+
import { defaultLogger, maskUrlForLog, serializeError } from './utils.js';
|
|
10
13
|
|
|
11
14
|
export interface DownloadedMedia {
|
|
12
15
|
path: string;
|
|
@@ -79,6 +82,7 @@ function isPrivateHost(hostname: string): boolean {
|
|
|
79
82
|
if (ipVersion === 4) {
|
|
80
83
|
const [a, b] = hostname.split('.').map(Number);
|
|
81
84
|
return (
|
|
85
|
+
a === 0 ||
|
|
82
86
|
a === 10 ||
|
|
83
87
|
a === 127 ||
|
|
84
88
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
@@ -111,27 +115,23 @@ function normalizeDownloadUrl(downloadUrl: string, webhookUrl: string): string {
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
118
|
+
function replaceDownloadUrlOrigin(downloadUrl: string, webhookUrl: string): string | null {
|
|
119
|
+
try {
|
|
120
|
+
const sourceUrl = new URL(downloadUrl);
|
|
121
|
+
const webhook = new URL(webhookUrl);
|
|
122
|
+
if (sourceUrl.protocol === webhook.protocol && sourceUrl.host === webhook.host) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
sourceUrl.protocol = webhook.protocol;
|
|
127
|
+
sourceUrl.host = webhook.host;
|
|
128
|
+
return sourceUrl.toString();
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
127
131
|
}
|
|
128
|
-
|
|
129
|
-
return join(resolveHomeDir(env), '.openclaw');
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
export
|
|
133
|
-
return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
|
|
134
|
-
}
|
|
134
|
+
export { resolveManagedMediaDir } from './state-paths.js';
|
|
135
135
|
|
|
136
136
|
/** Maximum file size for download/upload (100 MB). */
|
|
137
137
|
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
@@ -139,6 +139,20 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
|
139
139
|
/** Timeout for media download requests (30 seconds). */
|
|
140
140
|
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
141
141
|
|
|
142
|
+
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
143
|
+
|
|
144
|
+
class MaxFileSizeExceededError extends Error {
|
|
145
|
+
readonly size: number;
|
|
146
|
+
readonly maxSize: number;
|
|
147
|
+
|
|
148
|
+
constructor(size: number, maxSize: number) {
|
|
149
|
+
super(`File size ${size} exceeds limit ${maxSize}`);
|
|
150
|
+
this.name = 'MaxFileSizeExceededError';
|
|
151
|
+
this.size = size;
|
|
152
|
+
this.maxSize = maxSize;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
142
156
|
export class MediaService {
|
|
143
157
|
private api: Bitrix24Api;
|
|
144
158
|
private logger: Logger;
|
|
@@ -155,6 +169,111 @@ export class MediaService {
|
|
|
155
169
|
this.dirReady = true;
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
private buildManagedMediaPath(fileName: string, fallbackName = 'file'): {
|
|
173
|
+
savePath: string;
|
|
174
|
+
tempPath: string;
|
|
175
|
+
safeFileName: string;
|
|
176
|
+
} {
|
|
177
|
+
const safeFileName = basename(fileName).trim() || fallbackName;
|
|
178
|
+
const savePath = join(resolveManagedMediaDir(), `${randomUUID()}_${safeFileName}`);
|
|
179
|
+
return {
|
|
180
|
+
savePath,
|
|
181
|
+
tempPath: `${savePath}.tmp`,
|
|
182
|
+
safeFileName,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async streamResponseToFile(response: Response, tempPath: string): Promise<number> {
|
|
187
|
+
const contentLength = Number(response.headers.get('content-length') ?? 0);
|
|
188
|
+
if (contentLength > MAX_FILE_SIZE) {
|
|
189
|
+
throw new MaxFileSizeExceededError(contentLength, MAX_FILE_SIZE);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!response.body) {
|
|
193
|
+
await writeFile(tempPath, EMPTY_BUFFER);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let totalBytes = 0;
|
|
198
|
+
const sizeGuard = new Transform({
|
|
199
|
+
transform(chunk, _encoding, callback) {
|
|
200
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
201
|
+
totalBytes += buffer.length;
|
|
202
|
+
if (totalBytes > MAX_FILE_SIZE) {
|
|
203
|
+
callback(new MaxFileSizeExceededError(totalBytes, MAX_FILE_SIZE));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
callback(null, buffer);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pipeline(
|
|
211
|
+
Readable.fromWeb(response.body as globalThis.ReadableStream<Uint8Array>),
|
|
212
|
+
sizeGuard,
|
|
213
|
+
createWriteStream(tempPath),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return totalBytes;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async encodeFileToBase64(localPath: string): Promise<string> {
|
|
220
|
+
const stream = createReadStream(localPath);
|
|
221
|
+
let encoded = '';
|
|
222
|
+
let carry = EMPTY_BUFFER;
|
|
223
|
+
|
|
224
|
+
for await (const chunk of stream) {
|
|
225
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
226
|
+
const source = carry.length > 0 ? Buffer.concat([carry, buffer]) : buffer;
|
|
227
|
+
const remainder = source.length % 3;
|
|
228
|
+
const readyLength = remainder === 0 ? source.length : source.length - remainder;
|
|
229
|
+
|
|
230
|
+
if (readyLength > 0) {
|
|
231
|
+
encoded += source.subarray(0, readyLength).toString('base64');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
carry = remainder === 0 ? EMPTY_BUFFER : source.subarray(readyLength);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (carry.length > 0) {
|
|
238
|
+
encoded += carry.toString('base64');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return encoded;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async resolveManagedUploadPath(localPath: string): Promise<string | null> {
|
|
245
|
+
await this.ensureDir();
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const [mediaDir, filePath] = await Promise.all([
|
|
249
|
+
realpath(resolveManagedMediaDir()),
|
|
250
|
+
realpath(localPath),
|
|
251
|
+
]);
|
|
252
|
+
const rel = relative(mediaDir, filePath);
|
|
253
|
+
return !rel.startsWith('..') && !rel.startsWith(sep) ? filePath : null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async fetchDownloadResponse(params: {
|
|
260
|
+
url: string;
|
|
261
|
+
fileId: string;
|
|
262
|
+
}): Promise<Response> {
|
|
263
|
+
try {
|
|
264
|
+
return await fetch(params.url, {
|
|
265
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
266
|
+
});
|
|
267
|
+
} catch (err) {
|
|
268
|
+
this.logger.warn('Bitrix file fetch failed', {
|
|
269
|
+
fileId: params.fileId,
|
|
270
|
+
url: maskUrlForLog(params.url),
|
|
271
|
+
error: serializeError(err),
|
|
272
|
+
});
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
158
277
|
/**
|
|
159
278
|
* Download a file from B24 using imbot.v2.File.download.
|
|
160
279
|
* Single-step: get download URL, then fetch the file.
|
|
@@ -198,45 +317,78 @@ export class MediaService {
|
|
|
198
317
|
});
|
|
199
318
|
}
|
|
200
319
|
|
|
320
|
+
const webhookFallbackUrl = replaceDownloadUrlOrigin(downloadUrl, webhookUrl);
|
|
321
|
+
const canRetryWithWebhookOrigin = Boolean(
|
|
322
|
+
webhookFallbackUrl && webhookFallbackUrl !== safeDownloadUrl,
|
|
323
|
+
);
|
|
324
|
+
|
|
201
325
|
// Download the file (with timeout)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this.logger.warn('Failed to download file', {
|
|
326
|
+
let response: Response;
|
|
327
|
+
try {
|
|
328
|
+
response = await this.fetchDownloadResponse({
|
|
329
|
+
url: safeDownloadUrl,
|
|
207
330
|
fileId,
|
|
208
|
-
status: response.status,
|
|
209
331
|
});
|
|
210
|
-
|
|
211
|
-
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (!canRetryWithWebhookOrigin || !webhookFallbackUrl) {
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
212
336
|
|
|
213
|
-
|
|
214
|
-
const contentLength = Number(response.headers.get('content-length') ?? 0);
|
|
215
|
-
if (contentLength > MAX_FILE_SIZE) {
|
|
216
|
-
this.logger.warn('File too large to download', {
|
|
337
|
+
this.logger.warn('Retrying Bitrix file download via webhook origin after fetch failure', {
|
|
217
338
|
fileId,
|
|
218
|
-
|
|
219
|
-
|
|
339
|
+
fromUrl: maskUrlForLog(safeDownloadUrl),
|
|
340
|
+
toUrl: maskUrlForLog(webhookFallbackUrl),
|
|
220
341
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
225
|
-
if (buffer.length > MAX_FILE_SIZE) {
|
|
226
|
-
this.logger.warn('Downloaded file exceeds size limit', {
|
|
342
|
+
response = await this.fetchDownloadResponse({
|
|
343
|
+
url: webhookFallbackUrl,
|
|
227
344
|
fileId,
|
|
228
|
-
size: buffer.length,
|
|
229
|
-
maxSize: MAX_FILE_SIZE,
|
|
230
345
|
});
|
|
231
|
-
return null;
|
|
232
346
|
}
|
|
233
347
|
|
|
234
|
-
|
|
235
|
-
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
if (canRetryWithWebhookOrigin && webhookFallbackUrl) {
|
|
350
|
+
this.logger.warn('Retrying Bitrix file download via webhook origin after HTTP failure', {
|
|
351
|
+
fileId,
|
|
352
|
+
status: response.status,
|
|
353
|
+
fromUrl: maskUrlForLog(safeDownloadUrl),
|
|
354
|
+
toUrl: maskUrlForLog(webhookFallbackUrl),
|
|
355
|
+
});
|
|
356
|
+
response = await this.fetchDownloadResponse({
|
|
357
|
+
url: webhookFallbackUrl,
|
|
358
|
+
fileId,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
this.logger.warn('Failed to download file', {
|
|
364
|
+
fileId,
|
|
365
|
+
status: response.status,
|
|
366
|
+
});
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
236
371
|
await this.ensureDir();
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
372
|
+
const { savePath, tempPath } = this.buildManagedMediaPath(fileName, `file_${fileId}`);
|
|
373
|
+
|
|
374
|
+
let size = 0;
|
|
375
|
+
try {
|
|
376
|
+
size = await this.streamResponseToFile(response, tempPath);
|
|
377
|
+
await rename(tempPath, savePath);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
await unlink(tempPath).catch(() => undefined);
|
|
380
|
+
|
|
381
|
+
if (err instanceof MaxFileSizeExceededError) {
|
|
382
|
+
this.logger.warn('Downloaded file exceeds size limit', {
|
|
383
|
+
fileId,
|
|
384
|
+
size: err.size,
|
|
385
|
+
maxSize: err.maxSize,
|
|
386
|
+
});
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
240
392
|
|
|
241
393
|
const responseContentType = normalizeResponseContentType(response.headers.get('content-type'));
|
|
242
394
|
const contentType = responseContentType && responseContentType !== 'application/octet-stream'
|
|
@@ -246,7 +398,7 @@ export class MediaService {
|
|
|
246
398
|
fileId,
|
|
247
399
|
fileName,
|
|
248
400
|
contentType,
|
|
249
|
-
size
|
|
401
|
+
size,
|
|
250
402
|
path: savePath,
|
|
251
403
|
});
|
|
252
404
|
|
|
@@ -275,8 +427,17 @@ export class MediaService {
|
|
|
275
427
|
const { localPath, fileName, webhookUrl, bot, dialogId, message } = params;
|
|
276
428
|
|
|
277
429
|
try {
|
|
430
|
+
const managedPath = await this.resolveManagedUploadPath(localPath);
|
|
431
|
+
if (!managedPath) {
|
|
432
|
+
this.logger.warn('Refusing outbound media upload from unmanaged local path', {
|
|
433
|
+
fileName,
|
|
434
|
+
path: localPath,
|
|
435
|
+
});
|
|
436
|
+
return { ok: false };
|
|
437
|
+
}
|
|
438
|
+
|
|
278
439
|
// Check file size before reading
|
|
279
|
-
const fileStat = await stat(
|
|
440
|
+
const fileStat = await stat(managedPath);
|
|
280
441
|
if (fileStat.size > MAX_FILE_SIZE) {
|
|
281
442
|
this.logger.warn('File too large to upload', {
|
|
282
443
|
fileName,
|
|
@@ -286,11 +447,11 @@ export class MediaService {
|
|
|
286
447
|
return { ok: false };
|
|
287
448
|
}
|
|
288
449
|
|
|
289
|
-
|
|
290
|
-
const base64Content =
|
|
450
|
+
// Encode incrementally to avoid holding both the raw file and base64 string in memory.
|
|
451
|
+
const base64Content = await this.encodeFileToBase64(managedPath);
|
|
291
452
|
|
|
292
453
|
const result = await this.api.uploadFile(webhookUrl, bot, dialogId, {
|
|
293
|
-
name: fileName,
|
|
454
|
+
name: basename(fileName).trim() || basename(managedPath),
|
|
294
455
|
content: base64Content,
|
|
295
456
|
message,
|
|
296
457
|
});
|
|
@@ -315,7 +476,7 @@ export class MediaService {
|
|
|
315
476
|
const uniquePaths = [...new Set(paths)];
|
|
316
477
|
|
|
317
478
|
for (const filePath of uniquePaths) {
|
|
318
|
-
if (!this.isManagedMediaPath(filePath)) {
|
|
479
|
+
if (!(await this.isManagedMediaPath(filePath))) {
|
|
319
480
|
this.logger.debug('Skipping cleanup for unmanaged media path', { path: filePath });
|
|
320
481
|
continue;
|
|
321
482
|
}
|
|
@@ -338,11 +499,18 @@ export class MediaService {
|
|
|
338
499
|
}
|
|
339
500
|
}
|
|
340
501
|
|
|
341
|
-
private isManagedMediaPath(filePath: string): boolean {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
502
|
+
private async isManagedMediaPath(filePath: string): Promise<boolean> {
|
|
503
|
+
try {
|
|
504
|
+
const resolvedPath = await realpath(filePath);
|
|
505
|
+
// Resolve the media dir too in case of symlinks (e.g. /tmp → /private/tmp on macOS)
|
|
506
|
+
let mediaDir: string;
|
|
507
|
+
try { mediaDir = await realpath(resolveManagedMediaDir()); } catch { mediaDir = resolveManagedMediaDir(); }
|
|
508
|
+
// relative() returns '..' prefix if path escapes the base directory
|
|
509
|
+
const rel = relative(mediaDir, resolvedPath);
|
|
510
|
+
return !rel.startsWith('..') && !rel.startsWith(sep);
|
|
511
|
+
} catch {
|
|
512
|
+
// File doesn't exist or is inaccessible — treat as unmanaged
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
347
515
|
}
|
|
348
516
|
}
|
package/src/polling-service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFile, readFile, mkdir, rename } from 'node:fs/promises';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { resolvePollingStateDir } from './state-paths.js';
|
|
5
5
|
import type { Bitrix24Api, BotContext } from './api.js';
|
|
6
6
|
import type { B24V2FetchEventItem, Logger } from './types.js';
|
|
7
7
|
import { Bitrix24ApiError } from './utils.js';
|
|
@@ -90,8 +90,7 @@ export class PollingService {
|
|
|
90
90
|
this.abortSignal = opts.abortSignal;
|
|
91
91
|
this.logger = opts.logger;
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
this.offsetPath = join(stateDir, `poll-offset-${this.accountId}.json`);
|
|
93
|
+
this.offsetPath = join(resolvePollingStateDir(), `poll-offset-${this.accountId}.json`);
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
/**
|
package/src/send-service.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Bitrix24AccountConfig,
|
|
3
2
|
SendMessageResult,
|
|
4
3
|
B24Keyboard,
|
|
5
4
|
B24InputActionStatusCode,
|
|
@@ -229,10 +228,12 @@ export class SendService {
|
|
|
229
228
|
*/
|
|
230
229
|
async sendStreaming(
|
|
231
230
|
ctx: SendContext,
|
|
232
|
-
config: Bitrix24AccountConfig,
|
|
233
231
|
textIterator: AsyncIterable<string>,
|
|
232
|
+
options?: { updateIntervalMs?: number },
|
|
234
233
|
): Promise<SendMessageResult> {
|
|
235
|
-
|
|
234
|
+
// Internal WIP helper for future streaming delivery.
|
|
235
|
+
// It is intentionally not exposed through the public Bitrix24 plugin config yet.
|
|
236
|
+
const updateIntervalMs = Math.max(500, options?.updateIntervalMs ?? 10000);
|
|
236
237
|
await this.sendStatus(ctx, 'IMBOT_AGENT_ACTION_GENERATING');
|
|
237
238
|
|
|
238
239
|
// Step 1: Send initial placeholder
|