@iletai/nzb 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,32 @@
1
1
  import { autoRetry } from "@grammyjs/auto-retry";
2
- import { Menu } from "@grammyjs/menu";
3
- import { Bot, InlineKeyboard, Keyboard } from "grammy";
2
+ import { sequentialize } from "@grammyjs/runner";
3
+ import { apiThrottler } from "@grammyjs/transformer-throttler";
4
+ import { Bot, Keyboard } from "grammy";
4
5
  import { Agent as HttpsAgent } from "https";
5
- import { config, persistEnvVar, persistModel } from "../config.js";
6
- import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
7
- import { listSkills } from "../copilot/skills.js";
8
- import { restartDaemon } from "../daemon.js";
9
- import { searchMemories } from "../store/db.js";
10
- import { chunkMessage, escapeHtml, formatToolSummaryExpandable, toTelegramHTML } from "./formatter.js";
6
+ import { config } from "../config.js";
7
+ import { getPersistedUpdateOffset, isUpdateDuplicate, persistUpdateOffset } from "./dedup.js";
11
8
  import { registerCallbackHandlers } from "./handlers/callbacks.js";
9
+ import { registerCommandHandlers } from "./handlers/commands.js";
12
10
  import { sendFormattedReply } from "./handlers/helpers.js";
13
11
  import { registerInlineQueryHandler } from "./handlers/inline.js";
14
12
  import { registerMediaHandlers } from "./handlers/media.js";
15
- import { getReactionHelpText, registerReactionHandlers } from "./handlers/reactions.js";
16
- import { createSmartSuggestionsWithContext, registerSmartSuggestionHandlers } from "./handlers/suggestions.js";
17
- import { initLogChannel, logDebug, logError, logInfo } from "./log-channel.js";
13
+ import { registerReactionHandlers } from "./handlers/reactions.js";
14
+ import { registerMessageHandler } from "./handlers/streaming.js";
15
+ import { registerSmartSuggestionHandlers } from "./handlers/suggestions.js";
16
+ import { initLogChannel, logError, logInfo } from "./log-channel.js";
17
+ import { createMenus } from "./menus.js";
18
18
  let bot;
19
+ /** Abort controller for graceful fetch abort on shutdown — prevents 30s getUpdates hang and 409 conflicts. */
20
+ let fetchAbortController;
19
21
  const startedAt = Date.now();
22
+ // Direct-connection HTTPS agent for Telegram API requests.
23
+ // This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
24
+ // modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
25
+ const telegramAgent = new HttpsAgent({ keepAlive: true });
26
+ /** Getter for the singleton bot instance — used by extracted handler modules. */
27
+ export function getBot() {
28
+ return bot;
29
+ }
20
30
  // Helper: build uptime string
