@iletai/nzb 1.8.2 → 1.9.1

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.
@@ -818,4 +818,25 @@ export async function compactSession() {
818
818
  export function getFailoverManager() {
819
819
  return failoverManager;
820
820
  }
821
+ /** Run a one-off prompt on a temporary session with a specific model. Session is destroyed after use. */
822
+ export async function runOneOffPrompt(prompt, model, timeoutMs = 300_000) {
823
+ const client = await ensureClient();
824
+ const session = await client.createSession({
825
+ model,
826
+ configDir: SESSIONS_DIR,
827
+ onPermissionRequest: approveAll,
828
+ });
829
+ try {
830
+ const result = await session.sendAndWait({ prompt }, timeoutMs);
831
+ return result?.data?.content || "";
832
+ }
833
+ finally {
834
+ try {
835
+ await session.disconnect();
836
+ }
837
+ catch {
838
+ // Best-effort cleanup
839
+ }
840
+ }
841
+ }
821
842
  //# sourceMappingURL=orchestrator.js.map
@@ -749,6 +749,7 @@ export function createTools(deps) {
749
749
  .describe("Task type (required for add)"),
750
750
  payload: z.string().optional().describe("JSON payload for the task (optional for add)"),
751
751
  notify_telegram: z.boolean().optional().describe("Send result to Telegram (default: true)"),
752
+ model: z.string().optional().describe("AI model to use for prompt/vocab tasks (e.g. 'claude-haiku-4.5'). If not set, uses the default orchestrator model."),
752
753
  }),
753
754
  handler: async (args) => {
754
755
  try {
@@ -759,6 +760,7 @@ export function createTools(deps) {
759
760
  return "No cron jobs configured.";
760
761
  const lines = status.map((j) => `• ${j.id} — ${j.name} [${j.taskType}] ${j.enabled ? "✅" : "⏸️"} ` +
761
762
  `${j.active ? "active" : "inactive"} | ${j.cronExpression}` +
763
+ (j.model ? ` | model: ${j.model}` : "") +
762
764
  (j.nextRun ? ` | next: ${j.nextRun}` : ""));
763
765
  return `${status.length} cron job(s):\n${lines.join("\n")}`;
764
766
  }
@@ -773,10 +775,12 @@ export function createTools(deps) {
773
775
  taskType: args.task_type,
774
776
  payload: args.payload,
775
777
  notifyTelegram: args.notify_telegram,
778
+ model: args.model,
776
779
  });
777
780
  if (job.enabled)
778
781
  scheduleJob(job);
779
- return `Cron job '${job.id}' (${job.name}) created and scheduled: ${job.cronExpression}`;
782
+ return `Cron job '${job.id}' (${job.name}) created and scheduled: ${job.cronExpression}` +
783
+ (job.model ? ` [model: ${job.model}]` : "");
780
784
  }
781
785
  case "remove": {
782
786
  if (!args.job_id)
@@ -71,6 +71,7 @@ export function getSchedulerStatus() {
71
71
  enabled: job.enabled,
72
72
  active: activeTimers.has(job.id),
73
73
  nextRun,
74
+ model: job.model,
74
75
  };
75
76
  });
76
77
  }
