@ihazz/bitrix24 1.1.2 → 1.1.4
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 +11 -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 +160 -45
- package/src/polling-service.ts +2 -3
- package/src/send-service.ts +4 -3
- package/src/state-paths.ts +4 -0
- package/src/types.ts +1 -2
- package/src/utils.ts +31 -4
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 {
|
|
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';
|
|
5
8
|
import { Bitrix24Api } from './api.js';
|
|
6
9
|
import type { BotContext } from './api.js';
|
|
7
10
|
import type { Logger } from './types.js';
|
|
8
11
|
import { resolveManagedMediaDir } from './state-paths.js';
|
|
9
|
-
import { defaultLogger, serializeError } from './utils.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) ||
|
|
@@ -135,6 +139,20 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
|
135
139
|
/** Timeout for media download requests (30 seconds). */
|
|
136
140
|
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
137
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
|
+
|
|
138
156
|
export class MediaService {
|
|
139
157
|
private api: Bitrix24Api;
|
|
140
158
|
private logger: Logger;
|
|
@@ -151,6 +169,93 @@ export class MediaService {
|
|
|
151
169
|
this.dirReady = true;
|
|
152
170
|
}
|
|
153
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
|
+
|
|
154
259
|
private async fetchDownloadResponse(params: {
|
|
155
260
|
url: string;
|
|
156
261
|
fileId: string;
|
|
@@ -162,7 +267,7 @@ export class MediaService {
|
|
|
162
267
|
} catch (err) {
|
|
163
268
|
this.logger.warn('Bitrix file fetch failed', {
|
|
164
269
|
fileId: params.fileId,
|
|
165
|
-
url: params.url,
|
|
270
|
+
url: maskUrlForLog(params.url),
|
|
166
271
|
error: serializeError(err),
|
|
167
272
|
});
|
|
168
273
|
throw err;
|
|
@@ -231,8 +336,8 @@ export class MediaService {
|
|
|
231
336
|
|
|
232
337
|
this.logger.warn('Retrying Bitrix file download via webhook origin after fetch failure', {
|
|
233
338
|
fileId,
|
|
234
|
-
fromUrl: safeDownloadUrl,
|
|
235
|
-
toUrl: webhookFallbackUrl,
|
|
339
|
+
fromUrl: maskUrlForLog(safeDownloadUrl),
|
|
340
|
+
toUrl: maskUrlForLog(webhookFallbackUrl),
|
|
236
341
|
});
|
|
237
342
|
response = await this.fetchDownloadResponse({
|
|
238
343
|
url: webhookFallbackUrl,
|
|
@@ -245,8 +350,8 @@ export class MediaService {
|
|
|
245
350
|
this.logger.warn('Retrying Bitrix file download via webhook origin after HTTP failure', {
|
|
246
351
|
fileId,
|
|
247
352
|
status: response.status,
|
|
248
|
-
fromUrl: safeDownloadUrl,
|
|
249
|
-
toUrl: webhookFallbackUrl,
|
|
353
|
+
fromUrl: maskUrlForLog(safeDownloadUrl),
|
|
354
|
+
toUrl: maskUrlForLog(webhookFallbackUrl),
|
|
250
355
|
});
|
|
251
356
|
response = await this.fetchDownloadResponse({
|
|
252
357
|
url: webhookFallbackUrl,
|
|
@@ -263,33 +368,27 @@ export class MediaService {
|
|
|
263
368
|
}
|
|
264
369
|
}
|
|
265
370
|
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
if (contentLength > MAX_FILE_SIZE) {
|
|
269
|
-
this.logger.warn('File too large to download', {
|
|
270
|
-
fileId,
|
|
271
|
-
size: contentLength,
|
|
272
|
-
maxSize: MAX_FILE_SIZE,
|
|
273
|
-
});
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
371
|
+
await this.ensureDir();
|
|
372
|
+
const { savePath, tempPath } = this.buildManagedMediaPath(fileName, `file_${fileId}`);
|
|
276
373
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
this.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
});
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
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);
|
|
286
380
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
}
|
|
293
392
|
|
|
294
393
|
const responseContentType = normalizeResponseContentType(response.headers.get('content-type'));
|
|
295
394
|
const contentType = responseContentType && responseContentType !== 'application/octet-stream'
|
|
@@ -299,7 +398,7 @@ export class MediaService {
|
|
|
299
398
|
fileId,
|
|
300
399
|
fileName,
|
|
301
400
|
contentType,
|
|
302
|
-
size
|
|
401
|
+
size,
|
|
303
402
|
path: savePath,
|
|
304
403
|
});
|
|
305
404
|
|
|
@@ -328,8 +427,17 @@ export class MediaService {
|
|
|
328
427
|
const { localPath, fileName, webhookUrl, bot, dialogId, message } = params;
|
|
329
428
|
|
|
330
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
|
+
|
|
331
439
|
// Check file size before reading
|
|
332
|
-
const fileStat = await stat(
|
|
440
|
+
const fileStat = await stat(managedPath);
|
|
333
441
|
if (fileStat.size > MAX_FILE_SIZE) {
|
|
334
442
|
this.logger.warn('File too large to upload', {
|
|
335
443
|
fileName,
|
|
@@ -339,11 +447,11 @@ export class MediaService {
|
|
|
339
447
|
return { ok: false };
|
|
340
448
|
}
|
|
341
449
|
|
|
342
|
-
|
|
343
|
-
const base64Content =
|
|
450
|
+
// Encode incrementally to avoid holding both the raw file and base64 string in memory.
|
|
451
|
+
const base64Content = await this.encodeFileToBase64(managedPath);
|
|
344
452
|
|
|
345
453
|
const result = await this.api.uploadFile(webhookUrl, bot, dialogId, {
|
|
346
|
-
name: fileName,
|
|
454
|
+
name: basename(fileName).trim() || basename(managedPath),
|
|
347
455
|
content: base64Content,
|
|
348
456
|
message,
|
|
349
457
|
});
|
|
@@ -368,7 +476,7 @@ export class MediaService {
|
|
|
368
476
|
const uniquePaths = [...new Set(paths)];
|
|
369
477
|
|
|
370
478
|
for (const filePath of uniquePaths) {
|
|
371
|
-
if (!this.isManagedMediaPath(filePath)) {
|
|
479
|
+
if (!(await this.isManagedMediaPath(filePath))) {
|
|
372
480
|
this.logger.debug('Skipping cleanup for unmanaged media path', { path: filePath });
|
|
373
481
|
continue;
|
|
374
482
|
}
|
|
@@ -391,11 +499,18 @@ export class MediaService {
|
|
|
391
499
|
}
|
|
392
500
|
}
|
|
393
501
|
|
|
394
|
-
private isManagedMediaPath(filePath: string): boolean {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}
|
|
400
515
|
}
|
|
401
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
|
package/src/state-paths.ts
CHANGED
|
@@ -22,3 +22,7 @@ function resolveOpenClawStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
22
22
|
export function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
23
23
|
return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
export function resolvePollingStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
27
|
+
return join(resolveOpenClawStateDir(env), 'state', 'bitrix24');
|
|
28
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -411,8 +411,7 @@ export interface Bitrix24AccountConfig {
|
|
|
411
411
|
groups?: Record<string, Bitrix24GroupConfig>;
|
|
412
412
|
agentWatch?: Record<string, Bitrix24AgentWatchConfig[]>;
|
|
413
413
|
showTyping?: boolean;
|
|
414
|
-
|
|
415
|
-
updateIntervalMs?: number;
|
|
414
|
+
verboseLog?: boolean;
|
|
416
415
|
}
|
|
417
416
|
|
|
418
417
|
export interface Bitrix24PluginConfig extends Bitrix24AccountConfig {
|
package/src/utils.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Logger } from './types.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Mask a token for safe logging: show first 4 and last 4 characters.
|
|
3
5
|
*/
|
|
@@ -7,11 +9,11 @@ export function maskToken(token: string): string {
|
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
|
-
* Mask
|
|
12
|
+
* Mask sensitive URL parts before logging them.
|
|
11
13
|
*/
|
|
12
|
-
export function
|
|
14
|
+
export function maskUrlForLog(rawUrl: string): string {
|
|
13
15
|
try {
|
|
14
|
-
const url = new URL(
|
|
16
|
+
const url = new URL(rawUrl);
|
|
15
17
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
16
18
|
|
|
17
19
|
if (pathParts[0] === 'rest' && pathParts.length >= 3) {
|
|
@@ -25,10 +27,17 @@ export function maskWebhookUrl(webhookUrl: string): string {
|
|
|
25
27
|
url.search = '';
|
|
26
28
|
return url.toString();
|
|
27
29
|
} catch {
|
|
28
|
-
return '[masked
|
|
30
|
+
return '[masked url]';
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Mask Bitrix24 webhook credentials in a REST URL before logging it.
|
|
36
|
+
*/
|
|
37
|
+
export function maskWebhookUrl(webhookUrl: string): string {
|
|
38
|
+
return maskUrlForLog(webhookUrl);
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* Convert an unknown error into a log-friendly plain object.
|
|
34
43
|
*/
|
|
@@ -114,6 +123,24 @@ export function stripChannelPrefix(id: string): string {
|
|
|
114
123
|
return id.replace(CHANNEL_PREFIX_RE, '');
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
const noop = (): void => undefined;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a logger that keeps warnings/errors and enables info/debug only in verbose mode.
|
|
130
|
+
*/
|
|
131
|
+
export function createVerboseLogger(logger: Logger = defaultLogger, verbose = false): Logger {
|
|
132
|
+
if (verbose) {
|
|
133
|
+
return logger;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
info: noop,
|
|
138
|
+
debug: noop,
|
|
139
|
+
warn: (...args: unknown[]) => logger.warn(...args),
|
|
140
|
+
error: (...args: unknown[]) => logger.error(...args),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
/**
|
|
118
145
|
* Simple console logger fallback.
|
|
119
146
|
*/
|