@iletai/nzb 1.7.0 → 1.7.4
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/api/server.js +37 -6
- package/dist/cli.js +1 -0
- package/dist/config.js +12 -3
- package/dist/copilot/client.js +17 -16
- package/dist/copilot/mcp-config.js +2 -0
- package/dist/copilot/orchestrator.js +289 -125
- package/dist/copilot/skills.js +4 -2
- package/dist/copilot/tools.js +48 -11
- package/dist/copilot/types.js +2 -0
- package/dist/daemon.js +11 -10
- package/dist/setup.js +3 -2
- package/dist/store/conversation.js +96 -0
- package/dist/store/db.js +7 -206
- package/dist/store/memory.js +90 -0
- package/dist/store/team-store.js +51 -0
- package/dist/telegram/bot.js +85 -8
- package/dist/telegram/handlers/commands.js +1 -1
- package/dist/telegram/handlers/media.js +63 -6
- package/dist/telegram/handlers/streaming.js +223 -188
- package/dist/telegram/handlers/suggestions.js +22 -1
- package/dist/telegram/log-channel.js +2 -2
- package/dist/telegram/menus.js +243 -99
- package/dist/tui/ansi.js +19 -0
- package/dist/tui/api-client.js +158 -0
- package/dist/tui/debug.js +27 -0
- package/dist/tui/renderer.js +59 -0
- package/dist/tui/stream.js +163 -0
- package/dist/update.js +2 -0
- package/dist/utils.js +102 -0
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
// ── Agent Teams CRUD ──────────────────────────────────────────
|
|
3
|
+
export function createTeam(id, taskDescription, originChannel) {
|
|
4
|
+
const db = getDb();
|
|
5
|
+
db.prepare(`INSERT INTO agent_teams (id, task_description, origin_channel) VALUES (?, ?, ?)`).run(id, taskDescription, originChannel ?? null);
|
|
6
|
+
}
|
|
7
|
+
export function addTeamMember(teamId, workerName, role) {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
db.prepare(`INSERT INTO team_members (team_id, worker_name, role, status) VALUES (?, ?, ?, 'pending')`).run(teamId, workerName, role);
|
|
10
|
+
db.prepare(`UPDATE agent_teams SET member_count = member_count + 1 WHERE id = ?`).run(teamId);
|
|
11
|
+
}
|
|
12
|
+
export function updateTeamMemberResult(teamId, workerName, result, status) {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
db.prepare(`UPDATE team_members SET result = ?, status = ?, completed_at = CURRENT_TIMESTAMP WHERE team_id = ? AND worker_name = ?`).run(result, status, teamId, workerName);
|
|
15
|
+
if (status === "completed" || status === "error") {
|
|
16
|
+
db.prepare(`UPDATE agent_teams SET completed_count = completed_count + 1 WHERE id = ?`).run(teamId);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function getTeam(id) {
|
|
20
|
+
const db = getDb();
|
|
21
|
+
return db.prepare(`SELECT * FROM agent_teams WHERE id = ?`).get(id);
|
|
22
|
+
}
|
|
23
|
+
export function getTeamMembers(teamId) {
|
|
24
|
+
const db = getDb();
|
|
25
|
+
return db
|
|
26
|
+
.prepare(`SELECT worker_name, role, status, result FROM team_members WHERE team_id = ? ORDER BY id`)
|
|
27
|
+
.all(teamId);
|
|
28
|
+
}
|
|
29
|
+
export function completeTeam(teamId, aggregatedResult, status = "completed") {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
db.prepare(`UPDATE agent_teams SET status = ?, aggregated_result = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?`).run(status, aggregatedResult, teamId);
|
|
32
|
+
}
|
|
33
|
+
export function getActiveTeams() {
|
|
34
|
+
const db = getDb();
|
|
35
|
+
return db
|
|
36
|
+
.prepare(`SELECT id, status, task_description, member_count, completed_count, created_at FROM agent_teams WHERE status = 'active' ORDER BY created_at DESC`)
|
|
37
|
+
.all();
|
|
38
|
+
}
|
|
39
|
+
export function getTeamByWorkerName(workerName) {
|
|
40
|
+
const db = getDb();
|
|
41
|
+
const row = db
|
|
42
|
+
.prepare(`SELECT team_id FROM team_members WHERE worker_name = ? AND status IN ('pending', 'running') LIMIT 1`)
|
|
43
|
+
.get(workerName);
|
|
44
|
+
return row?.team_id;
|
|
45
|
+
}
|
|
46
|
+
export function cleanupTeam(teamId) {
|
|
47
|
+
const db = getDb();
|
|
48
|
+
db.prepare(`DELETE FROM team_members WHERE team_id = ?`).run(teamId);
|
|
49
|
+
db.prepare(`DELETE FROM agent_teams WHERE id = ?`).run(teamId);
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=team-store.js.map
|
package/dist/telegram/bot.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { autoRetry } from "@grammyjs/auto-retry";
|
|
2
2
|
import { sequentialize } from "@grammyjs/runner";
|
|
3
3
|
import { apiThrottler } from "@grammyjs/transformer-throttler";
|
|
4
|
+
import { realpathSync } from "fs";
|
|
4
5
|
import { Bot, Keyboard } from "grammy";
|
|
5
6
|
import { Agent as HttpsAgent } from "https";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { resolve as pathResolve } from "path";
|
|
6
9
|
import { config } from "../config.js";
|
|
7
10
|
import { getPersistedUpdateOffset, isUpdateDuplicate, persistUpdateOffset } from "./dedup.js";
|
|
8
11
|
import { registerCallbackHandlers } from "./handlers/callbacks.js";
|
|
@@ -19,6 +22,11 @@ let bot;
|
|
|
19
22
|
/** Abort controller for graceful fetch abort on shutdown — prevents 30s getUpdates hang and 409 conflicts. */
|
|
20
23
|
let fetchAbortController;
|
|
21
24
|
const startedAt = Date.now();
|
|
25
|
+
const INITIAL_POLL_RETRY_DELAY = 5000;
|
|
26
|
+
const MAX_POLL_RETRY_DELAY = 300_000; // 5 minutes
|
|
27
|
+
let pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
|
|
28
|
+
let consecutivePollingFailures = 0;
|
|
29
|
+
const MAX_CONSECUTIVE_POLLING_FAILURES = 10;
|
|
22
30
|
// Direct-connection HTTPS agent for Telegram API requests.
|
|
23
31
|
// This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
|
|
24
32
|
// modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
|
|
@@ -171,22 +179,30 @@ export async function startBot() {
|
|
|
171
179
|
...(savedOffset ? { offset: savedOffset + 1 } : {}),
|
|
172
180
|
onStart: () => {
|
|
173
181
|
console.log("[nzb] Telegram bot connected");
|
|
182
|
+
consecutivePollingFailures = 0;
|
|
183
|
+
pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
|
|
174
184
|
void logInfo(`🚀 NZB v${process.env.npm_package_version || "?"} started (model: ${config.copilotModel})`);
|
|
175
185
|
},
|
|
176
186
|
})
|
|
177
187
|
.catch(async (err) => {
|
|
188
|
+
consecutivePollingFailures++;
|
|
178
189
|
if (err?.error_code === 401) {
|
|
179
190
|
console.error("[nzb] Warning: Telegram bot token is invalid or expired. Run 'nzb setup' and re-enter your bot token from @BotFather.");
|
|
180
191
|
return; // Unrecoverable — don't retry
|
|
181
192
|
}
|
|
193
|
+
if (consecutivePollingFailures >= MAX_CONSECUTIVE_POLLING_FAILURES) {
|
|
194
|
+
console.error(`[nzb] Telegram polling failed ${consecutivePollingFailures} consecutive times. Stopping retry attempts. Restart NZB to try again.`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
182
197
|
if (err?.error_code === 409) {
|
|
183
|
-
console.error(
|
|
198
|
+
console.error(`[nzb] Warning: Telegram polling conflict (409). Restarting polling in ${pollRetryDelay / 1000}s...`);
|
|
184
199
|
}
|
|
185
200
|
else {
|
|
186
|
-
console.error("[nzb] Error: Telegram polling stopped:", err?.message || err,
|
|
201
|
+
console.error("[nzb] Error: Telegram polling stopped:", err?.message || err, `— restarting in ${pollRetryDelay / 1000}s...`);
|
|
187
202
|
}
|
|
188
|
-
// Auto-restart polling
|
|
189
|
-
await new Promise((r) => setTimeout(r,
|
|
203
|
+
// Auto-restart polling with exponential backoff
|
|
204
|
+
await new Promise((r) => setTimeout(r, pollRetryDelay));
|
|
205
|
+
pollRetryDelay = Math.min(pollRetryDelay * 2, MAX_POLL_RETRY_DELAY);
|
|
190
206
|
if (bot) {
|
|
191
207
|
console.log("[nzb] Re-starting Telegram polling...");
|
|
192
208
|
startBot().catch((e) => console.error("[nzb] Failed to re-start Telegram polling:", e));
|
|
@@ -215,17 +231,78 @@ export async function sendWorkerNotification(message) {
|
|
|
215
231
|
const { truncateForTelegram } = await import("./formatter.js");
|
|
216
232
|
await bot.api.sendMessage(config.authorizedUserId, truncateForTelegram(message));
|
|
217
233
|
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
console.error("[nzb] Worker notification failed:", err instanceof Error ? err.message : err);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Check if a URL points to an internal/private network address. */
|
|
239
|
+
function isInternalUrl(urlStr) {
|
|
240
|
+
try {
|
|
241
|
+
const url = new URL(urlStr);
|
|
242
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, ""); // Strip IPv6 brackets
|
|
243
|
+
if (hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1")
|
|
244
|
+
return true;
|
|
245
|
+
if (hostname.startsWith("127."))
|
|
246
|
+
return true; // Entire 127.0.0.0/8 loopback range
|
|
247
|
+
if (hostname.startsWith("10."))
|
|
248
|
+
return true;
|
|
249
|
+
if (hostname.startsWith("172.") &&
|
|
250
|
+
parseInt(hostname.split(".")[1]) >= 16 &&
|
|
251
|
+
parseInt(hostname.split(".")[1]) <= 31)
|
|
252
|
+
return true;
|
|
253
|
+
if (hostname.startsWith("192.168."))
|
|
254
|
+
return true;
|
|
255
|
+
if (hostname.startsWith("169.254."))
|
|
256
|
+
return true; // Entire 169.254.0.0/16 link-local range
|
|
257
|
+
if (hostname.endsWith(".internal") || hostname.endsWith(".local"))
|
|
258
|
+
return true;
|
|
259
|
+
// IPv6 private/link-local: fe80::/10, fc00::/7 (fd00::/8), IPv4-mapped ::ffff:x
|
|
260
|
+
if (/^(fe[89ab][0-9a-f]|f[cd][0-9a-f]{2}):/i.test(hostname))
|
|
261
|
+
return true;
|
|
262
|
+
if (hostname.startsWith("::ffff:"))
|
|
263
|
+
return true;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
218
266
|
catch {
|
|
219
|
-
//
|
|
267
|
+
// Expected: invalid URL treated as internal for safety
|
|
268
|
+
return true;
|
|
220
269
|
}
|
|
221
270
|
}
|
|
222
|
-
/**
|
|
271
|
+
/** Allowlisted directories for local file photo access. */
|
|
272
|
+
const PHOTO_ALLOWED_DIRS = [tmpdir(), "/tmp"];
|
|
273
|
+
/** Validate a local file path is within allowed directories. */
|
|
274
|
+
function isAllowedFilePath(filePath) {
|
|
275
|
+
try {
|
|
276
|
+
const resolved = realpathSync(pathResolve(filePath));
|
|
277
|
+
return PHOTO_ALLOWED_DIRS.some((dir) => resolved.startsWith(dir));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Expected: file may not exist or path may be inaccessible
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Send a photo to the authorized user. Accepts a file path or HTTPS URL. */
|
|
223
285
|
export async function sendPhoto(photo, caption) {
|
|
224
286
|
if (!bot || config.authorizedUserId === undefined)
|
|
225
287
|
return;
|
|
226
|
-
|
|
288
|
+
let input;
|
|
289
|
+
if (photo.startsWith("https://")) {
|
|
290
|
+
if (isInternalUrl(photo)) {
|
|
291
|
+
throw new Error("URL points to an internal/private network address");
|
|
292
|
+
}
|
|
293
|
+
input = photo;
|
|
294
|
+
}
|
|
295
|
+
else if (photo.startsWith("http://")) {
|
|
296
|
+
throw new Error("Only HTTPS URLs are allowed for photos");
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
if (!isAllowedFilePath(photo)) {
|
|
300
|
+
throw new Error("File path is not within allowed directories");
|
|
301
|
+
}
|
|
227
302
|
const { InputFile } = await import("grammy");
|
|
228
|
-
|
|
303
|
+
input = new InputFile(photo);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
229
306
|
await bot.api.sendPhoto(config.authorizedUserId, input, {
|
|
230
307
|
caption,
|
|
231
308
|
});
|
|
@@ -2,7 +2,7 @@ import { config, persistEnvVar, persistModel } from "../../config.js";
|
|
|
2
2
|
import { cancelCurrentMessage, compactSession, getQueueSize, getWorkers, resetSession, } from "../../copilot/orchestrator.js";
|
|
3
3
|
import { listSkills } from "../../copilot/skills.js";
|
|
4
4
|
import { restartDaemon } from "../../daemon.js";
|
|
5
|
-
import { searchMemories } from "../../store/
|
|
5
|
+
import { searchMemories } from "../../store/memory.js";
|
|
6
6
|
import { chunkMessage } from "../formatter.js";
|
|
7
7
|
import { buildSettingsText, formatMemoryList } from "../menus.js";
|
|
8
8
|
import { getReactionHelpText } from "./reactions.js";
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { config } from "../../config.js";
|
|
2
2
|
import { sendToOrchestrator } from "../../copilot/orchestrator.js";
|
|
3
3
|
import { logInfo } from "../log-channel.js";
|
|
4
|
-
import {
|
|
4
|
+
import { scheduleTempCleanup, sendFormattedReply } from "./helpers.js";
|
|
5
|
+
const MEDIA_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
6
|
+
/** Fetch with an AbortController timeout. Throws AbortError on timeout. */
|
|
7
|
+
async function fetchWithTimeout(url, init) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timeout = setTimeout(() => controller.abort(), MEDIA_DOWNLOAD_TIMEOUT_MS);
|
|
10
|
+
try {
|
|
11
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
clearTimeout(timeout);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
/** Register photo, document, and voice message handlers on the bot. */
|
|
6
18
|
export function registerMediaHandlers(bot) {
|
|
7
19
|
// Handle photo messages — download and pass to AI
|
|
@@ -23,13 +35,34 @@ export function registerMediaHandlers(bot) {
|
|
|
23
35
|
return;
|
|
24
36
|
}
|
|
25
37
|
const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
|
|
26
|
-
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetchWithTimeout(url);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
44
|
+
await ctx.reply("⏱ Media download timed out. Please try again.", {
|
|
45
|
+
reply_parameters: { message_id: userMessageId },
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
27
51
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
28
52
|
const base64Data = buffer.toString("base64");
|
|
29
53
|
const ext = filePath.split(".").pop() || "jpg";
|
|
30
54
|
const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
55
|
+
// Save to disk first as fallback — if vision fails, the AI can still reference the file
|
|
56
|
+
const { mkdtempSync, writeFileSync } = await import("fs");
|
|
57
|
+
const { join } = await import("path");
|
|
58
|
+
const { tmpdir } = await import("os");
|
|
59
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "nzb-photo-"));
|
|
60
|
+
const localPath = join(tmpDir, `photo.${ext}`);
|
|
61
|
+
writeFileSync(localPath, buffer);
|
|
62
|
+
scheduleTempCleanup(tmpDir);
|
|
31
63
|
const attachment = { type: "blob", data: base64Data, mimeType };
|
|
32
|
-
|
|
64
|
+
const promptWithPath = `${caption}\n\n[Image also saved to: ${localPath}]`;
|
|
65
|
+
sendToOrchestrator(promptWithPath, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
33
66
|
if (done)
|
|
34
67
|
void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
|
|
35
68
|
}, undefined, undefined, 0, [attachment]);
|
|
@@ -68,7 +101,19 @@ export function registerMediaHandlers(bot) {
|
|
|
68
101
|
const { tmpdir } = await import("os");
|
|
69
102
|
const tmpDir = mkdtempSync(join(tmpdir(), "nzb-doc-"));
|
|
70
103
|
const localPath = join(tmpDir, doc.file_name || "file");
|
|
71
|
-
|
|
104
|
+
let response;
|
|
105
|
+
try {
|
|
106
|
+
response = await fetchWithTimeout(url);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
110
|
+
await ctx.reply("⏱ Media download timed out. Please try again.", {
|
|
111
|
+
reply_parameters: { message_id: userMessageId },
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
72
117
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
73
118
|
writeFileSync(localPath, buffer);
|
|
74
119
|
scheduleTempCleanup(tmpDir);
|
|
@@ -126,7 +171,19 @@ export function registerMediaHandlers(bot) {
|
|
|
126
171
|
const tmpDir = mkdtempSync(join(tmpdir(), "nzb-voice-"));
|
|
127
172
|
const ext = filePath.split(".").pop() || "oga";
|
|
128
173
|
const localPath = join(tmpDir, `voice.${ext}`);
|
|
129
|
-
|
|
174
|
+
let response;
|
|
175
|
+
try {
|
|
176
|
+
response = await fetchWithTimeout(url);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
180
|
+
await ctx.reply("⏱ Media download timed out. Please try again.", {
|
|
181
|
+
reply_parameters: { message_id: userMessageId },
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
130
187
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
131
188
|
writeFileSync(localPath, buffer);
|
|
132
189
|
scheduleTempCleanup(tmpDir);
|
|
@@ -136,7 +193,7 @@ export function registerMediaHandlers(bot) {
|
|
|
136
193
|
const formData = new FormData();
|
|
137
194
|
formData.append("file", new Blob([buffer], { type: "audio/ogg" }), `voice.${ext}`);
|
|
138
195
|
formData.append("model", "whisper-1");
|
|
139
|
-
const whisperResp = await
|
|
196
|
+
const whisperResp = await fetchWithTimeout("https://api.openai.com/v1/audio/transcriptions", {
|
|
140
197
|
method: "POST",
|
|
141
198
|
headers: { Authorization: `Bearer ${config.openaiApiKey}` },
|
|
142
199
|
body: formData,
|