@iletai/nzb 1.6.4 → 1.7.3

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.
@@ -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
@@ -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,9 @@ 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;
22
28
  // Direct-connection HTTPS agent for Telegram API requests.
23
29
  // This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
24
30
  // modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
@@ -171,6 +177,7 @@ export async function startBot() {
171
177
  ...(savedOffset ? { offset: savedOffset + 1 } : {}),
172
178
  onStart: () => {
173
179
  console.log("[nzb] Telegram bot connected");
180
+ pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
174
181
  void logInfo(`🚀 NZB v${process.env.npm_package_version || "?"} started (model: ${config.copilotModel})`);
175
182
  },
176
183
  })
@@ -180,13 +187,14 @@ export async function startBot() {
180
187
  return; // Unrecoverable — don't retry
181
188
  }
182
189
  if (err?.error_code === 409) {
183
- console.error("[nzb] Warning: Telegram polling conflict (409). Restarting polling in 5 seconds...");
190
+ console.error(`[nzb] Warning: Telegram polling conflict (409). Restarting polling in ${pollRetryDelay / 1000}s...`);
184
191
  }
185
192
  else {
186
- console.error("[nzb] Error: Telegram polling stopped:", err?.message || err, "— restarting in 5 seconds...");
193
+ console.error("[nzb] Error: Telegram polling stopped:", err?.message || err, `— restarting in ${pollRetryDelay / 1000}s...`);
187
194
  }
188
- // Auto-restart polling after a delay
189
- await new Promise((r) => setTimeout(r, 5000));
195
+ // Auto-restart polling with exponential backoff
196
+ await new Promise((r) => setTimeout(r, pollRetryDelay));
197
+ pollRetryDelay = Math.min(pollRetryDelay * 2, MAX_POLL_RETRY_DELAY);
190
198
  if (bot) {
191
199
  console.log("[nzb] Re-starting Telegram polling...");
192
200
  startBot().catch((e) => console.error("[nzb] Failed to re-start Telegram polling:", e));
@@ -215,17 +223,78 @@ export async function sendWorkerNotification(message) {
215
223
  const { truncateForTelegram } = await import("./formatter.js");
216
224
  await bot.api.sendMessage(config.authorizedUserId, truncateForTelegram(message));
217
225
  }
226
+ catch (err) {
227
+ console.error("[nzb] Worker notification failed:", err instanceof Error ? err.message : err);
228
+ }
229
+ }
230
+ /** Check if a URL points to an internal/private network address. */
231
+ function isInternalUrl(urlStr) {
232
+ try {
233
+ const url = new URL(urlStr);
234
+ const hostname = url.hostname.replace(/^\[|\]$/g, ""); // Strip IPv6 brackets
235
+ if (hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1")
236
+ return true;
237
+ if (hostname.startsWith("127."))
238
+ return true; // Entire 127.0.0.0/8 loopback range
239
+ if (hostname.startsWith("10."))
240
+ return true;
241
+ if (hostname.startsWith("172.") &&
242
+ parseInt(hostname.split(".")[1]) >= 16 &&
243
+ parseInt(hostname.split(".")[1]) <= 31)
244
+ return true;
245
+ if (hostname.startsWith("192.168."))
246
+ return true;
247
+ if (hostname.startsWith("169.254."))
248
+ return true; // Entire 169.254.0.0/16 link-local range
249
+ if (hostname.endsWith(".internal") || hostname.endsWith(".local"))
250
+ return true;
251
+ // IPv6 private/link-local: fe80::/10, fc00::/7 (fd00::/8), IPv4-mapped ::ffff:x
252
+ if (/^(fe[89ab][0-9a-f]|f[cd][0-9a-f]{2}):/i.test(hostname))
253
+ return true;
254
+ if (hostname.startsWith("::ffff:"))
255
+ return true;
256
+ return false;
257
+ }
218
258
  catch {
219
- // best-effort don't crash if notification fails
259
+ // Expected: invalid URL treated as internal for safety
260
+ return true;
220
261
  }
221
262
  }
222
- /** Send a photo to the authorized user. Accepts a file path or URL. */
263
+ /** Allowlisted directories for local file photo access. */
264
+ const PHOTO_ALLOWED_DIRS = [tmpdir(), "/tmp"];
265
+ /** Validate a local file path is within allowed directories. */
266
+ function isAllowedFilePath(filePath) {
267
+ try {
268
+ const resolved = realpathSync(pathResolve(filePath));
269
+ return PHOTO_ALLOWED_DIRS.some((dir) => resolved.startsWith(dir));
270
+ }
271
+ catch {
272
+ // Expected: file may not exist or path may be inaccessible
273
+ return false;
274
+ }
275
+ }
276
+ /** Send a photo to the authorized user. Accepts a file path or HTTPS URL. */
223
277
  export async function sendPhoto(photo, caption) {
224
278
  if (!bot || config.authorizedUserId === undefined)
225
279
  return;
226
- try {
280
+ let input;
281
+ if (photo.startsWith("https://")) {
282
+ if (isInternalUrl(photo)) {
283
+ throw new Error("URL points to an internal/private network address");
284
+ }
285
+ input = photo;
286
+ }
287
+ else if (photo.startsWith("http://")) {
288
+ throw new Error("Only HTTPS URLs are allowed for photos");
289
+ }
290
+ else {
291
+ if (!isAllowedFilePath(photo)) {
292
+ throw new Error("File path is not within allowed directories");
293
+ }
227
294
  const { InputFile } = await import("grammy");
228
- const input = photo.startsWith("http") ? photo : new InputFile(photo);
295
+ input = new InputFile(photo);
296
+ }
297
+ try {
229
298
  await bot.api.sendPhoto(config.authorizedUserId, input, {
230
299
  caption,
231
300
  });
@@ -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/db.js";
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 { sendFormattedReply, scheduleTempCleanup } from "./helpers.js";
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,21 +35,37 @@ export function registerMediaHandlers(bot) {
23
35
  return;
24
36
  }
25
37
  const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
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
+ }
51
+ const buffer = Buffer.from(await response.arrayBuffer());
52
+ const base64Data = buffer.toString("base64");
53
+ const ext = filePath.split(".").pop() || "jpg";
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
26
56
  const { mkdtempSync, writeFileSync } = await import("fs");
27
57
  const { join } = await import("path");
28
58
  const { tmpdir } = await import("os");
29
59
  const tmpDir = mkdtempSync(join(tmpdir(), "nzb-photo-"));
30
- const ext = filePath.split(".").pop() || "jpg";
31
60
  const localPath = join(tmpDir, `photo.${ext}`);
32
- const response = await fetch(url);
33
- const buffer = Buffer.from(await response.arrayBuffer());
34
61
  writeFileSync(localPath, buffer);
35
62
  scheduleTempCleanup(tmpDir);
36
- const prompt = `[User sent a photo saved at: ${localPath}]\n\nCaption: ${caption}\n\nPlease analyze this image. The file is at ${localPath} — you can use bash to view it with tools if needed.`;
37
- sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
63
+ const attachment = { type: "blob", data: base64Data, mimeType };
64
+ const promptWithPath = `${caption}\n\n[Image also saved to: ${localPath}]`;
65
+ sendToOrchestrator(promptWithPath, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
38
66
  if (done)
39
67
  void sendFormattedReply(bot, chatId, text, { replyTo: userMessageId });
40
- });
68
+ }, undefined, undefined, 0, [attachment]);
41
69
  }
