@ihazz/bitrix24 0.2.4 → 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.
@@ -1,16 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { writeFile, readFile, mkdir } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import { join, basename } from 'node:path';
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 { defaultLogger } from './utils.js';
7
-
8
- interface Logger {
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
- const MEDIA_DIR = join(tmpdir(), 'openclaw-b24-media');
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(MEDIA_DIR, { recursive: true });
154
+ await mkdir(resolveManagedMediaDir(), { recursive: true });
80
155
  this.dirReady = true;
81
156
  }
82
157
 
83
158
  /**
84
- * Download a file from B24 and save it locally.
85
- * Uses the user's access token to call disk.file.get for the download URL.
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
- clientEndpoint: string;
92
- userToken: string;
93
- webhookUrl?: string;
166
+ webhookUrl: string;
167
+ bot: BotContext;
168
+ dialogId: string;
94
169
  }): Promise<DownloadedMedia | null> {
95
- const { fileId, fileName, extension, clientEndpoint, userToken, webhookUrl } = params;
170
+ const { fileId, fileName, extension, webhookUrl, bot, dialogId } = params;
96
171
 
97
172
  try {
98
- // Get download URL from B24 REST API
99
- // Try webhook URL first (more reliable event tokens often lack disk scope),
100
- // fall back to event access token.
101
- let fileInfo: { DOWNLOAD_URL: string; [key: string]: unknown };
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
- const downloadUrl = fileInfo.DOWNLOAD_URL;
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 DOWNLOAD_URL for file', { fileId });
188
+ this.logger.warn('No downloadUrl for file', { fileId });
116
189
  return null;
117
190
  }
118
191
 
119
- // Download the file
120
- const response = await fetch(downloadUrl);
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 savePath = join(MEDIA_DIR, `${randomUUID()}_${fileName}`);
237
+ const safeFileName = basename(fileName) || 'file';
238
+ const savePath = join(resolveManagedMediaDir(), `${randomUUID()}_${safeFileName}`);
134
239
  await writeFile(savePath, buffer);
135
240
 
136
- const contentType = mimeFromExtension(extension);
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', { fileId, error: err });
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: get folder → upload → commit.
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
- chatId: number;
160
- clientEndpoint: string;
161
- botToken: string;
162
- }): Promise<boolean> {
163
- const { localPath, fileName, chatId, clientEndpoint, botToken } = params;
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
- // Read the file
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
- // Step 1: Get chat folder
170
- const folderId = await this.api.getChatFolder(clientEndpoint, botToken, chatId);
292
+ const result = await this.api.uploadFile(webhookUrl, bot, dialogId, {
293
+ name: fileName,
294
+ content: base64Content,
295
+ message,
296
+ });
171
297
 
172
- // Step 2: Upload file (base64)
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
- content,
179
- );
180
-
181
- // Step 3: Publish to chat
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
- chatId,
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
  }
@@ -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 (multi-level: >> text → >>>>text)
83
- text = text.replace(/^(>{1,})\s?(.*)$/gm, (_m, arrows: string, content: string) => {
84
- return '>'.repeat(arrows.length * 2) + content;
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: ![alt](url) → [IMG size=medium]url [/IMG] (note: space before closing tag is required by B24)
115
- text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[IMG size=medium]$2 [/IMG]');
122
+ // 3a. Images: ![alt](url) → [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
- return `[CODE]${inlineCodes[Number(idx)]}[/CODE]`;
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
- return `[CODE]${fencedBlocks[Number(idx)]}[/CODE]`;
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
- return escapes[Number(idx)];
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.message.add.
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
  */