@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.
- package/README.md +77 -4
- package/dist/src/api.d.ts +10 -5
- package/dist/src/api.d.ts.map +1 -1
- package/dist/src/api.js +42 -8
- package/dist/src/api.js.map +1 -1
- package/dist/src/channel.d.ts +20 -1
- package/dist/src/channel.d.ts.map +1 -1
- package/dist/src/channel.js +2303 -81
- package/dist/src/channel.js.map +1 -1
- package/dist/src/i18n.d.ts +1 -0
- package/dist/src/i18n.d.ts.map +1 -1
- package/dist/src/i18n.js +79 -68
- package/dist/src/i18n.js.map +1 -1
- package/dist/src/inbound-handler.d.ts +10 -0
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/inbound-handler.js +281 -16
- package/dist/src/inbound-handler.js.map +1 -1
- package/dist/src/media-service.d.ts +4 -0
- package/dist/src/media-service.d.ts.map +1 -1
- package/dist/src/media-service.js +147 -14
- package/dist/src/media-service.js.map +1 -1
- package/dist/src/message-utils.d.ts.map +1 -1
- package/dist/src/message-utils.js +113 -4
- package/dist/src/message-utils.js.map +1 -1
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.d.ts.map +1 -1
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/send-service.d.ts +2 -1
- package/dist/src/send-service.d.ts.map +1 -1
- package/dist/src/send-service.js +34 -5
- package/dist/src/send-service.js.map +1 -1
- package/dist/src/state-paths.d.ts +1 -0
- package/dist/src/state-paths.d.ts.map +1 -1
- package/dist/src/state-paths.js +9 -0
- package/dist/src/state-paths.js.map +1 -1
- package/dist/src/types.d.ts +92 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api.ts +62 -13
- package/src/channel.ts +3746 -843
- package/src/i18n.ts +81 -68
- package/src/inbound-handler.ts +357 -17
- package/src/media-service.ts +185 -15
- package/src/message-utils.ts +144 -4
- package/src/runtime.ts +1 -0
- package/src/send-service.ts +52 -4
- package/src/state-paths.ts +11 -0
- package/src/types.ts +122 -0
package/src/media-service.ts
CHANGED
|
@@ -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
|
|
245
|
-
|
|
275
|
+
private async stageWorkspaceUploadPath(localPath: string): Promise<string> {
|
|
276
|
+
const { savePath, tempPath } = this.buildManagedMediaPath(basename(localPath), 'file');
|
|
246
277
|
|
|
247
278
|
try {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
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);
|
package/src/message-utils.ts
CHANGED
|
@@ -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) →
|
|
162
|
-
text = text.replace(/\[([^\]]+)\]\(([
|
|
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
|
|
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.
|
|
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
package/src/send-service.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
}
|
package/src/state-paths.ts
CHANGED
|
@@ -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
|
}
|