@ihazz/bitrix24 0.1.6 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihazz/bitrix24",
3
- "version": "0.1.6",
3
+ "version": "0.2.2",
4
4
  "description": "Bitrix24 Messenger channel for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/api.ts CHANGED
@@ -307,6 +307,18 @@ export class Bitrix24Api {
307
307
  return result.result;
308
308
  }
309
309
 
310
+ async getFileInfoViaWebhook(
311
+ webhookUrl: string,
312
+ fileId: number,
313
+ ): Promise<{ DOWNLOAD_URL: string; [key: string]: unknown }> {
314
+ const result = await this.callWebhook<{ DOWNLOAD_URL: string; [key: string]: unknown }>(
315
+ webhookUrl,
316
+ 'disk.file.get',
317
+ { id: fileId },
318
+ );
319
+ return result.result;
320
+ }
321
+
310
322
  /**
311
323
  * Get the disk folder ID for a chat (needed for file uploads).
312
324
  */
package/src/channel.ts CHANGED
@@ -38,6 +38,40 @@ interface GatewayState {
38
38
 
39
39
  let gatewayState: GatewayState | null = null;
40
40
 
41
+ // ─── i18n helpers ────────────────────────────────────────────────────────────
42
+
43
+ const I18N_MEDIA_DOWNLOAD_FAILED: Record<string, (files: string) => string> = {
44
+ en: (f) => `⚠️ Could not download file(s): ${f}.\n\nFile processing is currently only available for the primary user (webhook owner). This limitation will be removed in a future release.`,
45
+ ru: (f) => `⚠️ Не удалось загрузить файл(ы): ${f}.\n\nОбработка файлов пока доступна только для основного пользователя (автора вебхука). В будущих версиях это ограничение будет снято.`,
46
+ de: (f) => `⚠️ Datei(en) konnten nicht heruntergeladen werden: ${f}.\n\nDateiverarbeitung ist derzeit nur für den Hauptbenutzer (Webhook-Besitzer) verfügbar. Diese Einschränkung wird in einer zukünftigen Version behoben.`,
47
+ es: (f) => `⚠️ No se pudo descargar el/los archivo(s): ${f}.\n\nEl procesamiento de archivos actualmente solo está disponible para el usuario principal (propietario del webhook). Esta limitación se eliminará en una versión futura.`,
48
+ fr: (f) => `⚠️ Impossible de télécharger le(s) fichier(s) : ${f}.\n\nLe traitement des fichiers est actuellement réservé à l'utilisateur principal (propriétaire du webhook). Cette limitation sera levée dans une prochaine version.`,
49
+ pt: (f) => `⚠️ Não foi possível baixar o(s) arquivo(s): ${f}.\n\nO processamento de arquivos está disponível apenas para o usuário principal (dono do webhook). Essa limitação será removida em uma versão futura.`,
50
+ };
51
+
52
+ function mediaDownloadFailedMsg(lang: string | undefined, fileNames: string): string {
53
+ const code = (lang ?? 'en').toLowerCase().slice(0, 2);
54
+ const fn = I18N_MEDIA_DOWNLOAD_FAILED[code] ?? I18N_MEDIA_DOWNLOAD_FAILED.en;
55
+ return fn(fileNames);
56
+ }
57
+
58
+ // ─── Default bot avatar (64×64 red circle with white "C") ───────────────────
59
+
60
+ const DEFAULT_AVATAR_BASE64 =
61
+ 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAABp0lEQVR42u1bsa3DIBBNmVlcZgbKNF7BK6TIHG4yi2fwAjS/8AIs4IZAdC5RAtxh+LyTniJFlq33gLsD7i4XGOxU+7vduiA5OEwOs8PioB2Mw+5g6dfQ/ws9558fWiatHF4OG5FMxUbvUS2Qvjo8aCStADS9/1oj+SdNY1sA/jvPWoiPgiP+y4wYT/PetDZtBXgVjSbk1ddKyB9Yi0QN8u5sa90bs29QkuTvOURjLUOIu9TIi5NmFENxr3lzBvEMIQyLTyBvv9ZAPkGENTs6xIS6khYTInOTnKqIJwoxpgqgayYfIYJOze3tPxHARu0daFdnWiAfIYL5eRdJW85myEeI8GBZ+9LTVfD9miXjK5XWCn1HZcX9wjm9yNb5mwAbpwCVkf+cMX7L+dmmZYXkDwwhAaYOyHtMIQHmTgSYQwIsHAJUTt5jEY3/DQigQwKYTgQwIQH2TgTYQyc/PTjAA5gB8AGIAsgDkAliL4DdIM4DcCKEM0GcCuNeADdDuBvE7TDqA1AhghohVImhThCVoqgVRrU4+gXQMYKeIXSNoW8QnaPoHe7F3lyXWKOZrIuJAAAAAElFTkSuQmCC';
62
+
63
+ // ─── Default command keyboard ────────────────────────────────────────────────
64
+
65
+ /** Default keyboard shown with command responses and welcome messages. */
66
+ export const DEFAULT_COMMAND_KEYBOARD: B24Keyboard = [
67
+ { TEXT: 'Help', COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
68
+ { TEXT: 'Status', COMMAND: 'status', DISPLAY: 'LINE' },
69
+ { TEXT: 'Commands', COMMAND: 'commands', DISPLAY: 'LINE' },
70
+ { TYPE: 'NEWLINE' },
71
+ { TEXT: 'New session', COMMAND: 'new', DISPLAY: 'LINE' },
72
+ { TEXT: 'Models', COMMAND: 'models', DISPLAY: 'LINE' },
73
+ ];
74
+
41
75
  // ─── Keyboard / Button conversion ────────────────────────────────────────────
42
76
 
43
77
  /** Generic button format used by OpenClaw channelData. */
@@ -143,9 +177,16 @@ async function ensureBotRegistered(
143
177
 
144
178
  if (existing) {
145
179
  logger.info(`Bot "${code}" already registered (ID=${existing.ID}), updating EVENT_HANDLER`);
180
+ const updateProps: Record<string, unknown> = {
181
+ NAME: name,
182
+ WORK_POSITION: 'AI Assistant',
183
+ COLOR: 'RED',
184
+ };
185
+ updateProps.PERSONAL_PHOTO = config.botAvatar || DEFAULT_AVATAR_BASE64;
186
+
146
187
  await api.updateBot(webhookUrl, existing.ID, {
147
188
  EVENT_HANDLER: callbackUrl,
148
- PROPERTIES: { NAME: name },
189
+ PROPERTIES: updateProps,
149
190
  });
150
191
  return existing.ID;
151
192
  }
@@ -162,7 +203,8 @@ async function ensureBotRegistered(
162
203
  PROPERTIES: {
163
204
  NAME: name,
164
205
  WORK_POSITION: 'AI Assistant',
165
- COLOR: 'AZURE',
206
+ COLOR: 'RED',
207
+ PERSONAL_PHOTO: config.botAvatar || DEFAULT_AVATAR_BASE64,
166
208
  },
167
209
  });
168
210
  logger.info(`Bot "${code}" registered (ID=${botId})`);
@@ -280,6 +322,7 @@ export const bitrix24Plugin = {
280
322
  reactions: false,
281
323
  threads: false,
282
324
  nativeCommands: true,
325
+ inlineButtons: 'all',
283
326
  },
284
327
 
285
328
  config: {
@@ -498,6 +541,7 @@ export const bitrix24Plugin = {
498
541
  extension: m.extension,
499
542
  clientEndpoint: msgCtx.clientEndpoint,
500
543
  userToken: msgCtx.userToken,
544
+ webhookUrl: config.webhookUrl,
501
545
  }),
502
546
  ),
503
547
  )).filter(Boolean) as DownloadedMedia[];
