@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.
Files changed (127) 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 +19 -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 +229 -61
  100. package/src/polling-service.ts +2 -3
  101. package/src/send-service.ts +4 -3
  102. package/src/state-paths.ts +28 -0
  103. package/src/types.ts +1 -2
  104. package/src/utils.ts +31 -4
  105. package/tests/access-control.test.ts +0 -398
  106. package/tests/api.test.ts +0 -226
  107. package/tests/channel-flow.test.ts +0 -1692
  108. package/tests/channel.test.ts +0 -842
  109. package/tests/commands.test.ts +0 -57
  110. package/tests/config.test.ts +0 -210
  111. package/tests/dedup.test.ts +0 -50
  112. package/tests/fixtures/onimbotjoinchat.json +0 -48
  113. package/tests/fixtures/onimbotmessageadd-file.json +0 -86
  114. package/tests/fixtures/onimbotmessageadd-text.json +0 -59
  115. package/tests/fixtures/onimcommandadd.json +0 -45
  116. package/tests/group-access.test.ts +0 -340
  117. package/tests/history-cache.test.ts +0 -117
  118. package/tests/i18n.test.ts +0 -90
  119. package/tests/inbound-handler.test.ts +0 -1033
  120. package/tests/index.test.ts +0 -94
  121. package/tests/media-service.test.ts +0 -319
  122. package/tests/message-utils.test.ts +0 -184
  123. package/tests/polling-service.test.ts +0 -115
  124. package/tests/rate-limiter.test.ts +0 -52
  125. package/tests/send-service.test.ts +0 -162
  126. package/tsconfig.json +0 -22
  127. package/vitest.config.ts +0 -9
@@ -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
- const normalized = String(entry).trim().toLowerCase();
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[] {
@@ -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 { homedir } from 'node:os';
5
- 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';
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 { defaultLogger, serializeError } from './utils.js';
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 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
- }
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
- 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);
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 function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): string {
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
- 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', {
326
+ let response: Response;
327
+ try {
328
+ response = await this.fetchDownloadResponse({
329
+ url: safeDownloadUrl,
207
330
  fileId,
208
- status: response.status,
209
331
  });
210
- return null;
211
- }
332
+ } catch (err) {
333
+ if (!canRetryWithWebhookOrigin || !webhookFallbackUrl) {
334
+ throw err;
335
+ }
212
336
 
213
- // Check file size before downloading into memory
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
- size: contentLength,
219
- maxSize: MAX_FILE_SIZE,
339
+ fromUrl: maskUrlForLog(safeDownloadUrl),
340
+ toUrl: maskUrlForLog(webhookFallbackUrl),
220
341
  });
221
- return null;
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
- // Save to temp directory with UUID prefix for uniqueness
235
- // Sanitize fileName to prevent path traversal
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 safeFileName = basename(fileName) || 'file';
238
- const savePath = join(resolveManagedMediaDir(), `${randomUUID()}_${safeFileName}`);
239
- await writeFile(savePath, buffer);
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: buffer.length,
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(localPath);
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
- const content = await readFile(localPath);
290
- 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);
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
- const mediaDir = resolveManagedMediaDir();
343
- const resolvedPath = resolvePath(filePath);
344
- // relative() returns '..' prefix if path escapes the base directory
345
- const rel = relative(mediaDir, resolvedPath);
346
- 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
+ }
347
515
  }
348
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