@@ -22,7 +22,7 @@ export async function executeCronTask(job) {
22
22
  const payload = JSON.parse(job.payload);
23
23
  switch (job.taskType) {
24
24
  case "prompt":
25
- return await executePromptTask(payload);
25
+ return await executePromptTask(payload, job.model);
26
26
  case "health_check":
27
27
  return await executeHealthCheckTask();
28
28
  case "backup":
@@ -37,9 +37,14 @@ export async function executeCronTask(job) {
37
37
  throw new Error(`Unknown task type: ${job.taskType}`);
38
38
  }
39
39
  }
40
- async function executePromptTask(payload) {
40
+ async function executePromptTask(payload, model) {
41
41
  const prompt = payload.prompt || "Scheduled check-in. Anything to report?";
42
42
  try {
43
+ // Use a lightweight one-off session if a specific model is configured
44
+ if (model) {
45
+ const { runOneOffPrompt } = await import("../copilot/orchestrator.js");
46
+ return await runOneOffPrompt(`[Scheduled task] ${prompt}`, model);
47
+ }
43
48
  const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
44
49
  // No internal timeout — the scheduler's withTaskTimeout() handles it
45
50
  // using the per-job configurable timeoutMs (default 5min).
@@ -174,13 +179,20 @@ async function executeVocabTask(job, payload) {
174
179
  // Step 1: Get vocab from AI
175
180
  let aiResponse;
176
181
  try {
177
- const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
178
- aiResponse = await new Promise((resolve) => {
179
- sendToOrchestrator(`[Scheduled vocab task] ${prompt}`, { type: "background" }, (text, done) => {
180
- if (done)
181
- resolve(text);
182
+ // Use a lightweight one-off session if a specific model is configured
183
+ if (job.model) {
184
+ const { runOneOffPrompt } = await import("../copilot/orchestrator.js");
185
+ aiResponse = await runOneOffPrompt(`[Scheduled vocab task] ${prompt}`, job.model);
186
+ }
187
+ else {
188
+ const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
189
+ aiResponse = await new Promise((resolve) => {
190
+ sendToOrchestrator(`[Scheduled vocab task] ${prompt}`, { type: "background" }, (text, done) => {
191
+ if (done)
192
+ resolve(text);
193
+ });
182
194
  });
183
- });
195
+ }
184
196
  }
185
197
  catch (err) {
186
198
  throw new Error(`Vocab AI prompt failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -250,7 +262,8 @@ async function generateAndSendVocabAudio(word) {
250
262
  }
251
263
  catch { /* ignore */ }
252
264
  // Generate TTS with macOS say
253
- execSync(`say -v Samantha -o "${aiffPath}" "${word.replace(/"/g, '\\"')}"`, {
265
+ const safeWord = word.replace(/["`$\\]/g, "");
266
+ execSync(`say -v Samantha -o "${aiffPath}" "${safeWord}"`, {
254
267
  timeout: 10_000,
255
268
  });
256
269
  if (!existsSync(aiffPath)) {
@@ -263,16 +276,21 @@ async function generateAndSendVocabAudio(word) {
263
276
  if (!existsSync(m4aPath)) {
264
277
  throw new Error("Audio conversion failed: m4a file not created");
265
278
  }
279
+ // Resolve symlink path (macOS /tmp → /private/tmp) for grammy InputFile
280
+ const { realpathSync: realpath } = await import("fs");
281
+ const resolvedPath = realpath(m4aPath);
282
+ console.log(`[nzb] Vocab TTS: sending voice for "${word}" (${resolvedPath}, ${statSync(resolvedPath).size} bytes)`);
266
283
  // Send voice via Telegram
267
284
  const { sendVoice } = await import("../telegram/bot.js");
268
- await sendVoice(m4aPath, `🔊 ${word}`);
285
+ await sendVoice(resolvedPath, `🔊 ${word}`);
286
+ console.log(`[nzb] Vocab TTS: voice sent successfully for "${word}"`);
269
287
  // Clean up temp files
270
288
  try {
271
289
  unlinkSync(aiffPath);
272
290
  }
273
291
  catch { /* ignore */ }
274
292
  try {
275
- unlinkSync(m4aPath);
293
+ unlinkSync(resolvedPath);
276
294
  }
277
295
  catch { /* ignore */ }
278
296
  }
@@ -2,8 +2,8 @@ import { getDb } from "./db.js";
2
2
  export function createCronJob(input) {
3
3
  const db = getDb();
4
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);
5
+ db.prepare(`INSERT INTO cron_jobs (id, name, cron_expression, task_type, payload, enabled, notify_telegram, max_retries, timeout_ms, model, 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, input.model ?? null, now, now);
7
7
  return getCronJob(input.id);
8
8
  }
9
9
  export function getCronJob(id) {
@@ -56,6 +56,10 @@ export function updateCronJob(id, updates) {
56
56
  fields.push("timeout_ms = ?");
57
57
  values.push(updates.timeoutMs);
58
58
  }
59
+ if (updates.model !== undefined) {
60
+ fields.push("model = ?");
61
+ values.push(updates.model);
62
+ }
59
63
  if (updates.lastRunAt !== undefined) {
60
64
  fields.push("last_run_at = ?");
61
65
  values.push(updates.lastRunAt);
@@ -121,6 +125,7 @@ function mapCronJobRow(row) {
121
125
  notifyTelegram: row.notify_telegram === 1,
122
126
  maxRetries: row.max_retries,
123
127
  timeoutMs: row.timeout_ms,
128
+ model: row.model ?? null,
124
129
  lastRunAt: row.last_run_at,
125
130
  nextRunAt: row.next_run_at,
126
131
  createdAt: row.created_at,
package/dist/store/db.js CHANGED
@@ -113,6 +113,13 @@ export function getDb() {
113
113
  )
114
114
  `);
115
115
  db.exec(`CREATE INDEX IF NOT EXISTS idx_cron_runs_job ON cron_runs(job_id, started_at)`);
116
+ // Migrate: add model column to cron_jobs if missing
117
+ try {
118
+ db.prepare(`SELECT model FROM cron_jobs LIMIT 1`).get();
119
+ }
120
+ catch {
121
+ db.exec(`ALTER TABLE cron_jobs ADD COLUMN model TEXT`);
122
+ }
116
123
  // Migrate: if the table already existed with a stricter CHECK, recreate it
117
124
  try {
118
125
  db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('system', '__migration_test__', 'test')`).run();
@@ -276,11 +276,25 @@ function isInternalUrl(urlStr) {
276
276
  }
277
277
  /** Allowlisted directories for local file photo access. */
278
278
  const PHOTO_ALLOWED_DIRS = [tmpdir(), "/tmp"];
279
+ /** Resolve allowed dirs at startup so symlinks are handled (e.g. macOS /tmp → /private/tmp). */
280
+ const RESOLVED_ALLOWED_DIRS = (() => {
281
+ const resolved = new Set();
282
+ for (const dir of PHOTO_ALLOWED_DIRS) {
283
+ resolved.add(dir);
284
+ try {
285
+ resolved.add(realpathSync(dir));
286
+ }
287
+ catch {
288
+ // Directory may not exist — keep the original
289
+ }
290
+ }
291
+ return [...resolved];
292
+ })();
279
293
  /** Validate a local file path is within allowed directories. */
280
294
  function isAllowedFilePath(filePath) {
281
295
  try {
282
296
  const resolved = realpathSync(pathResolve(filePath));
283
- return PHOTO_ALLOWED_DIRS.some((dir) => resolved.startsWith(dir));
297
+ return RESOLVED_ALLOWED_DIRS.some((dir) => resolved.startsWith(dir));
284
298
  }
285
299
  catch {
286
300
  // Expected: file may not exist or path may be inaccessible
@@ -3,6 +3,33 @@ import { scheduleJob, triggerJob, unscheduleJob } from "../../cron/scheduler.js"
3
3
  import { deleteCronJob, getCronJob, getRecentRuns, listCronJobs, updateCronJob, } from "../../store/cron-store.js";
4
4
  import { isMessageNotModifiedError } from "../formatter.js";
5
5
  const JOBS_PER_PAGE = 5;
6
+ // ── Schedule presets ─────────────────────────────────────────────────
7
+ const SCHEDULE_PRESETS = [
8
+ { label: "Every 1 min", cron: "* * * * *" },
9
+ { label: "Every 5 min", cron: "*/5 * * * *" },
10
+ { label: "Every 15 min", cron: "*/15 * * * *" },
11
+ { label: "Every 30 min", cron: "*/30 * * * *" },
12
+ { label: "Every 1 hour", cron: "0 * * * *" },
13
+ { label: "Every 6 hours", cron: "0 */6 * * *" },
14
+ { label: "Every day at 8AM", cron: "0 8 * * *" },
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
+ ];
23
+ // In-memory state for users awaiting custom cron expression input
24
+ const pendingCustomSchedule = new Map();
25
+ /** Check if a user is awaiting custom cron input and return the job ID. */
26
+ export function getPendingCustomScheduleJobId(userId) {
27
+ return pendingCustomSchedule.get(userId);
28
+ }
29
+ /** Clear pending custom schedule state for a user. */
30
+ export function clearPendingCustomSchedule(userId) {
31
+ pendingCustomSchedule.delete(userId);
32
+ }
6
33
  // ── Keyboard builders ────────────────────────────────────────────────
7
34
  function buildCronMainMenu() {
8
35
  return new InlineKeyboard()
@@ -18,6 +45,8 @@ function buildJobListKeyboard(jobs, page, totalPages) {
18
45
  for (const job of pageJobs) {
19
46
  const toggleLabel = job.enabled ? "⏸" : "▶️";
20
47
  kb.text(`${toggleLabel} ${job.name}`, `cron:toggle:${job.id}`)
48
+ .text("⏱", `cron:schedule:${job.id}`)
49
+ .text("🤖", `cron:model:${job.id}`)
21
50
  .text("▶ Run", `cron:trigger:${job.id}`)
22
51
  .text("📊", `cron:history:${job.id}`)
23
52
  .text("🗑", `cron:delete:${job.id}`)
@@ -33,6 +62,41 @@ function buildJobListKeyboard(jobs, page, totalPages) {
33
62
  kb.text("🔙 Back", "cron:back");
34
63
  return kb;
35
64
  }
65
+ function buildScheduleKeyboard(jobId) {
66
+ const kb = new InlineKeyboard();
67
+ for (let i = 0; i < SCHEDULE_PRESETS.length; i++) {
68
+ const preset = SCHEDULE_PRESETS[i];
69
+ kb.text(`${preset.label}`, `cron:setsched:${jobId}:${i}`).row();
70
+ }
71
+ kb.text("✏️ Custom", `cron:customsched:${jobId}`).row();
72
+ kb.text("🔙 Back to list", "cron:list");
73
+ return kb;
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
+ }
87
+ /** Apply a schedule change: update DB and reschedule. */
88
+ function applyScheduleChange(jobId, cronExpression) {
89
+ const updated = updateCronJob(jobId, { cronExpression });
90
+ if (!updated)
91
+ throw new Error("Job not found");
92
+ let rescheduled = false;
93
+ if (updated.enabled) {
94
+ unscheduleJob(jobId);
95
+ scheduleJob(updated);
96
+ rescheduled = true;
97
+ }
98
+ return { job: updated, rescheduled };
99
+ }
36
100
  // ── Text formatters ──────────────────────────────────────────────────
37
101
  function formatJobLine(job, index) {
38
102
  const status = job.enabled ? "✅" : "⏸";
@@ -44,8 +108,11 @@ function formatJobLine(job, index) {
44
108
  minute: "2-digit",
45
109
  })
46
110
  : "—";
111
+ const modelTag = job.model
112
+ ? ` | 🤖 ${MODEL_PRESETS.find((p) => p.id === job.model)?.short ?? job.model}`
113
+ : "";
47
114
  return (`${index}. ${status} ${job.name}\n` +
48
- ` ⏰ ${job.cronExpression} | 🏷 ${job.taskType}\n` +
115
+ ` ⏰ ${job.cronExpression} | 🏷 ${job.taskType}${modelTag}\n` +
49
116
  ` 📅 Next: ${nextRun}`);
50
117
  }
51
118
  function buildJobListText(jobs, page, totalPages) {
@@ -338,6 +405,163 @@ export function registerCronHandlers(bot) {
338
405
  }
339
406
  }
340
407
  });
408
+ // Schedule change — show preset options for a job
409
+ bot.callbackQuery(/^cron:schedule:(.+)$/, async (ctx) => {
410
+ try {
411
+ const jobId = ctx.match[1];
412
+ const job = getCronJob(jobId);
413
+ if (!job) {
414
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
415
+ return;
416
+ }
417
+ const text = `⏱ Change Schedule: ${job.name}\n\n` +
418
+ `Current: ${job.cronExpression}\n\n` +
419
+ "Select a preset or enter a custom expression:";
420
+ await ctx.answerCallbackQuery();
421
+ await ctx.editMessageText(text, {
422
+ reply_markup: buildScheduleKeyboard(job.id),
423
+ });
424
+ }
425
+ catch (err) {
426
+ if (!isMessageNotModifiedError(err)) {
427
+ console.error("[nzb] Cron schedule menu error:", err instanceof Error ? err.message : err);
428
+ await ctx
429
+ .answerCallbackQuery({ text: "Error loading schedule options", show_alert: true })
430
+ .catch(() => { });
431
+ }
432
+ }
433
+ });
434
+ // Set schedule from preset
435
+ bot.callbackQuery(/^cron:setsched:(.+):(\d+)$/, async (ctx) => {
436
+ try {
437
+ const jobId = ctx.match[1];
438
+ const presetIndex = parseInt(ctx.match[2], 10);
439
+ const preset = SCHEDULE_PRESETS[presetIndex];
440
+ if (!preset) {
441
+ await ctx.answerCallbackQuery({ text: "Invalid preset", show_alert: true });
442
+ return;
443
+ }
444
+ const job = getCronJob(jobId);
445
+ if (!job) {
446
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
447
+ return;
448
+ }
449
+ const { rescheduled } = applyScheduleChange(jobId, preset.cron);
450
+ const statusNote = rescheduled ? " (rescheduled)" : "";
451
+ await ctx.answerCallbackQuery(`Schedule → ${preset.cron}${statusNote}`);
452
+ await showCronList(ctx, 0, true);
453
+ }
454
+ catch (err) {
455
+ if (!isMessageNotModifiedError(err)) {
456
+ console.error("[nzb] Cron set schedule error:", err instanceof Error ? err.message : err);
457
+ await ctx
458
+ .answerCallbackQuery({ text: "Error updating schedule", show_alert: true })
459
+ .catch(() => { });
460
+ }
461
+ }
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
+ });
531
+ // Custom schedule — prompt user to type cron expression
532
+ bot.callbackQuery(/^cron:customsched:(.+)$/, async (ctx) => {
533
+ try {
534
+ const jobId = ctx.match[1];
535
+ const job = getCronJob(jobId);
536
+ if (!job) {
537
+ await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
538
+ return;
539
+ }
540
+ const userId = ctx.from?.id;
541
+ if (userId) {
542
+ pendingCustomSchedule.set(userId, jobId);
543
+ }
544
+ await ctx.answerCallbackQuery();
545
+ await ctx.editMessageText(`✏️ Custom Schedule: ${job.name}\n\n` +
546
+ `Current: ${job.cronExpression}\n\n` +
547
+ "Type your cron expression in the chat.\n\n" +
548
+ "Examples:\n" +
549
+ "• 0 9 * * MON-FRI — weekdays at 9AM\n" +
550
+ "• */10 * * * * — every 10 minutes\n" +
551
+ "• 0 0 1 * * — first day of month\n" +
552
+ "• 30 14 * * * — daily at 2:30PM", {
553
+ reply_markup: new InlineKeyboard().text("❌ Cancel", "cron:list"),
554
+ });
555
+ }
556
+ catch (err) {
557
+ if (!isMessageNotModifiedError(err)) {
558
+ console.error("[nzb] Cron custom schedule error:", err instanceof Error ? err.message : err);
559
+ await ctx
560
+ .answerCallbackQuery({ text: "Error", show_alert: true })
561
+ .catch(() => { });
562
+ }
563
+ }
564
+ });
341
565
  // Back to main cron menu
342
566
  bot.callbackQuery("cron:back", async (ctx) => {
343
567
  try {
@@ -350,5 +574,46 @@ export function registerCronHandlers(bot) {
350
574
  }
351
575
  }
352
576
  });
577
+ // Intercept text messages when a user is in "custom cron schedule" mode.
578
+ // Must be registered before the main streaming handler so it can short-circuit.
579
+ bot.on("message:text", async (ctx, next) => {
580
+ const userId = ctx.from?.id;
581
+ if (!userId)
582
+ return next();
583
+ const jobId = pendingCustomSchedule.get(userId);
584
+ if (!jobId)
585
+ return next();
586
+ // Clear pending state immediately so subsequent messages go to the AI
587
+ pendingCustomSchedule.delete(userId);
588
+ const cronExpr = ctx.message.text.trim();
589
+ if (!cronExpr) {
590
+ await ctx.reply("❌ Empty expression. Schedule not changed.", {
591
+ reply_markup: new InlineKeyboard().text("🔙 Back to list", "cron:list"),
592
+ });
593
+ return;
594
+ }
595
+ const job = getCronJob(jobId);
596
+ if (!job) {
597
+ await ctx.reply("❌ Job no longer exists.");
598
+ return;
599
+ }
600
+ try {
601
+ const { job: updated, rescheduled } = applyScheduleChange(jobId, cronExpr);
602
+ const statusNote = rescheduled ? " and rescheduled" : "";
603
+ await ctx.reply(`✅ Schedule updated${statusNote}!\n\n` +
604
+ `📋 ${updated.name}\n` +
605
+ `⏰ ${updated.cronExpression}`, {
606
+ reply_markup: new InlineKeyboard().text("📋 Back to list", "cron:list"),
607
+ });
608
+ }
609
+ catch (err) {
610
+ const msg = err instanceof Error ? err.message : String(err);
611
+ await ctx.reply(`❌ Invalid cron expression: ${cronExpr}\n\n${msg}\n\nTry again or tap Cancel.`, {
612
+ reply_markup: new InlineKeyboard()
613
+ .text("🔄 Retry", `cron:customsched:${jobId}`)
614
+ .text("❌ Cancel", "cron:list"),
615
+ });
616
+ }
617
+ });
353
618
  }
354
619
  //# sourceMappingURL=cron.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.8.2",
3
+ "version": "1.9.1",
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"