Improve Telegram formatting and web synthesis
This commit is contained in:
parent
605c135b08
commit
0675f9a441
8 changed files with 425 additions and 508 deletions
378
src/bot.js
378
src/bot.js
|
|
@ -2,14 +2,12 @@ import 'dotenv/config';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Telegraf } from 'telegraf';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
const {
|
||||
TELEGRAM_BOT_TOKEN,
|
||||
GEMINI_API_KEY,
|
||||
GEMINI_MODEL = 'gemini-2.5-flash-lite',
|
||||
OPENROUTER_API_KEY,
|
||||
OPENROUTER_MODEL = 'openrouter/free',
|
||||
OPENROUTER_MODEL = 'openai/gpt-5.4-mini',
|
||||
SEARXNG_URL = 'http://192.168.0.225:8080',
|
||||
PERSONALITY_FILE = 'personality.md',
|
||||
MEMORY_DB = 'marvin.sqlite',
|
||||
MAX_HISTORY_MESSAGES = '20',
|
||||
|
|
@ -19,12 +17,14 @@ if (!TELEGRAM_BOT_TOKEN) {
|
|||
throw new Error('Missing TELEGRAM_BOT_TOKEN in environment');
|
||||
}
|
||||
|
||||
if (!GEMINI_API_KEY && !OPENROUTER_API_KEY) {
|
||||
throw new Error('Missing AI provider key: set GEMINI_API_KEY or OPENROUTER_API_KEY');
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
throw new Error('Missing OPENROUTER_API_KEY in environment');
|
||||
}
|
||||
|
||||
const bot = new Telegraf(TELEGRAM_BOT_TOKEN);
|
||||
const ai = GEMINI_API_KEY ? new GoogleGenAI({ apiKey: GEMINI_API_KEY }) : null;
|
||||
const webUserAgent = 'Mozilla/5.0 (compatible; MarvinBot/1.0; +https://github.com/hbrain/marvin)';
|
||||
const webSearchResultLimit = 3;
|
||||
const searxngBaseUrl = new URL(SEARXNG_URL);
|
||||
|
||||
const systemInstruction = readFileSync(PERSONALITY_FILE, 'utf8').trim();
|
||||
const maxHistoryMessages = Number.parseInt(MAX_HISTORY_MESSAGES, 10);
|
||||
|
|
@ -109,30 +109,212 @@ function buildSystemInstruction(chatId) {
|
|||
}
|
||||
|
||||
function cleanReply(reply) {
|
||||
return reply.replace(/^\s*Marvin:\s*/i, '').trim();
|
||||
return reply
|
||||
.replace(/^\s*Marvin:\s*/i, '')
|
||||
// Remove label variants anywhere in the reply, e.g.:
|
||||
// **Personal comment:**, **Personal comment**:, Personal comment:, (Personal comment: ...)
|
||||
.replace(/\(?[ \t]*\*{0,3}[ \t]*personal[ \t]+comment[ \t]*\*{0,3}[ \t]*(?::|[-–—])?[ \t]*\*{0,3}[ \t]*/giu, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatHistoryForGemini(history) {
|
||||
return history
|
||||
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.content}`)
|
||||
.join('\n\n');
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function askGemini(chatId, history) {
|
||||
if (!ai) throw new Error('Gemini is not configured');
|
||||
function formatTelegramReply(reply) {
|
||||
const cleaned = cleanReply(reply).replace(/\r\n/g, '\n').trim();
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: GEMINI_MODEL,
|
||||
contents: formatHistoryForGemini(history),
|
||||
config: {
|
||||
systemInstruction: buildSystemInstruction(chatId),
|
||||
const codeBlocks = [];
|
||||
const withPlaceholders = cleaned.replace(/```([\s\S]*?)```/g, (_, code) => {
|
||||
const token = `__CODEBLOCK_${codeBlocks.length}__`;
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.trim())}</code></pre>`);
|
||||
return token;
|
||||
});
|
||||
|
||||
let html = escapeHtml(withPlaceholders)
|
||||
.replace(/^###\s+(.+)$/gm, '<b>$1</b>')
|
||||
.replace(/^##\s+(.+)$/gm, '<b>$1</b>')
|
||||
.replace(/^#\s+(.+)$/gm, '<b>$1</b>')
|
||||
.replace(/^\*\*(.+?)\*\*/gm, '<b>$1</b>')
|
||||
.replace(/^__(.+?)__$/gm, '<i>$1</i>')
|
||||
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
||||
.replace(/^\s*[-*]\s+/gm, '• ')
|
||||
.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
codeBlocks.forEach((block, index) => {
|
||||
html = html.replace(`__CODEBLOCK_${index}__`, block);
|
||||
});
|
||||
|
||||
if (html.length > 180 && !html.includes('\n')) {
|
||||
html = html.replace(/(?<=[.!?])\s+(?=[A-Z0-9(])/g, '\n');
|
||||
}
|
||||
|
||||
return html.trim();
|
||||
}
|
||||
|
||||
async function sendAssistantReply(ctx, reply, extra = {}) {
|
||||
const html = formatTelegramReply(reply);
|
||||
return ctx.reply(html, {
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text) {
|
||||
return text
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>');
|
||||
}
|
||||
|
||||
function stripHtml(html) {
|
||||
return decodeHtmlEntities(
|
||||
html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSearxngUrl(rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl, searxngBaseUrl).toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchWeb(query) {
|
||||
const searchUrl = new URL('/search', searxngBaseUrl);
|
||||
searchUrl.searchParams.set('q', query);
|
||||
searchUrl.searchParams.set('language', 'auto');
|
||||
searchUrl.searchParams.set('time_range', '');
|
||||
searchUrl.searchParams.set('safesearch', '0');
|
||||
searchUrl.searchParams.set('categories', 'general');
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'User-Agent': webUserAgent,
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
});
|
||||
|
||||
return cleanReply(response.text?.trim() || 'I got an empty response. Try again?');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Web search failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const results = [];
|
||||
const articleRegex = /<article\b[\s\S]*?<\/article>/gi;
|
||||
let articleMatch;
|
||||
|
||||
while ((articleMatch = articleRegex.exec(html)) && results.length < webSearchResultLimit) {
|
||||
const article = articleMatch[0];
|
||||
const titleMatch = article.match(/<h3><a href="([^"]+)"[^>]*>([\s\S]*?)<\/a><\/h3>/i);
|
||||
const snippetMatch = article.match(/<p class="content">([\s\S]*?)<\/p>/i);
|
||||
|
||||
if (!titleMatch) continue;
|
||||
|
||||
const url = resolveSearxngUrl(titleMatch[1]);
|
||||
const title = stripHtml(titleMatch[2]);
|
||||
const snippet = snippetMatch ? stripHtml(snippetMatch[1]) : '';
|
||||
|
||||
if (!title || !url) continue;
|
||||
results.push({ title, url, snippet });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function askOpenRouter(chatId, history) {
|
||||
function formatWebEvidence(results) {
|
||||
if (results.length === 0) return 'No web search results were found.';
|
||||
|
||||
return results
|
||||
.map((result, index) => `${index + 1}. ${result.title}\n Snippet: ${result.snippet || 'No snippet available.'}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
async function fetchWebPageEvidence(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': webUserAgent,
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch page: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const text = stripHtml(html)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return text.slice(0, 5000);
|
||||
}
|
||||
|
||||
function formatFetchedPageEvidence(results) {
|
||||
if (results.length === 0) return 'No page evidence was collected.';
|
||||
|
||||
return results
|
||||
.map((result, index) => {
|
||||
const parts = [`${index + 1}. ${result.title}`];
|
||||
if (result.snippet) parts.push(` Search snippet: ${result.snippet}`);
|
||||
if (result.pageText) parts.push(` Page text: ${result.pageText}`);
|
||||
if (result.fetchError) parts.push(` Fetch error: ${result.fetchError}`);
|
||||
return parts.join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function formatMessagesForOpenRouter(history, image) {
|
||||
if (!image) return history;
|
||||
|
||||
const messages = history.slice(0, -1);
|
||||
const lastMessage = history.at(-1) || { role: 'user', content: 'Describe this image.' };
|
||||
|
||||
messages.push({
|
||||
role: lastMessage.role,
|
||||
content: [
|
||||
{ type: 'text', text: lastMessage.content },
|
||||
{ type: 'image_url', image_url: { url: `data:${image.mimeType};base64,${image.data}` } },
|
||||
],
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function fetchTelegramImage(ctx, fileId) {
|
||||
const fileUrl = await ctx.telegram.getFileLink(fileId);
|
||||
const response = await fetch(fileUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download Telegram image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const mimeType = response.headers.get('content-type') || 'image/jpeg';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
data: buffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
async function askOpenRouter(chatId, history, image = null) {
|
||||
if (!OPENROUTER_API_KEY) throw new Error('OpenRouter is not configured');
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
|
|
@ -147,7 +329,7 @@ async function askOpenRouter(chatId, history) {
|
|||
model: OPENROUTER_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: buildSystemInstruction(chatId) },
|
||||
...history,
|
||||
...formatMessagesForOpenRouter(history, image),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
|
@ -163,17 +345,47 @@ async function askOpenRouter(chatId, history) {
|
|||
return cleanReply(data.choices?.[0]?.message?.content?.trim() || 'I got an empty response. Try again?');
|
||||
}
|
||||
|
||||
async function askAi(chatId, history) {
|
||||
if (!GEMINI_API_KEY) return askOpenRouter(chatId, history);
|
||||
async function askAi(chatId, history, image = null) {
|
||||
return askOpenRouter(chatId, history, image);
|
||||
}
|
||||
|
||||
try {
|
||||
return await askGemini(chatId, history);
|
||||
} catch (error) {
|
||||
if (!OPENROUTER_API_KEY) throw error;
|
||||
async function askWebAi(chatId, query) {
|
||||
const searchResults = await searchWeb(query);
|
||||
|
||||
console.error('Gemini failed, falling back to OpenRouter:', error);
|
||||
return askOpenRouter(chatId, history);
|
||||
if (searchResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultsWithPages = [];
|
||||
for (const result of searchResults) {
|
||||
try {
|
||||
result.pageText = await fetchWebPageEvidence(result.url);
|
||||
} catch (error) {
|
||||
result.fetchError = error.message;
|
||||
result.pageText = '';
|
||||
}
|
||||
resultsWithPages.push(result);
|
||||
}
|
||||
|
||||
const webContext = [
|
||||
'Answer the user directly and naturally using the evidence below.',
|
||||
'Synthesize the page content into one concise answer.',
|
||||
'Do not mention browsing, searching, source lists, or limitations.',
|
||||
'Do not output raw URLs unless the user explicitly asks for sources.',
|
||||
'If evidence is incomplete, give the best approximate answer and only mention uncertainty if absolutely necessary.',
|
||||
'Prefer current facts in the evidence over memory.',
|
||||
'Keep the answer concise and useful.',
|
||||
'',
|
||||
`User question: ${query}`,
|
||||
'',
|
||||
formatFetchedPageEvidence(resultsWithPages),
|
||||
].join('\n');
|
||||
|
||||
const reply = await askOpenRouter(chatId, [
|
||||
{ role: 'user', content: webContext },
|
||||
]);
|
||||
|
||||
return cleanReply(reply);
|
||||
}
|
||||
|
||||
bot.start((ctx) => {
|
||||
|
|
@ -181,9 +393,10 @@ bot.start((ctx) => {
|
|||
});
|
||||
|
||||
bot.command('help', (ctx) => {
|
||||
ctx.reply(`Just send me a text message. I remember recent conversation using SQLite.
|
||||
ctx.reply(`Just send me a text message, a photo with an optional caption, or use /web for browsing. I remember recent conversation using SQLite.
|
||||
|
||||
Commands:
|
||||
/web query - search the web and summarize current info
|
||||
/image prompt - generate and send an image
|
||||
/remember key = value - save a permanent fact
|
||||
/memories - show permanent facts
|
||||
|
|
@ -233,6 +446,38 @@ bot.command('forget', (ctx) => {
|
|||
ctx.reply('Recent chat history cleared for this chat. Long-term memories were kept.');
|
||||
});
|
||||
|
||||
bot.command('web', async (ctx) => {
|
||||
const query = ctx.message.text.replace(/^\/web(@\w+)?\s*/i, '').trim();
|
||||
|
||||
if (!query) {
|
||||
await ctx.reply('Use: /web query\nExample: /web latest OpenRouter gpt-5.4-mini model ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.sendChatAction('typing');
|
||||
rememberMessage(ctx.chat.id, 'user', `[web] ${query}`);
|
||||
|
||||
const reply = await askWebAi(ctx.chat.id, query);
|
||||
|
||||
rememberMessage(ctx.chat.id, 'assistant', reply);
|
||||
await ctx.reply(reply);
|
||||
} catch (error) {
|
||||
await handleAiError(ctx, error);
|
||||
}
|
||||
});
|
||||
|
||||
function shouldGenerateImage(text) {
|
||||
const normalized = text.toLowerCase();
|
||||
|
||||
const cues = [
|
||||
/\b(image|picture|photo|illustration|art|drawing|draw|paint|painting|sketch|generate an image|create an image|make an image|generate a picture|create a picture|make a picture)\b/i,
|
||||
/^\/image(@\w+)?\b/i,
|
||||
];
|
||||
|
||||
return cues.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
bot.command('image', async (ctx) => {
|
||||
const prompt = ctx.message.text.replace(/^\/image(@\w+)?\s*/i, '').trim();
|
||||
|
||||
|
|
@ -251,29 +496,72 @@ bot.command('image', async (ctx) => {
|
|||
}
|
||||
});
|
||||
|
||||
async function handleAiError(ctx, error) {
|
||||
console.error('Failed to handle message:', error);
|
||||
|
||||
if (error?.status === 429) {
|
||||
await ctx.reply('AI quota/rate limit was hit. Try again later, or change the model/API provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply('Sorry, something went wrong. Check the server logs.');
|
||||
}
|
||||
|
||||
async function handleImageMessage(ctx, fileId, caption) {
|
||||
try {
|
||||
await ctx.sendChatAction('typing');
|
||||
const image = await fetchTelegramImage(ctx, fileId);
|
||||
|
||||
rememberMessage(ctx.chat.id, 'user', `[image] ${caption}`);
|
||||
|
||||
const history = getHistory(ctx.chat.id);
|
||||
const reply = await askAi(ctx.chat.id, history, image);
|
||||
|
||||
rememberMessage(ctx.chat.id, 'assistant', reply);
|
||||
await sendAssistantReply(ctx, reply);
|
||||
} catch (error) {
|
||||
await handleAiError(ctx, error);
|
||||
}
|
||||
}
|
||||
|
||||
bot.on('photo', async (ctx) => {
|
||||
const largestPhoto = ctx.message.photo.at(-1);
|
||||
const caption = ctx.message.caption?.trim() || 'Describe this image.';
|
||||
|
||||
await handleImageMessage(ctx, largestPhoto.file_id, caption);
|
||||
});
|
||||
|
||||
bot.on('document', async (ctx) => {
|
||||
const document = ctx.message.document;
|
||||
|
||||
if (!document.mime_type?.startsWith('image/')) return;
|
||||
|
||||
const caption = ctx.message.caption?.trim() || 'Describe this image.';
|
||||
await handleImageMessage(ctx, document.file_id, caption);
|
||||
});
|
||||
|
||||
bot.on('text', async (ctx) => {
|
||||
const text = ctx.message.text;
|
||||
|
||||
if (!text || text.startsWith('/')) return;
|
||||
|
||||
try {
|
||||
await ctx.sendChatAction('typing');
|
||||
rememberMessage(ctx.chat.id, 'user', text);
|
||||
|
||||
const history = getHistory(ctx.chat.id);
|
||||
const reply = await askAi(ctx.chat.id, history);
|
||||
|
||||
rememberMessage(ctx.chat.id, 'assistant', reply);
|
||||
await ctx.reply(reply);
|
||||
} catch (error) {
|
||||
console.error('Failed to handle message:', error);
|
||||
|
||||
if (error?.status === 429) {
|
||||
await ctx.reply('AI quota/rate limit was hit. Try again later, or change the model/API provider.');
|
||||
if (shouldGenerateImage(text)) {
|
||||
await ctx.sendChatAction('upload_photo');
|
||||
const imageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(text)}?width=1024&height=1024&nologo=true&safe=true`;
|
||||
await ctx.replyWithPhoto({ url: imageUrl }, { caption: text.slice(0, 1024) });
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply('Sorry, something went wrong. Check the server logs.');
|
||||
await ctx.sendChatAction('typing');
|
||||
rememberMessage(ctx.chat.id, 'user', text);
|
||||
|
||||
const reply = (await askWebAi(ctx.chat.id, text)) ?? await askAi(ctx.chat.id, getHistory(ctx.chat.id));
|
||||
|
||||
rememberMessage(ctx.chat.id, 'assistant', reply);
|
||||
await sendAssistantReply(ctx, reply);
|
||||
} catch (error) {
|
||||
await handleAiError(ctx, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue