Initial Telegram AI bot
This commit is contained in:
commit
2f88229a1f
8 changed files with 1047 additions and 0 deletions
265
src/bot.js
Normal file
265
src/bot.js
Normal 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.');
|
||||
Loading…
Add table
Add a link
Reference in a new issue