@iletai/nzb 1.3.5 → 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.
@@ -395,14 +395,14 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
395
395
  processQueue();
396
396
  });
397
397
  // Deliver response to user FIRST, then log best-effort
398
- callback(finalContent, true);
399
398
  try {
400
399
  logMessage("out", sourceLabel, finalContent);
401
400
  }
402
401
  catch {
403
402
  /* best-effort */
404
403
  }
405
- // Log both sides of the conversation after delivery
404
+ // Log both sides of the conversation before delivery so we have the row ID
405
+ let assistantLogId;
406
406
  try {
407
407
  const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
408
408
  logConversation(logRole, prompt, sourceLabel, telegramMsgId);
@@ -411,11 +411,12 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
411
411
  /* best-effort */
412
412
  }
413
413
  try {
414
- logConversation("assistant", finalContent, sourceLabel);
414
+ assistantLogId = logConversation("assistant", finalContent, sourceLabel);
415
415
  }
416
416
  catch {
417
417
  /* best-effort */
418
418
  }
419
+ callback(finalContent, true, { assistantLogId });
419
420
  // Auto-continue: if the response was cut short by timeout, automatically
420
421
  // send a follow-up "Continue" message so the user doesn't have to
421
422
  if (finalContent.includes("⏱ Response was cut short (timeout)")) {
package/dist/store/db.js CHANGED
@@ -100,7 +100,6 @@ export function getDb() {
100
100
  removeMemory: db.prepare(`DELETE FROM memories WHERE id = ?`),
101
101
  memorySummary: db.prepare(`SELECT id, category, content FROM memories ORDER BY category, last_accessed DESC`),
102
102
  getConversationByMsgId: db.prepare(`SELECT id FROM conversation_log WHERE telegram_msg_id = ? LIMIT 1`),
103
- getConversationAround: db.prepare(`SELECT role, content, source, ts FROM conversation_log WHERE id BETWEEN ? - 4 AND ? + 4 ORDER BY id ASC`),
104
103
  };
105
104
  }
106
105
  return db;
@@ -119,23 +118,35 @@ export function deleteState(key) {
119
118
  getDb(); // ensure init
120
119
  stmtCache.deleteState.run(key);
121
120
  }
122
- /** Log a conversation turn (user, assistant, or system) with optional Telegram message ID. */
121
+ /** Log a conversation turn (user, assistant, or system) with optional Telegram message ID. Returns the row ID. */
123
122
  export function logConversation(role, content, source, telegramMsgId) {
124
123
  getDb(); // ensure init
125
- stmtCache.logConversation.run(role, content, source, telegramMsgId ?? null);
124
+ const result = stmtCache.logConversation.run(role, content, source, telegramMsgId ?? null);
126
125
  // Keep last 200 entries to support context recovery after session loss
127
126
  logInsertCount++;
128
127
  if (logInsertCount % 50 === 0) {
129
128
  stmtCache.pruneConversation.run();
130
129
  }
130
+ return result.lastInsertRowid;
131
131
  }
132
- /** Get conversation context around a Telegram message ID (±2 turns). */
132
+ /** Get conversation context around a Telegram message ID (±4 rows using proper subquery). */
133
133
  export function getConversationContext(telegramMsgId) {
134
- getDb(); // ensure init
134
+ const db = getDb();
135
135
  const row = stmtCache.getConversationByMsgId.get(telegramMsgId);
136
136
  if (!row)
137
137
  return undefined;
138
- const rows = stmtCache.getConversationAround.all(row.id, row.id);
138
+ // Fetch 4 rows before + the target + 4 rows after (handles ID gaps from pruning)
139
+ const rows = db.prepare(`
140
+ SELECT role, content, source, ts FROM (
141
+ SELECT * FROM conversation_log WHERE id < ? ORDER BY id DESC LIMIT 4
142
+ )
143
+ UNION ALL
144
+ SELECT role, content, source, ts FROM conversation_log WHERE id = ?
145
+ UNION ALL
146
+ SELECT role, content, source, ts FROM (
147
+ SELECT * FROM conversation_log WHERE id > ? ORDER BY id ASC LIMIT 4
148
+ )
149
+ `).all(row.id, row.id, row.id);
139
150
  if (rows.length === 0)
140
151
  return undefined;
141
152
  return rows
@@ -146,10 +157,10 @@ export function getConversationContext(telegramMsgId) {
146
157
  })
147
158
  .join("\n");
148
159
  }
149
- /** Update the most recent assistant log entry with its Telegram message ID (for reply-to lookups). */
150
- export function updateLastAssistantTelegramMsgId(telegramMsgId) {
160
+ /** Set Telegram message ID on a specific conversation_log row (race-free). */
161
+ export function setConversationTelegramMsgId(rowId, telegramMsgId) {
151
162
  const db = getDb();
152
- db.prepare(`UPDATE conversation_log SET telegram_msg_id = ? WHERE id = (SELECT id FROM conversation_log WHERE role = 'assistant' ORDER BY id DESC LIMIT 1)`).run(telegramMsgId);
163
+ db.prepare(`UPDATE conversation_log SET telegram_msg_id = ? WHERE id = ?`).run(telegramMsgId, rowId);
153
164
  }
154
165
  /** Get recent conversation history formatted for injection into system message. */
155
166
  export function getRecentConversation(limit = 20) {
@@ -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
  }
@@ -454,10 +511,11 @@ export function createBot() {
454
511
  userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
455
512
  }
456
513
  }
