@iletai/nzb 1.4.3 → 1.4.5

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,13 +2,15 @@ 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";
6
5
  import { config, persistEnvVar, persistModel } from "../config.js";
7
6
  import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
8
7
  import { listSkills } from "../copilot/skills.js";
9
8
  import { restartDaemon } from "../daemon.js";
10
9
  import { searchMemories } from "../store/db.js";
11
10
  import { chunkMessage, escapeHtml, formatToolSummaryExpandable, toTelegramHTML } from "./formatter.js";
11
+ import { registerCallbackHandlers } from "./handlers/callbacks.js";
12
+ import { sendFormattedReply } from "./handlers/helpers.js";
13
+ import { registerMediaHandlers } from "./handlers/media.js";
12
14
  import { initLogChannel, logDebug, logError, logInfo } from "./log-channel.js";
13
15
  let bot;
14
16
  const startedAt = Date.now();
@@ -20,61 +22,6 @@ function getUptimeStr() {
20
22
  const seconds = uptime % 60;
21
23
  return hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
22
24
  }
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
- }
78
25
  // Settings sub-menu
79
26
  const settingsMenu = new Menu("settings-menu")
80
27
  .text((ctx) => `${config.showReasoning ? "✅" : "❌"} Show Reasoning`, async (ctx) => {
@@ -211,43 +158,8 @@ export function createBot() {
211
158
  });
212
159
  // Register interactive menu plugin
213
160
  bot.use(mainMenu);
214
- // Callback handlers for contextual inline buttons
215
- bot.callbackQuery("retry", async (ctx) => {
216
- await ctx.answerCallbackQuery({ text: "Retrying..." });
217
- const originalMsg = ctx.callbackQuery.message;
218
- if (originalMsg?.reply_to_message && "text" in originalMsg.reply_to_message && originalMsg.reply_to_message.text) {
219
- const retryPrompt = originalMsg.reply_to_message.text;
220
- const chatId = ctx.chat.id;
221
- sendToOrchestrator(retryPrompt, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
222
- if (done) {
223
- void (async () => {
224
- try {
225
- const formatted = toTelegramHTML(text);
226
- const chunks = chunkMessage(formatted);
227
- await bot.api.editMessageText(chatId, originalMsg.message_id, chunks[0], { parse_mode: "HTML" });
228
- }
229
- catch {
230
- try {
231
- await ctx.reply(text);
232
- }
233
- catch { }
234
- }
235
- })();
236
- }
237
- });
238
- }
239
- });
240
- bot.callbackQuery("explain_error", async (ctx) => {
241
- await ctx.answerCallbackQuery({ text: "Explaining..." });
242
- const originalMsg = ctx.callbackQuery.message;
243
- if (originalMsg && "text" in originalMsg && originalMsg.text) {
244
- const chatId = ctx.chat.id;
245
- sendToOrchestrator(`Explain this error in simple terms and suggest a fix:\n${originalMsg.text}`, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
246
- if (done)
247
- void sendFormattedReply(bot, chatId, text);
248
- });
249
- }
250
- });
161
+ // Register callback + media handlers from extracted modules
162
+ registerCallbackHandlers(bot);
251
163
  // Persistent reply keyboard — quick actions always visible below chat input
252
164
  const replyKeyboard = new Keyboard()
253
165
  .text("📊 Status").text("❌ Cancel").row()
@@ -731,183 +643,8 @@ export function createBot() {
731
643
  }
732
644
  }, onToolEvent, onUsage);
733
645
  });
