@iletai/nzb 1.8.1 → 1.8.2
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/cron/task-runner.js +126 -8
- package/dist/telegram/bot.js +25 -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);
|
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";
|
|
@@ -30,6 +31,8 @@ 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
|
}
|
|
@@ -38,14 +41,12 @@ async function executePromptTask(payload) {
|
|
|
38
41
|
const prompt = payload.prompt || "Scheduled check-in. Anything to report?";
|
|
39
42
|
try {
|
|
40
43
|
const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// No internal timeout — the scheduler's withTaskTimeout() handles it
|
|
45
|
+
// using the per-job configurable timeoutMs (default 5min).
|
|
46
|
+
return await new Promise((resolve) => {
|
|
44
47
|
sendToOrchestrator(`[Scheduled task] ${prompt}`, { type: "background" }, (text, done) => {
|
|
45
48
|
if (done) {
|
|
46
|
-
|
|
47
|
-
fullResponse = text;
|
|
48
|
-
resolve(fullResponse);
|
|
49
|
+
resolve(text);
|
|
49
50
|
}
|
|
50
51
|
});
|
|
51
52
|
});
|
|
@@ -106,7 +107,6 @@ async function executeBackupTask() {
|
|
|
106
107
|
.sort()
|
|
107
108
|
.reverse();
|
|
108
109
|
for (const old of files.slice(10)) {
|
|
109
|
-
const { unlinkSync } = await import("fs");
|
|
110
110
|
unlinkSync(join(BACKUPS_DIR, old));
|
|
111
111
|
// Also remove corresponding WAL
|
|
112
112
|
const walFile = old + "-wal";
|
|
@@ -158,6 +158,124 @@ async function executeWebhookTask(payload, timeoutMs) {
|
|
|
158
158
|
throw err;
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
const VOCAB_PROMPT = `Generate a single advanced English vocabulary word for a Vietnamese learner. Pick an uncommon but useful word.
|
|
162
|
+
|
|
163
|
+
You MUST respond in EXACTLY this format (no extra text, no markdown fences):
|
|
164
|
+
|
|
165
|
+
WORD: <word>
|
|
166
|
+
IPA: <IPA pronunciation>
|
|
167
|
+
POS: <part of speech abbreviation: n., v., adj., adv., etc.>
|
|
168
|
+
VI: <Vietnamese translation>
|
|
169
|
+
EN_EXAMPLE: <example sentence in English using the word>
|
|
170
|
+
VI_EXAMPLE: <Vietnamese translation of the example sentence>`;
|
|
171
|
+
async function executeVocabTask(job, payload) {
|
|
172
|
+
const customPrompt = payload.prompt;
|
|
173
|
+
const prompt = customPrompt || VOCAB_PROMPT;
|
|
174
|
+
// Step 1: Get vocab from AI
|
|
175
|
+
let aiResponse;
|
|
176
|
+
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
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
throw new Error(`Vocab AI prompt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
187
|
+
}
|
|
188
|
+
// Step 2: Parse the AI response
|
|
189
|
+
const parsed = parseVocabResponse(aiResponse);
|
|
190
|
+
// Step 3: Format the message
|
|
191
|
+
const formattedMessage = formatVocabMessage(parsed);
|
|
192
|
+
// Step 4: Send formatted text to Telegram
|
|
193
|
+
if (config.telegramEnabled) {
|
|
194
|
+
const { sendProactiveMessage } = await import("../telegram/bot.js");
|
|
195
|
+
await sendProactiveMessage(formattedMessage);
|
|
196
|
+
}
|
|
197
|
+
// Step 5: Generate TTS audio and send voice (macOS only)
|
|
198
|
+
if (config.telegramEnabled && parsed.word) {
|
|
199
|
+
try {
|
|
200
|
+
await generateAndSendVocabAudio(parsed.word);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
console.error("[nzb] Vocab TTS failed (non-fatal):", err instanceof Error ? err.message : err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return formattedMessage;
|
|
207
|
+
}
|
|
208
|
+
function parseVocabResponse(response) {
|
|
209
|
+
const get = (key) => {
|
|
210
|
+
const regex = new RegExp(`^${key}:\\s*(.+)$`, "mi");
|
|
211
|
+
const match = response.match(regex);
|
|
212
|
+
return match?.[1]?.trim() ?? "";
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
word: get("WORD"),
|
|
216
|
+
ipa: get("IPA"),
|
|
217
|
+
pos: get("POS"),
|
|
218
|
+
vi: get("VI"),
|
|
219
|
+
enExample: get("EN_EXAMPLE"),
|
|
220
|
+
viExample: get("VI_EXAMPLE"),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function formatVocabMessage(v) {
|
|
224
|
+
const lines = ["📖 WORD OF THE MINUTE", ""];
|
|
225
|
+
if (v.word) {
|
|
226
|
+
const ipaPart = v.ipa ? ` • ${v.ipa}` : "";
|
|
227
|
+
lines.push(`✨ ${v.word}${ipaPart}`);
|
|
228
|
+
}
|
|
229
|
+
if (v.pos)
|
|
230
|
+
lines.push(`🏷 ${v.pos}`);
|
|
231
|
+
if (v.vi)
|
|
232
|
+
lines.push(`🇻🇳 ${v.vi}`);
|
|
233
|
+
lines.push("");
|
|
234
|
+
if (v.enExample)
|
|
235
|
+
lines.push(`💬 ${v.enExample}`);
|
|
236
|
+
if (v.viExample)
|
|
237
|
+
lines.push(`🔄 ${v.viExample}`);
|
|
238
|
+
return lines.join("\n");
|
|
239
|
+
}
|
|
240
|
+
async function generateAndSendVocabAudio(word) {
|
|
241
|
+
const aiffPath = "/tmp/vocab-word.aiff";
|
|
242
|
+
const m4aPath = "/tmp/vocab-word.m4a";
|
|
243
|
+
// Clean up previous files
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(aiffPath);
|
|
246
|
+
}
|
|
247
|
+
catch { /* ignore */ }
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(m4aPath);
|
|
250
|
+
}
|
|
251
|
+
catch { /* ignore */ }
|
|
252
|
+
// Generate TTS with macOS say
|
|
253
|
+
execSync(`say -v Samantha -o "${aiffPath}" "${word.replace(/"/g, '\\"')}"`, {
|
|
254
|
+
timeout: 10_000,
|
|
255
|
+
});
|
|
256
|
+
if (!existsSync(aiffPath)) {
|
|
257
|
+
throw new Error("TTS generation failed: aiff file not created");
|
|
258
|
+
}
|
|
259
|
+
// Convert to m4a
|
|
260
|
+
execSync(`afconvert -f mp4f -d aac "${aiffPath}" "${m4aPath}"`, {
|
|
261
|
+
timeout: 10_000,
|
|
262
|
+
});
|
|
263
|
+
if (!existsSync(m4aPath)) {
|
|
264
|
+
throw new Error("Audio conversion failed: m4a file not created");
|
|
265
|
+
}
|
|
266
|
+
// Send voice via Telegram
|
|
267
|
+
const { sendVoice } = await import("../telegram/bot.js");
|
|
268
|
+
await sendVoice(m4aPath, `🔊 ${word}`);
|
|
269
|
+
// Clean up temp files
|
|
270
|
+
try {
|
|
271
|
+
unlinkSync(aiffPath);
|
|
272
|
+
}
|
|
273
|
+
catch { /* ignore */ }
|
|
274
|
+
try {
|
|
275
|
+
unlinkSync(m4aPath);
|
|
276
|
+
}
|
|
277
|
+
catch { /* ignore */ }
|
|
278
|
+
}
|
|
161
279
|
function formatBytes(bytes) {
|
|
162
280
|
if (bytes < 1024)
|
|
163
281
|
return `${bytes}B`;
|
package/dist/telegram/bot.js
CHANGED
|
@@ -318,4 +318,29 @@ export async function sendPhoto(photo, caption) {
|
|
|
318
318
|
throw err;
|
|
319
319
|
}
|
|
320
320
|
}
|
|
321
|
+
/** Send a voice/audio file to the authorized user (or a specific chat). Accepts a local file path. */
|
|
322
|
+
export async function sendVoice(filePath, caption, chatId) {
|
|
323
|
+
if (!bot || config.authorizedUserId === undefined)
|
|
324
|
+
return;
|
|
325
|
+
const targetChat = chatId ?? config.authorizedUserId;
|
|
326
|
+
if (!isAllowedFilePath(filePath)) {
|
|
327
|
+
throw new Error("File path is not within allowed directories");
|
|
328
|
+
}
|
|
329
|
+
const { InputFile } = await import("grammy");
|
|
330
|
+
const input = new InputFile(filePath);
|
|
331
|
+
try {
|
|
332
|
+
// Use sendAudio for .m4a (shows as playable audio with duration),
|
|
333
|
+
// sendVoice for .ogg (shows as voice message bubble)
|
|
334
|
+
if (filePath.endsWith(".ogg")) {
|
|
335
|
+
await bot.api.sendVoice(targetChat, input, { caption });
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
await bot.api.sendAudio(targetChat, input, { caption });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
console.error("[nzb] Failed to send voice:", err instanceof Error ? err.message : err);
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
321
346
|
//# sourceMappingURL=bot.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());
|