@iletai/nzb 1.8.1 → 1.9.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.
- package/dist/api/server.js +27 -0
- package/dist/copilot/orchestrator.js +21 -0
- package/dist/copilot/tools.js +5 -1
- package/dist/cron/scheduler.js +1 -0
- package/dist/cron/task-runner.js +140 -10
- package/dist/store/cron-store.js +7 -2
- package/dist/store/db.js +7 -0
- package/dist/telegram/bot.js +40 -1
- package/dist/telegram/handlers/cron.js +174 -0
- package/dist/telegram/menus.js +14 -0
- package/package.json +1 -1
package/dist/api/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
|
7
7
|
import { restartDaemon } from "../daemon.js";
|
|
8
8
|
import { API_TOKEN_PATH, ensureNZBHome } from "../paths.js";
|
|
9
9
|
import { searchMemories } from "../store/memory.js";
|
|
10
|
+
import { sendVoice } from "../telegram/bot.js";
|
|
10
11
|
import { sendPhoto } from "../telegram/bot.js";
|
|
11
12
|
// Ensure token file exists (generate on first run)
|
|
12
13
|
let apiToken = null;
|
|
@@ -211,6 +212,32 @@ app.post("/send-photo", async (req, res) => {
|
|
|
211
212
|
res.status(500).json({ error: msg });
|
|
212
213
|
}
|
|
213
214
|
});
|
|
215
|
+
// Send a voice/audio file to Telegram (protected by bearer token auth middleware)
|
|
216
|
+
app.post("/send-voice", async (req, res) => {
|
|
217
|
+
const { voice, caption, chat_id } = req.body;
|
|
218
|
+
if (!voice || typeof voice !== "string") {
|
|
219
|
+
res.status(400).json({ error: "Missing 'voice' (file path) in request body" });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Only allow local file paths (no URLs for voice)
|
|
223
|
+
if (voice.startsWith("http://") || voice.startsWith("https://")) {
|
|
224
|
+
res.status(400).json({ error: "Only local file paths are allowed for voice" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const chatId = chat_id ? parseInt(chat_id, 10) : undefined;
|
|
228
|
+
if (chat_id && (isNaN(chatId) || chatId === 0)) {
|
|
229
|
+
res.status(400).json({ error: "Invalid 'chat_id'" });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await sendVoice(voice, caption, chatId);
|
|
234
|
+
res.json({ status: "sent" });
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
238
|
+
res.status(500).json({ error: msg });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
214
241
|
// Global error handler — catch unhandled Express errors
|
|
215
242
|
app.use((err, _req, res, _next) => {
|
|
216
243
|
console.error("[nzb] Express error:", err.message);
|
|
@@ -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
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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)
|
package/dist/cron/scheduler.js
CHANGED
package/dist/cron/task-runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
2
3
|
import { freemem, totalmem } from "os";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
import { config } from "../config.js";
|
|
@@ -21,7 +22,7 @@ export async function executeCronTask(job) {
|
|
|
21
22
|
const payload = JSON.parse(job.payload);
|
|
22
23
|
switch (job.taskType) {
|
|
23
24
|
case "prompt":
|
|
24
|
-
return await executePromptTask(payload);
|
|
25
|
+
return await executePromptTask(payload, job.model);
|
|
25
26
|
case "health_check":
|
|
26
27
|
return await executeHealthCheckTask();
|
|
27
28
|
case "backup":
|
|
@@ -30,22 +31,27 @@ export async function executeCronTask(job) {
|
|
|
30
31
|
return await executeNotificationTask(job, payload);
|
|
31
32
|
case "webhook":
|
|
32
33
|
return await executeWebhookTask(payload, job.timeoutMs);
|
|
34
|
+
case "vocab":
|
|
35
|
+
return await executeVocabTask(job, payload);
|
|
33
36
|
default:
|
|
34
37
|
throw new Error(`Unknown task type: ${job.taskType}`);
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
|
-
async function executePromptTask(payload) {
|
|
40
|
+
async function executePromptTask(payload, model) {
|
|
38
41
|
const prompt = payload.prompt || "Scheduled check-in. Anything to report?";
|
|
39
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
|
+
}
|
|
40
48
|
const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
// No internal timeout — the scheduler's withTaskTimeout() handles it
|
|
50
|
+
// using the per-job configurable timeoutMs (default 5min).
|
|
51
|
+
return await new Promise((resolve) => {
|
|
44
52
|
sendToOrchestrator(`[Scheduled task] ${prompt}`, { type: "background" }, (text, done) => {
|
|
45
53
|
if (done) {
|
|
46
|
-
|
|
47
|
-
fullResponse = text;
|
|
48
|
-
resolve(fullResponse);
|
|
54
|
+
resolve(text);
|
|
49
55
|
}
|
|
50
56
|
});
|
|
51
57
|
});
|
|
@@ -106,7 +112,6 @@ async function executeBackupTask() {
|
|
|
106
112
|
.sort()
|
|
107
113
|
.reverse();
|
|
108
114
|
for (const old of files.slice(10)) {
|
|
109
|
-
const { unlinkSync } = await import("fs");
|
|
110
115
|
unlinkSync(join(BACKUPS_DIR, old));
|
|
111
116
|
// Also remove corresponding WAL
|
|
112
117
|
const walFile = old + "-wal";
|
|
@@ -158,6 +163,131 @@ async function executeWebhookTask(payload, timeoutMs) {
|
|
|
158
163
|
throw err;
|
|
159
164
|
}
|
|
160
165
|
}
|
|
166
|
+
const VOCAB_PROMPT = `Generate a single advanced English vocabulary word for a Vietnamese learner. Pick an uncommon but useful word.
|
|
167
|
+
|
|
168
|
+
You MUST respond in EXACTLY this format (no extra text, no markdown fences):
|
|
169
|
+
|
|
170
|
+
WORD: <word>
|
|
171
|
+
IPA: <IPA pronunciation>
|
|
172
|
+
POS: <part of speech abbreviation: n., v., adj., adv., etc.>
|
|
173
|
+
VI: <Vietnamese translation>
|
|
174
|
+
EN_EXAMPLE: <example sentence in English using the word>
|
|
175
|
+
VI_EXAMPLE: <Vietnamese translation of the example sentence>`;
|
|
176
|
+
async function executeVocabTask(job, payload) {
|
|
177
|
+
const customPrompt = payload.prompt;
|
|
178
|
+
const prompt = customPrompt || VOCAB_PROMPT;
|
|
179
|
+
// Step 1: Get vocab from AI
|
|
180
|
+
let aiResponse;
|
|
181
|
+
try {
|
|
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
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw new Error(`Vocab AI prompt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
199
|
+
}
|
|
200
|
+
// Step 2: Parse the AI response
|
|
201
|
+
const parsed = parseVocabResponse(aiResponse);
|
|
202
|
+
// Step 3: Format the message
|
|
203
|
+
const formattedMessage = formatVocabMessage(parsed);
|
|
204
|
+
// Step 4: Send formatted text to Telegram
|
|
205
|
+
if (config.telegramEnabled) {
|
|
206
|
+
const { sendProactiveMessage } = await import("../telegram/bot.js");
|
|
207
|
+
await sendProactiveMessage(formattedMessage);
|
|
208
|
+
}
|
|
209
|
+
// Step 5: Generate TTS audio and send voice (macOS only)
|
|
210
|
+
if (config.telegramEnabled && parsed.word) {
|
|
211
|
+
try {
|
|
212
|
+
await generateAndSendVocabAudio(parsed.word);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
console.error("[nzb] Vocab TTS failed (non-fatal):", err instanceof Error ? err.message : err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return formattedMessage;
|
|
219
|
+
}
|
|
220
|
+
function parseVocabResponse(response) {
|
|
221
|
+
const get = (key) => {
|
|
222
|
+
const regex = new RegExp(`^${key}:\\s*(.+)$`, "mi");
|
|
223
|
+
const match = response.match(regex);
|
|
224
|
+
return match?.[1]?.trim() ?? "";
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
word: get("WORD"),
|
|
228
|
+
ipa: get("IPA"),
|
|
229
|
+
pos: get("POS"),
|
|
230
|
+
vi: get("VI"),
|
|
231
|
+
enExample: get("EN_EXAMPLE"),
|
|
232
|
+
viExample: get("VI_EXAMPLE"),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function formatVocabMessage(v) {
|
|
236
|
+
const lines = ["📖 WORD OF THE MINUTE", ""];
|
|
237
|
+
if (v.word) {
|
|
238
|
+
const ipaPart = v.ipa ? ` • ${v.ipa}` : "";
|
|
239
|
+
lines.push(`✨ ${v.word}${ipaPart}`);
|
|
240
|
+
}
|
|
241
|
+
if (v.pos)
|
|
242
|
+
lines.push(`🏷 ${v.pos}`);
|
|
243
|
+
if (v.vi)
|
|
244
|
+
lines.push(`🇻🇳 ${v.vi}`);
|
|
245
|
+
lines.push("");
|
|
246
|
+
if (v.enExample)
|
|
247
|
+
lines.push(`💬 ${v.enExample}`);
|
|
248
|
+
if (v.viExample)
|
|
249
|
+
lines.push(`🔄 ${v.viExample}`);
|
|
250
|
+
return lines.join("\n");
|
|
251
|
+
}
|
|
252
|
+
async function generateAndSendVocabAudio(word) {
|
|
253
|
+
const aiffPath = "/tmp/vocab-word.aiff";
|
|
254
|
+
const m4aPath = "/tmp/vocab-word.m4a";
|
|
255
|
+
// Clean up previous files
|
|
256
|
+
try {
|
|
257
|
+
unlinkSync(aiffPath);
|
|
258
|
+
}
|
|
259
|
+
catch { /* ignore */ }
|
|
260
|
+
try {
|
|
261
|
+
unlinkSync(m4aPath);
|
|
262
|
+
}
|
|
263
|
+
catch { /* ignore */ }
|
|
264
|
+
// Generate TTS with macOS say
|
|
265
|
+
execSync(`say -v Samantha -o "${aiffPath}" "${word.replace(/"/g, '\\"')}"`, {
|
|
266
|
+
timeout: 10_000,
|
|
267
|
+
});
|
|
268
|
+
if (!existsSync(aiffPath)) {
|
|
269
|
+
throw new Error("TTS generation failed: aiff file not created");
|
|
270
|
+
}
|
|
271
|
+
// Convert to m4a
|
|
272
|
+
execSync(`afconvert -f mp4f -d aac "${aiffPath}" "${m4aPath}"`, {
|
|
273
|
+
timeout: 10_000,
|
|
274
|
+
});
|
|
275
|
+
if (!existsSync(m4aPath)) {
|
|
276
|
+
throw new Error("Audio conversion failed: m4a file not created");
|
|
277
|
+
}
|
|
278
|
+
// Send voice via Telegram
|
|
279
|
+
const { sendVoice } = await import("../telegram/bot.js");
|
|
280
|
+
await sendVoice(m4aPath, `🔊 ${word}`);
|
|
281
|
+
// Clean up temp files
|
|
282
|
+
try {
|
|
283
|
+
unlinkSync(aiffPath);
|
|
284
|
+
}
|
|
285
|
+
catch { /* ignore */ }
|
|
286
|
+
try {
|
|
287
|
+
unlinkSync(m4aPath);
|
|
288
|
+
}
|
|
289
|
+
catch { /* ignore */ }
|
|
290
|
+
}
|
|
161
291
|
function formatBytes(bytes) {
|
|
162
292
|
if (bytes < 1024)
|
|
163
293
|
return `${bytes}B`;
|
package/dist/store/cron-store.js
CHANGED
|
@@ -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();
|
package/dist/telegram/bot.js
CHANGED
|
@@ -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
|
|
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
|
|
@@ -318,4 +332,29 @@ export async function sendPhoto(photo, caption) {
|
|
|
318
332
|
throw err;
|
|
319
333
|
}
|
|
320
334
|
}
|
|
335
|
+
/** Send a voice/audio file to the authorized user (or a specific chat). Accepts a local file path. */
|
|
336
|
+
export async function sendVoice(filePath, caption, chatId) {
|
|
337
|
+
if (!bot || config.authorizedUserId === undefined)
|
|
338
|
+
return;
|
|
339
|
+
const targetChat = chatId ?? config.authorizedUserId;
|
|
340
|
+
if (!isAllowedFilePath(filePath)) {
|
|
341
|
+
throw new Error("File path is not within allowed directories");
|
|
342
|
+
}
|
|
343
|
+
const { InputFile } = await import("grammy");
|
|
344
|
+
const input = new InputFile(filePath);
|
|
345
|
+
try {
|
|
346
|
+
// Use sendAudio for .m4a (shows as playable audio with duration),
|
|
347
|
+
// sendVoice for .ogg (shows as voice message bubble)
|
|
348
|
+
if (filePath.endsWith(".ogg")) {
|
|
349
|
+
await bot.api.sendVoice(targetChat, input, { caption });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
await bot.api.sendAudio(targetChat, input, { caption });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
console.error("[nzb] Failed to send voice:", err instanceof Error ? err.message : err);
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
321
360
|
//# sourceMappingURL=bot.js.map
|
|
@@ -3,6 +3,26 @@ 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
|
+
// In-memory state for users awaiting custom cron expression input
|
|
17
|
+
const pendingCustomSchedule = new Map();
|
|
18
|
+
/** Check if a user is awaiting custom cron input and return the job ID. */
|
|
19
|
+
export function getPendingCustomScheduleJobId(userId) {
|
|
20
|
+
return pendingCustomSchedule.get(userId);
|
|
21
|
+
}
|
|
22
|
+
/** Clear pending custom schedule state for a user. */
|
|
23
|
+
export function clearPendingCustomSchedule(userId) {
|
|
24
|
+
pendingCustomSchedule.delete(userId);
|
|
25
|
+
}
|
|
6
26
|
// ── Keyboard builders ────────────────────────────────────────────────
|
|
7
27
|
function buildCronMainMenu() {
|
|
8
28
|
return new InlineKeyboard()
|
|
@@ -18,6 +38,7 @@ function buildJobListKeyboard(jobs, page, totalPages) {
|
|
|
18
38
|
for (const job of pageJobs) {
|
|
19
39
|
const toggleLabel = job.enabled ? "⏸" : "▶️";
|
|
20
40
|
kb.text(`${toggleLabel} ${job.name}`, `cron:toggle:${job.id}`)
|
|
41
|
+
.text("⏱", `cron:schedule:${job.id}`)
|
|
21
42
|
.text("▶ Run", `cron:trigger:${job.id}`)
|
|
22
43
|
.text("📊", `cron:history:${job.id}`)
|
|
23
44
|
.text("🗑", `cron:delete:${job.id}`)
|
|
@@ -33,6 +54,29 @@ function buildJobListKeyboard(jobs, page, totalPages) {
|
|
|
33
54
|
kb.text("🔙 Back", "cron:back");
|
|
34
55
|
return kb;
|
|
35
56
|
}
|
|
57
|
+
function buildScheduleKeyboard(jobId) {
|
|
58
|
+
const kb = new InlineKeyboard();
|
|
59
|
+
for (let i = 0; i < SCHEDULE_PRESETS.length; i++) {
|
|
60
|
+
const preset = SCHEDULE_PRESETS[i];
|
|
61
|
+
kb.text(`${preset.label}`, `cron:setsched:${jobId}:${i}`).row();
|
|
62
|
+
}
|
|
63
|
+
kb.text("✏️ Custom", `cron:customsched:${jobId}`).row();
|
|
64
|
+
kb.text("🔙 Back to list", "cron:list");
|
|
65
|
+
return kb;
|
|
66
|
+
}
|
|
67
|
+
/** Apply a schedule change: update DB and reschedule. */
|
|
68
|
+
function applyScheduleChange(jobId, cronExpression) {
|
|
69
|
+
const updated = updateCronJob(jobId, { cronExpression });
|
|
70
|
+
if (!updated)
|
|
71
|
+
throw new Error("Job not found");
|
|
72
|
+
let rescheduled = false;
|
|
73
|
+
if (updated.enabled) {
|
|
74
|
+
unscheduleJob(jobId);
|
|
75
|
+
scheduleJob(updated);
|
|
76
|
+
rescheduled = true;
|
|
77
|
+
}
|
|
78
|
+
return { job: updated, rescheduled };
|
|
79
|
+
}
|
|
36
80
|
// ── Text formatters ──────────────────────────────────────────────────
|
|
37
81
|
function formatJobLine(job, index) {
|
|
38
82
|
const status = job.enabled ? "✅" : "⏸";
|
|
@@ -338,6 +382,95 @@ export function registerCronHandlers(bot) {
|
|
|
338
382
|
}
|
|
339
383
|
}
|
|
340
384
|
});
|
|
385
|
+
// Schedule change — show preset options for a job
|
|
386
|
+
bot.callbackQuery(/^cron:schedule:(.+)$/, async (ctx) => {
|
|
387
|
+
try {
|
|
388
|
+
const jobId = ctx.match[1];
|
|
389
|
+
const job = getCronJob(jobId);
|
|
390
|
+
if (!job) {
|
|
391
|
+
await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const text = `⏱ Change Schedule: ${job.name}\n\n` +
|
|
395
|
+
`Current: ${job.cronExpression}\n\n` +
|
|
396
|
+
"Select a preset or enter a custom expression:";
|
|
397
|
+
await ctx.answerCallbackQuery();
|
|
398
|
+
await ctx.editMessageText(text, {
|
|
399
|
+
reply_markup: buildScheduleKeyboard(job.id),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
if (!isMessageNotModifiedError(err)) {
|
|
404
|
+
console.error("[nzb] Cron schedule menu error:", err instanceof Error ? err.message : err);
|
|
405
|
+
await ctx
|
|
406
|
+
.answerCallbackQuery({ text: "Error loading schedule options", show_alert: true })
|
|
407
|
+
.catch(() => { });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
// Set schedule from preset
|
|
412
|
+
bot.callbackQuery(/^cron:setsched:(.+):(\d+)$/, async (ctx) => {
|
|
413
|
+
try {
|
|
414
|
+
const jobId = ctx.match[1];
|
|
415
|
+
const presetIndex = parseInt(ctx.match[2], 10);
|
|
416
|
+
const preset = SCHEDULE_PRESETS[presetIndex];
|
|
417
|
+
if (!preset) {
|
|
418
|
+
await ctx.answerCallbackQuery({ text: "Invalid preset", show_alert: true });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const job = getCronJob(jobId);
|
|
422
|
+
if (!job) {
|
|
423
|
+
await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const { rescheduled } = applyScheduleChange(jobId, preset.cron);
|
|
427
|
+
const statusNote = rescheduled ? " (rescheduled)" : "";
|
|
428
|
+
await ctx.answerCallbackQuery(`Schedule → ${preset.cron}${statusNote}`);
|
|
429
|
+
await showCronList(ctx, 0, true);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
if (!isMessageNotModifiedError(err)) {
|
|
433
|
+
console.error("[nzb] Cron set schedule error:", err instanceof Error ? err.message : err);
|
|
434
|
+
await ctx
|
|
435
|
+
.answerCallbackQuery({ text: "Error updating schedule", show_alert: true })
|
|
436
|
+
.catch(() => { });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
// Custom schedule — prompt user to type cron expression
|
|
441
|
+
bot.callbackQuery(/^cron:customsched:(.+)$/, async (ctx) => {
|
|
442
|
+
try {
|
|
443
|
+
const jobId = ctx.match[1];
|
|
444
|
+
const job = getCronJob(jobId);
|
|
445
|
+
if (!job) {
|
|
446
|
+
await ctx.answerCallbackQuery({ text: "Job not found", show_alert: true });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const userId = ctx.from?.id;
|
|
450
|
+
if (userId) {
|
|
451
|
+
pendingCustomSchedule.set(userId, jobId);
|
|
452
|
+
}
|
|
453
|
+
await ctx.answerCallbackQuery();
|
|
454
|
+
await ctx.editMessageText(`✏️ Custom Schedule: ${job.name}\n\n` +
|
|
455
|
+
`Current: ${job.cronExpression}\n\n` +
|
|
456
|
+
"Type your cron expression in the chat.\n\n" +
|
|
457
|
+
"Examples:\n" +
|
|
458
|
+
"• 0 9 * * MON-FRI — weekdays at 9AM\n" +
|
|
459
|
+
"• */10 * * * * — every 10 minutes\n" +
|
|
460
|
+
"• 0 0 1 * * — first day of month\n" +
|
|
461
|
+
"• 30 14 * * * — daily at 2:30PM", {
|
|
462
|
+
reply_markup: new InlineKeyboard().text("❌ Cancel", "cron:list"),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
if (!isMessageNotModifiedError(err)) {
|
|
467
|
+
console.error("[nzb] Cron custom schedule error:", err instanceof Error ? err.message : err);
|
|
468
|
+
await ctx
|
|
469
|
+
.answerCallbackQuery({ text: "Error", show_alert: true })
|
|
470
|
+
.catch(() => { });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
});
|
|
341
474
|
// Back to main cron menu
|
|
342
475
|
bot.callbackQuery("cron:back", async (ctx) => {
|
|
343
476
|
try {
|
|
@@ -350,5 +483,46 @@ export function registerCronHandlers(bot) {
|
|
|
350
483
|
}
|
|
351
484
|
}
|
|
352
485
|
});
|
|
486
|
+
// Intercept text messages when a user is in "custom cron schedule" mode.
|
|
487
|
+
// Must be registered before the main streaming handler so it can short-circuit.
|
|
488
|
+
bot.on("message:text", async (ctx, next) => {
|
|
489
|
+
const userId = ctx.from?.id;
|
|
490
|
+
if (!userId)
|
|
491
|
+
return next();
|
|
492
|
+
const jobId = pendingCustomSchedule.get(userId);
|
|
493
|
+
if (!jobId)
|
|
494
|
+
return next();
|
|
495
|
+
// Clear pending state immediately so subsequent messages go to the AI
|
|
496
|
+
pendingCustomSchedule.delete(userId);
|
|
497
|
+
const cronExpr = ctx.message.text.trim();
|
|
498
|
+
if (!cronExpr) {
|
|
499
|
+
await ctx.reply("❌ Empty expression. Schedule not changed.", {
|
|
500
|
+
reply_markup: new InlineKeyboard().text("🔙 Back to list", "cron:list"),
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const job = getCronJob(jobId);
|
|
505
|
+
if (!job) {
|
|
506
|
+
await ctx.reply("❌ Job no longer exists.");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
const { job: updated, rescheduled } = applyScheduleChange(jobId, cronExpr);
|
|
511
|
+
const statusNote = rescheduled ? " and rescheduled" : "";
|
|
512
|
+
await ctx.reply(`✅ Schedule updated${statusNote}!\n\n` +
|
|
513
|
+
`📋 ${updated.name}\n` +
|
|
514
|
+
`⏰ ${updated.cronExpression}`, {
|
|
515
|
+
reply_markup: new InlineKeyboard().text("📋 Back to list", "cron:list"),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
520
|
+
await ctx.reply(`❌ Invalid cron expression: ${cronExpr}\n\n${msg}\n\nTry again or tap Cancel.`, {
|
|
521
|
+
reply_markup: new InlineKeyboard()
|
|
522
|
+
.text("🔄 Retry", `cron:customsched:${jobId}`)
|
|
523
|
+
.text("❌ Cancel", "cron:list"),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
});
|
|
353
527
|
}
|
|
354
528
|
//# sourceMappingURL=cron.js.map
|
package/dist/telegram/menus.js
CHANGED
|
@@ -239,6 +239,20 @@ export function createMenus(getUptimeStr) {
|
|
|
239
239
|
});
|
|
240
240
|
// Main interactive menu with navigation
|
|
241
241
|
const mainMenu = new Menu("main-menu")
|
|
242
|
+
.text("⏰ Cron", async (ctx) => {
|
|
243
|
+
try {
|
|
244
|
+
await ctx.answerCallbackQuery();
|
|
245
|
+
const { sendCronMenu } = await import("./handlers/cron.js");
|
|
246
|
+
await sendCronMenu(ctx);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
console.error("[nzb] Menu callback error:", err instanceof Error ? err.message : err);
|
|
250
|
+
await ctx.answerCallbackQuery({
|
|
251
|
+
text: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
252
|
+
show_alert: true,
|
|
253
|
+
}).catch(() => { });
|
|
254
|
+
}
|
|
255
|
+
})
|
|
242
256
|
.text("📊 Status", async (ctx) => {
|
|
243
257
|
try {
|
|
244
258
|
const workers = Array.from(getWorkers().values());
|