@iletai/nzb 1.9.0 → 1.10.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.
@@ -0,0 +1,208 @@
1
+ import { config } from "../config.js";
2
+ const FETCH_TIMEOUT_MS = 10_000;
3
+ const USER_AGENT = "nzb-research/1.0";
4
+ // ── HTTP helper ──────────────────────────────────────────────────────
5
+ async function fetchJson(url) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
8
+ try {
9
+ const res = await fetch(url, {
10
+ signal: controller.signal,
11
+ headers: { "User-Agent": USER_AGENT },
12
+ });
13
+ if (!res.ok)
14
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
15
+ return (await res.json());
16
+ }
17
+ finally {
18
+ clearTimeout(timer);
19
+ }
20
+ }
21
+ // ── Source fetchers ──────────────────────────────────────────────────
22
+ async function fetchHackerNews() {
23
+ const ids = await fetchJson("https://hacker-news.firebaseio.com/v0/topstories.json");
24
+ const top10 = ids.slice(0, 10);
25
+ const items = await Promise.all(top10.map((id) => fetchJson(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)));
26
+ const lines = items.map((item, i) => `${i + 1}. ${item.title ?? "Untitled"} (${item.score ?? 0} pts, ${item.descendants ?? 0} comments)${item.url ? `\n ${item.url}` : ""}`);
27
+ return lines.join("\n");
28
+ }
29
+ async function fetchGitHubTrending() {
30
+ const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
31
+ .toISOString()
32
+ .split("T")[0];
33
+ const data = await fetchJson(`https://api.github.com/search/repositories?q=created:>${since}&sort=stars&order=desc&per_page=5`);
34
+ const repos = data.items ?? [];
35
+ const lines = repos.map((r, i) => `${i + 1}. ${r.full_name ?? "unknown"} ⭐${r.stargazers_count ?? 0} [${r.language ?? "N/A"}]\n ${r.description ?? "No description"}\n ${r.html_url ?? ""}`);
36
+ return lines.join("\n");
37
+ }
38
+ async function fetchReddit(subreddit) {
39
+ const data = await fetchJson(`https://www.reddit.com/r/${subreddit}/hot.json?limit=5`);
40
+ const posts = data.data?.children ?? [];
41
+ const lines = posts
42
+ .filter((p) => p.data.title)
43
+ .map((p, i) => `${i + 1}. ${p.data.title} (${p.data.score ?? 0} pts, ${p.data.num_comments ?? 0} comments)`);
44
+ return lines.join("\n");
45
+ }
46
+ async function fetchGoldPrice() {
47
+ // Use a free metals API. Falls back to a message if unavailable.
48
+ try {
49
+ const data = await fetchJson("https://api.metals.dev/v1/latest?api_key=demo&currency=USD&unit=toz");
50
+ const metals = data.metals;
51
+ if (metals) {
52
+ const lines = [];
53
+ if (metals.gold)
54
+ lines.push(`Gold: $${metals.gold}/oz`);
55
+ if (metals.silver)
56
+ lines.push(`Silver: $${metals.silver}/oz`);
57
+ if (metals.platinum)
58
+ lines.push(`Platinum: $${metals.platinum}/oz`);
59
+ return lines.join("\n") || "No metal price data available.";
60
+ }
61
+ return JSON.stringify(data).slice(0, 500);
62
+ }
63
+ catch {
64
+ // Fallback: try alternative free API
65
+ try {
66
+ const data = await fetchJson("https://www.goldapi.io/api/XAU/USD");
67
+ const price = data.price;
68
+ return price ? `Gold: $${price}/oz` : JSON.stringify(data).slice(0, 300);
69
+ }
70
+ catch {
71
+ return "Gold price APIs unavailable. Could not fetch current prices.";
72
+ }
73
+ }
74
+ }
75
+ async function fetchCryptoPrices() {
76
+ const data = await fetchJson("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana,cardano&vs_currencies=usd&include_24hr_change=true");
77
+ const lines = [];
78
+ for (const [coin, info] of Object.entries(data)) {
79
+ const change = info.usd_24h_change != null ? ` (${info.usd_24h_change > 0 ? "+" : ""}${info.usd_24h_change.toFixed(2)}%)` : "";
80
+ lines.push(`${coin}: $${info.usd ?? "N/A"}${change}`);
81
+ }
82
+ return lines.join("\n") || "No crypto data available.";
83
+ }
84
+ // ── Presets ───────────────────────────────────────────────────────────
85
+ function getPresetSources(preset) {
86
+ switch (preset) {
87
+ case "tech-trends":
88
+ return [
89
+ { name: "HackerNews Top Stories", fetchData: fetchHackerNews },
90
+ { name: "GitHub Trending Repos (last 7 days)", fetchData: fetchGitHubTrending },
91
+ { name: "Reddit r/programming", fetchData: () => fetchReddit("programming") },
92
+ { name: "Reddit r/MachineLearning", fetchData: () => fetchReddit("MachineLearning") },
93
+ ];
94
+ case "gold-price":
95
+ return [
96
+ { name: "Gold & Metals Prices", fetchData: fetchGoldPrice },
97
+ ];
98
+ case "crypto":
99
+ return [
100
+ { name: "Cryptocurrency Prices", fetchData: fetchCryptoPrices },
101
+ ];
102
+ default:
103
+ return [];
104
+ }
105
+ }
106
+ function buildCustomSources(sources) {
107
+ return sources.map((s) => ({
108
+ name: s.name,
109
+ fetchData: async () => {
110
+ const data = await fetchJson(s.url);
111
+ return typeof data === "string" ? data : JSON.stringify(data).slice(0, 2000);
112
+ },
113
+ }));
114
+ }
115
+ // ── Fetch all sources in parallel ────────────────────────────────────
116
+ async function fetchAllSources(sources) {
117
+ const results = await Promise.allSettled(sources.map(async (src) => {
118
+ try {
119
+ const data = await src.fetchData();
120
+ return { name: src.name, data };
121
+ }
122
+ catch (err) {
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ return { name: src.name, data: `[Failed to fetch: ${msg}]` };
125
+ }
126
+ }));
127
+ const sections = [];
128
+ for (const result of results) {
129
+ if (result.status === "fulfilled") {
130
+ sections.push(`## ${result.value.name}\n${result.value.data}`);
131
+ }
132
+ }
133
+ return sections.join("\n\n");
134
+ }
135
+ // ── Main entry point ─────────────────────────────────────────────────
136
+ export async function executeResearchTask(job, payload) {
137
+ // 1. Determine sources
138
+ const preset = payload.preset;
139
+ const customSources = payload.sources;
140
+ const prompt = payload.prompt || "Summarize the following data concisely.";
141
+ let sources = [];
142
+ if (preset) {
143
+ sources = getPresetSources(preset);
144
+ if (sources.length === 0) {
145
+ throw new Error(`Unknown research preset: ${preset}. Available: tech-trends, gold-price, crypto`);
146
+ }
147
+ }
148
+ if (customSources && Array.isArray(customSources)) {
149
+ sources = sources.concat(buildCustomSources(customSources));
150
+ }
151
+ if (sources.length === 0) {
152
+ throw new Error("Research task requires 'preset' or 'sources' in payload. Available presets: tech-trends, gold-price, crypto");
153
+ }
154
+ // 2. Fetch real data from all sources
155
+ console.log(`[nzb] Research task '${job.id}': fetching ${sources.length} source(s)...`);
156
+ const fetchedData = await fetchAllSources(sources);
157
+ if (!fetchedData.trim()) {
158
+ throw new Error("All research sources failed to return data.");
159
+ }
160
+ // 3. Build AI prompt with real data
161
+ const aiPrompt = `[Scheduled research task]
162
+
163
+ You are given REAL-TIME data fetched from the internet just now. Use ONLY this data to produce your summary — do NOT use your built-in knowledge for facts or figures.
164
+
165
+ USER INSTRUCTIONS:
166
+ ${prompt}
167
+
168
+ --- FETCHED DATA (${new Date().toISOString()}) ---
169
+
170
+ ${fetchedData}
171
+
172
+ --- END OF DATA ---
173
+
174
+ Based on the data above, provide your summary following the user's instructions.`;
175
+ // 4. Send to AI for summarization
176
+ console.log(`[nzb] Research task '${job.id}': sending to AI for summarization...`);
177
+ let aiResponse;
178
+ try {
179
+ if (job.model) {
180
+ const { runOneOffPrompt } = await import("../copilot/orchestrator.js");
181
+ aiResponse = await runOneOffPrompt(aiPrompt, job.model);
182
+ }
183
+ else {
184
+ const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
185
+ aiResponse = await new Promise((resolve) => {
186
+ sendToOrchestrator(aiPrompt, { type: "background" }, (text, done) => {
187
+ if (done)
188
+ resolve(text);
189
+ });
190
+ });
191
+ }
192
+ }
193
+ catch (err) {
194
+ throw new Error(`Research AI summarization failed: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ // 5. Format and send to Telegram
197
+ const header = preset
198
+ ? `🔬 Research: ${preset}`
199
+ : "🔬 Research Report";
200
+ const formattedMessage = `${header}\n\n${aiResponse}`;
201
+ if (config.telegramEnabled) {
202
+ const { sendProactiveMessage } = await import("../telegram/bot.js");
203
+ await sendProactiveMessage(formattedMessage);
204
+ }
205
+ console.log(`[nzb] Research task '${job.id}': completed successfully.`);
206
+ return formattedMessage;
207
+ }
208
+ //# sourceMappingURL=research-runner.js.map
@@ -107,7 +107,10 @@ async function runJob(jobId) {
107
107
  }
108
108
  }
109
109
  console.log(`[nzb] Cron job '${job.id}' completed in ${finishedAt.getTime() - startedAt.getTime()}ms`);
110
- await notifyResult(job, `✅ Completed\n${result}`);
110
+ // Vocab and research tasks handle their own Telegram notifications
111
+ if (job.taskType !== "vocab" && job.taskType !== "research") {
112
+ await notifyResult(job, `✅ Completed\n${result}`);
113
+ }
111
114
  return result;
112
115
  }
113
116
  catch (err) {
@@ -4,6 +4,7 @@ import { freemem, totalmem } from "os";
4
4
  import { join } from "path";
5
5
  import { config } from "../config.js";
6
6
  import { DB_PATH, NZB_HOME } from "../paths.js";
7
+ import { executeResearchTask } from "./research-runner.js";
7
8
  const BACKUPS_DIR = join(NZB_HOME, "backups");
8
9
  /** Notify result to Telegram if the job has notifyTelegram enabled. */
9
10
  export async function notifyResult(job, message) {
@@ -33,6 +34,8 @@ export async function executeCronTask(job) {
33
34
  return await executeWebhookTask(payload, job.timeoutMs);
34
35
  case "vocab":
35
36
  return await executeVocabTask(job, payload);
37
+ case "research":
38
+ return await executeResearchTask(job, payload);
36
39
  default:
37
40
  throw new Error(`Unknown task type: ${job.taskType}`);
38
41
  }
@@ -262,7 +265,8 @@ async function generateAndSendVocabAudio(word) {
262
265
  }
263
266
  catch { /* ignore */ }
264
267
  // Generate TTS with macOS say
265
- execSync(`say -v Samantha -o "${aiffPath}" "${word.replace(/"/g, '\\"')}"`, {
268
+ const safeWord = word.replace(/["`$\\]/g, "");
269
+ execSync(`say -v Samantha -o "${aiffPath}" "${safeWord}"`, {
266
270
  timeout: 10_000,
267
271
  });
268
272
  if (!existsSync(aiffPath)) {
@@ -275,16 +279,21 @@ async function generateAndSendVocabAudio(word) {
275
279
  if (!existsSync(m4aPath)) {
276
280
  throw new Error("Audio conversion failed: m4a file not created");
277
281
  }
282
+ // Resolve symlink path (macOS /tmp → /private/tmp) for grammy InputFile
283
+ const { realpathSync: realpath } = await import("fs");
284
+ const resolvedPath = realpath(m4aPath);
285
+ console.log(`[nzb] Vocab TTS: sending voice for "${word}" (${resolvedPath}, ${statSync(resolvedPath).size} bytes)`);
278
286
  // Send voice via Telegram
279
287
  const { sendVoice } = await import("../telegram/bot.js");
280
- await sendVoice(m4aPath, `🔊 ${word}`);
288
+ await sendVoice(resolvedPath, `🔊 ${word}`);
289
+ console.log(`[nzb] Vocab TTS: voice sent successfully for "${word}"`);
281
290
  // Clean up temp files
282
291
  try {
283
292
  unlinkSync(aiffPath);
284
293
  }
285
294
  catch { /* ignore */ }
286
295
  try {
287
- unlinkSync(m4aPath);
296
+ unlinkSync(resolvedPath);
288
297
  }
289
298
  catch { /* ignore */ }
290
299
  }
@@ -13,6 +13,13 @@ const SCHEDULE_PRESETS = [
13
13
  { label: "Every 6 hours", cron: "0 */6 * * *" },
14
14
  { label: "Every day at 8AM", cron: "0 8 * * *" },
15
15
  ];
16
+ // ── Model presets ────────────────────────────────────────────────────
17
+ const MODEL_PRESETS = [
18
+ { label: "⚡ Haiku (fast & cheap)", id: "claude-haiku-4.5", short: "haiku" },
19
+ { label: "⚖️ Sonnet (balanced)", id: "claude-sonnet-4", short: "sonnet" },
20
+ { label: "🧠 Opus (most capable)", id: "claude-opus-4.6", short: "opus" },
21
+ { label: "⚡ GPT-4.1 (fast alt)", id: "gpt-4.1", short: "gpt-4.1" },
22
+ ];
16
23
  // In-memory state for users awaiting custom cron expression input
17
24
  const pendingCustomSchedule = new Map();
18
25
  /** Check if a user is awaiting custom cron input and return the job ID. */
@@ -39,6 +46,7 @@ function buildJobListKeyboard(jobs, page, totalPages) {
39
46
  const toggleLabel = job.enabled ? "⏸" : "▶️";
40
47
  kb.text(`${toggleLabel} ${job.name}`, `cron:toggle:${job.id}`)
41
48
  .text("⏱", `cron:schedule:${job.id}`)
49
+ .text("🤖", `cron:model:${job.id}`)
42
50
  .text("▶ Run", `cron:trigger:${job.id}`)
43
51
  .text("📊", `cron:history:${job.id}`)
44
52
  .text("🗑", `cron:delete:${job.id}`)
@@ -64,6 +72,18 @@ function buildScheduleKeyboard(jobId) {
64
72
  kb.text("🔙 Back to list", "cron:list");
65
73
  return kb;
66
74
  }
75
+ function buildModelKeyboard(jobId, currentModel) {
76
+ const kb = new InlineKeyboard();
77
+ for (let i = 0; i < MODEL_PRESETS.length; i++) {
78
+ const preset = MODEL_PRESETS[i];
79
+ const current = currentModel === preset.id ? " ✓" : "";
80
+ kb.text(`${preset.label}${current}`, `cron:setmodel:${jobId}:${i}`).row();
81
+ }
82
+ const noOverride = !currentModel ? " ✓" : "";
83
+ kb.text(`🚫 No model override (use default)${noOverride}`, `cron:setmodel:${jobId}:none`).row();
84
+ kb.text("🔙 Back to list", "cron:list");
85
+ return kb;
86
+ }
67
87
  /** Apply a schedule change: update DB and reschedule. */
68
88
  function applyScheduleChange(jobId, cronExpression) {
69
89
  const updated = updateCronJob(jobId, { cronExpression });
@@ -88,8 +108,11 @@ function formatJobLine(job, index) {
88
108
  minute: "2-digit",
89
109
  })
90
110
  : "—";
111
+ const modelTag = job.model
112
+ ? ` | 🤖 ${MODEL_PRESETS.find((p) => p.id === job.model)?.short ?? job.model}`
113
+ : "";
91
114
  return (`${index}. ${status} ${job.name}\n` +
92
- ` ⏰ ${job.cronExpression} | 🏷 ${job.taskType}\n` +
115
+ ` ⏰ ${job.cronExpression} | 🏷 ${job.taskType}${modelTag}\n` +
93
116
  ` 📅 Next: ${nextRun}`);
94
117
  }
95
118
  function buildJobListText(jobs, page, totalPages) {
@@ -437,6 +460,74 @@ export function registerCronHandlers(bot) {
437
460
  }
438
461
  }
439
462
  });
463
+ // Model selection — show model presets for a job
464
+ bot.callbackQuery(/^cron:model:(.+)$/, async (ctx) => {
465
+ try {
466
+ const jobId = ctx.match[1];
467
+ const job = getCronJob(jobId);
468
+ if (!job) {
469
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
470
+ return;
471
+ }
472
+ const currentLabel = job.model
473
+ ? MODEL_PRESETS.find((p) => p.id === job.model)?.label ?? job.model
474
+ : "Default (no override)";
475
+ const text = `🤖 Change Model: ${job.name}\n\n` +
476
+ `Current: ${currentLabel}\n\n` +
477
+ "Select a model for this job:";
478
+ await ctx.answerCallbackQuery();
479
+ await ctx.editMessageText(text, {
480
+ reply_markup: buildModelKeyboard(job.id, job.model),
481
+ });
482
+ }
483
+ catch (err) {
484
+ if (!isMessageNotModifiedError(err)) {
485
+ console.error("[nzb] Cron model menu error:", err instanceof Error ? err.message : err);
486
+ await ctx
487
+ .answerCallbackQuery({ text: "Error loading model options", show_alert: true })
488
+ .catch(() => { });
489
+ }
490
+ }
491
+ });
492
+ // Set model from preset or clear override
493
+ bot.callbackQuery(/^cron:setmodel:(.+):(none|\d+)$/, async (ctx) => {
494
+ try {
495
+ const jobId = ctx.match[1];
496
+ const selection = ctx.match[2];
497
+ const job = getCronJob(jobId);
498
+ if (!job) {
499
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
500
+ return;
501
+ }
502
+ let newModel;
503
+ let label;
504
+ if (selection === "none") {
505
+ newModel = null;
506
+ label = "default";
507
+ }
508
+ else {
509
+ const presetIndex = parseInt(selection, 10);
510
+ const preset = MODEL_PRESETS[presetIndex];
511
+ if (!preset) {
512
+ await ctx.answerCallbackQuery({ text: "Invalid model", show_alert: true });
513
+ return;
514
+ }
515
+ newModel = preset.id;
516
+ label = preset.short;
517
+ }
518
+ updateCronJob(jobId, { model: newModel });
519
+ await ctx.answerCallbackQuery(`Model → ${label}`);
520
+ await showCronList(ctx, 0, true);
521
+ }
522
+ catch (err) {
523
+ if (!isMessageNotModifiedError(err)) {
524
+ console.error("[nzb] Cron set model error:", err instanceof Error ? err.message : err);
525
+ await ctx
526
+ .answerCallbackQuery({ text: "Error updating model", show_alert: true })
527
+ .catch(() => { });
528
+ }
529
+ }
530
+ });
440
531
  // Custom schedule — prompt user to type cron expression
441
532
  bot.callbackQuery(/^cron:customsched:(.+)$/, async (ctx) => {
442
533
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "NZB — a personal AI assistant for developers, built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "nzb": "dist/cli.js"