@@ -511,6 +555,21 @@ export const bitrix24Plugin = {
511
555
  MediaUrls: downloaded.map((m) => m.path),
512
556
  MediaTypes: downloaded.map((m) => m.contentType),
513
557
  };
558
+ } else {
559
+ // All file downloads failed — notify the user
560
+ const fileNames = msgCtx.media.map((m) => m.name).join(', ');
561
+ logger.warn('All media downloads failed, notifying user', { fileNames });
562
+ const errSendCtx = {
563
+ webhookUrl: config.webhookUrl,
564
+ clientEndpoint: msgCtx.clientEndpoint,
565
+ botToken: msgCtx.botToken,
566
+ dialogId: msgCtx.chatId,
567
+ };
568
+ await sendService.sendText(
569
+ errSendCtx,
570
+ mediaDownloadFailedMsg(msgCtx.language, fileNames),
571
+ );
572
+ return;
514
573
  }
515
574
  }
516
575
 
@@ -545,7 +604,7 @@ export const bitrix24Plugin = {
545
604
  RawBody: body,
546
605
  From: `bitrix24:${msgCtx.chatId}`,
547
606
  To: `bitrix24:${msgCtx.chatId}`,
548
- SessionKey: route.sessionKey,
607
+ SessionKey: `${route.sessionKey}:bitrix24:${msgCtx.chatId}`,
549
608
  AccountId: route.accountId,
550
609
  ChatType: msgCtx.isDm ? 'direct' : 'group',
551
610
  ConversationLabel: msgCtx.senderName,
@@ -628,25 +687,40 @@ export const bitrix24Plugin = {
628
687
 
629
688
  logger.info('Inbound command', { commandName, commandParams, senderId, dialogId });
630
689
 
631
- const runtime = getBitrix24Runtime();
632
- const cfg = runtime.config.loadConfig();
690
+ let runtime;
691
+ let cfg;
692
+ try {
693
+ runtime = getBitrix24Runtime();
694
+ cfg = runtime.config.loadConfig();
695
+ } catch (err) {
696
+ logger.error('Failed to get runtime/config for command', err);
697
+ return;
698
+ }
633
699
 
634
700
  // Pairing-aware access control (commands don't send pairing replies)
635
- const accessResult = await checkAccessWithPairing({
636
- senderId,
637
- config,
638
- runtime,
639
- accountId: ctx.accountId,
640
- pairingAdapter: bitrix24Plugin.pairing,
641
- sendReply: async () => {},
642
- logger,
643
- });
701
+ let accessResult;
702
+ try {
703
+ accessResult = await checkAccessWithPairing({
704
+ senderId,
705
+ config,
706
+ runtime,
707
+ accountId: ctx.accountId,
708
+ pairingAdapter: bitrix24Plugin.pairing,
709
+ sendReply: async () => {},
710
+ logger,
711
+ });
712
+ } catch (err) {
713
+ logger.error('Access check failed for command', err);
714
+ return;
715
+ }
644
716
 
645
717
  if (accessResult !== 'allow') {
646
718
  logger.debug(`Command blocked (${accessResult})`, { senderId });
647
719
  return;
648
720
  }
649
721
 
722
+ logger.debug('Command access allowed, resolving route', { commandText });
723
+
650
724
  const route = runtime.channel.routing.resolveAgentRoute({
651
725
  cfg,
652
726
  channel: 'bitrix24',
@@ -654,8 +728,11 @@ export const bitrix24Plugin = {
654
728
  peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
655
729
  });
656
730
 
657
- // Native commands use a separate slash-command session
658
- const slashSessionKey = `bitrix24:slash:${senderId}`;
731
+ logger.debug('Command route resolved', { sessionKey: route.sessionKey });
732
+
733
+ // Each command invocation gets a unique ephemeral session
734
+ // so the gateway doesn't treat it as "already handled".
735
+ const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
659
736
 
660
737
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
661
738
  Body: commandText,
@@ -664,7 +741,7 @@ export const bitrix24Plugin = {
664
741
  CommandBody: commandText,
665
742
  CommandAuthorized: true,
666
743
  CommandSource: 'native',
667
- CommandTargetSessionKey: route.sessionKey,
744
+ CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${dialogId}`,
668
745
  From: `bitrix24:${dialogId}`,
669
746
  To: `slash:${senderId}`,
670
747
  SessionKey: slashSessionKey,
@@ -689,15 +766,23 @@ export const bitrix24Plugin = {
689
766
  dialogId,
690
767
  };
691
768
 
769
+ logger.debug('Dispatching command to agent', { commandText, slashSessionKey });
770
+
692
771
  try {
693
772
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
694
773
  ctx: inboundCtx,
695
774
  cfg,
696
775
  dispatcherOptions: {
697
776
  deliver: async (payload) => {
777
+ logger.debug('Command deliver callback', {
778
+ hasText: !!payload.text,
779
+ textLen: payload.text?.length ?? 0,
780
+ hasMedia: !!(payload.mediaUrl || payload.mediaUrls?.length),
781
+ });
698
782
  if (payload.text) {
699
- const keyboard = extractKeyboardFromPayload(payload);
700
- await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
783
+ // Use agent-provided keyboard if any, otherwise re-attach default command keyboard
784
+ const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
785
+ await sendService.sendText(sendCtx, payload.text, { keyboard });
701
786
  }
702
787
  },
703
788
  onReplyStart: async () => {
@@ -710,28 +795,57 @@ export const bitrix24Plugin = {
710
795
  },
711
796
  },
712
797
  });
798
+ logger.debug('Command dispatch completed', { commandText });
713
799
  } catch (err) {
714
800
  logger.error('Error dispatching command to agent', err);
715
801
  }
716
802
  },
717
803
 
718
804
  onJoinChat: async (event: B24JoinChatEvent) => {
805
+ const dialogId = event.data.PARAMS.DIALOG_ID;
806
+ const botEntry = Object.values(event.data.BOT)[0];
719
807
  logger.info('Bot joined chat', {
720
- dialogId: event.data.PARAMS.DIALOG_ID,
808
+ dialogId,
721
809
  userId: event.data.PARAMS.USER_ID,
810
+ hasBotEntry: !!botEntry,
811
+ botId: botEntry?.BOT_ID,
812
+ hasEndpoint: !!botEntry?.client_endpoint,
813
+ hasToken: !!botEntry?.access_token,
722
814
  });
723
- const dialogId = event.data.PARAMS.DIALOG_ID;
724
- const botEntry = Object.values(event.data.BOT)[0];
725
- if (botEntry && dialogId) {
726
- try {
815
+ if (!dialogId) return;
816
+
817
+ const welcomeText = `${config.botName ?? 'OpenClaw'} ready. Send me a message or pick a command below.`;
818
+ const welcomeOpts = { KEYBOARD: DEFAULT_COMMAND_KEYBOARD };
819
+
820
+ try {
821
+ // Prefer token-based call; fall back to webhook URL
822
+ if (botEntry?.client_endpoint && botEntry?.access_token) {
727
823
  await api.sendMessageWithToken(
728
824
  botEntry.client_endpoint,
729
825
  botEntry.access_token,
730
826
  dialogId,
731
- `${config.botName ?? 'OpenClaw'} ready. Send me a message to get started.`,
827
+ welcomeText,
828
+ welcomeOpts,
732
829
  );
733
- } catch (err) {
734
- logger.error('Failed to send welcome message', err);
830
+ } else if (config.webhookUrl) {
831
+ await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
832
+ } else {
833
+ logger.warn('No way to send welcome message — no token and no webhookUrl');
834
+ return;
835
+ }
836
+ logger.info('Welcome message sent', { dialogId });
837
+ } catch (err: unknown) {
838
+ const errMsg = err instanceof Error ? err.message : String(err);
839
+ logger.error('Failed to send welcome message', { error: errMsg, dialogId });
840
+ // Retry via webhook if token-based call failed
841
+ if (botEntry?.client_endpoint && config.webhookUrl) {
842
+ try {
843
+ await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
844
+ logger.info('Welcome message sent via webhook fallback', { dialogId });
845
+ } catch (err2: unknown) {
846
+ const errMsg2 = err2 instanceof Error ? err2.message : String(err2);
847
+ logger.error('Welcome message webhook fallback also failed', { error: errMsg2, dialogId });
848
+ }
735
849
  }
736
850
  }
737
851
  },
@@ -90,16 +90,25 @@ export class MediaService {
90
90
  extension: string;
91
91
  clientEndpoint: string;
92
92
  userToken: string;
93
+ webhookUrl?: string;
93
94
  }): Promise<DownloadedMedia | null> {
94
- const { fileId, fileName, extension, clientEndpoint, userToken } = params;
95
+ const { fileId, fileName, extension, clientEndpoint, userToken, webhookUrl } = params;
95
96
 
96
97
  try {
97
98
  // Get download URL from B24 REST API
98
- const fileInfo = await this.api.getFileInfo(
99
- clientEndpoint,
100
- userToken,
101
- Number(fileId),
102
- );
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));
111
+ }
103
112
 
104
113
  const downloadUrl = fileInfo.DOWNLOAD_URL;
105
114
  if (!downloadUrl) {
@@ -1,44 +1,174 @@
1
1
  import type { KeyboardButton, B24Keyboard } from './types.js';
2
2
 
3
+ // ─── Placeholder helpers ──────────────────────────────────────────────────────
4
+
5
+ const PH_ESC = '\x00ESC'; // escape sequences
6
+ const PH_FCODE = '\x00FC'; // fenced code blocks
7
+ const PH_ICODE = '\x00IC'; // inline code
8
+ const PH_HR = '\x00HR'; // horizontal rules
9
+
3
10
  /**
4
- * Convert Markdown text to Bitrix24 BB-code chat format.
11
+ * Convert Markdown (CommonMark + GFM subset) to Bitrix24 BB-code chat format.
5
12
  *
6
- * Supported conversions:
7
- * - **bold** / __bold__[B]bold[/B]
8
- * - *italic* / _italic_ [I]italic[/I]
9
- * - ~~strikethrough~~ → [S]strikethrough[/S]
10
- * - `inline code` [CODE]inline code[/CODE]
11
- * - ```code block``` → [CODE]code block[/CODE]
12
- * - [text](url)[URL=url]text[/URL]
13
- * - > blockquote → >>blockquote
13
+ * 4-phase pipeline:
14
+ * 1. Protect literals escape sequences, fenced code, inline code placeholders
15
+ * 2. Block rules — indented code, setext headings, horizontal rules, ATX headings,
16
+ * blockquotes, unordered lists
17
+ * 3. Inline rules — images, bold+italic, bold, italic, strikethrough, HTML inline
18
+ * formatting, links, autolinks
19
+ * 4. Restore — placeholdersBB-code equivalents
14
20
  */
15
21
  export function markdownToBbCode(md: string): string {
16
- let result = md;
17
-
18
- // Code blocks first (to avoid processing markdown inside them)
19
- result = result.replace(/```[\w]*\n?([\s\S]*?)```/g, '[CODE]$1[/CODE]');
20
-
21
- // Inline code
22
- result = result.replace(/`([^`]+)`/g, '[CODE]$1[/CODE]');
23
-
24
- // Bold: **text** or __text__
25
- result = result.replace(/\*\*(.+?)\*\*/g, '[B]$1[/B]');
26
- result = result.replace(/__(.+?)__/g, '[B]$1[/B]');
27
-
28
- // Italic: *text* or _text_ (but not inside words with underscores)
29
- result = result.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '[I]$1[/I]');
30
- result = result.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '[I]$1[/I]');
31
-
32
- // Strikethrough: ~~text~~
33
- result = result.replace(/~~(.+?)~~/g, '[S]$1[/S]');
34
-
35
- // Links: [text](url)
36
- result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[URL=$2]$1[/URL]');
37
-
38
- // Blockquotes: > text >>text
39
- result = result.replace(/^>\s?(.*)$/gm, '>>$1');
40
-
41
- return result;
22
+ let text = md;
23
+
24
+ // ── Phase 1: Protect literals ─────────────────────────────────────────────
25
+
26
+ // 1a. Escape sequences: \* \# \_ etc. → placeholders
27
+ const escapes: string[] = [];
28
+ text = text.replace(/\\([\\`*_{}[\]()#+\-.!~|>])/g, (_match, ch: string) => {
29
+ escapes.push(ch);
30
+ return `${PH_ESC}${escapes.length - 1}\x00`;
31
+ });
32
+
33
+ // 1b. Fenced code blocks (backticks and tildes)
34
+ const fencedBlocks: string[] = [];
35
+ text = text.replace(/^(`{3,})[^\n`]*\n([\s\S]*?)^\1[ \t]*$/gm, (_match, _fence, code: string) => {
36
+ fencedBlocks.push(code.replace(/\n$/, ''));
37
+ return `${PH_FCODE}${fencedBlocks.length - 1}\x00`;
38
+ });
39
+ text = text.replace(/^(~{3,})[^\n~]*\n([\s\S]*?)^\1[ \t]*$/gm, (_match, _fence, code: string) => {
40
+ fencedBlocks.push(code.replace(/\n$/, ''));
41
+ return `${PH_FCODE}${fencedBlocks.length - 1}\x00`;
42
+ });
43
+
44
+ // 1c. Inline code (backticks) — single and double backtick
45
+ const inlineCodes: string[] = [];
46
+ text = text.replace(/``([^`]+)``/g, (_match, code: string) => {
47
+ inlineCodes.push(code);
48
+ return `${PH_ICODE}${inlineCodes.length - 1}\x00`;
49
+ });
50
+ text = text.replace(/`([^`\n]+)`/g, (_match, code: string) => {
51
+ inlineCodes.push(code);
52
+ return `${PH_ICODE}${inlineCodes.length - 1}\x00`;
53
+ });
54
+
55
+ // ── Phase 2: Block rules (line-level, order matters) ──────────────────────
56
+
57
+ // 2a. Indented code blocks (4 spaces or 1 tab after a blank line)
58
+ // Collect consecutive indented lines preceded by a blank line
59
+ text = text.replace(/(?:^|\n)\n((?:(?: |\t).+\n?)+)/g, (_match, block: string) => {
60
+ const code = block.replace(/^(?: |\t)/gm, '').replace(/\n$/, '');
61
+ fencedBlocks.push(code);
62
+ return `\n${PH_FCODE}${fencedBlocks.length - 1}\x00`;
63
+ });
64
+
65
+ // 2b. Setext headings (BEFORE horizontal rules — `---` under text is H2, not HR)
66
+ text = text.replace(/^([^\n]+)\n={2,}[ \t]*$/gm, '[SIZE=24][B]$1[/B][/SIZE]');
67
+ text = text.replace(/^([^\n]+)\n-{2,}[ \t]*$/gm, '[SIZE=20][B]$1[/B][/SIZE]');
68
+
69
+ // 2c. Horizontal rules: ---, ***, ___, - - -, * * * (on their own line)
70
+ // Use placeholder to prevent underscores from being caught by bold/italic rules
71
+ text = text.replace(/^[ \t]*([-*_])[ \t]*\1[ \t]*\1(?:[ \t]*\1)*[ \t]*$/gm, `${PH_HR}\x00`);
72
+ text = text.replace(/^[ \t]*[-*_](?:[ \t]+[-*_]){2,}[ \t]*$/gm, `${PH_HR}\x00`);
73
+
74
+ // 2d. ATX headings: # through ######
75
+ text = text.replace(/^######\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=16][B]$1[/B][/SIZE]');
76
+ text = text.replace(/^#####\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=16][B]$1[/B][/SIZE]');
77
+ text = text.replace(/^####\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=16][B]$1[/B][/SIZE]');
78
+ text = text.replace(/^###\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=18][B]$1[/B][/SIZE]');
79
+ text = text.replace(/^##\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=20][B]$1[/B][/SIZE]');
80
+ text = text.replace(/^#\s+(.+?)(?:\s+#+)?$/gm, '[SIZE=24][B]$1[/B][/SIZE]');
81
+
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
+ });
86
+
87
+ // 2f. Task lists (GFM): - [x] done / - [ ] todo → emoji checkboxes
88
+ // Must run BEFORE generic list rules to avoid double-processing
89
+ text = text.replace(/^([ \t]*)[-*+]\s+\[x\]\s+(.*)$/gmi, (_m, indent: string, content: string) => {
90
+ const depth = Math.floor(indent.replace(/\t/g, ' ').length / 2);
91
+ return '\t'.repeat(depth) + '✅ ' + content;
92
+ });
93
+ text = text.replace(/^([ \t]*)[-*+]\s+\[ \]\s+(.*)$/gm, (_m, indent: string, content: string) => {
94
+ const depth = Math.floor(indent.replace(/\t/g, ' ').length / 2);
95
+ return '\t'.repeat(depth) + '☑️ ' + content;
96
+ });
97
+
98
+ // 2g. Ordered lists: 1. item / 2. item → number with dot
99
+ // Handle nested lists with tab indentation
100
+ text = text.replace(/^([ \t]*)(\d+)\.\s+(.*)$/gm, (_m, indent: string, num: string, content: string) => {
101
+ const depth = Math.floor(indent.replace(/\t/g, ' ').length / 2);
102
+ return '\t'.repeat(depth) + num + '. ' + content;
103
+ });
104
+
105
+ // 2h. Unordered lists: - item / * item / + item → • item
106
+ // Handle nested lists with tab indentation
107
+ text = text.replace(/^([ \t]*)[-*+]\s+(.*)$/gm, (_m, indent: string, content: string) => {
108
+ const depth = Math.floor(indent.replace(/\t/g, ' ').length / 2);
109
+ return '\t'.repeat(depth) + '• ' + content;
110
+ });
111
+
112
+ // ── Phase 3: Inline rules (order matters) ─────────────────────────────────
113
+
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]');
116
+
117
+ // 3b. Bold+Italic combined: ***text*** or ___text___ → [B][I]text[/I][/B]
118
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '[B][I]$1[/I][/B]');
119
+ text = text.replace(/___(.+?)___/g, '[B][I]$1[/I][/B]');
120
+
121
+ // 3c. Bold: **text** or __text__
122
+ text = text.replace(/\*\*(.+?)\*\*/g, '[B]$1[/B]');
123
+ text = text.replace(/__(.+?)__/g, '[B]$1[/B]');
124
+
125
+ // 3d. Italic: *text* or _text_ (word boundaries to avoid false positives)
126
+ text = text.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '[I]$1[/I]');
127
+ text = text.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '[I]$1[/I]');
128
+
129
+ // 3e. Strikethrough: ~~text~~
130
+ text = text.replace(/~~(.+?)~~/g, '[S]$1[/S]');
131
+
132
+ // 3f. HTML inline formatting tags
133
+ text = text.replace(/<u>([\s\S]*?)<\/u>/gi, '[U]$1[/U]');
134
+ text = text.replace(/<b>([\s\S]*?)<\/b>/gi, '[B]$1[/B]');
135
+ text = text.replace(/<strong>([\s\S]*?)<\/strong>/gi, '[B]$1[/B]');
136
+ text = text.replace(/<i>([\s\S]*?)<\/i>/gi, '[I]$1[/I]');
137
+ text = text.replace(/<em>([\s\S]*?)<\/em>/gi, '[I]$1[/I]');
138
+ text = text.replace(/<s>([\s\S]*?)<\/s>/gi, '[S]$1[/S]');
139
+ text = text.replace(/<del>([\s\S]*?)<\/del>/gi, '[S]$1[/S]');
140
+ text = text.replace(/<strike>([\s\S]*?)<\/strike>/gi, '[S]$1[/S]');
141
+
142
+ // 3g. Links: [text](url) → [URL=url]text[/URL]
143
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[URL=$2]$1[/URL]');
144
+
145
+ // 3h. Autolink URL: <https://...> → [URL]https://...[/URL]
146
+ text = text.replace(/<(https?:\/\/[^>]+)>/g, '[URL]$1[/URL]');
147
+
148
+ // 3i. Autolink email: <user@example.com> → [URL]mailto:user@example.com[/URL]
149
+ text = text.replace(/<([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>/g, '[URL]mailto:$1[/URL]');
150
+
151
+ // ── Phase 4: Restore placeholders ─────────────────────────────────────────
152
+
153
+ // 4a. Horizontal rules → visual separator
154
+ text = text.replace(new RegExp(`${PH_HR.replace(/\x00/g, '\\x00')}\\x00`, 'g'), '____________');
155
+
156
+ // 4b. Inline code → [CODE]...[/CODE]
157
+ text = text.replace(new RegExp(`${PH_ICODE.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
158
+ return `[CODE]${inlineCodes[Number(idx)]}[/CODE]`;
159
+ });
160
+
161
+ // 4b. Fenced/indented code blocks → [CODE]...[/CODE]
162
+ text = text.replace(new RegExp(`${PH_FCODE.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
163
+ return `[CODE]${fencedBlocks[Number(idx)]}[/CODE]`;
164
+ });
165
+
166
+ // 4c. Escape sequences → literal characters
167
+ text = text.replace(new RegExp(`${PH_ESC.replace(/\x00/g, '\\x00')}(\\d+)\\x00`, 'g'), (_m, idx: string) => {
168
+ return escapes[Number(idx)];
169
+ });
170
+
171
+ return text;
42
172
  }
43
173
 
44
174
  /**