@ihazz/bitrix24 1.1.12 → 1.1.14

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 (48) hide show
  1. package/README.md +77 -4
  2. package/dist/src/api.d.ts +10 -5
  3. package/dist/src/api.d.ts.map +1 -1
  4. package/dist/src/api.js +42 -8
  5. package/dist/src/api.js.map +1 -1
  6. package/dist/src/channel.d.ts +20 -1
  7. package/dist/src/channel.d.ts.map +1 -1
  8. package/dist/src/channel.js +2303 -81
  9. package/dist/src/channel.js.map +1 -1
  10. package/dist/src/i18n.d.ts +1 -0
  11. package/dist/src/i18n.d.ts.map +1 -1
  12. package/dist/src/i18n.js +79 -68
  13. package/dist/src/i18n.js.map +1 -1
  14. package/dist/src/inbound-handler.d.ts +10 -0
  15. package/dist/src/inbound-handler.d.ts.map +1 -1
  16. package/dist/src/inbound-handler.js +281 -16
  17. package/dist/src/inbound-handler.js.map +1 -1
  18. package/dist/src/media-service.d.ts +4 -0
  19. package/dist/src/media-service.d.ts.map +1 -1
  20. package/dist/src/media-service.js +147 -14
  21. package/dist/src/media-service.js.map +1 -1
  22. package/dist/src/message-utils.d.ts.map +1 -1
  23. package/dist/src/message-utils.js +113 -4
  24. package/dist/src/message-utils.js.map +1 -1
  25. package/dist/src/runtime.d.ts +1 -0
  26. package/dist/src/runtime.d.ts.map +1 -1
  27. package/dist/src/runtime.js.map +1 -1
  28. package/dist/src/send-service.d.ts +2 -1
  29. package/dist/src/send-service.d.ts.map +1 -1
  30. package/dist/src/send-service.js +34 -5
  31. package/dist/src/send-service.js.map +1 -1
  32. package/dist/src/state-paths.d.ts +1 -0
  33. package/dist/src/state-paths.d.ts.map +1 -1
  34. package/dist/src/state-paths.js +9 -0
  35. package/dist/src/state-paths.js.map +1 -1
  36. package/dist/src/types.d.ts +92 -0
  37. package/dist/src/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/api.ts +62 -13
  40. package/src/channel.ts +3746 -843
  41. package/src/i18n.ts +81 -68
  42. package/src/inbound-handler.ts +357 -17
  43. package/src/media-service.ts +185 -15
  44. package/src/message-utils.ts +144 -4
  45. package/src/runtime.ts +1 -0
  46. package/src/send-service.ts +52 -4
  47. package/src/state-paths.ts +11 -0
  48. package/src/types.ts +122 -0
@@ -1,14 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isIP } from 'node:net';
3
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';
4
+ import { copyFile, writeFile, mkdir, stat, unlink, rename, realpath } from 'node:fs/promises';
5
+ import { isAbsolute, join, basename, relative, resolve as resolvePath, sep } from 'node:path';
6
6
  import { Readable, Transform } from 'node:stream';
7
7
  import { pipeline } from 'node:stream/promises';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { Bitrix24Api } from './api.js';
9
10
  import type { BotContext } from './api.js';
10
11
  import type { Logger } from './types.js';
11
- import { resolveManagedMediaDir } from './state-paths.js';
12
+ import { resolveManagedMediaDir, resolveTrustedWorkspaceDirs } from './state-paths.js';
12
13
  import { defaultLogger, maskUrlForLog, serializeError } from './utils.js';
13
14
 
