@iletai/nzb 1.3.6 → 1.4.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,13 +1,13 @@
1
1
  import { autoRetry } from "@grammyjs/auto-retry";
2
2
  import { Menu } from "@grammyjs/menu";
3
- import { Bot, Keyboard } from "grammy";
3
+ import { Bot, InlineKeyboard, Keyboard } from "grammy";
4
4
  import { Agent as HttpsAgent } from "https";
5
5
  import { config, persistEnvVar, persistModel } from "../config.js";
6
6
  import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
7
7
  import { listSkills } from "../copilot/skills.js";
8
8
  import { restartDaemon } from "../daemon.js";
9
9
  import { searchMemories } from "../store/db.js";
10
- import { chunkMessage, formatToolSummaryExpandable, toTelegramMarkdown } from "./formatter.js";
10
+ import { chunkMessage, escapeHtml, formatToolSummaryExpandable, toTelegramHTML } from "./formatter.js";
11
11
  import { initLogChannel, logDebug, logError, logInfo } from "./log-channel.js";
12
12
  let bot;
13
13
  const startedAt = Date.now();
@@ -125,9 +125,7 @@ function formatMemoryList(memories) {
125
125
  });
126
126
  return `🧠 <b>${memories.length} memories</b>\n\n${sections.join("\n\n")}`;
127
127
  }
128
- function escapeHtml(text) {
129
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
130
- }
128
+ // escapeHtml is imported from formatter.ts
131
129
  export function createBot() {
132
130
  if (!config.telegramBotToken) {
133
131
  throw new Error("Telegram bot token is missing. Run 'nzb setup' and enter the bot token from @BotFather.");
@@ -157,6 +155,56 @@ export function createBot() {
157
155
  });
158
156
  // Register interactive menu plugin
159
157
  bot.use(mainMenu);
158
+ // Callback handlers for contextual inline buttons
159
+ bot.callbackQuery("retry", async (ctx) => {
160
+ await ctx.answerCallbackQuery({ text: "Retrying..." });
161
+ const originalMsg = ctx.callbackQuery.message;
162
+ if (originalMsg?.reply_to_message && "text" in originalMsg.reply_to_message && originalMsg.reply_to_message.text) {
163
+ const retryPrompt = originalMsg.reply_to_message.text;
164
+ const chatId = ctx.chat.id;
165
+ sendToOrchestrator(retryPrompt, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
166
+ if (done) {
167
+ const formatted = toTelegramHTML(text);
168
+ const chunks = chunkMessage(formatted);
169
+ void (async () => {
170
+ try {
171
+ await bot.api.editMessageText(chatId, originalMsg.message_id, chunks[0], { parse_mode: "HTML" });
172
+ }
173
+ catch {
174
+ try {
175
+ await ctx.reply(text);
176
+ }
177
+ catch { }
178
+ }
179
+ })();
180
+ }
181
+ });
182
+ }
183
+ });
184
+ bot.callbackQuery("explain_error", async (ctx) => {
185
+ await ctx.answerCallbackQuery({ text: "Explaining..." });
186
+ const originalMsg = ctx.callbackQuery.message;
187
+ if (originalMsg && "text" in originalMsg && originalMsg.text) {
188
+ const chatId = ctx.chat.id;
189
+ sendToOrchestrator(`Explain this error in simple terms and suggest a fix:\n${originalMsg.text}`, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
190
+ if (done) {
191
+ const formatted = toTelegramHTML(text);
192
+ const chunks = chunkMessage(formatted);
193
+ void (async () => {
194
+ try {
195
+ await ctx.reply(chunks[0], { parse_mode: "HTML" });
196
+ }
197
+ catch {
198
+ try {
199
+ await ctx.reply(text);
200
+ }
201
+ catch { }
202
+ }
203
+ })();
204
+ }
205
+ });
206
+ }
207
+ });
160
208
  // Persistent reply keyboard — quick actions always visible below chat input
161
209
  const replyKeyboard = new Keyboard()
162
210
  .text("📊 Status").text("❌ Cancel").row()
