@iletai/nzb 1.2.6 → 1.3.1

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.
Files changed (2) hide show
  1. package/dist/telegram/bot.js +237 -95
  2. package/package.json +2 -1
@@ -1,5 +1,6 @@
1
1
  import { autoRetry } from "@grammyjs/auto-retry";
2
- import { Bot, InlineKeyboard } from "grammy";
2
+ import { Menu } from "@grammyjs/menu";
3
+ import { Bot } from "grammy";
3
4
  import { Agent as HttpsAgent } from "https";
4
5
  import { config, persistEnvVar, persistModel } from "../config.js";
5
6
  import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
@@ -10,18 +11,94 @@ import { chunkMessage, formatToolSummaryExpandable, toTelegramMarkdown } from ".
10
11
  import { initLogChannel, logDebug, logError, logInfo } from "./log-channel.js";
11
12
  let bot;
12
13
  const startedAt = Date.now();
13
- // Inline keyboard menu for quick actions
14
- const mainMenu = new InlineKeyboard()
15
- .text("📊 Status", "action:status")
16
- .text("🤖 Model", "action:model")
14
+ // Helper: build uptime string
15
+ function getUptimeStr() {
16
+ const uptime = Math.floor((Date.now() - startedAt) / 1000);
17
+ const hours = Math.floor(uptime / 3600);
18
+ const minutes = Math.floor((uptime % 3600) / 60);
19
+ const seconds = uptime % 60;
20
+ return hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
21
+ }
22
+ // Settings sub-menu
23
+ const settingsMenu = new Menu("settings-menu")
24
+ .text((ctx) => `${config.showReasoning ? "✅" : "❌"} Show Reasoning`, async (ctx) => {
25
+ config.showReasoning = !config.showReasoning;
26
+ persistEnvVar("SHOW_REASONING", config.showReasoning ? "true" : "false");
27
+ ctx.menu.update();
28
+ await ctx.answerCallbackQuery(`Reasoning ${config.showReasoning ? "ON" : "OFF"}`);
29
+ })
30
+ .row()
31
+ .back("🔙 Back", async (ctx) => {
32
+ await ctx.editMessageText("NZB Menu:");
33
+ });
34
+ // Main interactive menu with navigation
35
+ const mainMenu = new Menu("main-menu")
36
+ .text("📊 Status", async (ctx) => {
37
+ const workers = Array.from(getWorkers().values());
38
+ const lines = [
39
+ "📊 NZB Status",
40
+ `Model: ${config.copilotModel}`,
41
+ `Uptime: ${getUptimeStr()}`,
42
+ `Workers: ${workers.length} active`,
43
+ `Queue: ${getQueueSize()} pending`,
44
+ ];
45
+ await ctx.answerCallbackQuery();
46
+ await ctx.reply(lines.join("\n"));
47
+ })
48
+ .text("🤖 Model", async (ctx) => {
49
+ await ctx.answerCallbackQuery();
50
+ await ctx.reply(`Current model: ${config.copilotModel}`);
51
+ })
17
52
  .row()
18
- .text("👥 Workers", "action:workers")
19
- .text("🧠 Skills", "action:skills")
53
+ .text("👥 Workers", async (ctx) => {
54
+ await ctx.answerCallbackQuery();
55
+ const workers = Array.from(getWorkers().values());
56
+ if (workers.length === 0) {
57
+ await ctx.reply("No active worker sessions.");
58
+ }
59
+ else {
60
+ const lines = workers.map((w) => `• ${w.name} (${w.workingDir}) — ${w.status}`);
61
+ await ctx.reply(lines.join("\n"));
62
+ }
63
+ })
64
+ .text("🧠 Skills", async (ctx) => {
65
+ await ctx.answerCallbackQuery();
66
+ const skills = listSkills();
67
+ if (skills.length === 0) {
68
+ await ctx.reply("No skills installed.");
69
+ }
70
+ else {
71
+ const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
72
+ await ctx.reply(lines.join("\n"));
73
+ }
74
+ })
20
75
  .row()