14
15
  export interface DownloadedMedia {
@@ -22,6 +23,11 @@ export interface UploadedMedia {
22
23
  messageId?: number;
23
24
  }
24
25
 
26
+ type ResolvedUploadPath = {
27
+ path: string;
28
+ cleanup: boolean;
29
+ };
30
+
25
31
  const MIME_MAP: Record<string, string> = {
26
32
  jpg: 'image/jpeg',
27
33
  jpeg: 'image/jpeg',
@@ -66,6 +72,11 @@ function mimeFromExtension(ext: string): string {
66
72
  return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
67
73
  }
68
74
 
75
+ function isPathInside(rootPath: string, filePath: string): boolean {
76
+ const rel = relative(rootPath, filePath);
77
+ return !rel.startsWith('..') && !rel.startsWith(sep);
78
+ }
79
+
69
80
  function normalizeResponseContentType(contentType: string | null): string | undefined {
70
81
  if (!contentType) {
71
82
  return undefined;
@@ -75,6 +86,23 @@ function normalizeResponseContentType(contentType: string | null): string | unde
75
86
  return normalized ? normalized : undefined;
76
87
  }
77
88
 
89
+ function normalizeLocalUploadPath(localPath: string): string | null {
90
+ const trimmed = localPath.trim();
91
+ if (!trimmed) {
92
+ return null;
93
+ }
94
+
95
+ if (trimmed.startsWith('file://')) {
96
+ try {
97
+ return fileURLToPath(trimmed);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ return trimmed;
104
+ }
105
+
78
106
  function isPrivateHost(hostname: string): boolean {
79
107
  if (hostname === 'localhost' || hostname === '::1') return true;
80
108
 
@@ -138,8 +166,10 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024;
138
166
 
139
167
  /** Timeout for media download requests (30 seconds). */
140
168
  const DOWNLOAD_TIMEOUT_MS = 30_000;
169
+ const DOWNLOADED_MEDIA_CLEANUP_DELAY_MS = 30 * 60 * 1000;
141
170
 
142
171
  const EMPTY_BUFFER = Buffer.alloc(0);
172
+ const MANAGED_FILE_NAME_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}_(.+)$/i;
143
173
 
144
174
  class MaxFileSizeExceededError extends Error {
145
175
  readonly size: number;
@@ -157,6 +187,7 @@ export class MediaService {
157
187
  private api: Bitrix24Api;
158
188
  private logger: Logger;
159
189
  private dirReady = false;
190
+ private pendingDownloadedMediaCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
160
191
 
161
192
  constructor(api: Bitrix24Api, logger?: Logger) {
162
193
  this.api = api;
@@ -241,19 +272,112 @@ export class MediaService {
241
272
  return encoded;
242
273
  }
243
274
 
244
- private async resolveManagedUploadPath(localPath: string): Promise<string | null> {
245
- await this.ensureDir();
275
+ private async stageWorkspaceUploadPath(localPath: string): Promise<string> {
276
+ const { savePath, tempPath } = this.buildManagedMediaPath(basename(localPath), 'file');
246
277
 
247
278
  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 {
279
+ const fileStat = await stat(localPath);
280
+ if (fileStat.size > MAX_FILE_SIZE) {
281
+ throw new MaxFileSizeExceededError(fileStat.size, MAX_FILE_SIZE);
282
+ }
283
+
284
+ await copyFile(localPath, tempPath);
285
+ await rename(tempPath, savePath);
286
+ return savePath;
287
+ } catch (error) {
288
+ await unlink(tempPath).catch(() => undefined);
289
+ await unlink(savePath).catch(() => undefined);
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ private async resolveManagedUploadPath(localPath: string): Promise<ResolvedUploadPath | null> {
295
+ await this.ensureDir();
296
+
297
+ const normalizedInputPath = normalizeLocalUploadPath(localPath);
298
+ if (!normalizedInputPath) {
255
299
  return null;
256
300
  }
301
+
302
+ const resolvedMediaDir = await realpath(resolveManagedMediaDir()).catch(() => null);
303
+ if (!resolvedMediaDir) {
304
+ return null;
305
+ }
306
+
307
+ const configuredWorkspaceRoots = resolveTrustedWorkspaceDirs();
308
+ const resolvedWorkspaceRoots = (await Promise.all(
309
+ configuredWorkspaceRoots.map(async (workspaceRoot) => {
310
+ try {
311
+ return await realpath(workspaceRoot);
312
+ } catch {
313
+ return null;
314
+ }
315
+ }),
316
+ )).filter((workspaceRoot): workspaceRoot is string => Boolean(workspaceRoot));
317
+
318
+ const candidatePaths = new Set<string>();
319
+ candidatePaths.add(normalizedInputPath);
320
+
321
+ if (!isAbsolute(normalizedInputPath)) {
322
+ candidatePaths.add(join(resolveManagedMediaDir(), normalizedInputPath));
323
+ for (const workspaceRoot of configuredWorkspaceRoots) {
324
+ candidatePaths.add(resolvePath(workspaceRoot, normalizedInputPath));
325
+ }
326
+ }
327
+
328
+ if (isAbsolute(normalizedInputPath)) {
329
+ const looksLikeWorkspacePath = configuredWorkspaceRoots.some((workspaceRoot) =>
330
+ isPathInside(workspaceRoot, normalizedInputPath),
331
+ );
332
+
333
+ if (looksLikeWorkspacePath) {
334
+ candidatePaths.add(join(resolveManagedMediaDir(), basename(normalizedInputPath)));
335
+ }
336
+ }
337
+
338
+ for (const candidatePath of candidatePaths) {
339
+ let filePath: string;
340
+
341
+ try {
342
+ filePath = await realpath(candidatePath);
343
+ } catch {
344
+ continue;
345
+ }
346
+
347
+ if (isPathInside(resolvedMediaDir, filePath)) {
348
+ return { path: filePath, cleanup: false };
349
+ }
350
+
351
+ for (const resolvedWorkspaceRoot of resolvedWorkspaceRoots) {
352
+ if (!isPathInside(resolvedWorkspaceRoot, filePath)) {
353
+ continue;
354
+ }
355
+
356
+ return {
357
+ path: await this.stageWorkspaceUploadPath(filePath),
358
+ cleanup: true,
359
+ };
360
+ }
361
+ }
362
+
363
+ return null;
364
+ }
365
+
366
+ private resolveOutboundFileName(fileName: string, managedPath: string): string {
367
+ const requestedBaseName = basename(fileName).trim();
368
+ const managedBaseName = basename(managedPath).trim();
369
+ const managedMatch = MANAGED_FILE_NAME_RE.exec(managedBaseName);
370
+ const logicalManagedName = managedMatch?.[1]?.trim() || managedBaseName;
371
+
372
+ if (requestedBaseName && requestedBaseName !== managedBaseName) {
373
+ return requestedBaseName;
374
+ }
375
+
376
+ if (requestedBaseName === managedBaseName && managedMatch?.[1]?.trim()) {
377
+ return managedMatch[1].trim();
378
+ }
379
+
380
+ return requestedBaseName || logicalManagedName;
257
381
  }
258
382
 
259
383
  private async fetchDownloadResponse(params: {
@@ -425,6 +549,7 @@ export class MediaService {
425
549
  message?: string;
426
550
  }): Promise<UploadedMedia> {
427
551
  const { localPath, fileName, webhookUrl, bot, dialogId, message } = params;
552
+ let stagedUploadPath: string | null = null;
428
553
 
429
554
  try {
430
555
  const managedPath = await this.resolveManagedUploadPath(localPath);
@@ -435,9 +560,12 @@ export class MediaService {
435
560
  });
436
561
  return { ok: false };
437
562
  }
563
+ if (managedPath.cleanup) {
564
+ stagedUploadPath = managedPath.path;
565
+ }
438
566
 
439
567
  // Check file size before reading
440
- const fileStat = await stat(managedPath);
568
+ const fileStat = await stat(managedPath.path);
441
569
  if (fileStat.size > MAX_FILE_SIZE) {
442
570
  this.logger.warn('File too large to upload', {
443
571
  fileName,
@@ -448,10 +576,11 @@ export class MediaService {
448
576
  }
449
577
 
450
578
  // Encode incrementally to avoid holding both the raw file and base64 string in memory.
451
- const base64Content = await this.encodeFileToBase64(managedPath);
579
+ const base64Content = await this.encodeFileToBase64(managedPath.path);
580
+ const outboundFileName = this.resolveOutboundFileName(fileName, managedPath.path);
452
581
 
453
582
  const result = await this.api.uploadFile(webhookUrl, bot, dialogId, {
454
- name: basename(fileName).trim() || basename(managedPath),
583
+ name: outboundFileName,
455
584
  content: base64Content,
456
585
  message,
457
586
  });
@@ -469,6 +598,10 @@ export class MediaService {
469
598
  error: serializeError(err),
470
599
  });
471
600
  return { ok: false };
601
+ } finally {
602
+ if (stagedUploadPath) {
603
+ await unlink(stagedUploadPath).catch(() => undefined);
604
+ }
472
605
  }
473
606
  }
474
607
 
@@ -476,6 +609,12 @@ export class MediaService {
476
609
  const uniquePaths = [...new Set(paths)];
477
610
 
478
611
  for (const filePath of uniquePaths) {
612
+ const scheduledCleanup = this.pendingDownloadedMediaCleanupTimers.get(filePath);
613
+ if (scheduledCleanup) {
614
+ clearTimeout(scheduledCleanup);
615
+ this.pendingDownloadedMediaCleanupTimers.delete(filePath);
616
+ }
617
+
479
618
  if (!(await this.isManagedMediaPath(filePath))) {
480
619
  this.logger.debug('Skipping cleanup for unmanaged media path', { path: filePath });
481
620
  continue;
@@ -499,6 +638,37 @@ export class MediaService {
499
638
  }
500
639
  }
501
640
 
641
+ scheduleDownloadedMediaCleanup(
642
+ paths: string[],
643
+ delayMs = DOWNLOADED_MEDIA_CLEANUP_DELAY_MS,
644
+ ): void {
645
+ const normalizedDelayMs = Math.max(0, Number(delayMs) || 0);
646
+ const uniquePaths = [...new Set(paths.map((path) => path.trim()).filter(Boolean))];
647
+
648
+ for (const filePath of uniquePaths) {
649
+ const scheduledCleanup = this.pendingDownloadedMediaCleanupTimers.get(filePath);
650
+ if (scheduledCleanup) {
651
+ clearTimeout(scheduledCleanup);
652
+ }
653
+
654
+ const timer = setTimeout(() => {
655
+ this.pendingDownloadedMediaCleanupTimers.delete(filePath);
656
+ this.cleanupDownloadedMedia([filePath]).catch((error) => {
657
+ this.logger.warn('Failed to cleanup downloaded media after delay', {
658
+ path: filePath,
659
+ error: serializeError(error),
660
+ });
661
+ });
662
+ }, normalizedDelayMs);
663
+
664
+ if (timer && typeof timer === 'object' && 'unref' in timer) {
665
+ timer.unref();
666
+ }
667
+
668
+ this.pendingDownloadedMediaCleanupTimers.set(filePath, timer);
669
+ }
670
+ }
671
+
502
672
  private async isManagedMediaPath(filePath: string): Promise<boolean> {
503
673
  try {
504
674
  const resolvedPath = await realpath(filePath);
@@ -4,6 +4,7 @@ import type { KeyboardButton, B24Keyboard } from './types.js';
4
4
 
5
5
  const PH_ESC = '\x00ESC'; // escape sequences
6
6
  const PH_BBCODE = '\x00BB'; // existing BBCode blocks
7
+ const PH_ENTITY = '\x00BE'; // existing Bitrix entity mentions
7
8
  const PH_FCODE = '\x00FC'; // fenced code blocks
8
9
  const PH_ICODE = '\x00IC'; // inline code
9
10
  const PH_HR = '\x00HR'; // horizontal rules
@@ -62,6 +63,7 @@ export function markdownToBbCode(md: string): string {
62
63
  return `${PH_ICODE}${inlineCodes.length - 1}\x00`;
63
64
  });
64
65
  const tableSeparators: string[] = [];
66
+ const bitrixEntityBlocks: string[] = [];
65
67
 
66
68
  // ── Phase 2: Block rules (line-level, order matters) ──────────────────────
67
69
 
@@ -149,6 +151,7 @@ export function markdownToBbCode(md: string): string {
149
151
  text = text.replace(/~~(.+?)~~/g, '[S]$1[/S]');
150
152
 
151
153
  // 3f. HTML inline formatting tags
154
+ text = text.replace(/<br\s*\/?>/gi, '[BR]');
152
155
  text = text.replace(/<u>([\s\S]*?)<\/u>/gi, '[U]$1[/U]');
153
156
  text = text.replace(/<b>([\s\S]*?)<\/b>/gi, '[B]$1[/B]');
154
157
  text = text.replace(/<strong>([\s\S]*?)<\/strong>/gi, '[B]$1[/B]');
@@ -158,15 +161,27 @@ export function markdownToBbCode(md: string): string {
158
161
  text = text.replace(/<del>([\s\S]*?)<\/del>/gi, '[S]$1[/S]');
159
162
  text = text.replace(/<strike>([\s\S]*?)<\/strike>/gi, '[S]$1[/S]');
160
163
 
161
- // 3g. Links: [text](url) → [URL=url]text[/URL]
162
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[URL=$2]$1[/URL]');
164
+ // 3g. Links: [text](url) → Bitrix BB-code links and action links
165
+ text = text.replace(/\[([^\]]+)\]\(([\s\S]*?)\)/g, (_match, label: string, rawTarget: string) => {
166
+ return convertMarkdownLink(label, rawTarget);
167
+ });
163
168
 
164
169
  // 3h. Autolink URL: <https://...> → [URL]https://...[/URL]
165
170
  text = text.replace(/<(https?:\/\/[^>]+)>/g, '[URL]$1[/URL]');
166
171
 
167
- // 3i. Autolink email: <user@example.com> → [URL]mailto:user@example.com[/URL]
172
+ // 3i. Autolink tel URI: <tel:+79991234567> → [CALL]+79991234567[/CALL]
173
+ text = text.replace(/<tel:([^>]+)>/gi, '[CALL]$1[/CALL]');
174
+
175
+ // 3j. Autolink email: <user@example.com> → [URL]mailto:user@example.com[/URL]
168
176
  text = text.replace(/<([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>/g, '[URL]mailto:$1[/URL]');
169
177
 
178
+ text = text.replace(/\[(CHAT|USER)=[^\]]+\][\s\S]*?\[\/\1\]/giu, (block: string) => {
179
+ bitrixEntityBlocks.push(block);
180
+ return `${PH_ENTITY}${bitrixEntityBlocks.length - 1}\x00`;
181
+ });
182
+
183
+ text = repairMalformedBitrixEntityMentions(text);
184
+
170
185
  // ── Phase 4: Restore placeholders ─────────────────────────────────────────
171
186
 
172
187
  // 4a. Horizontal rules → visual separator
@@ -199,7 +214,13 @@ export function markdownToBbCode(md: string): string {
199
214
  return value != null ? restoreBbCodeCodeBlock(value) : _m;
200
215
  });
201
216
 
202
- // 4d. Escape sequencesliteral characters
217
+ // 4d. Existing Bitrix entity mentions original source, untouched
218
+ text = text.replace(new RegExp(`${PH_ENTITY.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
219
+ const value = bitrixEntityBlocks[Number(idx)];
220
+ return value != null ? value : _m;
221
+ });
222
+
223
+ // 4e. Escape sequences → literal characters
203
224
  text = text.replace(new RegExp(`${PH_ESC.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
204
225
  const value = escapes[Number(idx)];
205
226
  return value != null ? value : _m;
@@ -208,6 +229,44 @@ export function markdownToBbCode(md: string): string {
208
229
  return text;
209
230
  }
210
231
 
232
+ function repairMalformedBitrixEntityMentions(text: string): string {
233
+ let repaired = text;
234
+
235
+ const quotedPatterns: RegExp[] = [
236
+ /\[(CHAT|USER)=([^\]]+)\]\s*«([^»\r\n]{1,80})»(?!\s*\[\/\1\])/giu,
237
+ /\[(CHAT|USER)=([^\]]+)\]\s*“([^”\r\n]{1,80})”(?!\s*\[\/\1\])/giu,
238
+ /\[(CHAT|USER)=([^\]]+)\]\s*"([^"\r\n]{1,80})"(?!\s*\[\/\1\])/giu,
239
+ /\[(CHAT|USER)=([^\]]+)\]\s*'([^'\r\n]{1,80})'(?!\s*\[\/\1\])/giu,
240
+ ];
241
+
242
+ for (const pattern of quotedPatterns) {
243
+ repaired = repaired.replace(pattern, (_match, rawTag: string, rawId: string, rawLabel: string) => {
244
+ return buildBitrixEntityMention(rawTag, rawId, rawLabel);
245
+ });
246
+ }
247
+
248
+ repaired = repaired.replace(
249
+ /\[(CHAT|USER)=([^\]]+)\]\s*([\p{Lu}\d][\p{L}\p{N}._+-]*(?:\s+[\p{Lu}\d][\p{L}\p{N}._+-]*){0,5})(?=$|[\s.,!?;:)\]]|\[)(?!\s*\[\/\1\])/gu,
250
+ (_match, rawTag: string, rawId: string, rawLabel: string) => {
251
+ return buildBitrixEntityMention(rawTag, rawId, rawLabel);
252
+ },
253
+ );
254
+
255
+ return repaired;
256
+ }
257
+
258
+ function buildBitrixEntityMention(rawTag: string, rawId: string, rawLabel: string): string {
259
+ const tag = rawTag.toUpperCase();
260
+ const id = rawId.trim();
261
+ const label = rawLabel.trim();
262
+
263
+ if (!id || !label) {
264
+ return `[${tag}=${rawId}]${rawLabel}`;
265
+ }
266
+
267
+ return `[${tag}=${id}]${label}[/${tag}]`;
268
+ }
269
+
211
270
  function isInlineAccentToken(value: string): boolean {
212
271
  const trimmed = value.trim();
213
272
  if (!trimmed || /[\r\n\t]/.test(trimmed) || trimmed.length > 64) {
@@ -645,6 +704,87 @@ function extractMarkdownTarget(rawTarget: string): string {
645
704
  return trimmed;
646
705
  }
647
706
 
707
+ function convertMarkdownLink(label: string, rawTarget: string): string {
708
+ const target = extractMarkdownTarget(rawTarget);
709
+ const normalizedTarget = target.trim();
710
+
711
+ if (!normalizedTarget) {
712
+ return label;
713
+ }
714
+
715
+ const sendMatch = normalizedTarget.match(/^send:(.+)$/i);
716
+ if (sendMatch) {
717
+ const payload = normalizeActionLinkPayload(sendMatch[1]);
718
+ return payload ? `[SEND=${payload}]${label}[/SEND]` : `[SEND]${label}[/SEND]`;
719
+ }
720
+
721
+ const putMatch = normalizedTarget.match(/^put:(.+)$/i);
722
+ if (putMatch) {
723
+ const payload = normalizeActionLinkPayload(putMatch[1]);
724
+ return payload ? `[PUT=${payload}]${label}[/PUT]` : `[PUT]${label}[/PUT]`;
725
+ }
726
+
727
+ const callMatch = normalizedTarget.match(/^(?:call|tel):(.+)$/i);
728
+ if (callMatch) {
729
+ const phone = callMatch[1].trim();
730
+ return phone ? `[CALL=${phone}]${label}[/CALL]` : `[CALL]${label}[/CALL]`;
731
+ }
732
+
733
+ const userMatch = normalizedTarget.match(/^user:(.+)$/i);
734
+ if (userMatch) {
735
+ return `[USER=${userMatch[1].trim()}]${label}[/USER]`;
736
+ }
737
+
738
+ const chatMatch = normalizedTarget.match(/^chat:(.+)$/i);
739
+ if (chatMatch) {
740
+ return `[CHAT=${chatMatch[1].trim()}]${label}[/CHAT]`;
741
+ }
742
+
743
+ const contextMatch = normalizedTarget.match(/^context:(.+)$/i);
744
+ if (contextMatch) {
745
+ return `[context=${contextMatch[1].trim()}]${label}[/context]`;
746
+ }
747
+
748
+ const diskMatch = normalizedTarget.match(/^disk:(.+)$/i);
749
+ if (diskMatch) {
750
+ return `[disk=${diskMatch[1].trim()}]`;
751
+ }
752
+
753
+ const timestampMatch = normalizedTarget.match(/^timestamp:(.+)$/i);
754
+ if (timestampMatch) {
755
+ const timestampMarkup = buildTimestampBbCode(timestampMatch[1].trim());
756
+ if (timestampMarkup) {
757
+ return timestampMarkup;
758
+ }
759
+ }
760
+
761
+ return `[URL=${normalizedTarget}]${label}[/URL]`;
762
+ }
763
+
764
+ function normalizeActionLinkPayload(value: string): string {
765
+ return value.trim().replace(/^\/\//, '/');
766
+ }
767
+
768
+ function buildTimestampBbCode(value: string): string {
769
+ if (!value) {
770
+ return '';
771
+ }
772
+
773
+ const [timestamp, rawQuery = ''] = value.split('?', 2);
774
+ const normalizedTimestamp = timestamp.trim();
775
+ if (!normalizedTimestamp) {
776
+ return '';
777
+ }
778
+
779
+ const params = new URLSearchParams(rawQuery);
780
+ const format = params.get('format')?.trim();
781
+ if (format) {
782
+ return `[timestamp=${normalizedTimestamp} format=${format}]`;
783
+ }
784
+
785
+ return `[timestamp=${normalizedTimestamp}]`;
786
+ }
787
+
648
788
  function normalizeBitrixImageUrl(rawUrl: string): string {
649
789
  const trimmed = rawUrl.trim();
650
790
  if (!trimmed) return trimmed;
package/src/runtime.ts CHANGED
@@ -2,6 +2,7 @@ interface ReplyPayload {
2
2
  text?: string;
3
3
  mediaUrl?: string;
4
4
  mediaUrls?: string[];
5
+ replyToId?: string;
5
6
  isError?: boolean;
6
7
  channelData?: Record<string, unknown>;
7
8
  }
@@ -41,6 +41,7 @@ export class SendService {
41
41
  options?: {
42
42
  keyboard?: B24Keyboard;
43
43
  convertMarkdown?: boolean;
44
+ replyToMessageId?: number;
44
45
  forwardMessages?: number[];
45
46
  system?: boolean;
46
47
  },
@@ -49,8 +50,41 @@ export class SendService {
49
50
  ? markdownToBbCode(text)
50
51
  : text;
51
52
 
52
- const chunks = splitMessage(convertedText);
53
- if (chunks.length === 0) return { ok: true };
53
+ const chunks = convertedText.length > 0 ? splitMessage(convertedText) : [];
54
+ if (chunks.length === 0) {
55
+ if (!options?.forwardMessages?.length) {
56
+ return { ok: true };
57
+ }
58
+
59
+ const msgOptions: {
60
+ forwardMessages?: number[];
61
+ replyToMessageId?: number;
62
+ system?: boolean;
63
+ } = {
64
+ forwardMessages: options.forwardMessages,
65
+ };
66
+
67
+ if (options?.replyToMessageId) {
68
+ msgOptions.replyToMessageId = options.replyToMessageId;
69
+ }
70
+ if (options?.system !== undefined) {
71
+ msgOptions.system = options.system;
72
+ }
73
+
74
+ try {
75
+ const messageId = await this.api.sendMessage(
76
+ ctx.webhookUrl,
77
+ ctx.bot,
78
+ ctx.dialogId,
79
+ null,
80
+ msgOptions,
81
+ );
82
+ return { ok: true, messageId };
83
+ } catch (error) {
84
+ this.logger.error('Failed to send forwarded message without text', { error: serializeError(error) });
85
+ throw error;
86
+ }
87
+ }
54
88
  let lastMessageId: number | undefined;
55
89
 
56
90
  for (let i = 0; i < chunks.length; i++) {
@@ -58,6 +92,7 @@ export class SendService {
58
92
  const isLast = i === chunks.length - 1;
59
93
  const msgOptions: {
60
94
  keyboard?: B24Keyboard;
95
+ replyToMessageId?: number;
61
96
  forwardMessages?: number[];
62
97
  system?: boolean;
63
98
  } = {};
@@ -65,6 +100,9 @@ export class SendService {
65
100
  if (isLast && options?.keyboard) {
66
101
  msgOptions.keyboard = options.keyboard;
67
102
  }
103
+ if (isFirst && options?.replyToMessageId) {
104
+ msgOptions.replyToMessageId = options.replyToMessageId;
105
+ }
68
106
  if (isFirst && options?.forwardMessages?.length) {
69
107
  msgOptions.forwardMessages = options.forwardMessages;
70
108
  }
@@ -171,12 +209,22 @@ export class SendService {
171
209
  /**
172
210
  * Send the default generic typing indicator.
173
211
  */
174
- async sendTyping(ctx: SendContext): Promise<void> {
212
+ async sendTyping(ctx: SendContext, duration?: number): Promise<void> {
175
213
  try {
176
- await this.api.notifyInputAction(ctx.webhookUrl, ctx.bot, ctx.dialogId);
214
+ if (duration === undefined) {
215
+ await this.api.notifyInputAction(ctx.webhookUrl, ctx.bot, ctx.dialogId);
216
+ } else {
217
+ await this.api.notifyInputAction(
218
+ ctx.webhookUrl,
219
+ ctx.bot,
220
+ ctx.dialogId,
221
+ { duration },
222
+ );
223
+ }
177
224
  } catch (error) {
178
225
  this.logger.debug('Failed to send typing indicator', {
179
226
  dialogId: ctx.dialogId,
227
+ duration,
180
228
  error: serializeError(error),
181
229
  });
182
230
  }
@@ -23,6 +23,17 @@ export function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): st
23
23
  return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
24
24
  }
25
25
 
26
+ export function resolveTrustedWorkspaceDirs(env: NodeJS.ProcessEnv = process.env): string[] {
27
+ const roots = [
28
+ env.OPENCLAW_WORKSPACE_DIR?.trim(),
29
+ env.CLAWDBOT_WORKSPACE_DIR?.trim(),
30
+ join(resolveOpenClawStateDir(env), 'workspace'),
31
+ join(resolveHomeDir(env), 'workspace'),
32
+ ].filter((value): value is string => Boolean(value));
33
+
34
+ return [...new Set(roots.map((root) => resolvePath(root)))];
35
+ }
36
+
26
37
  export function resolvePollingStateDir(env: NodeJS.ProcessEnv = process.env): string {
27
38
  return join(resolveOpenClawStateDir(env), 'state', 'bitrix24');
28
39
  }