@iletai/nzb 1.3.3 → 1.3.5
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/config.js +2 -0
- package/dist/copilot/orchestrator.js +2 -1
- package/dist/setup.js +16 -0
- package/dist/store/db.js +39 -4
- package/dist/telegram/bot.js +125 -3
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -14,6 +14,7 @@ const configSchema = z.object({
|
|
|
14
14
|
SHOW_REASONING: z.string().optional(),
|
|
15
15
|
LOG_CHANNEL_ID: z.string().optional(),
|
|
16
16
|
NODE_EXTRA_CA_CERTS: z.string().optional(),
|
|
17
|
+
OPENAI_API_KEY: z.string().optional(),
|
|
17
18
|
});
|
|
18
19
|
const raw = configSchema.parse(process.env);
|
|
19
20
|
// Apply NODE_EXTRA_CA_CERTS from .env if not already set via environment.
|
|
@@ -43,6 +44,7 @@ export const config = {
|
|
|
43
44
|
apiPort: parsedPort,
|
|
44
45
|
logChannelId: parsedLogChannelId,
|
|
45
46
|
workerTimeoutMs: parsedWorkerTimeout,
|
|
47
|
+
openaiApiKey: raw.OPENAI_API_KEY,
|
|
46
48
|
get copilotModel() {
|
|
47
49
|
return _copilotModel;
|
|
48
50
|
},
|
|
@@ -404,7 +404,8 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
404
404
|
}
|
|
405
405
|
// Log both sides of the conversation after delivery
|
|
406
406
|
try {
|
|
407
|
-
|
|
407
|
+
const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
|
|
408
|
+
logConversation(logRole, prompt, sourceLabel, telegramMsgId);
|
|
408
409
|
}
|
|
409
410
|
catch {
|
|
410
411
|
/* best-effort */
|
package/dist/setup.js
CHANGED
|
@@ -215,6 +215,20 @@ ${BOLD}╔═══════════════════════
|
|
|
215
215
|
else {
|
|
216
216
|
console.log(`\n${DIM} Skipping Google. You can always set it up later with: nzb setup${RESET}\n`);
|
|
217
217
|
}
|
|
218
|
+
// ── Voice / Whisper Setup ────────────────────────────────
|
|
219
|
+
console.log(`${BOLD}━━━ Voice Message Setup (optional) ━━━${RESET}\n`);
|
|
220
|
+
console.log(`NZB can transcribe voice messages using OpenAI's Whisper API.`);
|
|
221
|
+
console.log(`You need an OpenAI API key from ${CYAN}https://platform.openai.com/api-keys${RESET}`);
|
|
222
|
+
console.log();
|
|
223
|
+
const existingOpenaiKey = existing.OPENAI_API_KEY;
|
|
224
|
+
const openaiKey = await ask(rl, ` OpenAI API Key ${existingOpenaiKey ? `${DIM}(Enter to keep existing)${RESET}` : `${DIM}(Enter to skip)${RESET}`}: `);
|
|
225
|
+
const finalOpenaiKey = openaiKey.trim() || existingOpenaiKey || "";
|
|
226
|
+
if (finalOpenaiKey) {
|
|
227
|
+
console.log(`\n${GREEN} ✓ Whisper transcription enabled${RESET}\n`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.log(`\n${DIM} Skipping voice. Voice messages will be saved but not transcribed.${RESET}\n`);
|
|
231
|
+
}
|
|
218
232
|
// ── Model picker ─────────────────────────────────────────
|
|
219
233
|
console.log(`\n${BOLD}━━━ Default Model ━━━${RESET}\n`);
|
|
220
234
|
console.log(`${DIM}Fetching available models from Copilot...${RESET}`);
|
|
@@ -241,6 +255,8 @@ ${BOLD}╔═══════════════════════
|
|
|
241
255
|
lines.push(`AUTHORIZED_USER_ID=${userId}`);
|
|
242
256
|
lines.push(`API_PORT=${apiPort}`);
|
|
243
257
|
lines.push(`COPILOT_MODEL=${model}`);
|
|
258
|
+
if (finalOpenaiKey)
|
|
259
|
+
lines.push(`OPENAI_API_KEY=${finalOpenaiKey}`);
|
|
244
260
|
writeFileSync(ENV_PATH, lines.join("\n") + "\n");
|
|
245
261
|
// ── Done ─────────────────────────────────────────────────
|
|
246
262
|
console.log(`
|
package/dist/store/db.js
CHANGED
|
@@ -33,9 +33,19 @@ export function getDb() {
|
|
|
33
33
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
34
34
|
content TEXT NOT NULL,
|
|
35
35
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
36
|
+
telegram_msg_id INTEGER,
|
|
36
37
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
37
38
|
)
|
|
38
39
|
`);
|
|
40
|
+
// Migrate: add telegram_msg_id column if missing
|
|
41
|
+
try {
|
|
42
|
+
db.prepare(`SELECT telegram_msg_id FROM conversation_log LIMIT 1`).get();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN telegram_msg_id INTEGER`);
|
|
46
|
+
}
|
|
47
|
+
// Index for fast reply-to lookups
|
|
48
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_conv_telegram_msg ON conversation_log (telegram_msg_id)`);
|
|
39
49
|
db.exec(`
|
|
40
50
|
CREATE TABLE IF NOT EXISTS memories (
|
|
41
51
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -60,6 +70,7 @@ export function getDb() {
|
|
|
60
70
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
61
71
|
content TEXT NOT NULL,
|
|
62
72
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
73
|
+
telegram_msg_id INTEGER,
|
|
63
74
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
64
75
|
)
|
|
65
76
|
`);
|
|
@@ -83,11 +94,13 @@ export function getDb() {
|
|
|
83
94
|
getState: db.prepare(`SELECT value FROM nzb_state WHERE key = ?`),
|
|
84
95
|
setState: db.prepare(`INSERT OR REPLACE INTO nzb_state (key, value) VALUES (?, ?)`),
|
|
85
96
|
deleteState: db.prepare(`DELETE FROM nzb_state WHERE key = ?`),
|
|
86
|
-
logConversation: db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`),
|
|
97
|
+
logConversation: db.prepare(`INSERT INTO conversation_log (role, content, source, telegram_msg_id) VALUES (?, ?, ?, ?)`),
|
|
87
98
|
pruneConversation: db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`),
|
|
88
99
|
addMemory: db.prepare(`INSERT INTO memories (category, content, source) VALUES (?, ?, ?)`),
|
|
89
100
|
removeMemory: db.prepare(`DELETE FROM memories WHERE id = ?`),
|
|
90
101
|
memorySummary: db.prepare(`SELECT id, category, content FROM memories ORDER BY category, last_accessed DESC`),
|
|
102
|
+
getConversationByMsgId: db.prepare(`SELECT id FROM conversation_log WHERE telegram_msg_id = ? LIMIT 1`),
|
|
103
|
+
getConversationAround: db.prepare(`SELECT role, content, source, ts FROM conversation_log WHERE id BETWEEN ? - 4 AND ? + 4 ORDER BY id ASC`),
|
|
91
104
|
};
|
|
92
105
|
}
|
|
93
106
|
return db;
|
|
@@ -106,16 +119,38 @@ export function deleteState(key) {
|
|
|
106
119
|
getDb(); // ensure init
|
|
107
120
|
stmtCache.deleteState.run(key);
|
|
108
121
|
}
|
|
109
|
-
/** Log a conversation turn (user, assistant, or system). */
|
|
110
|
-
export function logConversation(role, content, source) {
|
|
122
|
+
/** Log a conversation turn (user, assistant, or system) with optional Telegram message ID. */
|
|
123
|
+
export function logConversation(role, content, source, telegramMsgId) {
|
|
111
124
|
getDb(); // ensure init
|
|
112
|
-
stmtCache.logConversation.run(role, content, source);
|
|
125
|
+
stmtCache.logConversation.run(role, content, source, telegramMsgId ?? null);
|
|
113
126
|
// Keep last 200 entries to support context recovery after session loss
|
|
114
127
|
logInsertCount++;
|
|
115
128
|
if (logInsertCount % 50 === 0) {
|
|
116
129
|
stmtCache.pruneConversation.run();
|
|
117
130
|
}
|
|
118
131
|
}
|
|
132
|
+
/** Get conversation context around a Telegram message ID (±2 turns). */
|
|
133
|
+
export function getConversationContext(telegramMsgId) {
|
|
134
|
+
getDb(); // ensure init
|
|
135
|
+
const row = stmtCache.getConversationByMsgId.get(telegramMsgId);
|
|
136
|
+
if (!row)
|
|
137
|
+
return undefined;
|
|
138
|
+
const rows = stmtCache.getConversationAround.all(row.id, row.id);
|
|
139
|
+
if (rows.length === 0)
|
|
140
|
+
return undefined;
|
|
141
|
+
return rows
|
|
142
|
+
.map((r) => {
|
|
143
|
+
const tag = r.role === "user" ? "You" : r.role === "assistant" ? "NZB" : "System";
|
|
144
|
+
const content = r.content.length > 400 ? r.content.slice(0, 400) + "…" : r.content;
|
|
145
|
+
return `${tag}: ${content}`;
|
|
146
|
+
})
|
|
147
|
+
.join("\n");
|
|
148
|
+
}
|
|
149
|
+
/** Update the most recent assistant log entry with its Telegram message ID (for reply-to lookups). */
|
|
150
|
+
export function updateLastAssistantTelegramMsgId(telegramMsgId) {
|
|
151
|
+
const db = getDb();
|
|
152
|
+
db.prepare(`UPDATE conversation_log SET telegram_msg_id = ? WHERE id = (SELECT id FROM conversation_log WHERE role = 'assistant' ORDER BY id DESC LIMIT 1)`).run(telegramMsgId);
|
|
153
|
+
}
|
|
119
154
|
/** Get recent conversation history formatted for injection into system message. */
|
|
120
155
|
export function getRecentConversation(limit = 20) {
|
|
121
156
|
const db = getDb();
|
package/dist/telegram/bot.js
CHANGED
|
@@ -439,12 +439,20 @@ export function createBot() {
|
|
|
439
439
|
const onUsage = (usage) => {
|
|
440
440
|
usageInfo = usage;
|
|
441
441
|
};
|
|
442
|
-
// If user replies to a message, include
|
|
442
|
+
// If user replies to a message, include surrounding conversation context
|
|
443
443
|
let userPrompt = ctx.message.text;
|
|
444
444
|
const replyMsg = ctx.message.reply_to_message;
|
|
445
445
|
if (replyMsg && "text" in replyMsg && replyMsg.text) {
|
|
446
|
-
|
|
447
|
-
|
|
446
|
+
// Try to find full conversation context around the replied message
|
|
447
|
+
const { getConversationContext } = await import("../store/db.js");
|
|
448
|
+
const context = getConversationContext(replyMsg.message_id);
|
|
449
|
+
if (context) {
|
|
450
|
+
userPrompt = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n[Your reply]: ${userPrompt}`;
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
const quoted = replyMsg.text.length > 500 ? replyMsg.text.slice(0, 500) + "…" : replyMsg.text;
|
|
454
|
+
userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
|
|
455
|
+
}
|
|
448
456
|
}
|
|
449
457
|
sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
450
458
|
if (done) {
|
|
@@ -505,6 +513,11 @@ export function createBot() {
|
|
|
505
513
|
await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
506
514
|
}
|
|
507
515
|
catch { }
|
|
516
|
+
try {
|
|
517
|
+
const { updateLastAssistantTelegramMsgId } = await import("../store/db.js");
|
|
518
|
+
updateLastAssistantTelegramMsgId(placeholderMsgId);
|
|
519
|
+
}
|
|
520
|
+
catch { }
|
|
508
521
|
return;
|
|
509
522
|
}
|
|
510
523
|
catch {
|
|
@@ -514,6 +527,11 @@ export function createBot() {
|
|
|
514
527
|
await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
515
528
|
}
|
|
516
529
|
catch { }
|
|
530
|
+
try {
|
|
531
|
+
const { updateLastAssistantTelegramMsgId } = await import("../store/db.js");
|
|
532
|
+
updateLastAssistantTelegramMsgId(placeholderMsgId);
|
|
533
|
+
}
|
|
534
|
+
catch { }
|
|
517
535
|
return;
|
|
518
536
|
}
|
|
519
537
|
catch {
|
|
@@ -736,6 +754,110 @@ export function createBot() {
|
|
|
736
754
|
});
|
|
737
755
|
}
|
|
738
756
|
});
|
|
757
|
+
// Handle voice messages — download, transcribe via Whisper, send to AI
|
|
758
|
+
bot.on("message:voice", async (ctx) => {
|
|
759
|
+
const chatId = ctx.chat.id;
|
|
760
|
+
const userMessageId = ctx.message.message_id;
|
|
761
|
+
const duration = ctx.message.voice.duration;
|
|
762
|
+
void logInfo(`🎤 Voice received: ${duration}s`);
|
|
763
|
+
try {
|
|
764
|
+
await ctx.react("👀");
|
|
765
|
+
}
|
|
766
|
+
catch { }
|
|
767
|
+
// Limit voice duration to 5 minutes
|
|
768
|
+
if (duration > 300) {
|
|
769
|
+
await ctx.reply("❌ Voice too long (max 5 min).", { reply_parameters: { message_id: userMessageId } });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
const file = await ctx.api.getFile(ctx.message.voice.file_id);
|
|
774
|
+
const filePath = file.file_path;
|
|
775
|
+
if (!filePath) {
|
|
776
|
+
await ctx.reply("❌ Could not download voice.", { reply_parameters: { message_id: userMessageId } });
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
|
|
780
|
+
const { mkdtempSync, writeFileSync } = await import("fs");
|
|
781
|
+
const { join } = await import("path");
|
|
782
|
+
const { tmpdir } = await import("os");
|
|
783
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "nzb-voice-"));
|
|
784
|
+
const ext = filePath.split(".").pop() || "oga";
|
|
785
|
+
const localPath = join(tmpDir, `voice.${ext}`);
|
|
786
|
+
const response = await fetch(url);
|
|
787
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
788
|
+
writeFileSync(localPath, buffer);
|
|
789
|
+
let prompt;
|
|
790
|
+
if (config.openaiApiKey) {
|
|
791
|
+
// Transcribe using OpenAI Whisper API
|
|
792
|
+
try {
|
|
793
|
+
const formData = new FormData();
|
|
794
|
+
formData.append("file", new Blob([buffer], { type: "audio/ogg" }), `voice.${ext}`);
|
|
795
|
+
formData.append("model", "whisper-1");
|
|
796
|
+
const whisperResp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
797
|
+
method: "POST",
|
|
798
|
+
headers: { Authorization: `Bearer ${config.openaiApiKey}` },
|
|
799
|
+
body: formData,
|
|
800
|
+
});
|
|
801
|
+
if (!whisperResp.ok) {
|
|
802
|
+
const errText = await whisperResp.text();
|
|
803
|
+
throw new Error(`Whisper API ${whisperResp.status}: ${errText.slice(0, 200)}`);
|
|
804
|
+
}
|
|
805
|
+
const result = (await whisperResp.json());
|
|
806
|
+
const transcript = result.text?.trim();
|
|
807
|
+
if (!transcript) {
|
|
808
|
+
prompt = `[User sent a voice message (${duration}s) but transcription was empty. File saved at: ${localPath}]`;
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
prompt = `[Voice message transcribed (${duration}s)]: ${transcript}`;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch (whisperErr) {
|
|
815
|
+
console.error("[nzb] Whisper transcription failed:", whisperErr instanceof Error ? whisperErr.message : whisperErr);
|
|
816
|
+
prompt = `[User sent a voice message (${duration}s), saved at: ${localPath}. Transcription failed: ${whisperErr instanceof Error ? whisperErr.message : String(whisperErr)}]`;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
prompt = `[User sent a voice message (${duration}s), saved at: ${localPath}. No OPENAI_API_KEY configured for transcription. You can tell the user to set it up in ~/.nzb/.env]`;
|
|
821
|
+
}
|
|
822
|
+
sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
823
|
+
if (done) {
|
|
824
|
+
const formatted = toTelegramMarkdown(text);
|
|
825
|
+
const chunks = chunkMessage(formatted);
|
|
826
|
+
const fallbackChunks = chunkMessage(text);
|
|
827
|
+
void (async () => {
|
|
828
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
829
|
+
if (i > 0)
|
|
830
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
831
|
+
const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
|
|
832
|
+
try {
|
|
833
|
+
await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
|
|
834
|
+
parse_mode: "MarkdownV2",
|
|
835
|
+
reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
try {
|
|
840
|
+
await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
|
|
841
|
+
reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
catch { }
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
849
|
+
}
|
|
850
|
+
catch { }
|
|
851
|
+
})();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
await ctx.reply(`❌ Error processing voice: ${err instanceof Error ? err.message : String(err)}`, {
|
|
857
|
+
reply_parameters: { message_id: userMessageId },
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
});
|
|
739
861
|
return bot;
|
|
740
862
|
}
|
|
741
863
|
export async function startBot() {
|