21
- .text("🗂 Memory", "action:memory")
22
- .text("⚙️ Settings", "action:settings")
76
+ .text("🗂 Memory", async (ctx) => {
77
+ await ctx.answerCallbackQuery();
78
+ const memories = searchMemories(undefined, undefined, 50);
79
+ if (memories.length === 0) {
80
+ await ctx.reply("No memories stored.");
81
+ }
82
+ else {
83
+ const lines = memories.map((m) => `#${m.id} [${m.category}] ${m.content}`);
84
+ await ctx.reply(lines.join("\n") + `\n\n${memories.length} total`);
85
+ }
86
+ })
87
+ .submenu("⚙️ Settings", "settings-menu", async (ctx) => {
88
+ await ctx.editMessageText("⚙️ Settings\n\n" +
89
+ `🔧 Show Reasoning: ${config.showReasoning ? "✅ ON" : "❌ OFF"}\n` +
90
+ ` └ Hiển thị tools đã dùng + thời gian cuối mỗi phản hồi\n\n` +
91
+ `🤖 Model: ${config.copilotModel}\n` +
92
+ ` └ Dùng /model <name> để đổi`);
93
+ })
23
94
  .row()
24
- .text("❌ Cancel", "action:cancel");
95
+ .text("❌ Cancel", async (ctx) => {
96
+ await ctx.answerCallbackQuery();
97
+ const cancelled = await cancelCurrentMessage();
98
+ await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
99
+ });
100
+ // Register sub-menu as child
101
+ mainMenu.register(settingsMenu);
25
102
  // Direct-connection HTTPS agent for Telegram API requests.
26
103
  // This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
27
104
  // modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
@@ -53,6 +130,8 @@ export function createBot() {
53
130
  }
54
131
  await next();
55
132
  });
133
+ // Register interactive menu plugin
134
+ bot.use(mainMenu);
56
135
  // /start and /help — with inline menu
57
136
  bot.command("start", (ctx) => ctx.reply("NZB is online. Send me anything, or use the menu below:", { reply_markup: mainMenu }));
58
137
  bot.command("help", (ctx) => ctx.reply("I'm NZB, your AI daemon.\n\n" +
@@ -156,92 +235,12 @@ export function createBot() {
156
235
  });
157
236
  }, 500);
158
237
  });
