@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.
- package/dist/telegram/bot.js +68 -691
- package/dist/telegram/dedup.js +47 -0
- package/dist/telegram/formatter.js +109 -16
- package/dist/telegram/handlers/commands.js +151 -0
- package/dist/telegram/handlers/streaming.js +416 -0
- package/dist/telegram/menus.js +180 -0
- package/dist/telegram/safe-api.js +41 -0
- package/package.json +4 -2
package/dist/telegram/bot.js
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
import { autoRetry } from "@grammyjs/auto-retry";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
6
|
-
import {
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
250
|
-
bot
|
|
251
|
-
|
|
252
|
-
|
|
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
|
}
|