@ihazz/bitrix24 1.1.2 → 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.
Files changed (104) hide show
  1. package/README.md +5 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +55 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/access-control.d.ts +43 -0
  7. package/dist/src/access-control.d.ts.map +1 -0
  8. package/dist/src/access-control.js +128 -0
  9. package/dist/src/access-control.js.map +1 -0
  10. package/dist/src/api.d.ts +161 -0
  11. package/dist/src/api.d.ts.map +1 -0
  12. package/dist/src/api.js +357 -0
  13. package/dist/src/api.js.map +1 -0
  14. package/dist/src/bot-avatar.d.ts +7 -0
  15. package/dist/src/bot-avatar.d.ts.map +1 -0
  16. package/dist/src/bot-avatar.js +7 -0
  17. package/dist/src/bot-avatar.js.map +1 -0
  18. package/dist/src/channel.d.ts +216 -0
  19. package/dist/src/channel.d.ts.map +1 -0
  20. package/dist/src/channel.js +2324 -0
  21. package/dist/src/channel.js.map +1 -0
  22. package/dist/src/commands.d.ts +22 -0
  23. package/dist/src/commands.d.ts.map +1 -0
  24. package/dist/src/commands.js +160 -0
  25. package/dist/src/commands.js.map +1 -0
  26. package/dist/src/config-schema.d.ts +356 -0
  27. package/dist/src/config-schema.d.ts.map +1 -0
  28. package/dist/src/config-schema.js +43 -0
  29. package/dist/src/config-schema.js.map +1 -0
  30. package/dist/src/config.d.ts +11 -0
  31. package/dist/src/config.d.ts.map +1 -0
  32. package/dist/src/config.js +50 -0
  33. package/dist/src/config.js.map +1 -0
  34. package/dist/src/dedup.d.ts +22 -0
  35. package/dist/src/dedup.d.ts.map +1 -0
  36. package/dist/src/dedup.js +49 -0
  37. package/dist/src/dedup.js.map +1 -0
  38. package/dist/src/group-access.d.ts +52 -0
  39. package/dist/src/group-access.d.ts.map +1 -0
  40. package/dist/src/group-access.js +180 -0
  41. package/dist/src/group-access.js.map +1 -0
  42. package/dist/src/history-cache.d.ts +41 -0
  43. package/dist/src/history-cache.d.ts.map +1 -0
  44. package/dist/src/history-cache.js +82 -0
  45. package/dist/src/history-cache.js.map +1 -0
  46. package/dist/src/i18n.d.ts +22 -0
  47. package/dist/src/i18n.d.ts.map +1 -0
  48. package/dist/src/i18n.js +175 -0
  49. package/dist/src/i18n.js.map +1 -0
  50. package/dist/src/inbound-handler.d.ts +92 -0
  51. package/dist/src/inbound-handler.d.ts.map +1 -0
  52. package/dist/src/inbound-handler.js +417 -0
  53. package/dist/src/inbound-handler.js.map +1 -0
  54. package/dist/src/media-service.d.ts +52 -0
  55. package/dist/src/media-service.d.ts.map +1 -0
  56. package/dist/src/media-service.js +423 -0
  57. package/dist/src/media-service.js.map +1 -0
  58. package/dist/src/message-utils.d.ts +34 -0
  59. package/dist/src/message-utils.d.ts.map +1 -0
  60. package/dist/src/message-utils.js +392 -0
  61. package/dist/src/message-utils.js.map +1 -0
  62. package/dist/src/polling-service.d.ts +39 -0
  63. package/dist/src/polling-service.d.ts.map +1 -0
  64. package/dist/src/polling-service.js +204 -0
  65. package/dist/src/polling-service.js.map +1 -0
  66. package/dist/src/rate-limiter.d.ts +22 -0
  67. package/dist/src/rate-limiter.d.ts.map +1 -0
  68. package/dist/src/rate-limiter.js +72 -0
  69. package/dist/src/rate-limiter.js.map +1 -0
  70. package/dist/src/runtime.d.ts +106 -0
  71. package/dist/src/runtime.d.ts.map +1 -0
  72. package/dist/src/runtime.js +11 -0
  73. package/dist/src/runtime.js.map +1 -0
  74. package/dist/src/send-service.d.ts +66 -0
  75. package/dist/src/send-service.d.ts.map +1 -0
  76. package/dist/src/send-service.js +177 -0
  77. package/dist/src/send-service.js.map +1 -0
  78. package/dist/src/state-paths.d.ts +3 -0
  79. package/dist/src/state-paths.d.ts.map +1 -0
  80. package/dist/src/state-paths.js +23 -0
  81. package/dist/src/state-paths.js.map +1 -0
  82. package/dist/src/types.d.ts +381 -0
  83. package/dist/src/types.d.ts.map +1 -0
  84. package/dist/src/types.js +3 -0
  85. package/dist/src/types.js.map +1 -0
  86. package/dist/src/utils.d.ts +60 -0
  87. package/dist/src/utils.d.ts.map +1 -0
  88. package/dist/src/utils.js +131 -0
  89. package/dist/src/utils.js.map +1 -0
  90. package/index.ts +1 -1
  91. package/openclaw.plugin.json +278 -1
  92. package/package.json +11 -2
  93. package/src/api.ts +0 -3
  94. package/src/channel.ts +76 -73
  95. package/src/config-schema.ts +1 -2
  96. package/src/config.ts +6 -8
  97. package/src/group-access.ts +1 -8
  98. package/src/inbound-handler.ts +128 -15
  99. package/src/media-service.ts +160 -45
  100. package/src/polling-service.ts +2 -3
  101. package/src/send-service.ts +4 -3
  102. package/src/state-paths.ts +4 -0
  103. package/src/types.ts +1 -2
  104. package/src/utils.ts +31 -4
