Initial Telegram AI bot

This commit is contained in:
hbrain 2026-05-22 06:01:00 +00:00
commit 2f88229a1f
8 changed files with 1047 additions and 0 deletions

265
src/bot.js Normal file
View file

@ -0,0 +1,265 @@
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 = 'mistralai/mistral-small-3.2-24b-instruct: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 formatHistoryForGemini(history) {
return history
.map((message) => `${message.role === 'user' ? 'User' : 'Marvin'}: ${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 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 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:
/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.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.');