@@ -402,8 +450,9 @@ export function createBot() {
402
450
  void logDebug(`🔧 Tool start: ${event.toolName}${event.detail ? ` — ${event.detail}` : ""}`);
403
451
  currentToolName = event.toolName;
404
452
  toolHistory.push({ name: event.toolName, startTime: Date.now(), detail: event.detail });
453
+ const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
405
454
  const existingText = lastEditedText.replace(/^🔧 .*\n\n/, "");
406
- enqueueEdit(`🔧 ${event.toolName}\n\n${existingText}`.trim() || `🔧 ${event.toolName}`);
455
+ enqueueEdit(`🔧 ${event.toolName} (${elapsed}s...)\n\n${existingText}`.trim() || `🔧 ${event.toolName}`);
407
456
  }
408
457
  else if (event.type === "tool_complete") {
409
458
  for (let i = toolHistory.length - 1; i >= 0; i--) {
@@ -412,14 +461,22 @@ export function createBot() {
412
461
  break;
413
462
  }
414
463
  }
464
+ // Show completion with checkmark
465
+ const completedTool = toolHistory.find((t) => t.name === event.toolName && t.durationMs !== undefined);
466
+ if (completedTool) {
467
+ const dur = (completedTool.durationMs / 1000).toFixed(1);
468
+ const existingText = lastEditedText.replace(/^🔧 .*\n\n/, "").replace(/^✅ .*\n\n/, "");
469
+ enqueueEdit(`✅ ${event.toolName} (${dur}s)\n\n${existingText}`.trim());
470
+ }
415
471
  currentToolName = undefined;
416
472
  }
417
473
  else if (event.type === "tool_partial_result" && event.detail) {
418
474
  const now = Date.now();
419
475
  if (now - lastEditTime >= EDIT_INTERVAL_MS) {
420
476
  lastEditTime = now;
477
+ const elapsed = ((now - handlerStartTime) / 1000).toFixed(1);
421
478
  const truncated = event.detail.length > 500 ? "⋯\n" + event.detail.slice(-500) : event.detail;
422
- const toolLine = `🔧 ${currentToolName || event.toolName}\n\`\`\`\n${truncated}\n\`\`\``;
479
+ const toolLine = `🔧 ${currentToolName || event.toolName} (${elapsed}s...)\n<pre>${escapeHtml(truncated)}</pre>`;
423
480
  enqueueEdit(toolLine);
424
481
  }
425
482
  }