@@ -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.logger = opts.logger ?? defaultLogger;
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.logger.info('Fetch: message payload', {
162
- eventId: item.eventId,
163
- messageId,
164
- dialogId,
165
- chatId: data.message.chatId,
166
- forward: data.message.forward,
167
- params: data.message.params,
168
- text: data.message.text,
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: 10,
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
- this.logger.warn('Received webhook without event type', payload);
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
+ }
@@ -1,12 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isIP } from 'node:net';
3
- import { writeFile, readFile, mkdir, stat, unlink } from 'node:fs/promises';
4
- import { join, basename, resolve as resolvePath, relative, sep } from 'node:path';
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
- // Check file size before downloading into memory
267
- const contentLength = Number(response.headers.get('content-length') ?? 0);
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
- const buffer = Buffer.from(await response.arrayBuffer());
278
- if (buffer.length > MAX_FILE_SIZE) {
279
- this.logger.warn('Downloaded file exceeds size limit', {
280
- fileId,
281
- size: buffer.length,
282
- maxSize: MAX_FILE_SIZE,
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
- // Save to temp directory with UUID prefix for uniqueness
288
- // Sanitize fileName to prevent path traversal
289
- await this.ensureDir();
290
- const safeFileName = basename(fileName) || 'file';
291
- const savePath = join(resolveManagedMediaDir(), `${randomUUID()}_${safeFileName}`);
292
- await writeFile(savePath, buffer);
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: buffer.length,
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(localPath);
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
- const content = await readFile(localPath);
343
- const base64Content = content.toString('base64');
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
- const mediaDir = resolveManagedMediaDir();
396
- const resolvedPath = resolvePath(filePath);
397
- // relative() returns '..' prefix if path escapes the base directory
398
- const rel = relative(mediaDir, resolvedPath);
399
- return !rel.startsWith('..') && !rel.startsWith(sep);
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
  }
@@ -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
- const stateDir = join(homedir(), '.openclaw', 'state', 'bitrix24');
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
  /**
@@ -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
- const updateIntervalMs = config.updateIntervalMs ?? 10000;
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
@@ -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
- streamUpdates?: boolean;
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 Bitrix24 webhook credentials in a REST URL before logging it.
12
+ * Mask sensitive URL parts before logging them.
11
13
  */
12
- export function maskWebhookUrl(webhookUrl: string): string {
14
+ export function maskUrlForLog(rawUrl: string): string {
13
15
  try {
14
- const url = new URL(webhookUrl);
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 webhook url]';
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
  */