@iletai/nzb 1.7.3 → 1.8.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,142 @@
1
+ import { getDb } from "./db.js";
2
+ export function createCronJob(input) {
3
+ const db = getDb();
4
+ const now = new Date().toISOString();
5
+ db.prepare(`INSERT INTO cron_jobs (id, name, cron_expression, task_type, payload, enabled, notify_telegram, max_retries, timeout_ms, created_at, updated_at)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.cronExpression, input.taskType, input.payload ?? "{}", input.enabled !== false ? 1 : 0, input.notifyTelegram !== false ? 1 : 0, input.maxRetries ?? 0, input.timeoutMs ?? 300_000, now, now);
7
+ return getCronJob(input.id);
8
+ }
9
+ export function getCronJob(id) {
10
+ const db = getDb();
11
+ const row = db.prepare(`SELECT * FROM cron_jobs WHERE id = ?`).get(id);
12
+ return row ? mapCronJobRow(row) : undefined;
13
+ }
14
+ export function listCronJobs(enabledOnly = false) {
15
+ const db = getDb();
16
+ const sql = enabledOnly ? `SELECT * FROM cron_jobs WHERE enabled = 1` : `SELECT * FROM cron_jobs`;
17
+ const rows = db.prepare(sql).all();
18
+ return rows.map(mapCronJobRow);
19
+ }
20
+ export function updateCronJob(id, updates) {
21
+ const db = getDb();
22
+ const existing = getCronJob(id);
23
+ if (!existing)
24
+ return undefined;
25
+ const fields = [];
26
+ const values = [];
27
+ if (updates.name !== undefined) {
28
+ fields.push("name = ?");
29
+ values.push(updates.name);
30
+ }
31
+ if (updates.cronExpression !== undefined) {
32
+ fields.push("cron_expression = ?");
33
+ values.push(updates.cronExpression);
34
+ }
35
+ if (updates.taskType !== undefined) {
36
+ fields.push("task_type = ?");
37
+ values.push(updates.taskType);
38
+ }
39
+ if (updates.payload !== undefined) {
40
+ fields.push("payload = ?");
41
+ values.push(updates.payload);
42
+ }
43
+ if (updates.enabled !== undefined) {
44
+ fields.push("enabled = ?");
45
+ values.push(updates.enabled ? 1 : 0);
46
+ }
47
+ if (updates.notifyTelegram !== undefined) {
48
+ fields.push("notify_telegram = ?");
49
+ values.push(updates.notifyTelegram ? 1 : 0);
50
+ }
51
+ if (updates.maxRetries !== undefined) {
52
+ fields.push("max_retries = ?");
53
+ values.push(updates.maxRetries);
54
+ }
55
+ if (updates.timeoutMs !== undefined) {
56
+ fields.push("timeout_ms = ?");
57
+ values.push(updates.timeoutMs);
58
+ }
59
+ if (updates.lastRunAt !== undefined) {
60
+ fields.push("last_run_at = ?");
61
+ values.push(updates.lastRunAt);
62
+ }
63
+ if (updates.nextRunAt !== undefined) {
64
+ fields.push("next_run_at = ?");
65
+ values.push(updates.nextRunAt);
66
+ }
67
+ if (fields.length === 0)
68
+ return existing;
69
+ fields.push("updated_at = ?");
70
+ values.push(new Date().toISOString());
71
+ values.push(id);
72
+ db.prepare(`UPDATE cron_jobs SET ${fields.join(", ")} WHERE id = ?`).run(...values);
73
+ return getCronJob(id);
74
+ }
75
+ export function deleteCronJob(id) {
76
+ const db = getDb();
77
+ const result = db.prepare(`DELETE FROM cron_jobs WHERE id = ?`).run(id);
78
+ if (result.changes > 0) {
79
+ db.prepare(`DELETE FROM cron_runs WHERE job_id = ?`).run(id);
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ export function recordCronRun(jobId, status, startedAt, finishedAt, result, error) {
85
+ const db = getDb();
86
+ const durationMs = finishedAt ? finishedAt.getTime() - startedAt.getTime() : null;
87
+ const info = db
88
+ .prepare(`INSERT INTO cron_runs (job_id, status, started_at, finished_at, result, error, duration_ms)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
90
+ .run(jobId, status, startedAt.toISOString(), finishedAt?.toISOString() ?? null, result, error, durationMs);
91
+ // Auto-prune: keep only 50 most recent runs per job
92
+ db.prepare(`DELETE FROM cron_runs WHERE job_id = ? AND id NOT IN (
93
+ SELECT id FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT 50
94
+ )`).run(jobId, jobId);
95
+ return {
96
+ id: Number(info.lastInsertRowid),
97
+ jobId,
98
+ status,
99
+ startedAt: startedAt.toISOString(),
100
+ finishedAt: finishedAt?.toISOString() ?? null,
101
+ result,
102
+ error,
103
+ durationMs,
104
+ };
105
+ }
106
+ export function getRecentRuns(jobId, limit = 10) {
107
+ const db = getDb();
108
+ const rows = db
109
+ .prepare(`SELECT * FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT ?`)
110
+ .all(jobId, limit);
111
+ return rows.map(mapCronRunRow);
112
+ }
113
+ function mapCronJobRow(row) {
114
+ return {
115
+ id: row.id,
116
+ name: row.name,
117
+ cronExpression: row.cron_expression,
118
+ taskType: row.task_type,
119
+ payload: row.payload,
120
+ enabled: row.enabled === 1,
121
+ notifyTelegram: row.notify_telegram === 1,
122
+ maxRetries: row.max_retries,
123
+ timeoutMs: row.timeout_ms,
124
+ lastRunAt: row.last_run_at,
125
+ nextRunAt: row.next_run_at,
126
+ createdAt: row.created_at,
127
+ updatedAt: row.updated_at,
128
+ };
129
+ }
130
+ function mapCronRunRow(row) {
131
+ return {
132
+ id: row.id,
133
+ jobId: row.job_id,
134
+ status: row.status,
135
+ startedAt: row.started_at,
136
+ finishedAt: row.finished_at,
137
+ result: row.result,
138
+ error: row.error,
139
+ durationMs: row.duration_ms,
140
+ };
141
+ }
142
+ //# sourceMappingURL=cron-store.js.map
package/dist/store/db.js CHANGED
@@ -8,6 +8,7 @@ export function getDb() {
8
8
  ensureNZBHome();
9
9
  db = new Database(DB_PATH);
10
10
  db.pragma("journal_mode = WAL");
11
+ db.pragma("busy_timeout = 5000");
11
12
  db.exec(`
12
13
  CREATE TABLE IF NOT EXISTS worker_sessions (
13
14
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -81,6 +82,37 @@ export function getDb() {
81
82
  FOREIGN KEY(team_id) REFERENCES agent_teams(id)
82
83
  )
83
84
  `);
85
+ db.exec(`
86
+ CREATE TABLE IF NOT EXISTS cron_jobs (
87
+ id TEXT PRIMARY KEY,
88
+ name TEXT NOT NULL,
89
+ cron_expression TEXT NOT NULL,
90
+ task_type TEXT NOT NULL,
91
+ payload TEXT NOT NULL DEFAULT '{}',
92
+ enabled INTEGER NOT NULL DEFAULT 1,
93
+ notify_telegram INTEGER NOT NULL DEFAULT 1,
94
+ max_retries INTEGER NOT NULL DEFAULT 0,
95
+ timeout_ms INTEGER NOT NULL DEFAULT 300000,
96
+ created_at TEXT DEFAULT (datetime('now')),
97
+ updated_at TEXT DEFAULT (datetime('now')),
98
+ last_run_at TEXT,
99
+ next_run_at TEXT
100
+ )
101
+ `);
102
+ db.exec(`
103
+ CREATE TABLE IF NOT EXISTS cron_runs (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ job_id TEXT NOT NULL,
106
+ status TEXT NOT NULL,
107
+ started_at TEXT DEFAULT (datetime('now')),
108
+ finished_at TEXT,
109
+ result TEXT,
110
+ error TEXT,
111
+ duration_ms INTEGER,
112
+ FOREIGN KEY(job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE
113
+ )
114
+ `);
115
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_cron_runs_job ON cron_runs(job_id, started_at)`);
84
116
  // Migrate: if the table already existed with a stricter CHECK, recreate it
85
117
  try {
86
118
  db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('system', '__migration_test__', 'test')`).run();
@@ -10,6 +10,7 @@ import { config } from "../config.js";
10
10
  import { getPersistedUpdateOffset, isUpdateDuplicate, persistUpdateOffset } from "./dedup.js";
11
11
  import { registerCallbackHandlers } from "./handlers/callbacks.js";
12
12
  import { registerCommandHandlers } from "./handlers/commands.js";
13
+ import { registerCronHandlers } from "./handlers/cron.js";
13
14
  import { sendFormattedReply } from "./handlers/helpers.js";
14
15
  import { registerInlineQueryHandler } from "./handlers/inline.js";
15
16
  import { registerMediaHandlers } from "./handlers/media.js";
@@ -25,6 +26,8 @@ const startedAt = Date.now();
25
26
  const INITIAL_POLL_RETRY_DELAY = 5000;
26
27
  const MAX_POLL_RETRY_DELAY = 300_000; // 5 minutes
27
28
  let pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
29
+ let consecutivePollingFailures = 0;
30
+ const MAX_CONSECUTIVE_POLLING_FAILURES = 10;
28
31
  // Direct-connection HTTPS agent for Telegram API requests.
29
32
  // This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
30
33
  // modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
@@ -102,6 +105,7 @@ export function createBot() {
102
105
  bot.use(mainMenu);
103
106
  // --- Handler registrations ---
104
107
  registerCallbackHandlers(bot);
108
+ registerCronHandlers(bot);
105
109
  registerInlineQueryHandler(bot);
106
110
  registerSmartSuggestionHandlers(bot);
107
111
  registerReactionHandlers(bot);
@@ -150,6 +154,7 @@ export async function startBot() {
150
154
  { command: "workers", description: "List active workers" },
151
155
  { command: "skills", description: "List installed skills" },
152
156
  { command: "memory", description: "Show stored memories" },
157
+ { command: "cron", description: "Manage cron jobs" },
153
158
  { command: "settings", description: "Bot settings" },
154
159
  { command: "restart", description: "Restart NZB" },
155
160
  ]);
@@ -177,15 +182,21 @@ export async function startBot() {
177
182
  ...(savedOffset ? { offset: savedOffset + 1 } : {}),
178
183
  onStart: () => {
179
184
  console.log("[nzb] Telegram bot connected");
185
+ consecutivePollingFailures = 0;
180
186
  pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
181
187
  void logInfo(`🚀 NZB v${process.env.npm_package_version || "?"} started (model: ${config.copilotModel})`);
182
188
  },
183
189
  })