42
70
  catch (err) {
43
71
  await ctx.reply(`❌ Error processing photo: ${err instanceof Error ? err.message : String(err)}`, {
@@ -73,7 +101,19 @@ export function registerMediaHandlers(bot) {
73
101
  const { tmpdir } = await import("os");
74
102
  const tmpDir = mkdtempSync(join(tmpdir(), "nzb-doc-"));
75
103
  const localPath = join(tmpDir, doc.file_name || "file");
76
- const response = await fetch(url);
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
+ }
77
117
  const buffer = Buffer.from(await response.arrayBuffer());
78
118
  writeFileSync(localPath, buffer);
79
119
  scheduleTempCleanup(tmpDir);
@@ -131,7 +171,19 @@ export function registerMediaHandlers(bot) {
131
171
  const tmpDir = mkdtempSync(join(tmpdir(), "nzb-voice-"));
132
172
  const ext = filePath.split(".").pop() || "oga";
133
173
  const localPath = join(tmpDir, `voice.${ext}`);
134
- const response = await fetch(url);
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
+ }
135
187
  const buffer = Buffer.from(await response.arrayBuffer());
136
188
  writeFileSync(localPath, buffer);
137
189
  scheduleTempCleanup(tmpDir);
@@ -141,7 +193,7 @@ export function registerMediaHandlers(bot) {
141
193
  const formData = new FormData();
142
194
  formData.append("file", new Blob([buffer], { type: "audio/ogg" }), `voice.${ext}`);
143
195
  formData.append("model", "whisper-1");
144
- const whisperResp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
196
+ const whisperResp = await fetchWithTimeout("https://api.openai.com/v1/audio/transcriptions", {
145
197
  method: "POST",
146
198
  headers: { Authorization: `Bearer ${config.openaiApiKey}` },
147
199
  body: formData,