@iletai/nzb 1.7.4 → 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,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.4",
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",