734
- // Handle photo messages download and pass to AI as image description request
735
- bot.on("message:photo", async (ctx) => {
736
- const chatId = ctx.chat.id;
737
- const userMessageId = ctx.message.message_id;
738
- const caption = ctx.message.caption || "Describe this image and analyze what you see.";
739
- void logInfo(`📸 Photo received: ${caption.slice(0, 80)}`);
740
- try {
741
- await ctx.react("👀");
742
- }
743
- catch { }
744
- const photo = ctx.message.photo[ctx.message.photo.length - 1];
745
- try {
746
- const file = await ctx.api.getFile(photo.file_id);
747
- const filePath = file.file_path;
748
- if (!filePath) {
749
- await ctx.reply("❌ Could not download photo.", { reply_parameters: { message_id: userMessageId } });
750
- return;
751
- }
752
- const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
753
- const { mkdtempSync, writeFileSync } = await import("fs");
754
- const { join } = await import("path");
755
- const { tmpdir } = await import("os");
756
- const tmpDir = mkdtempSync(join(tmpdir(), "nzb-photo-"));
757
- const ext = filePath.split(".").pop() || "jpg";
758
- const localPath = join(tmpDir, `photo.${ext}`);
759
- const response = await fetch(url);
760
- const buffer = Buffer.from(await response.arrayBuffer());
761
- writeFileSync(localPath, buffer);
762
- scheduleTempCleanup(tmpDir);
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.`;
764
- sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
765
- if (done)
766
- void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
767
- });
768
- }
769
- catch (err) {
770
- await ctx.reply(`❌ Error processing photo: ${err instanceof Error ? err.message : String(err)}`, {
771
- reply_parameters: { message_id: userMessageId },
772
- });
773
- }
774
- });
775
- // Handle document/file messages — download and pass to AI
776
- bot.on("message:document", async (ctx) => {
777
- const chatId = ctx.chat.id;
778
- const userMessageId = ctx.message.message_id;
779
- const doc = ctx.message.document;
780
- const caption = ctx.message.caption || `Analyze this file: ${doc.file_name || "unknown"}`;
781
- void logInfo(`📄 Document received: ${doc.file_name || "unknown"} (${doc.file_size || 0} bytes)`);
782
- try {
783
- await ctx.react("👀");
784
- }
785
- catch { }
786
- // Limit file size to 10MB
787
- if (doc.file_size && doc.file_size > 10 * 1024 * 1024) {
788
- await ctx.reply("❌ File too large (max 10MB).", { reply_parameters: { message_id: userMessageId } });
789
- return;
790
- }
791
- try {
792
- const file = await ctx.api.getFile(doc.file_id);
793
- const filePath = file.file_path;
794
- if (!filePath) {
795
- await ctx.reply("❌ Could not download file.", { reply_parameters: { message_id: userMessageId } });
796
- return;
797
- }
798
- const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
799
- const { mkdtempSync, writeFileSync } = await import("fs");
800
- const { join } = await import("path");
801
- const { tmpdir } = await import("os");
802
- const tmpDir = mkdtempSync(join(tmpdir(), "nzb-doc-"));
803
- const localPath = join(tmpDir, doc.file_name || "file");
804
- const response = await fetch(url);
805
- const buffer = Buffer.from(await response.arrayBuffer());
806
- writeFileSync(localPath, buffer);
807
- scheduleTempCleanup(tmpDir);
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.`;
809
- sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
810
- if (done)
811
- void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
812
- });
813
- }
814
- catch (err) {
815
- await ctx.reply(`❌ Error processing file: ${err instanceof Error ? err.message : String(err)}`, {
816
- reply_parameters: { message_id: userMessageId },
817
- });
818
- }
819
- });
820
- // Handle voice messages — download, transcribe via Whisper, send to AI
821
- bot.on("message:voice", async (ctx) => {
822
- const chatId = ctx.chat.id;
823
- const userMessageId = ctx.message.message_id;
824
- const duration = ctx.message.voice.duration;
825
- void logInfo(`🎤 Voice received: ${duration}s`);
826
- try {
827
- await ctx.react("👀");
828
- }
829
- catch { }
830
- // Limit voice duration to 5 minutes
831
- if (duration > 300) {
832
- await ctx.reply("❌ Voice too long (max 5 min).", { reply_parameters: { message_id: userMessageId } });
833
- return;
834
- }
835
- // If voice is a reply, include context
836
- let voiceReplyContext = "";
837
- const voiceReplyMsg = ctx.message.reply_to_message;
838
- if (voiceReplyMsg && "text" in voiceReplyMsg && voiceReplyMsg.text) {
839
- const { getConversationContext } = await import("../store/db.js");
840
- const context = getConversationContext(voiceReplyMsg.message_id);
841
- if (context) {
842
- voiceReplyContext = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n`;
843
- }
844
- else {
845
- const quoted = voiceReplyMsg.text.length > 500 ? voiceReplyMsg.text.slice(0, 500) + "…" : voiceReplyMsg.text;
846
- voiceReplyContext = `[Replying to: "${quoted}"]\n\n`;
847
- }
848
- }
849
- try {
850
- const file = await ctx.api.getFile(ctx.message.voice.file_id);
851
- const filePath = file.file_path;
852
- if (!filePath) {
853
- await ctx.reply("❌ Could not download voice.", { reply_parameters: { message_id: userMessageId } });
854
- return;
855
- }
856
- const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
857
- const { mkdtempSync, writeFileSync } = await import("fs");
858
- const { join } = await import("path");
859
- const { tmpdir } = await import("os");
860
- const tmpDir = mkdtempSync(join(tmpdir(), "nzb-voice-"));
861
- const ext = filePath.split(".").pop() || "oga";
862
- const localPath = join(tmpDir, `voice.${ext}`);
863
- const response = await fetch(url);
864
- const buffer = Buffer.from(await response.arrayBuffer());
865
- writeFileSync(localPath, buffer);
866
- scheduleTempCleanup(tmpDir);
867
- let prompt;
868
- if (config.openaiApiKey) {
869
- // Transcribe using OpenAI Whisper API
870
- try {
871
- const formData = new FormData();
872
- formData.append("file", new Blob([buffer], { type: "audio/ogg" }), `voice.${ext}`);
873
- formData.append("model", "whisper-1");
874
- const whisperResp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
875
- method: "POST",
876
- headers: { Authorization: `Bearer ${config.openaiApiKey}` },
877
- body: formData,
878
- });
879
- if (!whisperResp.ok) {
880
- const errText = await whisperResp.text();
881
- throw new Error(`Whisper API ${whisperResp.status}: ${errText.slice(0, 200)}`);
882
- }
883
- const result = (await whisperResp.json());
884
- const transcript = result.text?.trim();
885
- if (!transcript) {
886
- prompt = `[User sent a voice message (${duration}s) but transcription was empty. File saved at: ${localPath}]`;
887
- }
888
- else {
889
- prompt = `[Voice message transcribed (${duration}s)]: ${transcript}`;
890
- }
891
- }
892
- catch (whisperErr) {
893
- console.error("[nzb] Whisper transcription failed:", whisperErr instanceof Error ? whisperErr.message : whisperErr);
894
- prompt = `[User sent a voice message (${duration}s), saved at: ${localPath}. Transcription failed: ${whisperErr instanceof Error ? whisperErr.message : String(whisperErr)}]`;
895
- }
896
- }
897
- else {
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]`;
899
- }
900
- sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
901
- if (done)
902
- void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId, assistantLogId: meta?.assistantLogId });
903
- });
904
- }
905
- catch (err) {
906
- await ctx.reply(`❌ Error processing voice: ${err instanceof Error ? err.message : String(err)}`, {
907
- reply_parameters: { message_id: userMessageId },
908
- });
909
- }
910
- });
646
+ // Register media handlers (photo, document, voice) from extracted module
647
+ registerMediaHandlers(bot);
911
648
  // Global error handler — prevents unhandled errors from crashing the bot