21
31
  function getUptimeStr() {
22
32
  const uptime = Math.floor((Date.now() - startedAt) / 1000);
@@ -25,181 +35,6 @@ function getUptimeStr() {
25
35
  const seconds = uptime % 60;
26
36
  return hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
27
37
  }
28
- // Worker timeout presets (ms → display label)
29
- const TIMEOUT_PRESETS = [
30
- { ms: 600_000, label: "10min" },
31
- { ms: 1_200_000, label: "20min" },
32
- { ms: 1_800_000, label: "30min" },
33
- { ms: 3_600_000, label: "60min" },
34
- { ms: 7_200_000, label: "120min" },
35
- ];
36
- // Dynamic model list — fetched from Copilot SDK, cached for 5 minutes
37
- let cachedModels;
38
- let cachedModelsAt = 0;
39
- const MODEL_CACHE_TTL = 5 * 60_000;
40
- async function getAvailableModels() {
41
- if (cachedModels && Date.now() - cachedModelsAt < MODEL_CACHE_TTL) {
42
- return cachedModels;
43
- }
44
- try {
45
- const { getClient } = await import("../copilot/client.js");
46
- const client = await getClient();
47
- const models = await client.listModels();
48
- if (models.length > 0) {
49
- cachedModels = models.map((m) => m.id);
50
- cachedModelsAt = Date.now();
51
- return cachedModels;
52
- }
53
- }
54
- catch {
55
- /* fall through to fallback */
56
- }
57
- return cachedModels ?? [config.copilotModel];
58
- }
59
- function getTimeoutLabel() {
60
- const preset = TIMEOUT_PRESETS.find((p) => p.ms === config.workerTimeoutMs);
61
- return preset ? preset.label : `${Math.round(config.workerTimeoutMs / 60_000)}min`;
62
- }
63
- function buildSettingsText() {
64
- return ("⚙️ Settings\n\n" +
65
- `⏱ Worker Timeout: ${getTimeoutLabel()}\n` +
66
- `🤖 Model: ${config.copilotModel}\n` +
67
- `🔧 Show Reasoning: ${config.showReasoning ? "✅ ON" : "❌ OFF"}\n\n` +
68
- `📌 v${process.env.npm_package_version || "?"} · uptime ${getUptimeStr()}`);
69
- }
70
- // Settings sub-menu
71
- const settingsMenu = new Menu("settings-menu")
72
- .text(() => `⏱ Timeout: ${getTimeoutLabel()}`, async (ctx) => {
73
- const idx = TIMEOUT_PRESETS.findIndex((p) => p.ms === config.workerTimeoutMs);
74
- const next = TIMEOUT_PRESETS[(idx + 1) % TIMEOUT_PRESETS.length];
75
- config.workerTimeoutMs = next.ms;
76
- persistEnvVar("WORKER_TIMEOUT", String(next.ms));
77
- ctx.menu.update();
78
- await ctx.editMessageText(buildSettingsText());
79
- await ctx.answerCallbackQuery(`Timeout → ${next.label}`);
80
- })
81
- .row()
82
- .text(() => `🤖 ${config.copilotModel}`, async (ctx) => {
83
- const models = await getAvailableModels();
84
- if (models.length === 0) {
85
- await ctx.answerCallbackQuery("No models available");
86
- return;
87
- }
88
- const idx = models.indexOf(config.copilotModel);
89
- const next = models[(idx + 1) % models.length];
90
- config.copilotModel = next;
91
- persistModel(next);
92
- ctx.menu.update();
93
- await ctx.editMessageText(buildSettingsText());
94
- await ctx.answerCallbackQuery(`Model → ${next}`);
95
- })
96
- .row()
97
- .text(() => `${config.showReasoning ? "✅" : "❌"} Show Reasoning`, async (ctx) => {
98
- config.showReasoning = !config.showReasoning;
99
- persistEnvVar("SHOW_REASONING", config.showReasoning ? "true" : "false");
100
- ctx.menu.update();
101
- await ctx.editMessageText(buildSettingsText());
102
- await ctx.answerCallbackQuery(`Reasoning ${config.showReasoning ? "ON" : "OFF"}`);
103
- })
104
- .row()
105
- .text(() => `📌 v${process.env.npm_package_version || "?"} · uptime ${getUptimeStr()}`, async (ctx) => {
106
- await ctx.answerCallbackQuery(`Uptime: ${getUptimeStr()}`);
107
- })
108
- .row()
109
- .back("🔙 Back", async (ctx) => {
110
- await ctx.editMessageText("NZB Menu:");
111
- });
112
- // Main interactive menu with navigation
113
- const mainMenu = new Menu("main-menu")
114
- .text("📊 Status", async (ctx) => {
115
- const workers = Array.from(getWorkers().values());
116
- const lines = [
117
- "📊 NZB Status",
118
- `Model: ${config.copilotModel}`,
119
- `Uptime: ${getUptimeStr()}`,
120
- `Workers: ${workers.length} active`,
121
- `Queue: ${getQueueSize()} pending`,
122
- ];
123
- await ctx.answerCallbackQuery();
124
- await ctx.reply(lines.join("\n"));
125
- })
126
- .text("🤖 Model", async (ctx) => {
127
- await ctx.answerCallbackQuery();
128
- await ctx.reply(`Current model: ${config.copilotModel}`);
129
- })
130
- .row()
131
- .text("👥 Workers", async (ctx) => {
132
- await ctx.answerCallbackQuery();
133
- const workers = Array.from(getWorkers().values());
134
- if (workers.length === 0) {
135
- await ctx.reply("No active worker sessions.");
136
- }
137
- else {
138
- const lines = workers.map((w) => `• ${w.name} (${w.workingDir}) — ${w.status}`);
139
- await ctx.reply(lines.join("\n"));
140
- }
141
- })
142
- .text("🧠 Skills", async (ctx) => {
143
- await ctx.answerCallbackQuery();
144
- const skills = listSkills();
145
- if (skills.length === 0) {
146
- await ctx.reply("No skills installed.");
147
- }
148
- else {
149
- const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
150
- await ctx.reply(lines.join("\n"));
151
- }
152
- })
153
- .row()
154
- .text("🗂 Memory", async (ctx) => {
155
- await ctx.answerCallbackQuery();
156
- const memories = searchMemories(undefined, undefined, 50);
157
- if (memories.length === 0) {
158
- await ctx.reply("No memories stored.");
159
- }
160
- else {
161
- await ctx.reply(formatMemoryList(memories), { parse_mode: "HTML" });
162
- }
163
- })
164
- .submenu("⚙️ Settings", "settings-menu", async (ctx) => {
165
- await ctx.editMessageText(buildSettingsText());
166
- })
167
- .row()
168
- .text("❌ Cancel", async (ctx) => {
169
- await ctx.answerCallbackQuery();
170
- const cancelled = await cancelCurrentMessage();
171
- await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
172
- });
173
- // Register sub-menu as child
174
- mainMenu.register(settingsMenu);
175
- // Direct-connection HTTPS agent for Telegram API requests.
176
- // This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
177
- // modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
178
- const telegramAgent = new HttpsAgent({ keepAlive: true });
179
- const CATEGORY_ICONS = {
180
- project: "📦",
181
- preference: "⚙️",
182
- fact: "💡",
183
- person: "👤",
184
- routine: "🔄",
185
- };
186
- function formatMemoryList(memories) {
187
- const groups = {};
188
- for (const m of memories) {
189
- (groups[m.category] ??= []).push(m);
190
- }
191
- for (const items of Object.values(groups)) {
192
- items.sort((a, b) => a.id - b.id);
193
- }
194
- const sections = Object.entries(groups).map(([cat, items]) => {
195
- const icon = CATEGORY_ICONS[cat] || "📝";
196
- const header = `${icon} <b>${escapeHtml(cat.charAt(0).toUpperCase() + cat.slice(1))}</b>`;
197
- const lines = items.map((m) => `${m.id}. ${escapeHtml(m.content)}`);
198
- return `${header}\n${lines.join("\n")}`;
199
- });
200
- return `🧠 <b>${memories.length} memories</b>\n\n${sections.join("\n\n")}`;
201
- }
202
- // escapeHtml is imported from formatter.ts
203
38
  export function createBot() {
204
39
  if (!config.telegramBotToken) {
205
40
  throw new Error("Telegram bot token is missing. Run 'nzb setup' and enter the bot token from @BotFather.");
@@ -207,18 +42,44 @@ export function createBot() {
207
42
  if (config.authorizedUserId === undefined) {
208
43
  throw new Error("Telegram user ID is missing. Run 'nzb setup' and enter your Telegram user ID (get it from @userinfobot).");
209
44
  }
45
+ fetchAbortController = new AbortController();
210
46
  bot = new Bot(config.telegramBotToken, {
211
47
  client: {
212
48
  baseFetchConfig: {
213
49
  agent: telegramAgent,
214
50
  compress: true,
51
+ signal: fetchAbortController.signal,
215
52
  },
216
53
  },
217
54
  });
218
55
  console.log("[nzb] Telegram bot using direct HTTPS agent (proxy bypass)");
219
56
  initLogChannel(bot);
57
+ // --- API transforms ---
58
+ // Proactive rate limiting — limits request rate BEFORE Telegram rejects with 429
59
+ bot.api.config.use(apiThrottler());
220
60
  // Auto-retry on rate limit (429) and server errors (500+)
221
61
  bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
62
+ // --- Middleware ---
63
+ // Update deduplication + offset tracking middleware.
64
+ // Drops updates already seen (reconnect scenario) and persists the watermark
65
+ // so the next startBot() can resume from the correct offset.
66
+ bot.use(async (ctx, next) => {
67
+ const updateId = ctx.update?.update_id;
68
+ if (typeof updateId === "number") {
69
+ if (isUpdateDuplicate(updateId)) {
70
+ console.log(`[nzb] Telegram update dedup: skipping ${updateId}`);
71
+ return;
72
+ }
73
+ }
74
+ try {
75
+ await next();
76
+ }
77
+ finally {
78
+ if (typeof updateId === "number") {
79
+ persistUpdateOffset(updateId);
80
+ }
81
+ }
82
+ });
222
83
  // Auth middleware — only allow the authorized user
223
84
  bot.use(async (ctx, next) => {
224
85
  if (config.authorizedUserId !== undefined && ctx.from?.id !== config.authorizedUserId) {
@@ -227,15 +88,16 @@ export function createBot() {
227
88
  }
228
89
  await next();
229
90
  });
230
- // Register interactive menu plugin
91
+ // Sequentialize updates per chat — prevents race conditions when user sends
92
+ // multiple messages quickly (e.g. edits arriving before the original is processed).
93
+ bot.use(sequentialize((ctx) => String(ctx.chat?.id ?? "")));
94
+ // --- Menus ---
95
+ const { mainMenu, settingsMenu } = createMenus(getUptimeStr);
231
96
  bot.use(mainMenu);
232
- // Register callback + media handlers from extracted modules
97
+ // --- Handler registrations ---
233
98
  registerCallbackHandlers(bot);
234
- // 🚀 Breakthrough: Inline Query Mode — @bot in any chat
235
99
  registerInlineQueryHandler(bot);
236
- // 🚀 Breakthrough: Smart Suggestion button callbacks
237
100
  registerSmartSuggestionHandlers(bot);
238
- // 🚀 Breakthrough: Reaction-based AI actions
239
101
  registerReactionHandlers(bot);
240
102
  // Persistent reply keyboard — quick actions always visible below chat input
241
103
  const replyKeyboard = new Keyboard()
@@ -246,506 +108,11 @@ export function createBot() {
246
108
  .text("🔄 Restart")
247
109
  .resized()
248
110
  .persistent();
249
- // /start and /help — with inline menu + reply keyboard
250
- bot.command("start", async (ctx) => {
251
- await ctx.reply("NZB is online. Quick actions below ⬇️", { reply_markup: replyKeyboard });
252
- await ctx.reply("Or use the menu:", { reply_markup: mainMenu });
253
- });
254
- bot.command("help", (ctx) => ctx.reply("I'm NZB, your AI daemon.\n\n" +
255
- "Just send me a message and I'll handle it.\n\n" +
256
- "Commands:\n" +
257
- "/cancel — Cancel the current message\n" +
258
- "/model — Show current model\n" +
259
- "/model <name> — Switch model\n" +
260
- "/memory — Show stored memories\n" +
261
- "/skills — List installed skills\n" +
262
- "/workers — List active worker sessions\n" +
263
- "/status — Show system status\n" +
264
- "/settings — Bot settings\n" +
265
- "/restart — Restart NZB\n" +
266
- "/help — Show this help\n\n" +
267
- "⚡ Breakthrough Features:\n" +
268
- "• @bot query — Use me inline in any chat!\n" +
269
- "• React to any message to trigger AI:\n" +
270
- getReactionHelpText() +
271
- "\n" +
272
- "• Smart suggestions appear after each response", { reply_markup: mainMenu }));
273
- bot.command("cancel", async (ctx) => {
274
- const cancelled = await cancelCurrentMessage();
275
- await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
276
- });
277
- bot.command("model", async (ctx) => {
278
- const arg = ctx.match?.trim();
279
- if (arg) {
280
- // Validate against available models before persisting
281
- try {
282
- const { getClient } = await import("../copilot/client.js");
283
- const client = await getClient();
284
- const models = await client.listModels();
285
- const match = models.find((m) => m.id === arg);
286
- if (!match) {
287
- const suggestions = models
288
- .filter((m) => m.id.includes(arg) || m.id.toLowerCase().includes(arg.toLowerCase()))
289
- .map((m) => m.id);
290
- const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
291
- await ctx.reply(`Model '${arg}' not found.${hint}`);
292
- return;
293
- }
294
- }
295
- catch {
296
- // If validation fails (client not ready), allow the switch — will fail on next message if wrong
297
- }
298
- const previous = config.copilotModel;
299
- config.copilotModel = arg;
300
- persistModel(arg);
301
- await ctx.reply(`Model: ${previous} → ${arg}`);
302
- }
303
- else {
304
- await ctx.reply(`Current model: ${config.copilotModel}`);
305
- }
306
- });
307
- bot.command("memory", async (ctx) => {
308
- const memories = searchMemories(undefined, undefined, 50);
309
- if (memories.length === 0) {
310
- await ctx.reply("No memories stored.");
311
- }
312
- else {
313
- await ctx.reply(formatMemoryList(memories), { parse_mode: "HTML" });
314
- }
315
- });
316
- bot.command("skills", async (ctx) => {
317
- const skills = listSkills();
318
- if (skills.length === 0) {
319
- await ctx.reply("No skills installed.");
320
- }
321
- else {
322
- const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
323
- await ctx.reply(lines.join("\n"));
324
- }
325
- });
326
- bot.command("workers", async (ctx) => {
327
- const workers = Array.from(getWorkers().values());
328
- if (workers.length === 0) {
329
- await ctx.reply("No active worker sessions.");
330
- }
331
- else {
332
- const lines = workers.map((w) => `• ${w.name} (${w.workingDir}) — ${w.status}`);
333
- await ctx.reply(lines.join("\n"));
334
- }
335
- });
336
- bot.command("status", async (ctx) => {
337
- const uptime = Math.floor((Date.now() - startedAt) / 1000);
338
- const hours = Math.floor(uptime / 3600);
339
- const minutes = Math.floor((uptime % 3600) / 60);
340
- const seconds = uptime % 60;
341
- const uptimeStr = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
342
- const workers = Array.from(getWorkers().values());
343
- const lines = [
344
- "📊 NZB Status",
345
- `Model: ${config.copilotModel}`,
346
- `Uptime: ${uptimeStr}`,
347
- `Workers: ${workers.length} active`,
348
- `Queue: ${getQueueSize()} pending`,
349
- ];
350
- await ctx.reply(lines.join("\n"));
351
- });
352
- bot.command("restart", async (ctx) => {
353
- await ctx.reply("Restarting NZB...");
354
- setTimeout(() => {
355
- restartDaemon().catch((err) => {
356
- console.error("[nzb] Restart failed:", err);
357
- });
358
- }, 500);
359
- });
360
- bot.command("settings", async (ctx) => {
361
- await ctx.reply(buildSettingsText(), { reply_markup: settingsMenu });
362
- });
363
- // Reply keyboard button handlers — intercept before general text handler
364
- bot.hears("📊 Status", async (ctx) => {
365
- const workers = Array.from(getWorkers().values());
366
- const lines = [
367
- "📊 NZB Status",
368
- `Model: ${config.copilotModel}`,
369
- `Uptime: ${getUptimeStr()}`,
370
- `Workers: ${workers.length} active`,
371
- `Queue: ${getQueueSize()} pending`,
372
- ];
373
- await ctx.reply(lines.join("\n"));
374
- });
375
- bot.hears("❌ Cancel", async (ctx) => {
376
- const cancelled = await cancelCurrentMessage();
377
- await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
378
- });
379
- bot.hears("🧠 Memory", async (ctx) => {
380
- const memories = searchMemories(undefined, undefined, 50);
381
- if (memories.length === 0) {
382
- await ctx.reply("No memories stored.");
383
- }
384
- else {
385
- await ctx.reply(formatMemoryList(memories), { parse_mode: "HTML" });
386
- }
387
- });
388
- bot.hears("🔄 Restart", async (ctx) => {
389
- await ctx.reply("Restarting NZB...");
390
- setTimeout(() => {
391
- restartDaemon().catch(console.error);
392
- }, 500);
393
- });
394
- // Handle all text messages — progressive streaming with tool event feedback
395
- bot.on("message:text", async (ctx) => {
396
- const chatId = ctx.chat.id;
397
- const userMessageId = ctx.message.message_id;
398
- const replyParams = { message_id: userMessageId };
399
- const msgPreview = ctx.message.text.length > 80 ? ctx.message.text.slice(0, 80) + "…" : ctx.message.text;
400
- void logInfo(`📩 Message: ${msgPreview}`);
401
- // React with 👀 to acknowledge message received
402
- try {
403
- await ctx.react("👀");
404
- }
405
- catch {
406
- /* reactions may not be available */
407
- }
408
- // Typing indicator — keeps sending "typing" action every 4s until the final
409
- // response is delivered. We use bot.api directly for reliability, and await the
410
- // first call so the user sees typing immediately before any async work begins.
411
- let typingStopped = false;
412
- let typingInterval;
413
- const sendTyping = async () => {
414
- if (typingStopped)
415
- return;
416
- try {
417
- await bot.api.sendChatAction(chatId, "typing");
418
- }
419
- catch (err) {
420
- console.error("[nzb] typing error:", err instanceof Error ? err.message : err);
421
- }
422
- };
423
- const startTyping = async () => {
424
- await sendTyping();
425
- typingInterval = setInterval(() => void sendTyping(), 4000);
426
- };
427
- const stopTyping = () => {
428
- typingStopped = true;
429
- if (typingInterval) {
430
- clearInterval(typingInterval);
431
- typingInterval = undefined;
432
- }
433
- };
434
- await startTyping();
435
- // Progressive streaming state — all Telegram API calls are serialized through editChain
436
- // to prevent duplicate placeholder messages and race conditions
437
- let placeholderMsgId;
438
- let lastEditTime = 0;
439
- let lastEditedText = "";
440
- let currentToolName;
441
- const toolHistory = [];
442
- let usageInfo;
443
- let finalized = false;
444
- let editChain = Promise.resolve();
445
- const EDIT_INTERVAL_MS = 3000;
446
- // Minimum character delta before sending an edit — avoids wasting API calls on tiny changes
447
- const MIN_EDIT_DELTA = 50;
448
- // Minimum time before showing the first placeholder, so user sees "typing" first
449
- const FIRST_PLACEHOLDER_DELAY_MS = 1500;
450
- const handlerStartTime = Date.now();
451
- const enqueueEdit = (text) => {
452
- if (finalized || text === lastEditedText)
453
- return;
454
- editChain = editChain
455
- .then(async () => {
456
- if (finalized || text === lastEditedText)
457
- return;
458
- if (!placeholderMsgId) {
459
- // Let the typing indicator show for at least a short period
460
- const elapsed = Date.now() - handlerStartTime;
461
- if (elapsed < FIRST_PLACEHOLDER_DELAY_MS) {
462
- await new Promise((r) => setTimeout(r, FIRST_PLACEHOLDER_DELAY_MS - elapsed));
463
- }
464
- if (finalized)
465
- return;
466
- try {
467
- const msg = await ctx.reply(text, { reply_parameters: replyParams });
468
- placeholderMsgId = msg.message_id;
469
- // Stop typing once placeholder is visible — edits serve as the indicator now
470
- stopTyping();
471
- }
472
- catch {
473
- return;
474
- }
475
- }
476
- else {
477
- try {
478
- await bot.api.editMessageText(chatId, placeholderMsgId, text);
479
- }
480
- catch {
481
- return;
482
- }
483
- }
484
- lastEditedText = text;
485
- })
486
- .catch(() => { });
487
- };
488
- const onToolEvent = (event) => {
489
- console.log(`[nzb] Bot received tool event: ${event.type} ${event.toolName}`);
490
- if (event.type === "tool_start") {
491
- void logDebug(`🔧 Tool start: ${event.toolName}${event.detail ? ` — ${event.detail}` : ""}`);
492
- currentToolName = event.toolName;
493
- toolHistory.push({ name: event.toolName, startTime: Date.now(), detail: event.detail });
494
- const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
495
- const existingText = lastEditedText.replace(/^🔧 .*\n\n/, "");
496
- enqueueEdit(`🔧 ${event.toolName} (${elapsed}s...)\n\n${existingText}`.trim() || `🔧 ${event.toolName}`);
497
- }
498
- else if (event.type === "tool_complete") {
499
- for (let i = toolHistory.length - 1; i >= 0; i--) {
500
- if (toolHistory[i].name === event.toolName && toolHistory[i].durationMs === undefined) {
501
- toolHistory[i].durationMs = Date.now() - toolHistory[i].startTime;
502
- break;
503
- }
504
- }
505
- // Show completion with checkmark
506
- const completedTool = toolHistory.find((t) => t.name === event.toolName && t.durationMs !== undefined);
507
- if (completedTool) {
508
- const dur = (completedTool.durationMs / 1000).toFixed(1);
509
- const existingText = lastEditedText.replace(/^🔧 .*\n\n/, "").replace(/^✅ .*\n\n/, "");
510
- enqueueEdit(`✅ ${event.toolName} (${dur}s)\n\n${existingText}`.trim());
511
- }
512
- currentToolName = undefined;
513
- }
514
- else if (event.type === "tool_partial_result" && event.detail) {
515
- const now = Date.now();
516
- if (now - lastEditTime >= EDIT_INTERVAL_MS) {
517
- lastEditTime = now;
518
- const elapsed = ((now - handlerStartTime) / 1000).toFixed(1);
519
- const truncated = event.detail.length > 500 ? "⋯\n" + event.detail.slice(-500) : event.detail;
520
- const toolLine = `🔧 ${currentToolName || event.toolName} (${elapsed}s...)\n<pre>${escapeHtml(truncated)}</pre>`;
521
- enqueueEdit(toolLine);
522
- }
523
- }
524
- };
525
- // Notify user if their message is queued behind others
526
- const queueSize = getQueueSize();
527
- if (queueSize > 0) {
528
- try {
529
- await ctx.reply(`\u23f3 Queued (position ${queueSize + 1}) — I'll get to your message shortly.`, {
530
- reply_parameters: replyParams,
531
- });
532
- }
533
- catch {
534
- /* best-effort */
535
- }
536
- }
537
- const onUsage = (usage) => {
538
- usageInfo = usage;
539
- };
540
- // If user replies to a message, include surrounding conversation context
541
- let userPrompt = ctx.message.text;
542
- const replyMsg = ctx.message.reply_to_message;
543
- if (replyMsg && "text" in replyMsg && replyMsg.text) {
544
- // Try to find full conversation context around the replied message
545
- const { getConversationContext } = await import("../store/db.js");
546
- const context = getConversationContext(replyMsg.message_id);
547
- if (context) {
548
- userPrompt = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n[Your reply]: ${userPrompt}`;
549
- }
550
- else {
551
- const quoted = replyMsg.text.length > 500 ? replyMsg.text.slice(0, 500) + "…" : replyMsg.text;
552
- userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
553
- }
554
- }
555
- sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
556
- if (done) {
557
- finalized = true;
558
- stopTyping();
559
- const assistantLogId = meta?.assistantLogId;
560
- const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
561
- void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
562
- // Return the edit chain so callers can await final delivery
563
- return editChain.then(async () => {
564
- // Format error messages with a distinct visual
565
- const isError = text.startsWith("Error:");
566
- if (isError) {
567
- void logError(`Response error: ${text.slice(0, 200)}`);
568
- const errorText = `⚠️ ${text}`;
569
- const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
570
- if (placeholderMsgId) {
571
- try {
572
- await bot.api.editMessageText(chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
573
- return;
574
- }
575
- catch {
576
- /* fall through */
577
- }
578
- }
579
- try {
580
- await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
581
- }
582
- catch {
583
- /* nothing more we can do */
584
- }
585
- return;
586
- }
587
- let textWithMeta = text;
588
- if (usageInfo) {
589
- const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n));
590
- const parts = [];
591
- if (usageInfo.model)
592
- parts.push(usageInfo.model);
593
- parts.push(`⬆${fmtTokens(usageInfo.inputTokens)} ⬇${fmtTokens(usageInfo.outputTokens)}`);
594
- const totalTokens = usageInfo.inputTokens + usageInfo.outputTokens;
595
- parts.push(`Σ${fmtTokens(totalTokens)}`);
596
- if (usageInfo.duration)
597
- parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
598
- textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
599
- }
600
- const formatted = toTelegramHTML(textWithMeta);
601
- let fullFormatted = formatted;
602
- if (config.showReasoning && toolHistory.length > 0) {
603
- const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })));
604
- fullFormatted += expandable;
605
- }
606
- const chunks = chunkMessage(fullFormatted);
607
- const fallbackChunks = chunkMessage(textWithMeta);
608
- // 🚀 Breakthrough: Build smart suggestion buttons based on response content
609
- const smartKb = createSmartSuggestionsWithContext(text, ctx.message.text, 4);
610
- // Single chunk: edit placeholder in place
611
- if (placeholderMsgId && chunks.length === 1) {
612
- try {
613
- await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], {
614
- parse_mode: "HTML",
615
- reply_markup: smartKb,
616
- });
617
- try {
618
- await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
619
- }
620
- catch { }
621
- if (assistantLogId) {
622
- try {
623
- const { setConversationTelegramMsgId } = await import("../store/db.js");
624
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
625
- }
626
- catch { }
627
- }
628
- return;
629
- }
630
- catch {
631
- try {
632
- await bot.api.editMessageText(chatId, placeholderMsgId, fallbackChunks[0], {
633
- reply_markup: smartKb,
634
- });
635
- try {
636
- await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
637
- }
638
- catch { }
639
- if (assistantLogId) {
640
- try {
641
- const { setConversationTelegramMsgId } = await import("../store/db.js");
642
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
643
- }
644
- catch { }
645
- }
646
- return;
647
- }
648
- catch {
649
- /* fall through to send new messages */
650
- }
651
- }
652
- }
653
- // Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
654
- const totalChunks = chunks.length;
655
- let firstSentMsgId;
656
- const sendChunk = async (chunk, fallback, index) => {
657
- const isFirst = index === 0 && !placeholderMsgId;
658
- const isLast = index === totalChunks - 1;
659
- // Pagination header for multi-chunk messages
660
- const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
661
- const opts = {
662
- parse_mode: "HTML",
663
- ...(isFirst ? { reply_parameters: replyParams } : {}),
664
- ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
665
- };
666
- const fallbackOpts = {
667
- ...(isFirst ? { reply_parameters: replyParams } : {}),
668
- ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
669
- };
670
- const sent = await ctx
671
- .reply(pageTag + chunk, opts)
672
- .catch(() => ctx.reply(pageTag + fallback, fallbackOpts));
673
- if (index === 0 && sent)
674
- firstSentMsgId = sent.message_id;
675
- };
676
- let sendSucceeded = false;
677
- try {
678
- for (let i = 0; i < chunks.length; i++) {
679
- if (i > 0)
680
- await new Promise((r) => setTimeout(r, 300));
681
- await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i);
682
- }
683
- sendSucceeded = true;
684
- }
685
- catch {
686
- try {
687
- for (let i = 0; i < fallbackChunks.length; i++) {
688
- if (i > 0)
689
- await new Promise((r) => setTimeout(r, 300));
690
- const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
691
- const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
692
- if (i === 0 && sent)
693
- firstSentMsgId = sent.message_id;
694
- }
695
- sendSucceeded = true;
696
- }
697
- catch {
698
- /* nothing more we can do */
699
- }
700
- }
701
- // Only delete placeholder AFTER new messages sent successfully
702
- if (placeholderMsgId && sendSucceeded) {
703
- try {
704
- await bot.api.deleteMessage(chatId, placeholderMsgId);
705
- }
706
- catch {
707
- /* ignore — placeholder stays but user has the real message */
708
- }
709
- }
710
- // Track bot message ID for reply-to context lookups
711
- const botMsgId = firstSentMsgId ?? placeholderMsgId;
712
- if (assistantLogId && botMsgId) {
713
- try {
714
- const { setConversationTelegramMsgId } = await import("../store/db.js");
715
- setConversationTelegramMsgId(assistantLogId, botMsgId);
716
- }
717
- catch { }
718
- }
719
- // React ✅ on the user's original message to signal completion
720
- try {
721
- await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
722
- }
723
- catch {
724
- /* reactions may not be available */
725
- }
726
- });
727
- }
728
- else {
729
- // Progressive streaming: update placeholder periodically with delta threshold
730
- const now = Date.now();
731
- const textDelta = Math.abs(text.length - lastEditedText.length);
732
- if (now - lastEditTime >= EDIT_INTERVAL_MS && textDelta >= MIN_EDIT_DELTA) {
733
- lastEditTime = now;
734
- // Show beginning + end for context instead of just the tail
735
- let preview;
736
- if (text.length > 4000) {
737
- preview = text.slice(0, 1800) + "\n\n⋯\n\n" + text.slice(-1800);
738
- }
739
- else {
740
- preview = text;
741
- }
742
- const statusLine = currentToolName ? `🔧 ${currentToolName}\n\n` : "";
743
- enqueueEdit(statusLine + preview);
744
- }
745
- }
746
- }, onToolEvent, onUsage);
747
- });
748
- // Register media handlers (photo, document, voice) from extracted module
111
+ // Slash commands + reply keyboard button handlers
112
+ registerCommandHandlers(bot, { replyKeyboard, mainMenu, settingsMenu, getUptimeStr });
113
+ // Main streaming message handler
114
+ registerMessageHandler(bot, getBot);
115
+ // Media handlers (photo, document, voice)
749
116
  registerMediaHandlers(bot);
750
117
  // Global error handler — prevents unhandled errors from crashing the bot
751
118
  bot.catch((err) => {
@@ -780,6 +147,12 @@ export async function startBot() {
780
147
  catch (err) {
781
148
  console.error("[nzb] Failed to register bot commands:", err instanceof Error ? err.message : err);
782
149
  }
150
+ // Resume from the last processed update offset (if available) to avoid
151
+ // re-processing updates that were already handled before a restart.
152
+ const savedOffset = getPersistedUpdateOffset();
153
+ if (savedOffset) {
154
+ console.log(`[nzb] Resuming Telegram polling from update offset ${savedOffset}`);
155
+ }
783
156
  bot
784
157
  .start({
785
158
  allowed_updates: [
@@ -790,6 +163,7 @@ export async function startBot() {
790
163
  "message_reaction",
791
164
  "my_chat_member",
792
165
  ],
166
+ ...(savedOffset ? { offset: savedOffset + 1 } : {}),
793
167
  onStart: () => {
794
168
  console.log("[nzb] Telegram bot connected");
795
169
  void logInfo(`🚀 NZB v${process.env.npm_package_version || "?"} started (model: ${config.copilotModel})`);
@@ -816,6 +190,9 @@ export async function startBot() {
816
190
  }
817
191
  export async function stopBot() {
818
192
  if (bot) {
193
+ // Abort pending getUpdates fetch first to prevent 30s hang and 409 conflicts on restart
194
+ fetchAbortController?.abort();
195
+ fetchAbortController = undefined;
819
196
  await bot.stop();
820
197
  }
821
198
  }