457
- sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
514
+ sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
458
515
  if (done) {
459
516
  finalized = true;
460
517
  stopTyping();
518
+ const assistantLogId = meta?.assistantLogId;
461
519
  const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
462
520
  void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
463
521
  // Wait for in-flight edits to finish before sending the final response
@@ -467,9 +525,10 @@ export function createBot() {
467
525
  if (isError) {
468
526
  void logError(`Response error: ${text.slice(0, 200)}`);
469
527
  const errorText = `⚠️ ${text}`;
528
+ const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
470
529
  if (placeholderMsgId) {
471
530
  try {
472
- await bot.api.editMessageText(chatId, placeholderMsgId, errorText);
531
+ await bot.api.editMessageText(chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
473
532
  return;
474
533
  }
475
534
  catch {
@@ -477,7 +536,7 @@ export function createBot() {
477
536
  }
478
537
  }
479
538
  try {
480
- await ctx.reply(errorText, { reply_parameters: replyParams });
539
+ await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
481
540
  }
482
541
  catch {
483
542
  /* nothing more we can do */
@@ -497,7 +556,7 @@ export function createBot() {
497
556
  parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
498
557
  textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
499
558
  }
500
- const formatted = toTelegramMarkdown(textWithMeta);
559
+ const formatted = toTelegramHTML(textWithMeta);
501
560
  let fullFormatted = formatted;
502
561
  if (config.showReasoning && toolHistory.length > 0) {
503
562
  const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })));
@@ -508,16 +567,18 @@ export function createBot() {
508
567
  // Single chunk: edit placeholder in place
509
568
  if (placeholderMsgId && chunks.length === 1) {
510
569
  try {
511
- await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], { parse_mode: "MarkdownV2" });
570
+ await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], { parse_mode: "HTML" });
512
571
  try {
513
572
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
514
573
  }
515
574
  catch { }
516
- try {
517
- const { updateLastAssistantTelegramMsgId } = await import("../store/db.js");
518
- updateLastAssistantTelegramMsgId(placeholderMsgId);
575
+ if (assistantLogId) {
576
+ try {
577
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
578
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
579
+ }
580
+ catch { }
519
581
  }
520
- catch { }
521
582
  return;
522
583
  }
523
584
  catch {
@@ -527,11 +588,13 @@ export function createBot() {
527
588
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
528
589
  }
529
590
  catch { }
530
- try {
531
- const { updateLastAssistantTelegramMsgId } = await import("../store/db.js");
532
- updateLastAssistantTelegramMsgId(placeholderMsgId);
591
+ if (assistantLogId) {
592
+ try {
593
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
594
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
595
+ }
596
+ catch { }
533
597
  }
534
- catch { }
535
598
  return;
536
599
  }
537
600
  catch {
@@ -541,16 +604,19 @@ export function createBot() {
541
604
  }
542
605
  // Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
543
606
  const totalChunks = chunks.length;
607
+ let firstSentMsgId;
544
608
  const sendChunk = async (chunk, fallback, index) => {
545
609
  const isFirst = index === 0 && !placeholderMsgId;
546
610
  // Pagination header for multi-chunk messages
547
611
  const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
548
612
  const opts = isFirst
549
- ? { parse_mode: "MarkdownV2", reply_parameters: replyParams }
550
- : { parse_mode: "MarkdownV2" };
551
- await ctx
613
+ ? { parse_mode: "HTML", reply_parameters: replyParams }
614
+ : { parse_mode: "HTML" };
615
+ const sent = await ctx
552
616
  .reply(pageTag + chunk, opts)
553
617
  .catch(() => ctx.reply(pageTag + fallback, isFirst ? { reply_parameters: replyParams } : {}));
618
+ if (index === 0 && sent)
619
+ firstSentMsgId = sent.message_id;
554
620
  };
555
621
  let sendSucceeded = false;
556
622
  try {
@@ -567,7 +633,9 @@ export function createBot() {
567
633
  if (i > 0)
568
634
  await new Promise((r) => setTimeout(r, 300));
569
635
  const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
570
- await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
636
+ const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
637
+ if (i === 0 && sent)
638
+ firstSentMsgId = sent.message_id;
571
639
  }
572
640
  sendSucceeded = true;
573
641
  }
@@ -584,6 +652,15 @@ export function createBot() {
584
652
  /* ignore — placeholder stays but user has the real message */
585
653
  }
586
654
  }
655
+ // Track bot message ID for reply-to context lookups
656
+ const botMsgId = firstSentMsgId ?? placeholderMsgId;
657
+ if (assistantLogId && botMsgId) {
658
+ try {
659
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
660
+ setConversationTelegramMsgId(assistantLogId, botMsgId);
661
+ }
662
+ catch { }
663
+ }
587
664
  // React ✅ on the user's original message to signal completion
588
665
  try {
589
666
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
@@ -645,7 +722,7 @@ export function createBot() {
645
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.`;
646
723
  sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
647
724
  if (done) {
648
- const formatted = toTelegramMarkdown(text);
725
+ const formatted = toTelegramHTML(text);
649
726
  const chunks = chunkMessage(formatted);
650
727
  const fallbackChunks = chunkMessage(text);
651
728
  void (async () => {
@@ -655,7 +732,7 @@ export function createBot() {
655
732
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
656
733
  try {
657
734
  await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
658
- parse_mode: "MarkdownV2",
735
+ parse_mode: "HTML",
659
736
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
660
737
  });
661
738
  }
@@ -717,7 +794,7 @@ export function createBot() {
717
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.`;
718
795
  sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
719
796
  if (done) {
720
- const formatted = toTelegramMarkdown(text);
797
+ const formatted = toTelegramHTML(text);
721
798
  const chunks = chunkMessage(formatted);
722
799
  const fallbackChunks = chunkMessage(text);
723
800
  void (async () => {
@@ -727,7 +804,7 @@ export function createBot() {
727
804
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
728
805
  try {
729
806
  await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
730
- parse_mode: "MarkdownV2",
807
+ parse_mode: "HTML",
731
808
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
732
809
  });
733
810
  }
@@ -769,6 +846,20 @@ export function createBot() {
769
846
  await ctx.reply("❌ Voice too long (max 5 min).", { reply_parameters: { message_id: userMessageId } });
770
847
  return;
771
848
  }
849
+ // If voice is a reply, include context
850
+ let voiceReplyContext = "";
851
+ const voiceReplyMsg = ctx.message.reply_to_message;
852
+ if (voiceReplyMsg && "text" in voiceReplyMsg && voiceReplyMsg.text) {
853
+ const { getConversationContext } = await import("../store/db.js");
854
+ const context = getConversationContext(voiceReplyMsg.message_id);
855
+ if (context) {
856
+ voiceReplyContext = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n`;
857
+ }
858
+ else {
859
+ const quoted = voiceReplyMsg.text.length > 500 ? voiceReplyMsg.text.slice(0, 500) + "…" : voiceReplyMsg.text;
860
+ voiceReplyContext = `[Replying to: "${quoted}"]\n\n`;
861
+ }
862
+ }
772
863
  try {
773
864
  const file = await ctx.api.getFile(ctx.message.voice.file_id);
774
865
  const filePath = file.file_path;
@@ -819,31 +910,44 @@ export function createBot() {
819
910
  else {
820
911
  prompt = `[User sent a voice message (${duration}s), saved at: ${localPath}. No OPENAI_API_KEY configured for transcription. You can tell the user to set it up in ~/.nzb/.env]`;
821
912
  }
822
- sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
913
+ sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
823
914
  if (done) {
824
- const formatted = toTelegramMarkdown(text);
915
+ const assistantLogId = meta?.assistantLogId;
916
+ const formatted = toTelegramHTML(text);
825
917
  const chunks = chunkMessage(formatted);
826
918
  const fallbackChunks = chunkMessage(text);
827
919
  void (async () => {
920
+ let firstMsgId;
828
921
  for (let i = 0; i < chunks.length; i++) {
829
922
  if (i > 0)
830
923
  await new Promise((r) => setTimeout(r, 300));
831
924
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
832
925
  try {
833
- await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
834
- parse_mode: "MarkdownV2",
926
+ const sent = await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
927
+ parse_mode: "HTML",
835
928
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
836
929
  });
930
+ if (i === 0)
931
+ firstMsgId = sent.message_id;
837
932
  }
838
933
  catch {
839
934
  try {
840
- await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
935
+ const sent = await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
841
936
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
842
937
  });
938
+ if (i === 0)
939
+ firstMsgId = sent.message_id;
843
940
  }
844
941
  catch { }
845
942
  }
846
943
  }
944
+ if (assistantLogId && firstMsgId) {
945
+ try {
946
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
947
+ setConversationTelegramMsgId(assistantLogId, firstMsgId);
948
+ }
949
+ catch { }
950
+ }
847
951
  try {
848
952
  await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
849
953
  }
@@ -911,7 +1015,7 @@ export async function stopBot() {
911
1015
  export async function sendProactiveMessage(text) {
912
1016
  if (!bot || config.authorizedUserId === undefined)
913
1017
  return;
914
- const formatted = toTelegramMarkdown(text);
1018
+ const formatted = toTelegramHTML(text);
915
1019
  const chunks = chunkMessage(formatted);
916
1020
  const fallbackChunks = chunkMessage(text);
917
1021
  for (let i = 0; i < chunks.length; i++) {
@@ -919,7 +1023,7 @@ export async function sendProactiveMessage(text) {
919
1023
  await new Promise((r) => setTimeout(r, 300));
920
1024
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
921
1025
  try {
922
- 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" });
923
1027
  }
924
1028
  catch {
925
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.5",
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"