912
649
  bot.catch((err) => {
913
650
  const ctx = err.ctx;
@@ -97,6 +97,8 @@ export function toTelegramHTML(text) {
97
97
  out = out.replace(/`([^`\n]+)`/g, (_m, code) => stashToken(`<code>${escapeHtml(code)}</code>`));
98
98
  // 3. Stash markdown links → <a href>
99
99
  out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => stashToken(`<a href="${escapeHtml(url)}">${escapeHtml(linkText)}</a>`));
100
+ // 3a. Stash spoiler ||text|| → <tg-spoiler> (before tables, since || resembles table syntax)
101
+ out = out.replace(/\|\|(.+?)\|\|/g, (_m, inner) => stashToken(`<tg-spoiler>${escapeHtml(inner)}</tg-spoiler>`));
100
102
  // 4. Convert tables
101
103
  out = out.replace(/(?:^\|.+\|[ \t]*$\n?)+/gm, (table) => stashToken(convertTable(table) + "\n"));
102
104
  // 5. Convert headers → bold
@@ -0,0 +1,43 @@
1
+ import { sendToOrchestrator } from "../../copilot/orchestrator.js";
2
+ import { chunkMessage, toTelegramHTML } from "../formatter.js";
3
+ import { sendFormattedReply } from "./helpers.js";
4
+ /** Register inline keyboard callback handlers (Retry, Explain). */
5
+ export function registerCallbackHandlers(bot) {
6
+ bot.callbackQuery("retry", async (ctx) => {
7
+ await ctx.answerCallbackQuery({ text: "Retrying..." });
8
+ const originalMsg = ctx.callbackQuery.message;
9
+ if (originalMsg?.reply_to_message && "text" in originalMsg.reply_to_message && originalMsg.reply_to_message.text) {
10
+ const retryPrompt = originalMsg.reply_to_message.text;
11
+ const chatId = ctx.chat.id;
12
+ sendToOrchestrator(retryPrompt, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
13
+ if (done) {
14
+ void (async () => {
15
+ try {
16
+ const formatted = toTelegramHTML(text);
17
+ const chunks = chunkMessage(formatted);
18
+ await bot.api.editMessageText(chatId, originalMsg.message_id, chunks[0], { parse_mode: "HTML" });
19
+ }
20
+ catch {
21
+ try {
22
+ await ctx.reply(text);
23
+ }
24
+ catch { }
25
+ }
26
+ })();
27
+ }
28
+ });
29
+ }
30
+ });
31
+ bot.callbackQuery("explain_error", async (ctx) => {
32
+ await ctx.answerCallbackQuery({ text: "Explaining..." });
33
+ const originalMsg = ctx.callbackQuery.message;
34
+ if (originalMsg && "text" in originalMsg && originalMsg.text) {
35
+ const chatId = ctx.chat.id;
36
+ sendToOrchestrator(`Explain this error in simple terms and suggest a fix:\n${originalMsg.text}`, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
37
+ if (done)
38
+ void sendFormattedReply(bot, chatId, text);
39
+ });
40
+ }
41
+ });
42
+ }
43
+ //# sourceMappingURL=callbacks.js.map
@@ -0,0 +1,58 @@
1
+ import { rmSync } from "fs";
2
+ import { chunkMessage, toTelegramHTML } from "../formatter.js";
3
+ /**
4
+ * Send a formatted HTML reply with multi-chunk support and fallback to plain text.
5
+ * Consolidates the repeated toTelegramHTML → chunkMessage → fallback → send pattern.
6
+ */
7
+ export async function sendFormattedReply(botInstance, chatId, text, opts) {
8
+ const formatted = toTelegramHTML(text);
9
+ const chunks = chunkMessage(formatted);
10
+ const fallbackChunks = chunkMessage(text);
11
+ let firstMsgId;
12
+ for (let i = 0; i < chunks.length; i++) {
13
+ if (i > 0)
14
+ await new Promise((r) => setTimeout(r, 300));
15
+ const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
16
+ const replyParams = i === 0 && opts?.replyTo ? { message_id: opts.replyTo } : undefined;
17
+ try {
18
+ const sent = await botInstance.api.sendMessage(chatId, pageTag + chunks[i], {
19
+ parse_mode: "HTML",
20
+ reply_parameters: replyParams,
21
+ });
22
+ if (i === 0)
23
+ firstMsgId = sent.message_id;
24
+ }
25
+ catch {
26
+ try {
27
+ const sent = await botInstance.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
28
+ reply_parameters: replyParams,
29
+ });
30
+ if (i === 0)
31
+ firstMsgId = sent.message_id;
32
+ }
33
+ catch { }
34
+ }
35
+ }
36
+ if (opts?.assistantLogId && firstMsgId) {
37
+ try {
38
+ const { setConversationTelegramMsgId } = await import("../../store/db.js");
39
+ setConversationTelegramMsgId(opts.assistantLogId, firstMsgId);
40
+ }
41
+ catch { }
42
+ }
43
+ try {
44
+ await botInstance.api.setMessageReaction(chatId, opts?.replyTo ?? 0, [{ type: "emoji", emoji: "👍" }]);
45
+ }
46
+ catch { }
47
+ return firstMsgId;
48
+ }
49
+ /** Remove a temp directory after a delay (gives orchestrator time to use the file). */
50
+ export function scheduleTempCleanup(dirPath, delayMs = 5 * 60_000) {
51
+ setTimeout(() => {
52
+ try {
53
+ rmSync(dirPath, { recursive: true, force: true });
54
+ }
55
+ catch { }
56
+ }, delayMs);
57
+ }
58
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1,182 @@
1
+ import { config } from "../../config.js";
2
+ import { sendToOrchestrator } from "../../copilot/orchestrator.js";
3
+ import { logInfo } from "../log-channel.js";
4
+ import { sendFormattedReply, scheduleTempCleanup } from "./helpers.js";
5
+ /** Register photo, document, and voice message handlers on the bot. */
6
+ export function registerMediaHandlers(bot) {
7
+ // Handle photo messages — download and pass to AI
8
+ bot.on("message:photo", async (ctx) => {
9
+ const chatId = ctx.chat.id;
10
+ const userMessageId = ctx.message.message_id;
11
+ const caption = ctx.message.caption || "Describe this image and analyze what you see.";
12
+ void logInfo(`📸 Photo received: ${caption.slice(0, 80)}`);
13
+ try {
14
+ await ctx.react("👀");
15
+ }
16
+ catch { }
17
+ const photo = ctx.message.photo[ctx.message.photo.length - 1];
18
+ try {
19
+ const file = await ctx.api.getFile(photo.file_id);
20
+ const filePath = file.file_path;
21
+ if (!filePath) {
22
+ await ctx.reply("❌ Could not download photo.", { reply_parameters: { message_id: userMessageId } });
23
+ return;
24
+ }
25
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
26
+ const { mkdtempSync, writeFileSync } = await import("fs");
27
+ const { join } = await import("path");
28
+ const { tmpdir } = await import("os");
29
+ const tmpDir = mkdtempSync(join(tmpdir(), "nzb-photo-"));
30
+ const ext = filePath.split(".").pop() || "jpg";
31
+ const localPath = join(tmpDir, `photo.${ext}`);
32
+ const response = await fetch(url);
33
+ const buffer = Buffer.from(await response.arrayBuffer());
34
+ writeFileSync(localPath, buffer);
35
+ scheduleTempCleanup(tmpDir);
36
+ 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.`;
37
+ sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
38
+ if (done)
39
+ void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
40
+ });
41
+ }
42
+ catch (err) {
43
+ await ctx.reply(`❌ Error processing photo: ${err instanceof Error ? err.message : String(err)}`, {
44
+ reply_parameters: { message_id: userMessageId },
45
+ });
46
+ }
47
+ });
48
+ // Handle document/file messages — download and pass to AI
49
+ bot.on("message:document", async (ctx) => {
50
+ const chatId = ctx.chat.id;
51
+ const userMessageId = ctx.message.message_id;
52
+ const doc = ctx.message.document;
53
+ const caption = ctx.message.caption || `Analyze this file: ${doc.file_name || "unknown"}`;
54
+ void logInfo(`📄 Document received: ${doc.file_name || "unknown"} (${doc.file_size || 0} bytes)`);
55
+ try {
56
+ await ctx.react("👀");
57
+ }
58
+ catch { }
59
+ if (doc.file_size && doc.file_size > 10 * 1024 * 1024) {
60
+ await ctx.reply("❌ File too large (max 10MB).", { reply_parameters: { message_id: userMessageId } });
61
+ return;
62
+ }
63
+ try {
64
+ const file = await ctx.api.getFile(doc.file_id);
65
+ const filePath = file.file_path;
66
+ if (!filePath) {
67
+ await ctx.reply("❌ Could not download file.", { reply_parameters: { message_id: userMessageId } });
68
+ return;
69
+ }
70
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
71
+ const { mkdtempSync, writeFileSync } = await import("fs");
72
+ const { join } = await import("path");
73
+ const { tmpdir } = await import("os");
74
+ const tmpDir = mkdtempSync(join(tmpdir(), "nzb-doc-"));
75
+ const localPath = join(tmpDir, doc.file_name || "file");
76
+ const response = await fetch(url);
77
+ const buffer = Buffer.from(await response.arrayBuffer());
78
+ writeFileSync(localPath, buffer);
79
+ scheduleTempCleanup(tmpDir);
80
+ 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.`;
81
+ sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
82
+ if (done)
83
+ void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
84
+ });
85
+ }
86
+ catch (err) {
87
+ await ctx.reply(`❌ Error processing file: ${err instanceof Error ? err.message : String(err)}`, {
88
+ reply_parameters: { message_id: userMessageId },
89
+ });
90
+ }
91
+ });
92
+ // Handle voice messages — download, transcribe via Whisper, send to AI
93
+ bot.on("message:voice", async (ctx) => {
94
+ const chatId = ctx.chat.id;
95
+ const userMessageId = ctx.message.message_id;
96
+ const duration = ctx.message.voice.duration;
97
+ void logInfo(`🎤 Voice received: ${duration}s`);
98
+ try {
99
+ await ctx.react("👀");
100
+ }
101
+ catch { }
102
+ if (duration > 300) {
103
+ await ctx.reply("❌ Voice too long (max 5 min).", { reply_parameters: { message_id: userMessageId } });
104
+ return;
105
+ }
106
+ // If voice is a reply, include context
107
+ let voiceReplyContext = "";
108
+ const voiceReplyMsg = ctx.message.reply_to_message;
109
+ if (voiceReplyMsg && "text" in voiceReplyMsg && voiceReplyMsg.text) {
110
+ const { getConversationContext } = await import("../../store/db.js");
111
+ const context = getConversationContext(voiceReplyMsg.message_id);
112
+ if (context) {
113
+ voiceReplyContext = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n`;
114
+ }
115
+ else {
116
+ const quoted = voiceReplyMsg.text.length > 500 ? voiceReplyMsg.text.slice(0, 500) + "…" : voiceReplyMsg.text;
117
+ voiceReplyContext = `[Replying to: "${quoted}"]\n\n`;
118
+ }
119
+ }
120
+ try {
121
+ const file = await ctx.api.getFile(ctx.message.voice.file_id);
122
+ const filePath = file.file_path;
123
+ if (!filePath) {
124
+ await ctx.reply("❌ Could not download voice.", { reply_parameters: { message_id: userMessageId } });
125
+ return;
126
+ }
127
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
128
+ const { mkdtempSync, writeFileSync } = await import("fs");
129
+ const { join } = await import("path");
130
+ const { tmpdir } = await import("os");
131
+ const tmpDir = mkdtempSync(join(tmpdir(), "nzb-voice-"));
132
+ const ext = filePath.split(".").pop() || "oga";
133
+ const localPath = join(tmpDir, `voice.${ext}`);
134
+ const response = await fetch(url);
135
+ const buffer = Buffer.from(await response.arrayBuffer());
136
+ writeFileSync(localPath, buffer);
137
+ scheduleTempCleanup(tmpDir);
138
+ let prompt;
139
+ if (config.openaiApiKey) {
140
+ try {
141
+ const formData = new FormData();
142
+ formData.append("file", new Blob([buffer], { type: "audio/ogg" }), `voice.${ext}`);
143
+ formData.append("model", "whisper-1");
144
+ const whisperResp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
145
+ method: "POST",
146
+ headers: { Authorization: `Bearer ${config.openaiApiKey}` },
147
+ body: formData,
148
+ });
149
+ if (!whisperResp.ok) {
150
+ const errText = await whisperResp.text();
151
+ throw new Error(`Whisper API ${whisperResp.status}: ${errText.slice(0, 200)}`);
152
+ }
153
+ const result = (await whisperResp.json());
154
+ const transcript = result.text?.trim();
155
+ if (!transcript) {
156
+ prompt = `[User sent a voice message (${duration}s) but transcription was empty. File saved at: ${localPath}]`;
157
+ }
158
+ else {
159
+ prompt = `[Voice message transcribed (${duration}s)]: ${transcript}`;
160
+ }
161
+ }
162
+ catch (whisperErr) {
163
+ console.error("[nzb] Whisper transcription failed:", whisperErr instanceof Error ? whisperErr.message : whisperErr);
164
+ prompt = `[User sent a voice message (${duration}s), saved at: ${localPath}. Transcription failed: ${whisperErr instanceof Error ? whisperErr.message : String(whisperErr)}]`;
165
+ }
166
+ }
167
+ else {
168
+ 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]`;
169
+ }
170
+ sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
171
+ if (done)
172
+ void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId, assistantLogId: meta?.assistantLogId });
173
+ });
174
+ }
175
+ catch (err) {
176
+ await ctx.reply(`❌ Error processing voice: ${err instanceof Error ? err.message : String(err)}`, {
177
+ reply_parameters: { message_id: userMessageId },
178
+ });
179
+ }
180
+ });
181
+ }
182
+ //# sourceMappingURL=media.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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"
@@ -16,6 +16,8 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "build": "tsc",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
19
21
  "postinstall": "node scripts/fix-esm-imports.cjs",
20
22
  "daemon": "tsx src/daemon.ts",
21
23
  "tui": "tsx src/tui/index.ts",
@@ -61,6 +63,7 @@
61
63
  "@types/node": "^25.3.0",
62
64
  "prettier": "^3.8.1",
63
65
  "tsx": "^4.21.0",
64
- "typescript": "^5.9.3"
66
+ "typescript": "^5.9.3",
67
+ "vitest": "^4.1.0"
65
68
  }
66
69
  }