@iletai/nzb 1.4.0 → 1.4.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.
@@ -2,6 +2,7 @@ import { autoRetry } from "@grammyjs/auto-retry";
2
2
  import { Menu } from "@grammyjs/menu";
3
3
  import { Bot, InlineKeyboard, Keyboard } from "grammy";
4
4
  import { Agent as HttpsAgent } from "https";
5
+ import { rmSync } from "fs";
5
6
  import { config, persistEnvVar, persistModel } from "../config.js";
6
7
  import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
7
8
  import { listSkills } from "../copilot/skills.js";
@@ -19,6 +20,61 @@ function getUptimeStr() {
19
20
  const seconds = uptime % 60;
20
21
  return hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
21
22
  }
23
+ /**
24
+ * Send a formatted HTML reply with multi-chunk support and fallback to plain text.
25
+ * Consolidates the repeated toTelegramHTML → chunkMessage → fallback → send pattern.
26
+ */
27
+ async function sendFormattedReply(botInstance, chatId, text, opts) {
28
+ const formatted = toTelegramHTML(text);
29
+ const chunks = chunkMessage(formatted);
30
+ const fallbackChunks = chunkMessage(text);
31
+ let firstMsgId;
32
+ for (let i = 0; i < chunks.length; i++) {
33
+ if (i > 0)
34
+ await new Promise((r) => setTimeout(r, 300));
35
+ const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
36
+ const replyParams = i === 0 && opts?.replyTo ? { message_id: opts.replyTo } : undefined;
37
+ try {
38
+ const sent = await botInstance.api.sendMessage(chatId, pageTag + chunks[i], {
39
+ parse_mode: "HTML",
40
+ reply_parameters: replyParams,
41
+ });
42
+ if (i === 0)
43
+ firstMsgId = sent.message_id;
44
+ }
45
+ catch {
46
+ try {
47
+ const sent = await botInstance.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
48
+ reply_parameters: replyParams,
49
+ });
50
+ if (i === 0)
51
+ firstMsgId = sent.message_id;
52
+ }
53
+ catch { }
54
+ }
55
+ }
56
+ if (opts?.assistantLogId && firstMsgId) {
57
+ try {
58
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
59
+ setConversationTelegramMsgId(opts.assistantLogId, firstMsgId);
60
+ }
61
+ catch { }
62
+ }
63
+ try {
64
+ await botInstance.api.setMessageReaction(chatId, opts?.replyTo ?? 0, [{ type: "emoji", emoji: "👍" }]);
65
+ }
66
+ catch { }
67
+ return firstMsgId;
68
+ }
69
+ /** Remove a temp directory after a delay (gives orchestrator time to use the file). */
70
+ function scheduleTempCleanup(dirPath, delayMs = 5 * 60_000) {
71
+ setTimeout(() => {
72
+ try {
73
+ rmSync(dirPath, { recursive: true, force: true });
74
+ }
75
+ catch { }
76
+ }, delayMs);
77
+ }
22
78
  // Settings sub-menu
23
79
  const settingsMenu = new Menu("settings-menu")
