@ihazz/bitrix24 0.2.0 → 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.2.0",
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/channel.ts CHANGED
@@ -38,6 +38,28 @@ 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
+
41
63
  // ─── Default command keyboard ────────────────────────────────────────────────
42
64
 
43
65
  /** Default keyboard shown with command responses and welcome messages. */
@@ -155,9 +177,16 @@ async function ensureBotRegistered(
155
177
 
156
178
  if (existing) {
157
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
+
158
187
  await api.updateBot(webhookUrl, existing.ID, {
159
188
  EVENT_HANDLER: callbackUrl,
160
- PROPERTIES: { NAME: name },
189
+ PROPERTIES: updateProps,
161
190
  });
162
191
  return existing.ID;
163
192
  }
@@ -174,7 +203,8 @@ async function ensureBotRegistered(
174
203
  PROPERTIES: {
175
204
  NAME: name,
176
205
  WORK_POSITION: 'AI Assistant',
177
- COLOR: 'AZURE',
206
+ COLOR: 'RED',
207
+ PERSONAL_PHOTO: config.botAvatar || DEFAULT_AVATAR_BASE64,
178
208
  },
179
209
  });
180
210
  logger.info(`Bot "${code}" registered (ID=${botId})`);
@@ -292,6 +322,7 @@ export const bitrix24Plugin = {
292
322
  reactions: false,
293
323
  threads: false,
294
324
  nativeCommands: true,
325
+ inlineButtons: 'all',
295
326
  },
296
327
 
297
328
  config: {
@@ -524,6 +555,21 @@ export const bitrix24Plugin = {
524
555
  MediaUrls: downloaded.map((m) => m.path),
525
556
  MediaTypes: downloaded.map((m) => m.contentType),
526
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;
527
573
  }
528
574
  }
529
575
 
@@ -558,7 +604,7 @@ export const bitrix24Plugin = {
558
604
  RawBody: body,
559
605
  From: `bitrix24:${msgCtx.chatId}`,
560
606
  To: `bitrix24:${msgCtx.chatId}`,
561
- SessionKey: route.sessionKey,
607
+ SessionKey: `${route.sessionKey}:bitrix24:${msgCtx.chatId}`,
562
608
  AccountId: route.accountId,
563
609
  ChatType: msgCtx.isDm ? 'direct' : 'group',
564
610
  ConversationLabel: msgCtx.senderName,
@@ -695,7 +741,7 @@ export const bitrix24Plugin = {
695
741
  CommandBody: commandText,
696
742
  CommandAuthorized: true,
697
743
  CommandSource: 'native',
698
- CommandTargetSessionKey: route.sessionKey,
744
+ CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${dialogId}`,
699
745
  From: `bitrix24:${dialogId}`,
700
746
  To: `slash:${senderId}`,
701
747
  SessionKey: slashSessionKey,
@@ -756,23 +802,50 @@ export const bitrix24Plugin = {
756
802
  },
757
803
 
758
804
  onJoinChat: async (event: B24JoinChatEvent) => {
805
+ const dialogId = event.data.PARAMS.DIALOG_ID;
806
+ const botEntry = Object.values(event.data.BOT)[0];
759
807
  logger.info('Bot joined chat', {
760
- dialogId: event.data.PARAMS.DIALOG_ID,
808
+ dialogId,
761
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,
762
814
  });
763
- const dialogId = event.data.PARAMS.DIALOG_ID;
764
- const botEntry = Object.values(event.data.BOT)[0];
765
- if (botEntry && dialogId) {
766
- 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) {
767
823
  await api.sendMessageWithToken(
768
824
  botEntry.client_endpoint,
769
825
  botEntry.access_token,
770
826
  dialogId,
771
- `${config.botName ?? 'OpenClaw'} ready. Send me a message or pick a command below.`,
772
- { KEYBOARD: DEFAULT_COMMAND_KEYBOARD },
827
+ welcomeText,
828
+ welcomeOpts,
773
829
  );
774
- } catch (err) {
775
- 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
+ }
776
849
  }
777
850
  }
778
851
  },
@@ -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
  /**