@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 +1 -1
- package/src/api.ts +12 -0
- package/src/channel.ts +141 -27
- package/src/media-service.ts +15 -6
- package/src/message-utils.ts +165 -35
package/package.json
CHANGED
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:
|
|
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: '
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
827
|
+
welcomeText,
|
|
828
|
+
welcomeOpts,
|
|
732
829
|
);
|
|
733
|
-
}
|
|
734
|
-
|
|
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
|
},
|
package/src/media-service.ts
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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) {
|
package/src/message-utils.ts
CHANGED
|
@@ -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
|
|
11
|
+
* Convert Markdown (CommonMark + GFM subset) to Bitrix24 BB-code chat format.
|
|
5
12
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 — placeholders → BB-code equivalents
|
|
14
20
|
*/
|
|
15
21
|
export function markdownToBbCode(md: string): string {
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:  → [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
|
/**
|