24
80
  .text((ctx) => `${config.showReasoning ? "✅" : "❌"} Show Reasoning`, async (ctx) => {
@@ -164,10 +220,10 @@ export function createBot() {
164
220
  const chatId = ctx.chat.id;
165
221
  sendToOrchestrator(retryPrompt, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
166
222
  if (done) {
167
- const formatted = toTelegramHTML(text);
168
- const chunks = chunkMessage(formatted);
169
223
  void (async () => {
170
224
  try {
225
+ const formatted = toTelegramHTML(text);
226
+ const chunks = chunkMessage(formatted);
171
227
  await bot.api.editMessageText(chatId, originalMsg.message_id, chunks[0], { parse_mode: "HTML" });
172
228
  }
173
229
  catch {
@@ -187,21 +243,8 @@ export function createBot() {
187
243
  if (originalMsg && "text" in originalMsg && originalMsg.text) {
188
244
  const chatId = ctx.chat.id;
189
245
  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
- }
246
+ if (done)
247
+ void sendFormattedReply(bot, chatId, text);
205
248
  });
206
249
  }
207
250
  });
@@ -698,7 +741,6 @@ export function createBot() {
698
741
  await ctx.react("👀");
699
742
  }
700
743
  catch { }
701
- // Get the largest photo (last in array)
702
744
  const photo = ctx.message.photo[ctx.message.photo.length - 1];
703
745
  try {
704
746
  const file = await ctx.api.getFile(photo.file_id);
@@ -708,7 +750,6 @@ export function createBot() {
708
750
  return;
709
751
  }
710
752
  const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
711
- // Download to temp file
712
753
  const { mkdtempSync, writeFileSync } = await import("fs");
713
754
  const { join } = await import("path");
714
755
  const { tmpdir } = await import("os");
@@ -718,39 +759,11 @@ export function createBot() {
718
759
  const response = await fetch(url);
719
760
  const buffer = Buffer.from(await response.arrayBuffer());
720
761
  writeFileSync(localPath, buffer);
721
- // Send to orchestrator with image context
762
+ scheduleTempCleanup(tmpDir);
722
763
  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.`;
723
764
  sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
724
- if (done) {
725
- const formatted = toTelegramHTML(text);
726
- const chunks = chunkMessage(formatted);
727
- const fallbackChunks = chunkMessage(text);
728
- void (async () => {
729
- for (let i = 0; i < chunks.length; i++) {
730
- if (i > 0)
731
- await new Promise((r) => setTimeout(r, 300));
732
- const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
733
- try {
734
- await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
735
- parse_mode: "HTML",
736
- reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
737
- });
738
- }
739
- catch {
740
- try {
741
- await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
742
- reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
743
- });
744
- }
745
- catch { }
746
- }
747
- }
748
- try {
749
- await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
750
- }
751
- catch { }
752
- })();
753
- }
765
+ if (done)
766
+ void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
754
767
  });
755
768
  }
756
769
  catch (err) {
@@ -791,38 +804,11 @@ export function createBot() {
791
804
  const response = await fetch(url);
792
805
  const buffer = Buffer.from(await response.arrayBuffer());
793
806
  writeFileSync(localPath, buffer);
807
+ scheduleTempCleanup(tmpDir);
794
808
  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.`;
795
809
  sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
796
- if (done) {
797
- const formatted = toTelegramHTML(text);
798
- const chunks = chunkMessage(formatted);
799
- const fallbackChunks = chunkMessage(text);
800
- void (async () => {
801
- for (let i = 0; i < chunks.length; i++) {
802
- if (i > 0)
803
- await new Promise((r) => setTimeout(r, 300));
804
- const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
805
- try {
806
- await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
807
- parse_mode: "HTML",
808
- reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
809
- });
810
- }
811
- catch {
812
- try {
813
- await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
814
- reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
815
- });
816
- }
817
- catch { }
818
- }
819
- }
820
- try {
821
- await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
822
- }
823
- catch { }
824
- })();
825
- }
810
+ if (done)
811
+ void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
826
812
  });
827
813
  }
