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', PERSONALITY_FILE = 'personality.md', MEMORY_DB = 'marvin.sqlite', MAX_HISTORY_MESSAGES = '20', } = process.env; 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'); } const bot = new Telegraf(TELEGRAM_BOT_TOKEN); const ai = GEMINI_API_KEY ? new GoogleGenAI({ apiKey: GEMINI_API_KEY }) : null; const systemInstruction = readFileSync(PERSONALITY_FILE, 'utf8').trim(); const maxHistoryMessages = Number.parseInt(MAX_HISTORY_MESSAGES, 10); const db = new DatabaseSync(MEMORY_DB); db.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages (chat_id, id); CREATE TABLE IF NOT EXISTS memories ( chat_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (chat_id, key) ); `); const insertMessage = db.prepare('INSERT INTO messages (chat_id, role, content) VALUES (?, ?, ?)'); const recentMessages = db.prepare(` SELECT role, content FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT ? `); const deleteMessages = db.prepare('DELETE FROM messages WHERE chat_id = ?'); const upsertMemory = db.prepare(` INSERT INTO memories (chat_id, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP `); const listMemories = db.prepare('SELECT key, value FROM memories WHERE chat_id = ? ORDER BY key'); const deleteMemory = db.prepare('DELETE FROM memories WHERE chat_id = ? AND key = ?'); const deleteMemories = db.prepare('DELETE FROM memories WHERE chat_id = ?'); function rememberMessage(chatId, role, content) { insertMessage.run(String(chatId), role, content); } function getHistory(chatId) { return recentMessages.all(String(chatId), maxHistoryMessages).reverse(); } function forgetChat(chatId) { deleteMessages.run(String(chatId)); } function setMemory(chatId, key, value) { upsertMemory.run(String(chatId), key.trim().toLowerCase(), value.trim()); } function getMemories(chatId) { return listMemories.all(String(chatId)); } function forgetMemory(chatId, key) { return deleteMemory.run(String(chatId), key.trim().toLowerCase()); } function forgetAllMemories(chatId) { deleteMemories.run(String(chatId)); } function formatMemories(memories) { if (memories.length === 0) return 'No long-term memories stored.'; return memories.map((memory) => `${memory.key}: ${memory.value}`).join('\n'); } function buildSystemInstruction(chatId) { return `${systemInstruction}\n\nLong-term memory for this chat:\n${formatMemories(getMemories(chatId))}`; } function cleanReply(reply) { return reply.replace(/^\s*Marvin:\s*/i, '').trim(); } function formatHistoryForGemini(history) { return history .map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.content}`) .join('\n\n'); } async function askGemini(chatId, history) { if (!ai) throw new Error('Gemini is not configured'); const response = await ai.models.generateContent({ model: GEMINI_MODEL, contents: formatHistoryForGemini(history), config: { systemInstruction: buildSystemInstruction(chatId), }, }); return cleanReply(response.text?.trim() || 'I got an empty response. Try again?'); } async function askOpenRouter(chatId, history) { if (!OPENROUTER_API_KEY) throw new Error('OpenRouter is not configured'); const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${OPENROUTER_API_KEY}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://github.com/hbrain/marvin', 'X-Title': 'Marvin Telegram Bot', }, body: JSON.stringify({ model: OPENROUTER_MODEL, messages: [ { role: 'system', content: buildSystemInstruction(chatId) }, ...history, ], }), }); if (!response.ok) { const body = await response.text(); const error = new Error(`OpenRouter error ${response.status}: ${body}`); error.status = response.status; throw error; } const data = await response.json(); 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); try { return await askGemini(chatId, history); } catch (error) { if (!OPENROUTER_API_KEY) throw error; console.error('Gemini failed, falling back to OpenRouter:', error); return askOpenRouter(chatId, history); } } bot.start((ctx) => { ctx.reply('Hi, I am Marvin. Send me a message and I will reply.'); }); bot.command('help', (ctx) => { ctx.reply(`Just send me a text message. I remember recent conversation using SQLite. Commands: /image prompt - generate and send an image /remember key = value - save a permanent fact /memories - show permanent facts /forget_memory key - delete one permanent fact /forget_memories - delete all permanent facts /forget - clear recent chat history`); }); bot.command('remember', (ctx) => { const input = ctx.message.text.replace(/^\/remember(@\w+)?\s*/i, '').trim(); const separator = input.includes('=') ? '=' : ':'; const [key, ...valueParts] = input.split(separator); const value = valueParts.join(separator).trim(); if (!key?.trim() || !value) { ctx.reply('Use: /remember key = value\nExample: /remember location = Zagreb'); return; } setMemory(ctx.chat.id, key, value); ctx.reply(`Remembered: ${key.trim().toLowerCase()} = ${value}`); }); bot.command('memories', (ctx) => { ctx.reply(`Long-term memories:\n${formatMemories(getMemories(ctx.chat.id))}`); }); bot.command('forget_memory', (ctx) => { const key = ctx.message.text.replace(/^\/forget_memory(@\w+)?\s*/i, '').trim(); if (!key) { ctx.reply('Use: /forget_memory key\nExample: /forget_memory location'); return; } const result = forgetMemory(ctx.chat.id, key); ctx.reply(result.changes > 0 ? `Forgot: ${key.toLowerCase()}` : `No memory found for: ${key.toLowerCase()}`); }); bot.command('forget_memories', (ctx) => { forgetAllMemories(ctx.chat.id); ctx.reply('All long-term memories cleared for this chat.'); }); bot.command('forget', (ctx) => { forgetChat(ctx.chat.id); ctx.reply('Recent chat history cleared for this chat. Long-term memories were kept.'); }); bot.command('image', async (ctx) => { const prompt = ctx.message.text.replace(/^\/image(@\w+)?\s*/i, '').trim(); if (!prompt) { await ctx.reply('Use: /image prompt\nExample: /image a sad robot drinking coffee in Zagreb, noir style'); return; } try { await ctx.sendChatAction('upload_photo'); const imageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?width=1024&height=1024&nologo=true&safe=true`; await ctx.replyWithPhoto({ url: imageUrl }, { caption: prompt.slice(0, 1024) }); } catch (error) { console.error('Failed to generate image:', error); await ctx.reply('Sorry, image generation failed. Try a different prompt.'); } }); 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.'); return; } await ctx.reply('Sorry, something went wrong. Check the server logs.'); } }); bot.catch((error) => { console.error('Bot error:', error); }); process.once('SIGINT', () => bot.stop('SIGINT')); process.once('SIGTERM', () => bot.stop('SIGTERM')); await bot.launch(); console.log('Marvin Telegram bot is running.');