288 lines
8.7 KiB
JavaScript
288 lines
8.7 KiB
JavaScript
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.');
|