@iletai/nzb 1.3.5 → 1.4.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/copilot/orchestrator.js +4 -3
- package/dist/store/db.js +20 -9
- package/dist/telegram/bot.js +139 -35
- package/dist/telegram/formatter.js +60 -85
- package/package.json +1 -1
|
@@ -395,14 +395,14 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
395
395
|
processQueue();
|
|
396
396
|
});
|
|
397
397
|
// Deliver response to user FIRST, then log best-effort
|
|
398
|
-
callback(finalContent, true);
|
|
399
398
|
try {
|
|
400
399
|
logMessage("out", sourceLabel, finalContent);
|
|
401
400
|
}
|
|
402
401
|
catch {
|
|
403
402
|
/* best-effort */
|
|
404
403
|
}
|
|
405
|
-
// Log both sides of the conversation
|
|
404
|
+
// Log both sides of the conversation before delivery so we have the row ID
|
|
405
|
+
let assistantLogId;
|
|
406
406
|
try {
|
|
407
407
|
const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
|
|
408
408
|
logConversation(logRole, prompt, sourceLabel, telegramMsgId);
|
|
@@ -411,11 +411,12 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
411
411
|
/* best-effort */
|
|
412
412
|
}
|
|
413
413
|
try {
|
|
414
|
-
logConversation("assistant", finalContent, sourceLabel);
|
|
414
|
+
assistantLogId = logConversation("assistant", finalContent, sourceLabel);
|
|
415
415
|
}
|
|
416
416
|
catch {
|
|
417
417
|
/* best-effort */
|
|
418
418
|
}
|
|
419
|
+
callback(finalContent, true, { assistantLogId });
|
|
419
420
|
// Auto-continue: if the response was cut short by timeout, automatically
|
|
420
421
|
// send a follow-up "Continue" message so the user doesn't have to
|
|
421
422
|
if (finalContent.includes("⏱ Response was cut short (timeout)")) {
|
package/dist/store/db.js
CHANGED
|
@@ -100,7 +100,6 @@ export function getDb() {
|
|
|
100
100
|
removeMemory: db.prepare(`DELETE FROM memories WHERE id = ?`),
|
|
101
101
|
memorySummary: db.prepare(`SELECT id, category, content FROM memories ORDER BY category, last_accessed DESC`),
|
|
102
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`),
|
|
104
103
|
};
|
|
105
104
|
}
|
|
106
105
|
return db;
|
|
@@ -119,23 +118,35 @@ export function deleteState(key) {
|
|
|
119
118
|
getDb(); // ensure init
|
|
120
119
|
stmtCache.deleteState.run(key);
|
|
121
120
|
}
|
|
122
|
-
/** Log a conversation turn (user, assistant, or system) with optional Telegram message ID. */
|
|
121
|
+
/** Log a conversation turn (user, assistant, or system) with optional Telegram message ID. Returns the row ID. */
|
|
123
122
|
export function logConversation(role, content, source, telegramMsgId) {
|
|
124
123
|
getDb(); // ensure init
|
|
125
|
-
stmtCache.logConversation.run(role, content, source, telegramMsgId ?? null);
|
|
124
|
+
const result = stmtCache.logConversation.run(role, content, source, telegramMsgId ?? null);
|
|
126
125
|
// Keep last 200 entries to support context recovery after session loss
|
|
127
126
|
logInsertCount++;
|
|
128
127
|
if (logInsertCount % 50 === 0) {
|
|
129
128
|
stmtCache.pruneConversation.run();
|
|
130
129
|
}
|
|
130
|
+
return result.lastInsertRowid;
|
|
131
131
|
}
|
|
132
|
-
/** Get conversation context around a Telegram message ID (±
|
|
132
|
+
/** Get conversation context around a Telegram message ID (±4 rows using proper subquery). */
|
|
133
133
|
export function getConversationContext(telegramMsgId) {
|
|
134
|
-
getDb();
|
|
134
|
+
const db = getDb();
|
|
135
135
|
const row = stmtCache.getConversationByMsgId.get(telegramMsgId);
|
|
136
136
|
if (!row)
|
|
137
137
|
return undefined;
|
|
138
|
-
|
|
138
|
+
// Fetch 4 rows before + the target + 4 rows after (handles ID gaps from pruning)
|
|
139
|
+
const rows = db.prepare(`
|
|
140
|
+
SELECT role, content, source, ts FROM (
|
|
141
|
+
SELECT * FROM conversation_log WHERE id < ? ORDER BY id DESC LIMIT 4
|
|
142
|
+
)
|
|
143
|
+
UNION ALL
|
|
144
|
+
SELECT role, content, source, ts FROM conversation_log WHERE id = ?
|
|
145
|
+
UNION ALL
|
|
146
|
+
SELECT role, content, source, ts FROM (
|
|
147
|
+
SELECT * FROM conversation_log WHERE id > ? ORDER BY id ASC LIMIT 4
|
|
148
|
+
)
|
|
149
|
+
`).all(row.id, row.id, row.id);
|
|
139
150
|
if (rows.length === 0)
|
|
140
151
|
return undefined;
|
|
141
152
|
return rows
|
|
@@ -146,10 +157,10 @@ export function getConversationContext(telegramMsgId) {
|
|
|
146
157
|
})
|
|
147
158
|
.join("\n");
|
|
148
159
|
}
|
|
149
|
-
/**
|
|
150
|
-
export function
|
|
160
|
+
/** Set Telegram message ID on a specific conversation_log row (race-free). */
|
|
161
|
+
export function setConversationTelegramMsgId(rowId, telegramMsgId) {
|
|
151
162
|
const db = getDb();
|
|
152
|
-
db.prepare(`UPDATE conversation_log SET telegram_msg_id = ? WHERE id =
|
|
163
|
+
db.prepare(`UPDATE conversation_log SET telegram_msg_id = ? WHERE id = ?`).run(telegramMsgId, rowId);
|
|
153
164
|
}
|
|
154
165
|
/** Get recent conversation history formatted for injection into system message. */
|
|
155
166
|
export function getRecentConversation(limit = 20) {
|
package/dist/telegram/bot.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { autoRetry } from "@grammyjs/auto-retry";
|
|
2
2
|
import { Menu } from "@grammyjs/menu";
|
|
3
|
-
import { Bot, Keyboard } from "grammy";
|
|
3
|
+
import { Bot, InlineKeyboard, Keyboard } from "grammy";
|
|
4
4
|
import { Agent as HttpsAgent } from "https";
|
|
5
5
|
import { config, persistEnvVar, persistModel } from "../config.js";
|
|
6
6
|
import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
|
|
7
7
|
import { listSkills } from "../copilot/skills.js";
|
|
8
8
|
import { restartDaemon } from "../daemon.js";
|
|
9
9
|
import { searchMemories } from "../store/db.js";
|
|
10
|
-
import { chunkMessage, formatToolSummaryExpandable,
|
|
10
|
+
import { chunkMessage, escapeHtml, formatToolSummaryExpandable, toTelegramHTML } from "./formatter.js";
|
|
11
11
|
import { initLogChannel, logDebug, logError, logInfo } from "./log-channel.js";
|
|
12
12
|
let bot;
|
|
13
13
|
const startedAt = Date.now();
|
|
@@ -125,9 +125,7 @@ function formatMemoryList(memories) {
|
|
|
125
125
|
});
|
|
126
126
|
return `🧠 <b>${memories.length} memories</b>\n\n${sections.join("\n\n")}`;
|
|
127
127
|
}
|
|
128
|
-
|
|
129
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
130
|
-
}
|
|
128
|
+
// escapeHtml is imported from formatter.ts
|
|
131
129
|
export function createBot() {
|
|
132
130
|
if (!config.telegramBotToken) {
|
|
133
131
|
throw new Error("Telegram bot token is missing. Run 'nzb setup' and enter the bot token from @BotFather.");
|
|
@@ -157,6 +155,56 @@ export function createBot() {
|
|
|
157
155
|
});
|
|
158
156
|
// Register interactive menu plugin
|
|
159
157
|
bot.use(mainMenu);
|
|
158
|
+
// Callback handlers for contextual inline buttons
|
|
159
|
+
bot.callbackQuery("retry", async (ctx) => {
|
|
160
|
+
await ctx.answerCallbackQuery({ text: "Retrying..." });
|
|
161
|
+
const originalMsg = ctx.callbackQuery.message;
|
|
162
|
+
if (originalMsg?.reply_to_message && "text" in originalMsg.reply_to_message && originalMsg.reply_to_message.text) {
|
|
163
|
+
const retryPrompt = originalMsg.reply_to_message.text;
|
|
164
|
+
const chatId = ctx.chat.id;
|
|
165
|
+
sendToOrchestrator(retryPrompt, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
|
|
166
|
+
if (done) {
|
|
167
|
+
const formatted = toTelegramHTML(text);
|
|
168
|
+
const chunks = chunkMessage(formatted);
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
await bot.api.editMessageText(chatId, originalMsg.message_id, chunks[0], { parse_mode: "HTML" });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
try {
|
|
175
|
+
await ctx.reply(text);
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
bot.callbackQuery("explain_error", async (ctx) => {
|
|
185
|
+
await ctx.answerCallbackQuery({ text: "Explaining..." });
|
|
186
|
+
const originalMsg = ctx.callbackQuery.message;
|
|
187
|
+
if (originalMsg && "text" in originalMsg && originalMsg.text) {
|
|
188
|
+
const chatId = ctx.chat.id;
|
|
189
|
+
sendToOrchestrator(`Explain this error in simple terms and suggest a fix:\n${originalMsg.text}`, { type: "telegram", chatId, messageId: originalMsg.message_id }, (text, done) => {
|
|
190
|
+
if (done) {
|
|
191
|
+
const formatted = toTelegramHTML(text);
|
|
192
|
+
const chunks = chunkMessage(formatted);
|
|
193
|
+
void (async () => {
|
|
194
|
+
try {
|
|
195
|
+
await ctx.reply(chunks[0], { parse_mode: "HTML" });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
try {
|
|
199
|
+
await ctx.reply(text);
|
|
200
|
+
}
|
|
201
|
+
catch { }
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
160
208
|
// Persistent reply keyboard — quick actions always visible below chat input
|
|
161
209
|
const replyKeyboard = new Keyboard()
|
|
162
210
|
.text("📊 Status").text("❌ Cancel").row()
|
|
@@ -402,8 +450,9 @@ export function createBot() {
|
|
|
402
450
|
void logDebug(`🔧 Tool start: ${event.toolName}${event.detail ? ` — ${event.detail}` : ""}`);
|
|
403
451
|
currentToolName = event.toolName;
|
|
404
452
|
toolHistory.push({ name: event.toolName, startTime: Date.now(), detail: event.detail });
|
|
453
|
+
const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
|
|
405
454
|
const existingText = lastEditedText.replace(/^🔧 .*\n\n/, "");
|
|
406
|
-
enqueueEdit(`🔧 ${event.toolName}\n\n${existingText}`.trim() || `🔧 ${event.toolName}`);
|
|
455
|
+
enqueueEdit(`🔧 ${event.toolName} (${elapsed}s...)\n\n${existingText}`.trim() || `🔧 ${event.toolName}`);
|
|
407
456
|
}
|
|
408
457
|
else if (event.type === "tool_complete") {
|
|
409
458
|
for (let i = toolHistory.length - 1; i >= 0; i--) {
|
|
@@ -412,14 +461,22 @@ export function createBot() {
|
|
|
412
461
|
break;
|
|
413
462
|
}
|
|
414
463
|
}
|
|
464
|
+
// Show completion with checkmark
|
|
465
|
+
const completedTool = toolHistory.find((t) => t.name === event.toolName && t.durationMs !== undefined);
|
|
466
|
+
if (completedTool) {
|
|
467
|
+
const dur = (completedTool.durationMs / 1000).toFixed(1);
|
|
468
|
+
const existingText = lastEditedText.replace(/^🔧 .*\n\n/, "").replace(/^✅ .*\n\n/, "");
|
|
469
|
+
enqueueEdit(`✅ ${event.toolName} (${dur}s)\n\n${existingText}`.trim());
|
|
470
|
+
}
|
|
415
471
|
currentToolName = undefined;
|
|
416
472
|
}
|
|
417
473
|
else if (event.type === "tool_partial_result" && event.detail) {
|
|
418
474
|
const now = Date.now();
|
|
419
475
|
if (now - lastEditTime >= EDIT_INTERVAL_MS) {
|
|
420
476
|
lastEditTime = now;
|
|
477
|
+
const elapsed = ((now - handlerStartTime) / 1000).toFixed(1);
|
|
421
478
|
const truncated = event.detail.length > 500 ? "⋯\n" + event.detail.slice(-500) : event.detail;
|
|
422
|
-
const toolLine = `🔧 ${currentToolName || event.toolName}\n
|
|
479
|
+
const toolLine = `🔧 ${currentToolName || event.toolName} (${elapsed}s...)\n<pre>${escapeHtml(truncated)}</pre>`;
|
|
423
480
|
enqueueEdit(toolLine);
|
|
424
481
|
}
|
|
425
482
|
}
|
|
@@ -454,10 +511,11 @@ export function createBot() {
|
|
|
454
511
|
userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
|
|
455
512
|
}
|
|
456
513
|
}
|
|
457
|
-
sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
514
|
+
sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
|
|
458
515
|
if (done) {
|
|
459
516
|
finalized = true;
|
|
460
517
|
stopTyping();
|
|
518
|
+
const assistantLogId = meta?.assistantLogId;
|
|
461
519
|
const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
|
|
462
520
|
void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
|
|
463
521
|
// Wait for in-flight edits to finish before sending the final response
|
|
@@ -467,9 +525,10 @@ export function createBot() {
|
|
|
467
525
|
if (isError) {
|
|
468
526
|
void logError(`Response error: ${text.slice(0, 200)}`);
|
|
469
527
|
const errorText = `⚠️ ${text}`;
|
|
528
|
+
const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
|
|
470
529
|
if (placeholderMsgId) {
|
|
471
530
|
try {
|
|
472
|
-
await bot.api.editMessageText(chatId, placeholderMsgId, errorText);
|
|
531
|
+
await bot.api.editMessageText(chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
|
|
473
532
|
return;
|
|
474
533
|
}
|
|
475
534
|
catch {
|
|
@@ -477,7 +536,7 @@ export function createBot() {
|
|
|
477
536
|
}
|
|
478
537
|
}
|
|
479
538
|
try {
|
|
480
|
-
await ctx.reply(errorText, { reply_parameters: replyParams });
|
|
539
|
+
await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
|
|
481
540
|
}
|
|
482
541
|
catch {
|
|
483
542
|
/* nothing more we can do */
|
|
@@ -497,7 +556,7 @@ export function createBot() {
|
|
|
497
556
|
parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
|
|
498
557
|
textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
|
|
499
558
|
}
|
|
500
|
-
const formatted =
|
|
559
|
+
const formatted = toTelegramHTML(textWithMeta);
|
|
501
560
|
let fullFormatted = formatted;
|
|
502
561
|
if (config.showReasoning && toolHistory.length > 0) {
|
|
503
562
|
const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })));
|
|
@@ -508,16 +567,18 @@ export function createBot() {
|
|
|
508
567
|
// Single chunk: edit placeholder in place
|
|
509
568
|
if (placeholderMsgId && chunks.length === 1) {
|
|
510
569
|
try {
|
|
511
|
-
await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], { parse_mode: "
|
|
570
|
+
await bot.api.editMessageText(chatId, placeholderMsgId, chunks[0], { parse_mode: "HTML" });
|
|
512
571
|
try {
|
|
513
572
|
await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
514
573
|
}
|
|
515
574
|
catch { }
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
575
|
+
if (assistantLogId) {
|
|
576
|
+
try {
|
|
577
|
+
const { setConversationTelegramMsgId } = await import("../store/db.js");
|
|
578
|
+
setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
|
|
579
|
+
}
|
|
580
|
+
catch { }
|
|
519
581
|
}
|
|
520
|
-
catch { }
|
|
521
582
|
return;
|
|
522
583
|
}
|
|
523
584
|
catch {
|
|
@@ -527,11 +588,13 @@ export function createBot() {
|
|
|
527
588
|
await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
528
589
|
}
|
|
529
590
|
catch { }
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
591
|
+
if (assistantLogId) {
|
|
592
|
+
try {
|
|
593
|
+
const { setConversationTelegramMsgId } = await import("../store/db.js");
|
|
594
|
+
setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
|
|
595
|
+
}
|
|
596
|
+
catch { }
|
|
533
597
|
}
|
|
534
|
-
catch { }
|
|
535
598
|
return;
|
|
536
599
|
}
|
|
537
600
|
catch {
|
|
@@ -541,16 +604,19 @@ export function createBot() {
|
|
|
541
604
|
}
|
|
542
605
|
// Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
|
|
543
606
|
const totalChunks = chunks.length;
|
|
607
|
+
let firstSentMsgId;
|
|
544
608
|
const sendChunk = async (chunk, fallback, index) => {
|
|
545
609
|
const isFirst = index === 0 && !placeholderMsgId;
|
|
546
610
|
// Pagination header for multi-chunk messages
|
|
547
611
|
const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
|
|
548
612
|
const opts = isFirst
|
|
549
|
-
? { parse_mode: "
|
|
550
|
-
: { parse_mode: "
|
|
551
|
-
await ctx
|
|
613
|
+
? { parse_mode: "HTML", reply_parameters: replyParams }
|
|
614
|
+
: { parse_mode: "HTML" };
|
|
615
|
+
const sent = await ctx
|
|
552
616
|
.reply(pageTag + chunk, opts)
|
|
553
617
|
.catch(() => ctx.reply(pageTag + fallback, isFirst ? { reply_parameters: replyParams } : {}));
|
|
618
|
+
if (index === 0 && sent)
|
|
619
|
+
firstSentMsgId = sent.message_id;
|
|
554
620
|
};
|
|
555
621
|
let sendSucceeded = false;
|
|
556
622
|
try {
|
|
@@ -567,7 +633,9 @@ export function createBot() {
|
|
|
567
633
|
if (i > 0)
|
|
568
634
|
await new Promise((r) => setTimeout(r, 300));
|
|
569
635
|
const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
|
|
570
|
-
await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
|
|
636
|
+
const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
|
|
637
|
+
if (i === 0 && sent)
|
|
638
|
+
firstSentMsgId = sent.message_id;
|
|
571
639
|
}
|
|
572
640
|
sendSucceeded = true;
|
|
573
641
|
}
|
|
@@ -584,6 +652,15 @@ export function createBot() {
|
|
|
584
652
|
/* ignore — placeholder stays but user has the real message */
|
|
585
653
|
}
|
|
586
654
|
}
|
|
655
|
+
// Track bot message ID for reply-to context lookups
|
|
656
|
+
const botMsgId = firstSentMsgId ?? placeholderMsgId;
|
|
657
|
+
if (assistantLogId && botMsgId) {
|
|
658
|
+
try {
|
|
659
|
+
const { setConversationTelegramMsgId } = await import("../store/db.js");
|
|
660
|
+
setConversationTelegramMsgId(assistantLogId, botMsgId);
|
|
661
|
+
}
|
|
662
|
+
catch { }
|
|
663
|
+
}
|
|
587
664
|
// React ✅ on the user's original message to signal completion
|
|
588
665
|
try {
|
|
589
666
|
await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
@@ -645,7 +722,7 @@ export function createBot() {
|
|
|
645
722
|
const prompt = `[User sent a photo saved at: ${localPath}]\n\nCaption: ${caption}\n\nPlease analyze this image. The file is at ${localPath} — you can use bash to view it with tools if needed.`;
|
|
646
723
|
sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
647
724
|
if (done) {
|
|
648
|
-
const formatted =
|
|
725
|
+
const formatted = toTelegramHTML(text);
|
|
649
726
|
const chunks = chunkMessage(formatted);
|
|
650
727
|
const fallbackChunks = chunkMessage(text);
|
|
651
728
|
void (async () => {
|
|
@@ -655,7 +732,7 @@ export function createBot() {
|
|
|
655
732
|
const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
|
|
656
733
|
try {
|
|
657
734
|
await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
|
|
658
|
-
parse_mode: "
|
|
735
|
+
parse_mode: "HTML",
|
|
659
736
|
reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
|
|
660
737
|
});
|
|
661
738
|
}
|
|
@@ -717,7 +794,7 @@ export function createBot() {
|
|
|
717
794
|
const prompt = `[User sent a file: ${doc.file_name || "unknown"} (${doc.file_size || 0} bytes), saved at: ${localPath}]\n\nCaption: ${caption}\n\nPlease analyze this file. You can read it with bash tools.`;
|
|
718
795
|
sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
719
796
|
if (done) {
|
|
720
|
-
const formatted =
|
|
797
|
+
const formatted = toTelegramHTML(text);
|
|
721
798
|
const chunks = chunkMessage(formatted);
|
|
722
799
|
const fallbackChunks = chunkMessage(text);
|
|
723
800
|
void (async () => {
|
|
@@ -727,7 +804,7 @@ export function createBot() {
|
|
|
727
804
|
const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
|
|
728
805
|
try {
|
|
729
806
|
await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
|
|
730
|
-
parse_mode: "
|
|
807
|
+
parse_mode: "HTML",
|
|
731
808
|
reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
|
|
732
809
|
});
|
|
733
810
|
}
|
|
@@ -769,6 +846,20 @@ export function createBot() {
|
|
|
769
846
|
await ctx.reply("❌ Voice too long (max 5 min).", { reply_parameters: { message_id: userMessageId } });
|
|
770
847
|
return;
|
|
771
848
|
}
|
|
849
|
+
// If voice is a reply, include context
|
|
850
|
+
let voiceReplyContext = "";
|
|
851
|
+
const voiceReplyMsg = ctx.message.reply_to_message;
|
|
852
|
+
if (voiceReplyMsg && "text" in voiceReplyMsg && voiceReplyMsg.text) {
|
|
853
|
+
const { getConversationContext } = await import("../store/db.js");
|
|
854
|
+
const context = getConversationContext(voiceReplyMsg.message_id);
|
|
855
|
+
if (context) {
|
|
856
|
+
voiceReplyContext = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n`;
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
const quoted = voiceReplyMsg.text.length > 500 ? voiceReplyMsg.text.slice(0, 500) + "…" : voiceReplyMsg.text;
|
|
860
|
+
voiceReplyContext = `[Replying to: "${quoted}"]\n\n`;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
772
863
|
try {
|
|
773
864
|
const file = await ctx.api.getFile(ctx.message.voice.file_id);
|
|
774
865
|
const filePath = file.file_path;
|
|
@@ -819,31 +910,44 @@ export function createBot() {
|
|
|
819
910
|
else {
|
|
820
911
|
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
912
|
}
|
|
822
|
-
sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
913
|
+
sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
|
|
823
914
|
if (done) {
|
|
824
|
-
const
|
|
915
|
+
const assistantLogId = meta?.assistantLogId;
|
|
916
|
+
const formatted = toTelegramHTML(text);
|
|
825
917
|
const chunks = chunkMessage(formatted);
|
|
826
918
|
const fallbackChunks = chunkMessage(text);
|
|
827
919
|
void (async () => {
|
|
920
|
+
let firstMsgId;
|
|
828
921
|
for (let i = 0; i < chunks.length; i++) {
|
|
829
922
|
if (i > 0)
|
|
830
923
|
await new Promise((r) => setTimeout(r, 300));
|
|
831
924
|
const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
|
|
832
925
|
try {
|
|
833
|
-
await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
|
|
834
|
-
parse_mode: "
|
|
926
|
+
const sent = await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
|
|
927
|
+
parse_mode: "HTML",
|
|
835
928
|
reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
|
|
836
929
|
});
|
|
930
|
+
if (i === 0)
|
|
931
|
+
firstMsgId = sent.message_id;
|
|
837
932
|
}
|
|
838
933
|
catch {
|
|
839
934
|
try {
|
|
840
|
-
await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
|
|
935
|
+
const sent = await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
|
|
841
936
|
reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
|
|
842
937
|
});
|
|
938
|
+
if (i === 0)
|
|
939
|
+
firstMsgId = sent.message_id;
|
|
843
940
|
}
|
|
844
941
|
catch { }
|
|
845
942
|
}
|
|
846
943
|
}
|
|
944
|
+
if (assistantLogId && firstMsgId) {
|
|
945
|
+
try {
|
|
946
|
+
const { setConversationTelegramMsgId } = await import("../store/db.js");
|
|
947
|
+
setConversationTelegramMsgId(assistantLogId, firstMsgId);
|
|
948
|
+
}
|
|
949
|
+
catch { }
|
|
950
|
+
}
|
|
847
951
|
try {
|
|
848
952
|
await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
|
|
849
953
|
}
|
|
@@ -911,7 +1015,7 @@ export async function stopBot() {
|
|
|
911
1015
|
export async function sendProactiveMessage(text) {
|
|
912
1016
|
if (!bot || config.authorizedUserId === undefined)
|
|
913
1017
|
return;
|
|
914
|
-
const formatted =
|
|
1018
|
+
const formatted = toTelegramHTML(text);
|
|
915
1019
|
const chunks = chunkMessage(formatted);
|
|
916
1020
|
const fallbackChunks = chunkMessage(text);
|
|
917
1021
|
for (let i = 0; i < chunks.length; i++) {
|
|
@@ -919,7 +1023,7 @@ export async function sendProactiveMessage(text) {
|
|
|
919
1023
|
await new Promise((r) => setTimeout(r, 300));
|
|
920
1024
|
const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
|
|
921
1025
|
try {
|
|
922
|
-
await bot.api.sendMessage(config.authorizedUserId, pageTag + chunks[i], { parse_mode: "
|
|
1026
|
+
await bot.api.sendMessage(config.authorizedUserId, pageTag + chunks[i], { parse_mode: "HTML" });
|
|
923
1027
|
}
|
|
924
1028
|
catch {
|
|
925
1029
|
try {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
const TELEGRAM_MAX_LENGTH = 4096;
|
|
2
|
-
// Reserve space for
|
|
3
|
-
const CHUNK_TARGET = TELEGRAM_MAX_LENGTH -
|
|
2
|
+
// Reserve space for tag closure and pagination prefix
|
|
3
|
+
const CHUNK_TARGET = TELEGRAM_MAX_LENGTH - 40;
|
|
4
|
+
/** Escape HTML special characters. */
|
|
5
|
+
export function escapeHtml(text) {
|
|
6
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
7
|
+
}
|
|
4
8
|
/**
|
|
5
9
|
* Split a long message into chunks that fit within Telegram's message limit.
|
|
6
|
-
*
|
|
7
|
-
* closed at the split and reopened in the next chunk so MarkdownV2 stays valid.
|
|
10
|
+
* HTML-aware: if a split falls inside a <pre> block, close and reopen it.
|
|
8
11
|
*/
|
|
9
12
|
export function chunkMessage(text) {
|
|
10
13
|
if (text.length <= TELEGRAM_MAX_LENGTH) {
|
|
@@ -25,12 +28,13 @@ export function chunkMessage(text) {
|
|
|
25
28
|
splitAt = CHUNK_TARGET;
|
|
26
29
|
}
|
|
27
30
|
const segment = remaining.slice(0, splitAt);
|
|
28
|
-
// Count
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
+
// Count <pre> vs </pre> — mismatch means we're inside a code block
|
|
32
|
+
const opens = (segment.match(/<pre/g) || []).length;
|
|
33
|
+
const closes = (segment.match(/<\/pre>/g) || []).length;
|
|
34
|
+
const insideCodeBlock = opens > closes;
|
|
31
35
|
if (insideCodeBlock) {
|
|
32
|
-
chunks.push(segment + "\n
|
|
33
|
-
remaining = "
|
|
36
|
+
chunks.push(segment + "\n</pre>");
|
|
37
|
+
remaining = "<pre>" + remaining.slice(splitAt).trimStart();
|
|
34
38
|
}
|
|
35
39
|
else {
|
|
36
40
|
chunks.push(segment);
|
|
@@ -40,20 +44,7 @@ export function chunkMessage(text) {
|
|
|
40
44
|
return chunks;
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
|
-
*
|
|
44
|
-
*/
|
|
45
|
-
export function escapeSegment(text) {
|
|
46
|
-
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Escape only characters needed inside a MarkdownV2 link URL.
|
|
50
|
-
*/
|
|
51
|
-
function escapeLinkUrl(url) {
|
|
52
|
-
return url.replace(/([)\\])/g, "\\$1");
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Convert a markdown table into a readable mobile-friendly list.
|
|
56
|
-
* Returns already-escaped MarkdownV2 text ready to be stashed.
|
|
47
|
+
* Convert a markdown table into a readable mobile-friendly HTML list.
|
|
57
48
|
*/
|
|
58
49
|
function convertTable(table) {
|
|
59
50
|
const rows = table
|
|
@@ -71,100 +62,84 @@ function convertTable(table) {
|
|
|
71
62
|
.map((cols) => {
|
|
72
63
|
if (cols.length === 0)
|
|
73
64
|
return "";
|
|
74
|
-
const first =
|
|
65
|
+
const first = `<b>${escapeHtml(cols[0])}</b>`;
|
|
75
66
|
const rest = cols
|
|
76
67
|
.slice(1)
|
|
77
|
-
.map((c) =>
|
|
68
|
+
.map((c) => escapeHtml(c))
|
|
78
69
|
.join(" · ");
|
|
79
70
|
return rest ? `${first} — ${rest}` : first;
|
|
80
71
|
})
|
|
81
72
|
.join("\n");
|
|
82
73
|
}
|
|
83
74
|
/**
|
|
84
|
-
* Convert standard markdown from the AI into Telegram
|
|
75
|
+
* Convert standard markdown from the AI into Telegram HTML.
|
|
85
76
|
* Handles bold, italic, strikethrough, links, lists, blockquotes,
|
|
86
77
|
* code blocks, headers, tables, and horizontal rules.
|
|
87
78
|
*/
|
|
88
|
-
export function
|
|
79
|
+
export function toTelegramHTML(text) {
|
|
89
80
|
const stash = [];
|
|
90
81
|
const stashToken = (s) => {
|
|
91
82
|
stash.push(s);
|
|
92
|
-
return `\
|
|
83
|
+
return `\x00S${stash.length - 1}\x00`;
|
|
93
84
|
};
|
|
94
85
|
let out = text;
|
|
95
|
-
// 1. Stash fenced code blocks
|
|
96
|
-
out = out.replace(/```([a-z]*)\n?([\s\S]*?)```/g, (_m, lang, code) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
// 1. Stash fenced code blocks → <pre><code>
|
|
87
|
+
out = out.replace(/```([a-z]*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
|
|
88
|
+
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
89
|
+
return stashToken(`<pre><code${cls}>${escapeHtml(code.trim())}</code></pre>`);
|
|
90
|
+
});
|
|
91
|
+
// 2. Stash inline code → <code>
|
|
92
|
+
out = out.replace(/`([^`\n]+)`/g, (_m, code) => stashToken(`<code>${escapeHtml(code)}</code>`));
|
|
93
|
+
// 3. Stash markdown links → <a href>
|
|
94
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => stashToken(`<a href="${escapeHtml(url)}">${escapeHtml(linkText)}</a>`));
|
|
95
|
+
// 4. Convert tables
|
|
102
96
|
out = out.replace(/(?:^\|.+\|[ \t]*$\n?)+/gm, (table) => stashToken(convertTable(table) + "\n"));
|
|
103
97
|
// 5. Convert headers → bold
|
|
104
98
|
out = out.replace(/^#{1,6}\s+(.+)$/gm, (_m, title) => `**${title.trim()}**`);
|
|
105
99
|
// 6. Remove horizontal rules
|
|
106
100
|
out = out.replace(/^[-*_]{3,}\s*$/gm, "");
|
|
107
|
-
// 7.
|
|
108
|
-
out = out.replace(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// 9. Convert ordered lists: 1. item → 1\) item (stash \) to avoid double-escaping)
|
|
112
|
-
out = out.replace(/^(\s*)(\d+)\.\s+/gm, (_m, spaces, num) => spaces + num + stashToken("\\) "));
|
|
113
|
-
// 10. Extract strikethrough before escaping
|
|
114
|
-
const strikeParts = [];
|
|
115
|
-
out = out.replace(/~~(.+?)~~/g, (_m, inner) => {
|
|
116
|
-
strikeParts.push(inner);
|
|
117
|
-
return `\x00STRIKE${strikeParts.length - 1}\x00`;
|
|
118
|
-
});
|
|
119
|
-
// 11. Extract bold markers before escaping
|
|
120
|
-
const boldParts = [];
|
|
121
|
-
out = out.replace(/\*\*(.+?)\*\*/g, (_m, inner) => {
|
|
122
|
-
boldParts.push(inner);
|
|
123
|
-
return `\x00BOLD${boldParts.length - 1}\x00`;
|
|
101
|
+
// 7. Blockquotes → <blockquote>
|
|
102
|
+
out = out.replace(/(?:^>\s?(.*)$\n?)+/gm, (block) => {
|
|
103
|
+
const content = block.replace(/^>\s?/gm, "").trim();
|
|
104
|
+
return stashToken(`<blockquote>${escapeHtml(content)}</blockquote>`);
|
|
124
105
|
});
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
out = out.replace(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
out = out.replace(/\
|
|
142
|
-
out = out.replace(/\x00ITALIC(\d+)\x00/g, (_m, i) => `_${escapeSegment(italicParts[+i])}_`);
|
|
143
|
-
out = out.replace(/\x00UNDERLINE(\d+)\x00/g, (_m, i) => `__${escapeSegment(underlineParts[+i])}__`);
|
|
144
|
-
// 16. Restore stashed code blocks, inline code, links, tables
|
|
145
|
-
out = out.replace(/\x00STASH(\d+)\x00/g, (_m, i) => stash[+i]);
|
|
106
|
+
// 8. Unordered lists: - item or * item → • item
|
|
107
|
+
out = out.replace(/^(\s*)[-*]\s+/gm, "$1• ");
|
|
108
|
+
// 9. Ordered lists: keep as-is (1. 2. 3.)
|
|
109
|
+
// 10. Strikethrough ~~text~~ → <s>
|
|
110
|
+
out = out.replace(/~~(.+?)~~/g, (_m, inner) => stashToken(`<s>\x00ESC${inner}\x00ESC</s>`));
|
|
111
|
+
// 11. Bold **text** → <b>
|
|
112
|
+
out = out.replace(/\*\*(.+?)\*\*/g, (_m, inner) => stashToken(`<b>\x00ESC${inner}\x00ESC</b>`));
|
|
113
|
+
// 12. Italic *text* → <i>
|
|
114
|
+
out = out.replace(/\*(.+?)\*/g, (_m, inner) => stashToken(`<i>\x00ESC${inner}\x00ESC</i>`));
|
|
115
|
+
// 13. Underline __text__ → <u>
|
|
116
|
+
out = out.replace(/__(.+?)__/g, (_m, inner) => stashToken(`<u>\x00ESC${inner}\x00ESC</u>`));
|
|
117
|
+
// 14. Escape remaining plain text
|
|
118
|
+
out = escapeHtml(out);
|
|
119
|
+
// 15. Restore stashed tokens
|
|
120
|
+
out = out.replace(/\x00S(\d+)\x00/g, (_m, i) => stash[+i]);
|
|
121
|
+
// 16. Escape inner text of formatting tags (marked with ESC)
|
|
122
|
+
out = out.replace(/\x00ESC([\s\S]*?)\x00ESC/g, (_m, inner) => escapeHtml(inner));
|
|
146
123
|
// 17. Clean up excessive blank lines
|
|
147
124
|
out = out.replace(/\n{3,}/g, "\n\n");
|
|
148
125
|
return out.trim();
|
|
149
126
|
}
|
|
127
|
+
/** @deprecated Use toTelegramHTML instead. Kept for backward compatibility. */
|
|
128
|
+
export const toTelegramMarkdown = toTelegramHTML;
|
|
129
|
+
export const escapeSegment = escapeHtml;
|
|
150
130
|
/**
|
|
151
|
-
* Format tool call info as
|
|
152
|
-
* First line
|
|
131
|
+
* Format tool call info as Telegram HTML expandable blockquote.
|
|
132
|
+
* First line visible, tool list expands on tap.
|
|
153
133
|
*/
|
|
154
134
|
export function formatToolSummaryExpandable(toolCalls) {
|
|
155
135
|
if (toolCalls.length === 0)
|
|
156
136
|
return "";
|
|
157
137
|
const lines = toolCalls.map((t) => {
|
|
158
|
-
const name =
|
|
159
|
-
const dur = t.durationMs !== undefined
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const detail = t.detail ? `\n> _${escapeSegment(t.detail.slice(0, 60))}_` : "";
|
|
163
|
-
return `${escapeSegment("• ")}${name}${dur}${detail}`;
|
|
138
|
+
const name = escapeHtml(t.name);
|
|
139
|
+
const dur = t.durationMs !== undefined ? ` (${(t.durationMs / 1000).toFixed(1)}s)` : "";
|
|
140
|
+
const detail = t.detail ? `\n <i>${escapeHtml(t.detail.slice(0, 60))}</i>` : "";
|
|
141
|
+
return `• ${name}${dur}${detail}`;
|
|
164
142
|
});
|
|
165
|
-
|
|
166
|
-
const toolList = lines.join(`\n>`);
|
|
167
|
-
// Expandable: header visible, tool list hidden until tapped
|
|
168
|
-
return `\n\n**>${header}\n>${toolList}||`;
|
|
143
|
+
return `\n\n<blockquote expandable>🔧 Tools used:\n${lines.join("\n")}</blockquote>`;
|
|
169
144
|
}
|
|
170
145
|
//# sourceMappingURL=formatter.js.map
|