184
190
  .catch(async (err) => {
191
+ consecutivePollingFailures++;
185
192
  if (err?.error_code === 401) {
186
193
  console.error("[nzb] Warning: Telegram bot token is invalid or expired. Run 'nzb setup' and re-enter your bot token from @BotFather.");
187
194
  return; // Unrecoverable — don't retry
188
195
  }
196
+ if (consecutivePollingFailures >= MAX_CONSECUTIVE_POLLING_FAILURES) {
197
+ console.error(`[nzb] Telegram polling failed ${consecutivePollingFailures} consecutive times. Stopping retry attempts. Restart NZB to try again.`);
198
+ return;
199
+ }
189
200
  if (err?.error_code === 409) {
190
201
  console.error(`[nzb] Warning: Telegram polling conflict (409). Restarting polling in ${pollRetryDelay / 1000}s...`);
191
202
  }
@@ -5,6 +5,7 @@ import { restartDaemon } from "../../daemon.js";
5
5
  import { searchMemories } from "../../store/memory.js";
6
6
  import { chunkMessage } from "../formatter.js";
7
7
  import { buildSettingsText, formatMemoryList } from "../menus.js";
8
+ import { sendCronMenu } from "./cron.js";
8
9
  import { getReactionHelpText } from "./reactions.js";
9
10
  export function registerCommandHandlers(bot, deps) {
10
11
  const { replyKeyboard, mainMenu, settingsMenu, getUptimeStr } = deps;
@@ -30,6 +31,7 @@ export function registerCommandHandlers(bot, deps) {
30
31
  "/memory — Stored memories\n" +
31
32
  "/skills — Installed skills\n" +
32
33
  "/workers — Active worker sessions\n" +
34
+ "/cron — Manage cron jobs\n" +
33
35
  "/restart — Restart NZB\n\n" +
34
36
  "⚡ Breakthrough Features:\n" +
35
37
  "• @bot query — Use me inline in any chat!\n" +
@@ -194,6 +196,9 @@ export function registerCommandHandlers(bot, deps) {
194
196
  bot.command("settings", async (ctx) => {
195
197
  await ctx.reply(buildSettingsText(getUptimeStr), { reply_markup: settingsMenu });
196
198
  });
199
+ bot.command("cron", async (ctx) => {
200
+ await sendCronMenu(ctx);
201
+ });
197
202
  // Reply keyboard button handlers — intercept before general text handler
198
203
  bot.hears("📊 Status", async (ctx) => {
199
204
  const workers = Array.from(getWorkers().values());
@@ -0,0 +1,354 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { scheduleJob, triggerJob, unscheduleJob } from "../../cron/scheduler.js";
3
+ import { deleteCronJob, getCronJob, getRecentRuns, listCronJobs, updateCronJob, } from "../../store/cron-store.js";
4
+ import { isMessageNotModifiedError } from "../formatter.js";
5
+ const JOBS_PER_PAGE = 5;
6
+ // ── Keyboard builders ────────────────────────────────────────────────
7
+ function buildCronMainMenu() {
8
+ return new InlineKeyboard()
9
+ .text("📋 List Jobs", "cron:list")
10
+ .text("➕ Add Job", "cron:add")
11
+ .row()
12
+ .text("📊 Overview", "cron:overview");
13
+ }
14
+ function buildJobListKeyboard(jobs, page, totalPages) {
15
+ const kb = new InlineKeyboard();
16
+ const start = page * JOBS_PER_PAGE;
17
+ const pageJobs = jobs.slice(start, start + JOBS_PER_PAGE);
18
+ for (const job of pageJobs) {
19
+ const toggleLabel = job.enabled ? "⏸" : "▶️";
20
+ kb.text(`${toggleLabel} ${job.name}`, `cron:toggle:${job.id}`)
21
+ .text("▶ Run", `cron:trigger:${job.id}`)
22
+ .text("📊", `cron:history:${job.id}`)
23
+ .text("🗑", `cron:delete:${job.id}`)
24
+ .row();
25
+ }
26
+ if (totalPages > 1) {
27
+ if (page > 0)
28
+ kb.text("⬅️ Prev", `cron:list:${page - 1}`);
29
+ if (page < totalPages - 1)
30
+ kb.text("➡️ Next", `cron:list:${page + 1}`);
31
+ kb.row();
32
+ }
33
+ kb.text("🔙 Back", "cron:back");
34
+ return kb;
35
+ }
36
+ // ── Text formatters ──────────────────────────────────────────────────
37
+ function formatJobLine(job, index) {
38
+ const status = job.enabled ? "✅" : "⏸";
39
+ const nextRun = job.nextRunAt
40
+ ? new Date(job.nextRunAt).toLocaleString("en-US", {
41
+ month: "short",
42
+ day: "numeric",
43
+ hour: "2-digit",
44
+ minute: "2-digit",
45
+ })
46
+ : "—";
47
+ return (`${index}. ${status} ${job.name}\n` +
48
+ ` ⏰ ${job.cronExpression} | 🏷 ${job.taskType}\n` +
49
+ ` 📅 Next: ${nextRun}`);
50
+ }
51
+ function buildJobListText(jobs, page, totalPages) {
52
+ if (jobs.length === 0) {
53
+ return "📋 Cron Jobs\n\nNo jobs configured.\nUse ➕ Add Job or ask NZB to create one.";
54
+ }
55
+ const start = page * JOBS_PER_PAGE;
56
+ const pageJobs = jobs.slice(start, start + JOBS_PER_PAGE);
57
+ const enabled = jobs.filter((j) => j.enabled).length;
58
+ let text = `📋 Cron Jobs — ${jobs.length} total (${enabled} active, ${jobs.length - enabled} paused)\n\n`;
59
+ text += pageJobs.map((j, i) => formatJobLine(j, start + i + 1)).join("\n\n");
60
+ if (totalPages > 1) {
61
+ text += `\n\n📄 Page ${page + 1}/${totalPages}`;
62
+ }
63
+ return text;
64
+ }
65
+ function formatRunStatus(status) {
66
+ switch (status) {
67
+ case "success":
68
+ return "✅";
69
+ case "error":
70
+ return "❌";
71
+ case "timeout":
72
+ return "⏱";
73
+ case "running":
74
+ return "🔄";
75
+ default:
76
+ return "⬜";
77
+ }
78
+ }
79
+ // ── Shared list handler ──────────────────────────────────────────────
80
+ async function showCronList(ctx, page, isEdit) {
81
+ const jobs = listCronJobs();
82
+ const totalPages = Math.max(1, Math.ceil(jobs.length / JOBS_PER_PAGE));
83
+ const safePage = Math.min(page, totalPages - 1);
84
+ const text = buildJobListText(jobs, safePage, totalPages);
85
+ const keyboard = buildJobListKeyboard(jobs, safePage, totalPages);
86
+ if (isEdit) {
87
+ await ctx.editMessageText(text, { reply_markup: keyboard });
88
+ }
89
+ else {
90
+ await ctx.reply(text, { reply_markup: keyboard });
91
+ }
92
+ }
93
+ // ── Public: /cron command entry point ────────────────────────────────
94
+ /** Send the main cron menu. Called from /cron command handler. */
95
+ export async function sendCronMenu(ctx) {
96
+ await ctx.reply("⏰ Cron Task Manager", { reply_markup: buildCronMainMenu() });
97
+ }
98
+ // ── Public: register all cron callback handlers ──────────────────────
99
+ export function registerCronHandlers(bot) {
100
+ // List all jobs
101
+ bot.callbackQuery("cron:list", async (ctx) => {
102
+ try {
103
+ await ctx.answerCallbackQuery();
104
+ await showCronList(ctx, 0, true);
105
+ }
106
+ catch (err) {
107
+ if (!isMessageNotModifiedError(err)) {
108
+ console.error("[nzb] Cron list error:", err instanceof Error ? err.message : err);
109
+ }
110
+ }
111
+ });
112
+ // Paginated list
113
+ bot.callbackQuery(/^cron:list:(\d+)$/, async (ctx) => {
114
+ try {
115
+ const page = parseInt(ctx.match[1], 10);
116
+ await ctx.answerCallbackQuery();
117
+ await showCronList(ctx, page, true);
118
+ }
119
+ catch (err) {
120
+ if (!isMessageNotModifiedError(err)) {
121
+ console.error("[nzb] Cron list page error:", err instanceof Error ? err.message : err);
122
+ }
123
+ }
124
+ });
125
+ // Add job guidance
126
+ bot.callbackQuery("cron:add", async (ctx) => {
127
+ try {
128
+ await ctx.answerCallbackQuery();
129
+ await ctx.editMessageText("➕ Add Cron Job\n\n" +
130
+ "Send a message to NZB describing the task:\n\n" +
131
+ "Examples:\n" +
132
+ '• "Schedule a daily backup at midnight"\n' +
133
+ '• "Remind me every Monday at 9am to check emails"\n' +
134
+ '• "Run health check every 5 minutes"\n\n' +
135
+ "NZB will create the cron job automatically.", { reply_markup: new InlineKeyboard().text("🔙 Back", "cron:back") });
136
+ }
137
+ catch (err) {
138
+ if (!isMessageNotModifiedError(err)) {
139
+ console.error("[nzb] Cron add error:", err instanceof Error ? err.message : err);
140
+ }
141
+ }
142
+ });
143
+ // Overview with recent activity
144
+ bot.callbackQuery("cron:overview", async (ctx) => {
145
+ try {
146
+ const jobs = listCronJobs();
147
+ const enabled = jobs.filter((j) => j.enabled).length;
148
+ let text = `📊 Cron Overview\n\nTotal: ${jobs.length} jobs\n✅ Active: ${enabled}\n⏸ Paused: ${jobs.length - enabled}\n`;
149
+ if (jobs.length > 0) {
150
+ text += "\nRecent activity:\n";
151
+ for (const job of jobs.slice(0, 5)) {
152
+ const runs = getRecentRuns(job.id, 1);
153
+ const lastRun = runs[0];
154
+ if (lastRun) {
155
+ const time = new Date(lastRun.startedAt).toLocaleString("en-US", {
156
+ month: "short",
157
+ day: "numeric",
158
+ hour: "2-digit",
159
+ minute: "2-digit",
160
+ });
161
+ text += `${formatRunStatus(lastRun.status)} ${job.name} — ${time}\n`;
162
+ }
163
+ else {
164
+ text += `⬜ ${job.name} — never run\n`;
165
+ }
166
+ }
167
+ }
168
+ await ctx.answerCallbackQuery();
169
+ await ctx.editMessageText(text, {
170
+ reply_markup: new InlineKeyboard().text("📋 List Jobs", "cron:list").row().text("🔙 Back", "cron:back"),
171
+ });
172
+ }
173
+ catch (err) {
174
+ if (!isMessageNotModifiedError(err)) {
175
+ console.error("[nzb] Cron overview error:", err instanceof Error ? err.message : err);
176
+ await ctx
177
+ .answerCallbackQuery({ text: "Error loading overview", show_alert: true })
178
+ .catch(() => { });
179
+ }
180
+ }
181
+ });
182
+ // Toggle enable/disable
183
+ bot.callbackQuery(/^cron:toggle:(.+)$/, async (ctx) => {
184
+ try {
185
+ const jobId = ctx.match[1];
186
+ const job = getCronJob(jobId);
187
+ if (!job) {
188
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
189
+ return;
190
+ }
191
+ const newEnabled = !job.enabled;
192
+ updateCronJob(jobId, { enabled: newEnabled });
193
+ if (newEnabled) {
194
+ const updated = getCronJob(jobId);
195
+ if (updated)
196
+ scheduleJob(updated);
197
+ }
198
+ else {
199
+ unscheduleJob(jobId);
200
+ }
201
+ await ctx.answerCallbackQuery(`${job.name} → ${newEnabled ? "enabled ✅" : "disabled ⏸"}`);
202
+ await showCronList(ctx, 0, true);
203
+ }
204
+ catch (err) {
205
+ if (!isMessageNotModifiedError(err)) {
206
+ console.error("[nzb] Cron toggle error:", err instanceof Error ? err.message : err);
207
+ await ctx
208
+ .answerCallbackQuery({ text: "Error toggling job", show_alert: true })
209
+ .catch(() => { });
210
+ }
211
+ }
212
+ });
213
+ // Trigger job manually
214
+ bot.callbackQuery(/^cron:trigger:(.+)$/, async (ctx) => {
215
+ try {
216
+ const jobId = ctx.match[1];
217
+ const job = getCronJob(jobId);
218
+ if (!job) {
219
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
220
+ return;
221
+ }
222
+ await ctx.answerCallbackQuery(`▶ Running "${job.name}"...`);
223
+ // Fire-and-forget — don't block the callback response
224
+ triggerJob(jobId)
225
+ .then(async (result) => {
226
+ try {
227
+ const truncated = result.length > 200 ? result.slice(0, 200) + "…" : result;
228
+ await ctx.reply(`▶ ${job.name} completed:\n${truncated}`);
229
+ }
230
+ catch { }
231
+ })
232
+ .catch(async (err) => {
233
+ try {
234
+ await ctx.reply(`❌ ${job.name} failed: ${err instanceof Error ? err.message : String(err)}`);
235
+ }
236
+ catch { }
237
+ });
238
+ }
239
+ catch (err) {
240
+ console.error("[nzb] Cron trigger error:", err instanceof Error ? err.message : err);
241
+ await ctx
242
+ .answerCallbackQuery({ text: "Error triggering job", show_alert: true })
243
+ .catch(() => { });
244
+ }
245
+ });
246
+ // Delete confirmation — must be registered BEFORE the general delete handler
247
+ bot.callbackQuery(/^cron:delete:confirm:(.+)$/, async (ctx) => {
248
+ try {
249
+ const jobId = ctx.match[1];
250
+ const job = getCronJob(jobId);
251
+ const name = job?.name ?? jobId;
252
+ unscheduleJob(jobId);
253
+ const deleted = deleteCronJob(jobId);
254
+ if (deleted) {
255
+ await ctx.answerCallbackQuery(`🗑 Deleted: ${name}`);
256
+ }
257
+ else {
258
+ await ctx.answerCallbackQuery({ text: "Job already deleted", show_alert: true });
259
+ }
260
+ await showCronList(ctx, 0, true);
261
+ }
262
+ catch (err) {
263
+ if (!isMessageNotModifiedError(err)) {
264
+ console.error("[nzb] Cron delete error:", err instanceof Error ? err.message : err);
265
+ await ctx
266
+ .answerCallbackQuery({ text: "Error deleting job", show_alert: true })
267
+ .catch(() => { });
268
+ }
269
+ }
270
+ });
271
+ // Delete prompt (show confirmation dialog)
272
+ bot.callbackQuery(/^cron:delete:(.+)$/, async (ctx) => {
273
+ try {
274
+ const jobId = ctx.match[1];
275
+ // Skip if this is actually a confirm callback (shouldn't happen due to ordering, but safety check)
276
+ if (jobId.startsWith("confirm:"))
277
+ return;
278
+ const job = getCronJob(jobId);
279
+ if (!job) {
280
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
281
+ return;
282
+ }
283
+ await ctx.answerCallbackQuery();
284
+ await ctx.editMessageText(`🗑 Delete "${job.name}"?\n\n` +
285
+ `⏰ ${job.cronExpression}\n` +
286
+ `🏷 Type: ${job.taskType}\n` +
287
+ `Status: ${job.enabled ? "✅ Active" : "⏸ Paused"}\n\n` +
288
+ "⚠️ This permanently removes the job and its run history.", {
289
+ reply_markup: new InlineKeyboard()
290
+ .text("✅ Yes, delete", `cron:delete:confirm:${job.id}`)
291
+ .text("❌ Cancel", "cron:list"),
292
+ });
293
+ }
294
+ catch (err) {
295
+ if (!isMessageNotModifiedError(err)) {
296
+ console.error("[nzb] Cron delete prompt error:", err instanceof Error ? err.message : err);
297
+ }
298
+ }
299
+ });
300
+ // History — show recent runs for a job
301
+ bot.callbackQuery(/^cron:history:(.+)$/, async (ctx) => {
302
+ try {
303
+ const jobId = ctx.match[1];
304
+ const job = getCronJob(jobId);
305
+ if (!job) {
306
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
307
+ return;
308
+ }
309
+ const runs = getRecentRuns(jobId, 10);
310
+ let text = `📊 History: ${job.name}\n⏰ ${job.cronExpression}\n\n`;
311
+ if (runs.length === 0) {
312
+ text += "No runs recorded yet.";
313
+ }
314
+ else {
315
+ for (const run of runs) {
316
+ const time = new Date(run.startedAt).toLocaleString("en-US", {
317
+ month: "short",
318
+ day: "numeric",
319
+ hour: "2-digit",
320
+ minute: "2-digit",
321
+ });
322
+ const duration = run.durationMs !== null ? ` (${(run.durationMs / 1000).toFixed(1)}s)` : "";
323
+ const errorInfo = run.error ? `\n ⚠️ ${run.error.slice(0, 80)}` : "";
324
+ text += `${formatRunStatus(run.status)} ${time}${duration}${errorInfo}\n`;
325
+ }
326
+ }
327
+ await ctx.answerCallbackQuery();
328
+ await ctx.editMessageText(text, {
329
+ reply_markup: new InlineKeyboard().text("🔙 Back to list", "cron:list"),
330
+ });
331
+ }
332
+ catch (err) {
333
+ if (!isMessageNotModifiedError(err)) {
334
+ console.error("[nzb] Cron history error:", err instanceof Error ? err.message : err);
335
+ await ctx
336
+ .answerCallbackQuery({ text: "Error loading history", show_alert: true })
337
+ .catch(() => { });
338
+ }
339
+ }
340
+ });
341
+ // Back to main cron menu
342
+ bot.callbackQuery("cron:back", async (ctx) => {
343
+ try {
344
+ await ctx.answerCallbackQuery();
345
+ await ctx.editMessageText("⏰ Cron Task Manager", { reply_markup: buildCronMainMenu() });
346
+ }
347
+ catch (err) {
348
+ if (!isMessageNotModifiedError(err)) {
349
+ console.error("[nzb] Cron back error:", err instanceof Error ? err.message : err);
350
+ }
351
+ }
352
+ });
353
+ }
354
+ //# sourceMappingURL=cron.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.7.3",
3
+ "version": "1.8.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"
@@ -54,6 +54,7 @@
54
54
  "@grammyjs/runner": "^2.0.3",
55
55
  "@grammyjs/transformer-throttler": "^1.2.1",
56
56
  "better-sqlite3": "^12.6.2",
57
+ "croner": "^10.0.1",
57
58
  "dotenv": "^17.3.1",
58
59
  "express": "^5.2.1",
59
60
  "grammy": "^1.40.0",