828
814
  catch (err) {
@@ -877,6 +863,7 @@ export function createBot() {
877
863
  const response = await fetch(url);
878
864
  const buffer = Buffer.from(await response.arrayBuffer());
879
865
  writeFileSync(localPath, buffer);
866
+ scheduleTempCleanup(tmpDir);
880
867
  let prompt;
881
868
  if (config.openaiApiKey) {
882
869
  // Transcribe using OpenAI Whisper API
@@ -911,49 +898,8 @@ export function createBot() {
911
898
  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]`;
912
899
  }
913
900
  sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
914
- if (done) {
915
- const assistantLogId = meta?.assistantLogId;
916
- const formatted = toTelegramHTML(text);
917
- const chunks = chunkMessage(formatted);
918
- const fallbackChunks = chunkMessage(text);
919
- void (async () => {
920
- let firstMsgId;
921
- for (let i = 0; i < chunks.length; i++) {
922
- if (i > 0)
923
- await new Promise((r) => setTimeout(r, 300));
924
- const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
925
- try {
926
- const sent = await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
927
- parse_mode: "HTML",
928
- reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
929
- });
930
- if (i === 0)
931
- firstMsgId = sent.message_id;
932
- }
933
- catch {
934
- try {
935
- const sent = await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
936
- reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
937
- });
938
- if (i === 0)
939
- firstMsgId = sent.message_id;
940
- }
941
- catch { }
942
- }
943
- }
944
- if (assistantLogId && firstMsgId) {
945
- try {
946
- const { setConversationTelegramMsgId } = await import("../store/db.js");
947
- setConversationTelegramMsgId(assistantLogId, firstMsgId);
948
- }
949
- catch { }
950
- }
951
- try {
952
- await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
953
- }
954
- catch { }
955
- })();
956
- }
901
+ if (done)
902
+ void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId, assistantLogId: meta?.assistantLogId });
957
903
  });
958
904
  }
959
905
  catch (err) {
@@ -962,6 +908,14 @@ export function createBot() {
962
908
  });
963
909
  }
964
910
  });
911
+ // Global error handler — prevents unhandled errors from crashing the bot
912
+ bot.catch((err) => {
913
+ const ctx = err.ctx;
914
+ const e = err.error;
915
+ const msg = e instanceof Error ? e.message : String(e);
916
+ console.error(`[nzb] Bot error for ${ctx?.update?.update_id}: ${msg}`);
917
+ void logError(`Bot error: ${msg.slice(0, 200)}`);
918
+ });
965
919
  return bot;
966
920
  }
967
921
  export async function startBot() {
@@ -1015,25 +969,7 @@ export async function stopBot() {
1015
969
  export async function sendProactiveMessage(text) {
1016
970
  if (!bot || config.authorizedUserId === undefined)
1017
971
  return;
1018
- const formatted = toTelegramHTML(text);
1019
- const chunks = chunkMessage(formatted);
1020
- const fallbackChunks = chunkMessage(text);
1021
- for (let i = 0; i < chunks.length; i++) {
1022
- if (i > 0)
1023
- await new Promise((r) => setTimeout(r, 300));
1024
- const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
1025
- try {
1026
- await bot.api.sendMessage(config.authorizedUserId, pageTag + chunks[i], { parse_mode: "HTML" });
1027
- }
1028
- catch {
1029
- try {
1030
- await bot.api.sendMessage(config.authorizedUserId, pageTag + (fallbackChunks[i] ?? chunks[i]));
1031
- }
1032
- catch {
1033
- // Bot may not be connected yet
1034
- }
1035
- }
1036
- }
972
+ await sendFormattedReply(bot, config.authorizedUserId, text);
1037
973
  }
1038
974
  /** Send a worker lifecycle notification to the authorized user. */
1039
975
  export async function sendWorkerNotification(message) {
@@ -107,19 +107,27 @@ export function toTelegramHTML(text) {
107
107
  out = out.replace(/^(\s*)[-*]\s+/gm, "$1• ");
108
108
  // 9. Ordered lists: keep as-is (1. 2. 3.)
109
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
110
+ out = out.replace(/~~(.+?)~~/g, (_m, inner) => stashToken(`<s>${escapeHtml(inner)}</s>`));
111
+ // 11. Bold+italic ***text*** → <b><i>text</i></b>
112
+ out = out.replace(/\*\*\*(.+?)\*\*\*/g, (_m, inner) => stashToken(`<b><i>${escapeHtml(inner)}</i></b>`));
113
+ // 12. Bold **text** → <b> (inner may contain stash tokens, preserve them)
114
+ out = out.replace(/\*\*(.+?)\*\*/g, (_m, inner) => {
115
+ const escaped = escapeHtml(inner.replace(/\x00S\d+\x00/g, (tok) => `\x00KEEP${tok}\x00KEEP`));
116
+ const restored = escaped.replace(/\x00KEEP\x00S(\d+)\x00\x00KEEP/g, (_m2, i) => stash[+i]);
117
+ return stashToken(`<b>${restored}</b>`);
118
+ });
119
+ // 13. Italic *text* → <i>
120
+ out = out.replace(/\*(.+?)\*/g, (_m, inner) => {
121
+ const escaped = escapeHtml(inner.replace(/\x00S\d+\x00/g, (tok) => `\x00KEEP${tok}\x00KEEP`));
122
+ const restored = escaped.replace(/\x00KEEP\x00S(\d+)\x00\x00KEEP/g, (_m2, i) => stash[+i]);
123
+ return stashToken(`<i>${restored}</i>`);
124
+ });
125
+ // 14. Underline __text__ → <u>
126
+ out = out.replace(/__(.+?)__/g, (_m, inner) => stashToken(`<u>${escapeHtml(inner)}</u>`));
127
+ // 15. Escape remaining plain text
118
128
  out = escapeHtml(out);
119
- // 15. Restore stashed tokens
129
+ // 16. Restore stashed tokens
120
130
  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));
123
131
  // 17. Clean up excessive blank lines
124
132
  out = out.replace(/\n{3,}/g, "\n\n");
125
133
  return out.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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"