159
- // /settings — show toggleable settings with inline keyboard
160
- const buildSettingsKeyboard = () => new InlineKeyboard()
161
- .text(`${config.showReasoning ? "✅" : "❌"} Show Reasoning`, "setting:toggle:reasoning")
162
- .row()
163
- .text("🔙 Back to Menu", "action:menu");
164
- const buildSettingsText = () => "⚙️ Settings\n\n" +
165
- `🔧 Show Reasoning: ${config.showReasoning ? "✅ ON" : "❌ OFF"}\n` +
166
- ` └ Hiển thị tools đã dùng + thời gian cuối mỗi phản hồi\n\n` +
167
- `🤖 Model: ${config.copilotModel}\n` +
168
- ` └ Dùng /model <name> để đổi`;
169
238
  bot.command("settings", async (ctx) => {
170
- await ctx.reply(buildSettingsText(), { reply_markup: buildSettingsKeyboard() });
171
- });
172
- // Callback query handlers for inline menu buttons
173
- bot.callbackQuery("action:status", async (ctx) => {
174
- await ctx.answerCallbackQuery();
175
- const uptime = Math.floor((Date.now() - startedAt) / 1000);
176
- const hours = Math.floor(uptime / 3600);
177
- const minutes = Math.floor((uptime % 3600) / 60);
178
- const seconds = uptime % 60;
179
- const uptimeStr = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
180
- const workers = Array.from(getWorkers().values());
181
- const lines = [
182
- "📊 NZB Status",
183
- `Model: ${config.copilotModel}`,
184
- `Uptime: ${uptimeStr}`,
185
- `Workers: ${workers.length} active`,
186
- `Queue: ${getQueueSize()} pending`,
187
- ];
188
- await ctx.reply(lines.join("\n"));
189
- });
190
- bot.callbackQuery("action:model", async (ctx) => {
191
- await ctx.answerCallbackQuery();
192
- await ctx.reply(`Current model: ${config.copilotModel}`);
193
- });
194
- bot.callbackQuery("action:workers", async (ctx) => {
195
- await ctx.answerCallbackQuery();
196
- const workers = Array.from(getWorkers().values());
197
- if (workers.length === 0) {
198
- await ctx.reply("No active worker sessions.");
199
- }
200
- else {
201
- const lines = workers.map((w) => `• ${w.name} (${w.workingDir}) — ${w.status}`);
202
- await ctx.reply(lines.join("\n"));
203
- }
204
- });
205
- bot.callbackQuery("action:skills", async (ctx) => {
206
- await ctx.answerCallbackQuery();
207
- const skills = listSkills();
208
- if (skills.length === 0) {
209
- await ctx.reply("No skills installed.");
210
- }
211
- else {
212
- const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
213
- await ctx.reply(lines.join("\n"));
214
- }
215
- });
216
- bot.callbackQuery("action:memory", async (ctx) => {
217
- await ctx.answerCallbackQuery();
218
- const memories = searchMemories(undefined, undefined, 50);
219
- if (memories.length === 0) {
220
- await ctx.reply("No memories stored.");
221
- }
222
- else {
223
- const lines = memories.map((m) => `#${m.id} [${m.category}] ${m.content}`);
224
- await ctx.reply(lines.join("\n") + `\n\n${memories.length} total`);
225
- }
226
- });
227
- bot.callbackQuery("action:cancel", async (ctx) => {
228
- await ctx.answerCallbackQuery();
229
- const cancelled = await cancelCurrentMessage();
230
- await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
231
- });
232
- bot.callbackQuery("action:settings", async (ctx) => {
233
- await ctx.answerCallbackQuery();
234
- await ctx.reply(buildSettingsText(), { reply_markup: buildSettingsKeyboard() });
235
- });
236
- bot.callbackQuery("action:menu", async (ctx) => {
237
- await ctx.answerCallbackQuery();
238
- await ctx.editMessageText("NZB Menu:", { reply_markup: mainMenu });
239
- });
240
- bot.callbackQuery("setting:toggle:reasoning", async (ctx) => {
241
- config.showReasoning = !config.showReasoning;
242
- persistEnvVar("SHOW_REASONING", config.showReasoning ? "true" : "false");
243
- await ctx.answerCallbackQuery(`Reasoning ${config.showReasoning ? "ON" : "OFF"}`);
244
- await ctx.editMessageText(buildSettingsText(), { reply_markup: buildSettingsKeyboard() });
239
+ await ctx.reply("⚙️ Settings\n\n" +
240
+ `🔧 Show Reasoning: ${config.showReasoning ? "✅ ON" : "❌ OFF"}\n` +
241
+ ` └ Hiển thị tools đã dùng + thời gian cuối mỗi phản hồi\n\n` +
242
+ `🤖 Model: ${config.copilotModel}\n` +
243
+ ` └ Dùng /model <name> để đổi`, { reply_markup: settingsMenu });
245
244
  });
246
245
  // Handle all text messages — progressive streaming with tool event feedback
247
246
  bot.on("message:text", async (ctx) => {
@@ -532,6 +531,149 @@ export function createBot() {
532
531
  }
533
532
  }, onToolEvent, onUsage);
534
533
  });
534
+ // Handle photo messages — download and pass to AI as image description request
535
+ bot.on("message:photo", async (ctx) => {
536
+ const chatId = ctx.chat.id;
537
+ const userMessageId = ctx.message.message_id;
538
+ const caption = ctx.message.caption || "Describe this image and analyze what you see.";
539
+ void logInfo(`📸 Photo received: ${caption.slice(0, 80)}`);
540
+ try {
541
+ await ctx.react("👀");
542
+ }
543
+ catch { }
544
+ // Get the largest photo (last in array)
545
+ const photo = ctx.message.photo[ctx.message.photo.length - 1];
546
+ try {
547
+ const file = await ctx.api.getFile(photo.file_id);
548
+ const filePath = file.file_path;
549
+ if (!filePath) {
550
+ await ctx.reply("❌ Could not download photo.", { reply_parameters: { message_id: userMessageId } });
551
+ return;
552
+ }
553
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
554
+ // Download to temp file
555
+ const { mkdtempSync, writeFileSync } = await import("fs");
556
+ const { join } = await import("path");
557
+ const { tmpdir } = await import("os");
558
+ const tmpDir = mkdtempSync(join(tmpdir(), "nzb-photo-"));
559
+ const ext = filePath.split(".").pop() || "jpg";
560
+ const localPath = join(tmpDir, `photo.${ext}`);
561
+ const response = await fetch(url);
562
+ const buffer = Buffer.from(await response.arrayBuffer());
563
+ writeFileSync(localPath, buffer);
564
+ // Send to orchestrator with image context
565
+ 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.`;
566
+ sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
567
+ if (done) {
568
+ const formatted = toTelegramMarkdown(text);
569
+ const chunks = chunkMessage(formatted);
570
+ const fallbackChunks = chunkMessage(text);
571
+ void (async () => {
572
+ for (let i = 0; i < chunks.length; i++) {
573
+ if (i > 0)
574
+ await new Promise((r) => setTimeout(r, 300));
575
+ const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
576
+ try {
577
+ await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
578
+ parse_mode: "MarkdownV2",
579
+ reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
580
+ });
581
+ }
582
+ catch {
583
+ try {
584
+ await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
585
+ reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
586
+ });
587
+ }
588
+ catch { }
589
+ }
590
+ }
591
+ try {
592
+ await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
593
+ }
594
+ catch { }
595
+ })();
596
+ }
597
+ });
598
+ }
599
+ catch (err) {
600
+ await ctx.reply(`❌ Error processing photo: ${err instanceof Error ? err.message : String(err)}`, {
601
+ reply_parameters: { message_id: userMessageId },
602
+ });
603
+ }
604
+ });
605
+ // Handle document/file messages — download and pass to AI
606
+ bot.on("message:document", async (ctx) => {
607
+ const chatId = ctx.chat.id;
608
+ const userMessageId = ctx.message.message_id;
609
+ const doc = ctx.message.document;
610
+ const caption = ctx.message.caption || `Analyze this file: ${doc.file_name || "unknown"}`;
611
+ void logInfo(`📄 Document received: ${doc.file_name || "unknown"} (${doc.file_size || 0} bytes)`);
612
+ try {
613
+ await ctx.react("👀");
614
+ }
615
+ catch { }
616
+ // Limit file size to 10MB
617
+ if (doc.file_size && doc.file_size > 10 * 1024 * 1024) {
618
+ await ctx.reply("❌ File too large (max 10MB).", { reply_parameters: { message_id: userMessageId } });
619
+ return;
620
+ }
621
+ try {
622
+ const file = await ctx.api.getFile(doc.file_id);
623
+ const filePath = file.file_path;
624
+ if (!filePath) {
625
+ await ctx.reply("❌ Could not download file.", { reply_parameters: { message_id: userMessageId } });
626
+ return;
627
+ }
628
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
629
+ const { mkdtempSync, writeFileSync } = await import("fs");
630
+ const { join } = await import("path");
631
+ const { tmpdir } = await import("os");
632
+ const tmpDir = mkdtempSync(join(tmpdir(), "nzb-doc-"));
633
+ const localPath = join(tmpDir, doc.file_name || "file");
634
+ const response = await fetch(url);
635
+ const buffer = Buffer.from(await response.arrayBuffer());
636
+ writeFileSync(localPath, buffer);
637
+ 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.`;
638
+ sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
639
+ if (done) {
640
+ const formatted = toTelegramMarkdown(text);
641
+ const chunks = chunkMessage(formatted);
642
+ const fallbackChunks = chunkMessage(text);
643
+ void (async () => {
644
+ for (let i = 0; i < chunks.length; i++) {
645
+ if (i > 0)
646
+ await new Promise((r) => setTimeout(r, 300));
647
+ const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
648
+ try {
649
+ await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
650
+ parse_mode: "MarkdownV2",
651
+ reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
652
+ });
653
+ }
654
+ catch {
655
+ try {
656
+ await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
657
+ reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
658
+ });
659
+ }
660
+ catch { }
661
+ }
662
+ }
663
+ try {
664
+ await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
665
+ }
666
+ catch { }
667
+ })();
668
+ }
669
+ });
670
+ }
671
+ catch (err) {
672
+ await ctx.reply(`❌ Error processing file: ${err instanceof Error ? err.message : String(err)}`, {
673
+ reply_parameters: { message_id: userMessageId },
674
+ });
675
+ }
676
+ });
535
677
  return bot;
536
678
  }
537
679
  export async function startBot() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.2.6",
3
+ "version": "1.3.1",
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"
@@ -48,6 +48,7 @@
48
48
  "dependencies": {
49
49
  "@github/copilot-sdk": "^0.1.26",
50
50
  "@grammyjs/auto-retry": "^2.0.2",
51
+ "@grammyjs/menu": "^1.3.1",
51
52
  "better-sqlite3": "^12.6.2",
52
53
  "dotenv": "^17.3.1",
53
54
  "express": "^5.2.1",