@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¤cy=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
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/cron/task-runner.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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 {
|