@@ -468,9 +525,10 @@ export function createBot() {
468
525
  if (isError) {
469
526
  void logError(`Response error: ${text.slice(0, 200)}`);
470
527
  const errorText = `⚠️ ${text}`;
528
+ const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
471
529
  if (placeholderMsgId) {
472
530
  try {
473
- await bot.api.editMessageText(chatId, placeholderMsgId, errorText);
531
+ await bot.api.editMessageText(chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
474
532
  return;
475
533
  }
476
534
  catch {
@@ -478,7 +536,7 @@ export function createBot() {
478
536
  }
479
537
  }
480
538
  try {
481
- await ctx.reply(errorText, { reply_parameters: replyParams });
539
+ await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
482
540
  }
483
541
  catch {
484
542
  /* nothing more we can do */
@@ -498,7 +556,7 @@ export function createBot() {
498
556
  parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
499
557
  textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
500
558
  }
501
- const formatted = toTelegramMarkdown(textWithMeta);
559
+ const formatted = toTelegramHTML(textWithMeta);
502
560
  let fullFormatted = formatted;
503
561
  if (config.showReasoning && toolHistory.length > 0) {
504
562
  const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })));
@@ -509,7 +567,7 @@ export function createBot() {
509
567
  // Single chunk: edit placeholder in place
510
568
  if (placeholderMsgId && chunks.length === 1) {
511
569
  try {
512
- await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], { parse_mode: "MarkdownV2" });
570
+ await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], { parse_mode: "HTML" });
513
571
  try {
514
572
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
515
573
  }
@@ -552,8 +610,8 @@ export function createBot() {
552
610
  // Pagination header for multi-chunk messages
553
611
  const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
554
612
  const opts = isFirst
555
- ? { parse_mode: "MarkdownV2", reply_parameters: replyParams }
556
- : { parse_mode: "MarkdownV2" };
613
+ ? { parse_mode: "HTML", reply_parameters: replyParams }
614
+ : { parse_mode: "HTML" };
557
615
  const sent = await ctx
558
616
  .reply(pageTag + chunk, opts)
559
617
  .catch(() => ctx.reply(pageTag + fallback, isFirst ? { reply_parameters: replyParams } : {}));
@@ -664,7 +722,7 @@ export function createBot() {
664
722
  const prompt = `[User sent a photo saved at: ${localPath}]\n\nCaption: ${caption}\n\nPlease analyze this image. The file is at ${localPath} — you can use bash to view it with tools if needed.`;
665
723
  sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
666
724
  if (done) {
667
- const formatted = toTelegramMarkdown(text);
725
+ const formatted = toTelegramHTML(text);
668
726
  const chunks = chunkMessage(formatted);
669
727
  const fallbackChunks = chunkMessage(text);
670
728
  void (async () => {
@@ -674,7 +732,7 @@ export function createBot() {
674
732
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
675
733
  try {
676
734
  await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
677
- parse_mode: "MarkdownV2",
735
+ parse_mode: "HTML",
678
736
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
679
737
  });
680
738
  }
@@ -736,7 +794,7 @@ export function createBot() {
736
794
  const prompt = `[User sent a file: ${doc.file_name || "unknown"} (${doc.file_size || 0} bytes), saved at: ${localPath}]\n\nCaption: ${caption}\n\nPlease analyze this file. You can read it with bash tools.`;
737
795
  sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
738
796
  if (done) {
739
- const formatted = toTelegramMarkdown(text);
797
+ const formatted = toTelegramHTML(text);
740
798
  const chunks = chunkMessage(formatted);
741
799
  const fallbackChunks = chunkMessage(text);
742
800
  void (async () => {
@@ -746,7 +804,7 @@ export function createBot() {
746
804
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
747
805
  try {
748
806
  await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
749
- parse_mode: "MarkdownV2",
807
+ parse_mode: "HTML",
750
808
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
751
809
  });
752
810
  }
@@ -855,7 +913,7 @@ export function createBot() {
855
913
  sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
856
914
  if (done) {
857
915
  const assistantLogId = meta?.assistantLogId;
858
- const formatted = toTelegramMarkdown(text);
916
+ const formatted = toTelegramHTML(text);
859
917
  const chunks = chunkMessage(formatted);
860
918
  const fallbackChunks = chunkMessage(text);
861
919
  void (async () => {
@@ -866,7 +924,7 @@ export function createBot() {
866
924
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
867
925
  try {
868
926
  const sent = await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
869
- parse_mode: "MarkdownV2",
927
+ parse_mode: "HTML",
870
928
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
871
929
  });
872
930
  if (i === 0)
@@ -957,7 +1015,7 @@ export async function stopBot() {
957
1015
  export async function sendProactiveMessage(text) {
958
1016
  if (!bot || config.authorizedUserId === undefined)
959
1017
  return;
960
- const formatted = toTelegramMarkdown(text);
1018
+ const formatted = toTelegramHTML(text);
961
1019
  const chunks = chunkMessage(formatted);
962
1020
  const fallbackChunks = chunkMessage(text);
963
1021
  for (let i = 0; i < chunks.length; i++) {
@@ -965,7 +1023,7 @@ export async function sendProactiveMessage(text) {
965
1023
  await new Promise((r) => setTimeout(r, 300));
966
1024
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
967
1025
  try {
968
- await bot.api.sendMessage(config.authorizedUserId, pageTag + chunks[i], { parse_mode: "MarkdownV2" });
1026
+ await bot.api.sendMessage(config.authorizedUserId, pageTag + chunks[i], { parse_mode: "HTML" });
969
1027
  }
970
1028
  catch {
971
1029
  try {
@@ -1,10 +1,13 @@
1
1
  const TELEGRAM_MAX_LENGTH = 4096;
2
- // Reserve space for code block closure markers and pagination prefix
3
- const CHUNK_TARGET = TELEGRAM_MAX_LENGTH - 20;
2
+ // Reserve space for tag closure and pagination prefix
3
+ const CHUNK_TARGET = TELEGRAM_MAX_LENGTH - 40;
4
+ /** Escape HTML special characters. */
5
+ export function escapeHtml(text) {
6
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7
+ }
4
8
  /**
5
9
  * Split a long message into chunks that fit within Telegram's message limit.
6
- * Code-block-aware: if a split falls inside a fenced code block, the block is
7
- * closed at the split and reopened in the next chunk so MarkdownV2 stays valid.
10
+ * HTML-aware: if a split falls inside a <pre> block, close and reopen it.
8
11
  */
9
12
  export function chunkMessage(text) {
10
13
  if (text.length <= TELEGRAM_MAX_LENGTH) {
@@ -25,12 +28,13 @@ export function chunkMessage(text) {
25
28
  splitAt = CHUNK_TARGET;
26
29
  }
27
30
  const segment = remaining.slice(0, splitAt);
28
- // Count ``` markersodd means we're splitting inside a code block
29
- const markers = segment.match(/```/g);
30
- const insideCodeBlock = markers !== null && markers.length % 2 !== 0;
31
+ // Count <pre> vs </pre> mismatch means we're inside a code block
32
+ const opens = (segment.match(/<pre/g) || []).length;
33
+ const closes = (segment.match(/<\/pre>/g) || []).length;
34
+ const insideCodeBlock = opens > closes;
31
35
  if (insideCodeBlock) {
32
- chunks.push(segment + "\n```");
33
- remaining = "```\n" + remaining.slice(splitAt).trimStart();
36
+ chunks.push(segment + "\n</pre>");
37
+ remaining = "<pre>" + remaining.slice(splitAt).trimStart();
34
38
  }
35
39
  else {
36
40
  chunks.push(segment);
@@ -40,20 +44,7 @@ export function chunkMessage(text) {
40
44
  return chunks;
41
45
  }
42
46
  /**
43
- * Escape special characters for Telegram MarkdownV2 plain text segments.
44
- */
45
- export function escapeSegment(text) {
46
- return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
47
- }
48
- /**
49
- * Escape only characters needed inside a MarkdownV2 link URL.
50
- */
51
- function escapeLinkUrl(url) {
52
- return url.replace(/([)\\])/g, "\\$1");
53
- }
54
- /**
55
- * Convert a markdown table into a readable mobile-friendly list.
56
- * Returns already-escaped MarkdownV2 text ready to be stashed.
47
+ * Convert a markdown table into a readable mobile-friendly HTML list.
57
48
  */
58
49
  function convertTable(table) {
59
50
  const rows = table
@@ -71,100 +62,84 @@ function convertTable(table) {
71
62
  .map((cols) => {
72
63
  if (cols.length === 0)
73
64
  return "";
74
- const first = `*${escapeSegment(cols[0])}*`;
65
+ const first = `<b>${escapeHtml(cols[0])}</b>`;
75
66
  const rest = cols
76
67
  .slice(1)
77
- .map((c) => escapeSegment(c))
68
+ .map((c) => escapeHtml(c))
78
69
  .join(" · ");
79
70
  return rest ? `${first} — ${rest}` : first;
80
71
  })
81
72
  .join("\n");
82
73
  }
83
74
  /**
84
- * Convert standard markdown from the AI into Telegram MarkdownV2.
75
+ * Convert standard markdown from the AI into Telegram HTML.
85
76
  * Handles bold, italic, strikethrough, links, lists, blockquotes,
86
77
  * code blocks, headers, tables, and horizontal rules.
87
78
  */
88
- export function toTelegramMarkdown(text) {
79
+ export function toTelegramHTML(text) {
89
80
  const stash = [];
90
81
  const stashToken = (s) => {
91
82
  stash.push(s);
92
- return `\x00STASH${stash.length - 1}\x00`;
83
+ return `\x00S${stash.length - 1}\x00`;
93
84
  };
94
85
  let out = text;
95
- // 1. Stash fenced code blocks
96
- out = out.replace(/```([a-z]*)\n?([\s\S]*?)```/g, (_m, lang, code) => stashToken("```" + (lang || "") + "\n" + code.trim() + "\n```"));
97
- // 2. Stash inline code
98
- out = out.replace(/`([^`\n]+)`/g, (_m, code) => stashToken("`" + code + "`"));
99
- // 3. Stash markdown links — [text](url) → MarkdownV2 link
100
- out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => stashToken(`[${escapeSegment(linkText)}](${escapeLinkUrl(url)})`));
101
- // 4. Convert tables stash to avoid double-escaping
86
+ // 1. Stash fenced code blocks → <pre><code>
87
+ out = out.replace(/```([a-z]*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
88
+ const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
89
+ return stashToken(`<pre><code${cls}>${escapeHtml(code.trim())}</code></pre>`);
90
+ });
91
+ // 2. Stash inline code <code>
92
+ out = out.replace(/`([^`\n]+)`/g, (_m, code) => stashToken(`<code>${escapeHtml(code)}</code>`));
93
+ // 3. Stash markdown links → <a href>
94
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => stashToken(`<a href="${escapeHtml(url)}">${escapeHtml(linkText)}</a>`));
95
+ // 4. Convert tables
102
96
  out = out.replace(/(?:^\|.+\|[ \t]*$\n?)+/gm, (table) => stashToken(convertTable(table) + "\n"));
103
97
  // 5. Convert headers → bold
104
98
  out = out.replace(/^#{1,6}\s+(.+)$/gm, (_m, title) => `**${title.trim()}**`);
105
99
  // 6. Remove horizontal rules
106
100
  out = out.replace(/^[-*_]{3,}\s*$/gm, "");
107
- // 7. Convert blockquotes: > text MarkdownV2 blockquote (stash > to avoid escaping)
108
- out = out.replace(/^>\s?(.*)$/gm, (_m, content) => stashToken(">") + content);
109
- // 8. Convert unordered lists: - item or * item → • item
110
- out = out.replace(/^(\s*)[-*]\s+/gm, "$1• ");
111
- // 9. Convert ordered lists: 1. item → 1\) item (stash \) to avoid double-escaping)
112
- out = out.replace(/^(\s*)(\d+)\.\s+/gm, (_m, spaces, num) => spaces + num + stashToken("\\) "));
113
- // 10. Extract strikethrough before escaping
114
- const strikeParts = [];
115
- out = out.replace(/~~(.+?)~~/g, (_m, inner) => {
116
- strikeParts.push(inner);
117
- return `\x00STRIKE${strikeParts.length - 1}\x00`;
118
- });
119
- // 11. Extract bold markers before escaping
120
- const boldParts = [];
121
- out = out.replace(/\*\*(.+?)\*\*/g, (_m, inner) => {
122
- boldParts.push(inner);
123
- return `\x00BOLD${boldParts.length - 1}\x00`;
101
+ // 7. Blockquotes<blockquote>
102
+ out = out.replace(/(?:^>\s?(.*)$\n?)+/gm, (block) => {
103
+ const content = block.replace(/^>\s?/gm, "").trim();
104
+ return stashToken(`<blockquote>${escapeHtml(content)}</blockquote>`);
124
105
  });
125
- // 12. Extract italic markers before escaping
126
- const italicParts = [];
127
- out = out.replace(/\*(.+?)\*/g, (_m, inner) => {
128
- italicParts.push(inner);
129
- return `\x00ITALIC${italicParts.length - 1}\x00`;
130
- });
131
- // 13. Extract underline markers before escaping
132
- const underlineParts = [];
133
- out = out.replace(/__(.+?)__/g, (_m, inner) => {
134
- underlineParts.push(inner);
135
- return `\x00UNDERLINE${underlineParts.length - 1}\x00`;
136
- });
137
- // 14. Escape everything that remains
138
- out = escapeSegment(out);
139
- // 15. Restore formatting with escaped inner text
140
- out = out.replace(/\x00STRIKE(\d+)\x00/g, (_m, i) => `~${escapeSegment(strikeParts[+i])}~`);
141
- out = out.replace(/\x00BOLD(\d+)\x00/g, (_m, i) => `*${escapeSegment(boldParts[+i])}*`);
142
- out = out.replace(/\x00ITALIC(\d+)\x00/g, (_m, i) => `_${escapeSegment(italicParts[+i])}_`);
143
- out = out.replace(/\x00UNDERLINE(\d+)\x00/g, (_m, i) => `__${escapeSegment(underlineParts[+i])}__`);
144
- // 16. Restore stashed code blocks, inline code, links, tables
145
- out = out.replace(/\x00STASH(\d+)\x00/g, (_m, i) => stash[+i]);
106
+ // 8. Unordered lists: - item or * item → • item
107
+ out = out.replace(/^(\s*)[-*]\s+/gm, "$1• ");
108
+ // 9. Ordered lists: keep as-is (1. 2. 3.)
109
+ // 10. Strikethrough ~~text~~ → <s>
110
+ out = out.replace(/~~(.+?)~~/g, (_m, inner) => stashToken(`<s>\x00ESC${inner}\x00ESC</s>`));
111
+ // 11. Bold **text** → <b>
112
+ out = out.replace(/\*\*(.+?)\*\*/g, (_m, inner) => stashToken(`<b>\x00ESC${inner}\x00ESC</b>`));
113
+ // 12. Italic *text* → <i>
114
+ out = out.replace(/\*(.+?)\*/g, (_m, inner) => stashToken(`<i>\x00ESC${inner}\x00ESC</i>`));
115
+ // 13. Underline __text__ → <u>
116
+ out = out.replace(/__(.+?)__/g, (_m, inner) => stashToken(`<u>\x00ESC${inner}\x00ESC</u>`));
117
+ // 14. Escape remaining plain text
118
+ out = escapeHtml(out);
119
+ // 15. Restore stashed tokens
120
+ out = out.replace(/\x00S(\d+)\x00/g, (_m, i) => stash[+i]);
121
+ // 16. Escape inner text of formatting tags (marked with ESC)
122
+ out = out.replace(/\x00ESC([\s\S]*?)\x00ESC/g, (_m, inner) => escapeHtml(inner));
146
123
  // 17. Clean up excessive blank lines
147
124
  out = out.replace(/\n{3,}/g, "\n\n");
148
125
  return out.trim();
149
126
  }
127
+ /** @deprecated Use toTelegramHTML instead. Kept for backward compatibility. */
128
+ export const toTelegramMarkdown = toTelegramHTML;
129
+ export const escapeSegment = escapeHtml;
150
130
  /**
151
- * Format tool call info as a Telegram MarkdownV2 expandable blockquote.
152
- * First line (title) is always visible, tool list expands on tap.
131
+ * Format tool call info as Telegram HTML expandable blockquote.
132
+ * First line visible, tool list expands on tap.
153
133
  */
154
134
  export function formatToolSummaryExpandable(toolCalls) {
155
135
  if (toolCalls.length === 0)
156
136
  return "";
157
137
  const lines = toolCalls.map((t) => {
158
- const name = escapeSegment(t.name);
159
- const dur = t.durationMs !== undefined
160
- ? ` \\(${escapeSegment((t.durationMs / 1000).toFixed(1) + "s")}\\)`
161
- : "";
162
- const detail = t.detail ? `\n> _${escapeSegment(t.detail.slice(0, 60))}_` : "";
163
- return `${escapeSegment("• ")}${name}${dur}${detail}`;
138
+ const name = escapeHtml(t.name);
139
+ const dur = t.durationMs !== undefined ? ` (${(t.durationMs / 1000).toFixed(1)}s)` : "";
140
+ const detail = t.detail ? `\n <i>${escapeHtml(t.detail.slice(0, 60))}</i>` : "";
141
+ return `• ${name}${dur}${detail}`;
164
142
  });
165
- const header = escapeSegment("🔧 Tools used:");
166
- const toolList = lines.join(`\n>`);
167
- // Expandable: header visible, tool list hidden until tapped
168
- return `\n\n**>${header}\n>${toolList}||`;
143
+ return `\n\n<blockquote expandable>🔧 Tools used:\n${lines.join("\n")}</blockquote>`;
169
144
  }
170
145
  //# sourceMappingURL=formatter.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.3.6",
3
+ "version": "1.4.0",
4
4
  "description": "NZB — a personal AI assistant for developers, built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "nzb": "dist/cli.js"