@ihazz/bitrix24 0.2.5 → 1.0.0
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 +118 -164
- package/index.ts +46 -11
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/bitrix24/SKILL.md +70 -0
- package/src/access-control.ts +102 -48
- package/src/api.ts +434 -232
- package/src/channel.ts +1441 -365
- package/src/commands.ts +169 -31
- package/src/config-schema.ts +8 -3
- package/src/config.ts +11 -0
- package/src/dedup.ts +4 -0
- package/src/i18n.ts +127 -0
- package/src/inbound-handler.ts +306 -110
- package/src/media-service.ts +218 -65
- package/src/message-utils.ts +252 -10
- package/src/polling-service.ts +240 -0
- package/src/rate-limiter.ts +11 -6
- package/src/send-service.ts +140 -60
- package/src/types.ts +279 -185
- package/src/utils.ts +54 -3
- package/tests/access-control.test.ts +174 -58
- package/tests/api.test.ts +95 -0
- package/tests/channel.test.ts +230 -9
- package/tests/commands.test.ts +57 -0
- package/tests/config.test.ts +5 -1
- package/tests/i18n.test.ts +47 -0
- package/tests/inbound-handler.test.ts +554 -69
- package/tests/index.test.ts +94 -0
- package/tests/media-service.test.ts +146 -51
- package/tests/message-utils.test.ts +64 -0
- package/tests/polling-service.test.ts +77 -0
- package/tests/rate-limiter.test.ts +2 -2
- package/tests/send-service.test.ts +145 -0
package/src/media-service.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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';
|
|
5
6
|
import { Bitrix24Api } from './api.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
info: (...args: unknown[]) => void;
|
|
10
|
-
warn: (...args: unknown[]) => void;
|
|
11
|
-
error: (...args: unknown[]) => void;
|
|
12
|
-
debug: (...args: unknown[]) => void;
|
|
13
|
-
}
|
|
7
|
+
import type { BotContext } from './api.js';
|
|
8
|
+
import type { Logger } from './types.js';
|
|
9
|
+
import { defaultLogger, serializeError } from './utils.js';
|
|
14
10
|
|
|
15
11
|
export interface DownloadedMedia {
|
|
16
12
|
path: string;
|
|
@@ -18,6 +14,11 @@ export interface DownloadedMedia {
|
|
|
18
14
|
name: string;
|
|
19
15
|
}
|
|
20
16
|
|
|
17
|
+
export interface UploadedMedia {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
messageId?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
21
22
|
const MIME_MAP: Record<string, string> = {
|
|
22
23
|
jpg: 'image/jpeg',
|
|
23
24
|
jpeg: 'image/jpeg',
|
|
@@ -62,7 +63,81 @@ function mimeFromExtension(ext: string): string {
|
|
|
62
63
|
return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
function normalizeResponseContentType(contentType: string | null): string | undefined {
|
|
67
|
+
if (!contentType) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const normalized = contentType.split(';', 1)[0]?.trim().toLowerCase();
|
|
72
|
+
return normalized ? normalized : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPrivateHost(hostname: string): boolean {
|
|
76
|
+
if (hostname === 'localhost' || hostname === '::1') return true;
|
|
77
|
+
|
|
78
|
+
const ipVersion = isIP(hostname);
|
|
79
|
+
if (ipVersion === 4) {
|
|
80
|
+
const [a, b] = hostname.split('.').map(Number);
|
|
81
|
+
return (
|
|
82
|
+
a === 10 ||
|
|
83
|
+
a === 127 ||
|
|
84
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
85
|
+
(a === 192 && b === 168) ||
|
|
86
|
+
(a === 169 && b === 254)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (ipVersion === 6) {
|
|
91
|
+
const normalized = hostname.toLowerCase();
|
|
92
|
+
return normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeDownloadUrl(downloadUrl: string, webhookUrl: string): string {
|
|
99
|
+
try {
|
|
100
|
+
const sourceUrl = new URL(downloadUrl);
|
|
101
|
+
if (!isPrivateHost(sourceUrl.hostname)) {
|
|
102
|
+
return downloadUrl;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const webhook = new URL(webhookUrl);
|
|
106
|
+
sourceUrl.protocol = webhook.protocol;
|
|
107
|
+
sourceUrl.host = webhook.host;
|
|
108
|
+
return sourceUrl.toString();
|
|
109
|
+
} catch {
|
|
110
|
+
return downloadUrl;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
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
|
+
}
|
|
122
|
+
|
|
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);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return join(resolveHomeDir(env), '.openclaw');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
133
|
+
return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Maximum file size for download/upload (100 MB). */
|
|
137
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
138
|
+
|
|
139
|
+
/** Timeout for media download requests (30 seconds). */
|
|
140
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
66
141
|
|
|
67
142
|
export class MediaService {
|
|
68
143
|
private api: Bitrix24Api;
|
|
@@ -76,48 +151,57 @@ export class MediaService {
|
|
|
76
151
|
|
|
77
152
|
private async ensureDir(): Promise<void> {
|
|
78
153
|
if (this.dirReady) return;
|
|
79
|
-
await mkdir(
|
|
154
|
+
await mkdir(resolveManagedMediaDir(), { recursive: true });
|
|
80
155
|
this.dirReady = true;
|
|
81
156
|
}
|
|
82
157
|
|
|
83
158
|
/**
|
|
84
|
-
* Download a file from B24
|
|
85
|
-
*
|
|
159
|
+
* Download a file from B24 using imbot.v2.File.download.
|
|
160
|
+
* Single-step: get download URL, then fetch the file.
|
|
86
161
|
*/
|
|
87
162
|
async downloadMedia(params: {
|
|
88
163
|
fileId: string;
|
|
89
164
|
fileName: string;
|
|
90
165
|
extension: string;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
166
|
+
webhookUrl: string;
|
|
167
|
+
bot: BotContext;
|
|
168
|
+
dialogId: string;
|
|
94
169
|
}): Promise<DownloadedMedia | null> {
|
|
95
|
-
const { fileId, fileName, extension,
|
|
170
|
+
const { fileId, fileName, extension, webhookUrl, bot, dialogId } = params;
|
|
96
171
|
|
|
97
172
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (webhookUrl) {
|
|
103
|
-
try {
|
|
104
|
-
fileInfo = await this.api.getFileInfoViaWebhook(webhookUrl, Number(fileId));
|
|
105
|
-
} catch {
|
|
106
|
-
this.logger.debug('Webhook disk.file.get failed, falling back to token', { fileId });
|
|
107
|
-
fileInfo = await this.api.getFileInfo(clientEndpoint, userToken, Number(fileId));
|
|
108
|
-
}
|
|
109
|
-
} else {
|
|
110
|
-
fileInfo = await this.api.getFileInfo(clientEndpoint, userToken, Number(fileId));
|
|
173
|
+
const numericFileId = Number(fileId);
|
|
174
|
+
if (!Number.isFinite(numericFileId) || numericFileId <= 0) {
|
|
175
|
+
this.logger.warn('Invalid fileId, skipping download', { fileId });
|
|
176
|
+
return null;
|
|
111
177
|
}
|
|
112
178
|
|
|
113
|
-
|
|
179
|
+
// Get download URL via imbot.v2.File.download
|
|
180
|
+
const downloadUrl = await this.api.getFileDownloadUrl(
|
|
181
|
+
webhookUrl,
|
|
182
|
+
bot,
|
|
183
|
+
dialogId,
|
|
184
|
+
numericFileId,
|
|
185
|
+
);
|
|
186
|
+
|
|
114
187
|
if (!downloadUrl) {
|
|
115
|
-
this.logger.warn('No
|
|
188
|
+
this.logger.warn('No downloadUrl for file', { fileId });
|
|
116
189
|
return null;
|
|
117
190
|
}
|
|
118
191
|
|
|
119
|
-
|
|
120
|
-
|
|
192
|
+
const safeDownloadUrl = normalizeDownloadUrl(downloadUrl, webhookUrl);
|
|
193
|
+
if (safeDownloadUrl !== downloadUrl) {
|
|
194
|
+
this.logger.warn('Rewriting private Bitrix file URL to webhook origin', {
|
|
195
|
+
fromHost: new URL(downloadUrl).host,
|
|
196
|
+
toHost: new URL(safeDownloadUrl).host,
|
|
197
|
+
fileId,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Download the file (with timeout)
|
|
202
|
+
const response = await fetch(safeDownloadUrl, {
|
|
203
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
204
|
+
});
|
|
121
205
|
if (!response.ok) {
|
|
122
206
|
this.logger.warn('Failed to download file', {
|
|
123
207
|
fileId,
|
|
@@ -126,14 +210,38 @@ export class MediaService {
|
|
|
126
210
|
return null;
|
|
127
211
|
}
|
|
128
212
|
|
|
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', {
|
|
217
|
+
fileId,
|
|
218
|
+
size: contentLength,
|
|
219
|
+
maxSize: MAX_FILE_SIZE,
|
|
220
|
+
});
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
129
224
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
225
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
226
|
+
this.logger.warn('Downloaded file exceeds size limit', {
|
|
227
|
+
fileId,
|
|
228
|
+
size: buffer.length,
|
|
229
|
+
maxSize: MAX_FILE_SIZE,
|
|
230
|
+
});
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
130
233
|
|
|
131
234
|
// Save to temp directory with UUID prefix for uniqueness
|
|
235
|
+
// Sanitize fileName to prevent path traversal
|
|
132
236
|
await this.ensureDir();
|
|
133
|
-
const
|
|
237
|
+
const safeFileName = basename(fileName) || 'file';
|
|
238
|
+
const savePath = join(resolveManagedMediaDir(), `${randomUUID()}_${safeFileName}`);
|
|
134
239
|
await writeFile(savePath, buffer);
|
|
135
240
|
|
|
136
|
-
const
|
|
241
|
+
const responseContentType = normalizeResponseContentType(response.headers.get('content-type'));
|
|
242
|
+
const contentType = responseContentType && responseContentType !== 'application/octet-stream'
|
|
243
|
+
? responseContentType
|
|
244
|
+
: mimeFromExtension(extension);
|
|
137
245
|
this.logger.debug('Downloaded media', {
|
|
138
246
|
fileId,
|
|
139
247
|
fileName,
|
|
@@ -144,52 +252,97 @@ export class MediaService {
|
|
|
144
252
|
|
|
145
253
|
return { path: savePath, contentType, name: fileName };
|
|
146
254
|
} catch (err) {
|
|
147
|
-
this.logger.error('Error downloading media', {
|
|
255
|
+
this.logger.error('Error downloading media', {
|
|
256
|
+
fileId,
|
|
257
|
+
error: serializeError(err),
|
|
258
|
+
});
|
|
148
259
|
return null;
|
|
149
260
|
}
|
|
150
261
|
}
|
|
151
262
|
|
|
152
263
|
/**
|
|
153
|
-
* Upload a local file to a B24 chat.
|
|
154
|
-
* 3-step process
|
|
264
|
+
* Upload a local file to a B24 chat using imbot.v2.File.upload.
|
|
265
|
+
* Single call replaces the old 3-step process (folder.get → upload → commit).
|
|
155
266
|
*/
|
|
156
267
|
async uploadMediaToChat(params: {
|
|
157
268
|
localPath: string;
|
|
158
269
|
fileName: string;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
270
|
+
webhookUrl: string;
|
|
271
|
+
bot: BotContext;
|
|
272
|
+
dialogId: string;
|
|
273
|
+
message?: string;
|
|
274
|
+
}): Promise<UploadedMedia> {
|
|
275
|
+
const { localPath, fileName, webhookUrl, bot, dialogId, message } = params;
|
|
164
276
|
|
|
165
277
|
try {
|
|
166
|
-
//
|
|
278
|
+
// Check file size before reading
|
|
279
|
+
const fileStat = await stat(localPath);
|
|
280
|
+
if (fileStat.size > MAX_FILE_SIZE) {
|
|
281
|
+
this.logger.warn('File too large to upload', {
|
|
282
|
+
fileName,
|
|
283
|
+
size: fileStat.size,
|
|
284
|
+
maxSize: MAX_FILE_SIZE,
|
|
285
|
+
});
|
|
286
|
+
return { ok: false };
|
|
287
|
+
}
|
|
288
|
+
|
|
167
289
|
const content = await readFile(localPath);
|
|
290
|
+
const base64Content = content.toString('base64');
|
|
168
291
|
|
|
169
|
-
|
|
170
|
-
|
|
292
|
+
const result = await this.api.uploadFile(webhookUrl, bot, dialogId, {
|
|
293
|
+
name: fileName,
|
|
294
|
+
content: base64Content,
|
|
295
|
+
message,
|
|
296
|
+
});
|
|
171
297
|
|
|
172
|
-
|
|
173
|
-
const diskId = await this.api.uploadFile(
|
|
174
|
-
clientEndpoint,
|
|
175
|
-
botToken,
|
|
176
|
-
folderId,
|
|
298
|
+
this.logger.debug('Uploaded media to chat', {
|
|
177
299
|
fileName,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
await this.api.commitFileToChat(clientEndpoint, botToken, chatId, diskId);
|
|
183
|
-
|
|
184
|
-
this.logger.debug('Uploaded media to chat', { fileName, chatId, diskId });
|
|
185
|
-
return true;
|
|
300
|
+
dialogId,
|
|
301
|
+
messageId: result.messageId,
|
|
302
|
+
});
|
|
303
|
+
return { ok: true, messageId: result.messageId };
|
|
186
304
|
} catch (err) {
|
|
187
305
|
this.logger.error('Error uploading media to chat', {
|
|
188
306
|
fileName,
|
|
189
|
-
|
|
190
|
-
error: err,
|
|
307
|
+
dialogId,
|
|
308
|
+
error: serializeError(err),
|
|
191
309
|
});
|
|
192
|
-
return false;
|
|
310
|
+
return { ok: false };
|
|
193
311
|
}
|
|
194
312
|
}
|
|
313
|
+
|
|
314
|
+
async cleanupDownloadedMedia(paths: string[]): Promise<void> {
|
|
315
|
+
const uniquePaths = [...new Set(paths)];
|
|
316
|
+
|
|
317
|
+
for (const filePath of uniquePaths) {
|
|
318
|
+
if (!this.isManagedMediaPath(filePath)) {
|
|
319
|
+
this.logger.debug('Skipping cleanup for unmanaged media path', { path: filePath });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await unlink(filePath);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
const errorCode = error instanceof Error && 'code' in error
|
|
327
|
+
? String((error as Error & { code?: string }).code)
|
|
328
|
+
: undefined;
|
|
329
|
+
if (errorCode === 'ENOENT') {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
this.logger.warn('Failed to cleanup downloaded media', {
|
|
334
|
+
path: filePath,
|
|
335
|
+
error: serializeError(error),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
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);
|
|
347
|
+
}
|
|
195
348
|
}
|
package/src/message-utils.ts
CHANGED
|
@@ -6,6 +6,8 @@ const PH_ESC = '\x00ESC'; // escape sequences
|
|
|
6
6
|
const PH_FCODE = '\x00FC'; // fenced code blocks
|
|
7
7
|
const PH_ICODE = '\x00IC'; // inline code
|
|
8
8
|
const PH_HR = '\x00HR'; // horizontal rules
|
|
9
|
+
const PH_TSEP = '\x00TS'; // table separators
|
|
10
|
+
const INLINE_ACCENT_COLOR = '#7A1F3D';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Convert Markdown (CommonMark + GFM subset) to Bitrix24 BB-code chat format.
|
|
@@ -51,6 +53,7 @@ export function markdownToBbCode(md: string): string {
|
|
|
51
53
|
inlineCodes.push(code);
|
|
52
54
|
return `${PH_ICODE}${inlineCodes.length - 1}\x00`;
|
|
53
55
|
});
|
|
56
|
+
const tableSeparators: string[] = [];
|
|
54
57
|
|
|
55
58
|
// ── Phase 2: Block rules (line-level, order matters) ──────────────────────
|
|
56
59
|
|
|
@@ -79,10 +82,9 @@ export function markdownToBbCode(md: string): string {
|
|
|
79
82
|
text = text.replace(/^##\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=20][B]$1[/B][/SIZE]');
|
|
80
83
|
text = text.replace(/^#\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=24][B]$1[/B][/SIZE]');
|
|
81
84
|
|
|
82
|
-
// 2e. Blockquotes: > text → >>text
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
85
|
+
// 2e. Blockquotes: > text → >>text
|
|
86
|
+
// Nested quotes are flattened into Bitrix-compatible quote blocks with separators.
|
|
87
|
+
text = convertBlockquotes(text);
|
|
86
88
|
|
|
87
89
|
// 2f. Task lists (GFM): - [x] done / - [ ] todo → emoji checkboxes
|
|
88
90
|
// Must run BEFORE generic list rules to avoid double-processing
|
|
@@ -109,10 +111,19 @@ export function markdownToBbCode(md: string): string {
|
|
|
109
111
|
return '\t'.repeat(depth) + '• ' + content;
|
|
110
112
|
});
|
|
111
113
|
|
|
114
|
+
// 2i. GFM tables → simplified plain-text tables
|
|
115
|
+
text = convertGfmTables(text, (separator) => {
|
|
116
|
+
tableSeparators.push(separator);
|
|
117
|
+
return `${PH_TSEP}${tableSeparators.length - 1}\x00`;
|
|
118
|
+
});
|
|
119
|
+
|
|
112
120
|
// ── Phase 3: Inline rules (order matters) ─────────────────────────────────
|
|
113
121
|
|
|
114
|
-
// 3a. Images:  → [
|
|
115
|
-
text = text.replace(/!\[
|
|
122
|
+
// 3a. Images:  → [img size=medium]url [/img]
|
|
123
|
+
text = text.replace(/!\[[^\]]*\]\(([\s\S]*?)\)/g, (_match, rawTarget: string) => {
|
|
124
|
+
const imageUrl = extractMarkdownTarget(rawTarget);
|
|
125
|
+
return `[img size=medium]${normalizeBitrixImageUrl(imageUrl)} [/img]`;
|
|
126
|
+
});
|
|
116
127
|
|
|
117
128
|
// 3b. Bold+Italic combined: ***text*** or ___text___ → [B][I]text[/I][/B]
|
|
118
129
|
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '[B][I]$1[/I][/B]');
|
|
@@ -153,24 +164,47 @@ export function markdownToBbCode(md: string): string {
|
|
|
153
164
|
// 4a. Horizontal rules → visual separator
|
|
154
165
|
text = text.replace(new RegExp(`${PH_HR.replace(/\x00/g, '\\x00')}\\x00`, 'g'), '____________');
|
|
155
166
|
|
|
167
|
+
// 4aa. Table separators → underscore rows
|
|
168
|
+
text = text.replace(new RegExp(`${PH_TSEP.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
|
|
169
|
+
const value = tableSeparators[Number(idx)];
|
|
170
|
+
return value != null ? value : _m;
|
|
171
|
+
});
|
|
172
|
+
|
|
156
173
|
// 4b. Inline code → [CODE]...[/CODE]
|
|
157
174
|
text = text.replace(new RegExp(`${PH_ICODE.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
|
|
158
|
-
|
|
175
|
+
const value = inlineCodes[Number(idx)];
|
|
176
|
+
if (value == null) return _m;
|
|
177
|
+
return isInlineAccentToken(value)
|
|
178
|
+
? `[B][COLOR=${INLINE_ACCENT_COLOR}]${value}[/COLOR][/B]`
|
|
179
|
+
: `[CODE]${value}[/CODE]`;
|
|
159
180
|
});
|
|
160
181
|
|
|
161
182
|
// 4b. Fenced/indented code blocks → [CODE]...[/CODE]
|
|
162
183
|
text = text.replace(new RegExp(`${PH_FCODE.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
|
|
163
|
-
|
|
184
|
+
const value = fencedBlocks[Number(idx)];
|
|
185
|
+
return value != null ? `[CODE]${value}[/CODE]` : _m;
|
|
164
186
|
});
|
|
165
187
|
|
|
166
188
|
// 4c. Escape sequences → literal characters
|
|
167
189
|
text = text.replace(new RegExp(`${PH_ESC.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
|
|
168
|
-
|
|
190
|
+
const value = escapes[Number(idx)];
|
|
191
|
+
return value != null ? value : _m;
|
|
169
192
|
});
|
|
170
193
|
|
|
171
194
|
return text;
|
|
172
195
|
}
|
|
173
196
|
|
|
197
|
+
function isInlineAccentToken(value: string): boolean {
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
if (!trimmed || /\s/.test(trimmed)) return false;
|
|
200
|
+
|
|
201
|
+
if (/^\/[a-z0-9][a-z0-9._:-]*$/i.test(trimmed)) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return /^[A-Z][A-Z0-9+._/-]{1,15}$/.test(trimmed);
|
|
206
|
+
}
|
|
207
|
+
|
|
174
208
|
/**
|
|
175
209
|
* Split a long message into chunks respecting B24's 20000 char limit.
|
|
176
210
|
* Tries to split at line boundaries.
|
|
@@ -201,8 +235,216 @@ export function splitMessage(text: string, maxLen: number = 20000): string[] {
|
|
|
201
235
|
return chunks;
|
|
202
236
|
}
|
|
203
237
|
|
|
238
|
+
function convertGfmTables(
|
|
239
|
+
text: string,
|
|
240
|
+
registerSeparator: (separator: string) => string,
|
|
241
|
+
): string {
|
|
242
|
+
const lines = text.split('\n');
|
|
243
|
+
const result: string[] = [];
|
|
244
|
+
|
|
245
|
+
for (let index = 0; index < lines.length; index++) {
|
|
246
|
+
const currentLine = lines[index];
|
|
247
|
+
const nextLine = lines[index + 1];
|
|
248
|
+
|
|
249
|
+
if (looksLikeMarkdownTableRow(currentLine) && isMarkdownTableSeparator(nextLine)) {
|
|
250
|
+
const rows: string[][] = [splitMarkdownTableRow(currentLine)];
|
|
251
|
+
let rowIndex = index + 2;
|
|
252
|
+
|
|
253
|
+
while (rowIndex < lines.length && looksLikeMarkdownTableRow(lines[rowIndex])) {
|
|
254
|
+
rows.push(splitMarkdownTableRow(lines[rowIndex]));
|
|
255
|
+
rowIndex += 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
result.push(formatPlainTextTable(rows, registerSeparator));
|
|
259
|
+
index = rowIndex - 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
result.push(currentLine);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result.join('\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function convertBlockquotes(text: string): string {
|
|
270
|
+
const lines = text.split('\n');
|
|
271
|
+
const result: string[] = [];
|
|
272
|
+
|
|
273
|
+
for (let index = 0; index < lines.length; index++) {
|
|
274
|
+
const currentQuoteLine = parseMarkdownBlockquoteLine(lines[index]);
|
|
275
|
+
if (!currentQuoteLine) {
|
|
276
|
+
result.push(lines[index]);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const quoteRun: Array<{ depth: number; content: string }> = [currentQuoteLine];
|
|
281
|
+
let nextIndex = index + 1;
|
|
282
|
+
|
|
283
|
+
while (nextIndex < lines.length) {
|
|
284
|
+
const nextQuoteLine = parseMarkdownBlockquoteLine(lines[nextIndex]);
|
|
285
|
+
if (!nextQuoteLine) {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
quoteRun.push(nextQuoteLine);
|
|
290
|
+
nextIndex += 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const hasNestedQuote = quoteRun.some((line) => line.depth > 1);
|
|
294
|
+
const normalizedQuoteRun = hasNestedQuote
|
|
295
|
+
? quoteRun.filter((line) => line.content.trim().length > 0)
|
|
296
|
+
: quoteRun;
|
|
297
|
+
|
|
298
|
+
for (const line of normalizedQuoteRun) {
|
|
299
|
+
result.push(`>>${line.content}`);
|
|
300
|
+
if (hasNestedQuote) {
|
|
301
|
+
result.push('>>------------------------------------------------------');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
index = nextIndex - 1;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return result.join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function parseMarkdownBlockquoteLine(line: string): { depth: number; content: string } | null {
|
|
312
|
+
const indentMatch = /^[ \t]{0,3}/.exec(line);
|
|
313
|
+
let rest = line.slice(indentMatch?.[0].length ?? 0);
|
|
314
|
+
|
|
315
|
+
if (!rest.startsWith('>')) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let depth = 0;
|
|
320
|
+
while (rest.startsWith('>')) {
|
|
321
|
+
depth += 1;
|
|
322
|
+
rest = rest.slice(1);
|
|
323
|
+
if (rest.startsWith(' ')) {
|
|
324
|
+
rest = rest.slice(1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
depth,
|
|
330
|
+
content: rest,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function looksLikeMarkdownTableRow(line: string | undefined): boolean {
|
|
335
|
+
if (typeof line !== 'string') return false;
|
|
336
|
+
const trimmed = line.trim();
|
|
337
|
+
if (!trimmed.includes('|')) return false;
|
|
338
|
+
|
|
339
|
+
const cells = splitMarkdownTableRow(trimmed);
|
|
340
|
+
return cells.length >= 2 && cells.some((cell) => cell.length > 0);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isMarkdownTableSeparator(line: string | undefined): boolean {
|
|
344
|
+
if (typeof line !== 'string') return false;
|
|
345
|
+
const trimmed = line.trim();
|
|
346
|
+
if (!trimmed.includes('|')) return false;
|
|
347
|
+
|
|
348
|
+
const cells = splitMarkdownTableRow(trimmed);
|
|
349
|
+
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function splitMarkdownTableRow(line: string): string[] {
|
|
353
|
+
let normalized = line.trim();
|
|
354
|
+
|
|
355
|
+
if (normalized.startsWith('|')) {
|
|
356
|
+
normalized = normalized.slice(1);
|
|
357
|
+
}
|
|
358
|
+
if (normalized.endsWith('|')) {
|
|
359
|
+
normalized = normalized.slice(0, -1);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return normalized.split('|').map((cell) => cell.trim());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatPlainTextTable(
|
|
366
|
+
rows: string[][],
|
|
367
|
+
registerSeparator: (separator: string) => string,
|
|
368
|
+
): string {
|
|
369
|
+
const columnCount = Math.max(...rows.map((row) => row.length));
|
|
370
|
+
const normalizedRows = rows.map((row) => {
|
|
371
|
+
const cells = [...row];
|
|
372
|
+
while (cells.length < columnCount) {
|
|
373
|
+
cells.push('');
|
|
374
|
+
}
|
|
375
|
+
return cells;
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const columnWidths = Array.from({ length: columnCount }, (_, columnIndex) => {
|
|
379
|
+
return Math.max(...normalizedRows.map((row) => row[columnIndex].length));
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const formattedRows = normalizedRows.map((row) => {
|
|
383
|
+
return row
|
|
384
|
+
.map((cell, columnIndex) => cell.padEnd(columnWidths[columnIndex]))
|
|
385
|
+
.join(' | ')
|
|
386
|
+
.trimEnd();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const separator = registerSeparator('_'.repeat(Math.max(formattedRows[0]?.length ?? 0, 3)));
|
|
390
|
+
return [formattedRows[0], separator, ...formattedRows.slice(1)].join('\n');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function extractMarkdownTarget(rawTarget: string): string {
|
|
394
|
+
const trimmed = rawTarget.trim();
|
|
395
|
+
if (!trimmed) return '';
|
|
396
|
+
|
|
397
|
+
if (trimmed.startsWith('<')) {
|
|
398
|
+
const closingIndex = trimmed.indexOf('>');
|
|
399
|
+
if (closingIndex > 1) {
|
|
400
|
+
return trimmed.slice(1, closingIndex);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let depth = 0;
|
|
405
|
+
for (let index = 0; index < trimmed.length; index++) {
|
|
406
|
+
const char = trimmed[index];
|
|
407
|
+
if (char === '(') {
|
|
408
|
+
depth += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (char === ')') {
|
|
412
|
+
depth = Math.max(0, depth - 1);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (/\s/.test(char) && depth === 0) {
|
|
416
|
+
return trimmed.slice(0, index);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return trimmed;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function normalizeBitrixImageUrl(rawUrl: string): string {
|
|
424
|
+
const trimmed = rawUrl.trim();
|
|
425
|
+
if (!trimmed) return trimmed;
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const url = new URL(trimmed);
|
|
429
|
+
if (hasSupportedBitrixImageExtension(`${url.pathname}${url.hash}`)) {
|
|
430
|
+
return url.toString();
|
|
431
|
+
}
|
|
432
|
+
url.hash = 'image.jpg';
|
|
433
|
+
return url.toString();
|
|
434
|
+
} catch {
|
|
435
|
+
if (hasSupportedBitrixImageExtension(trimmed)) {
|
|
436
|
+
return trimmed;
|
|
437
|
+
}
|
|
438
|
+
return `${trimmed}#image.jpg`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function hasSupportedBitrixImageExtension(value: string): boolean {
|
|
443
|
+
return /\.(jpe?g|png|gif)(?:$|[?#])/i.test(value);
|
|
444
|
+
}
|
|
445
|
+
|
|
204
446
|
/**
|
|
205
|
-
* Build a KEYBOARD array for imbot.
|
|
447
|
+
* Build a KEYBOARD array for imbot.v2.Chat.Message.send.
|
|
206
448
|
*
|
|
207
449
|
* @param rows - Array of button rows. Each row is an array of buttons.
|
|
208
450
|
*/
|