diff --git a/.env.example b/.env.example index 7d3b734..170fe4a 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,7 @@ BOT_TOKEN=your_telegram_bot_token_here + +# KI/LLM Settings (optional) +AI_DEFAULT_API_KEY=your_openai_api_key +AI_DEFAULT_BASE_URL=https://api.openai.com/v1 +AI_DEFAULT_MODEL=gpt-4o-mini +AI_SYSTEM_PROMPT=Du bist ein freundlicher Chat-Bot in einer Telegram-Gruppe. Antworte kurz und prägnant auf Deutsch. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 1ffa4c2..fc03c31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Telegram bot built with TypeScript and [Telegraf](https://telegraf.js.org/). Uses Markov chains with trigrams to generate sentences from learned messages. Each chat has its own isolated Markov chain, creating unique "personalities" per group. +Telegram bot built with TypeScript and [Telegraf](https://telegraf.js.org/). Uses Markov chains with trigrams to generate sentences from learned messages. Optional KI/LLM integration via OpenAI-compatible API. Each chat has its own isolated data. ## Commands @@ -19,75 +19,97 @@ docker compose up -d --build # Docker deployment ``` src/ -├── index.ts # Bot entry point, commands, message handling +├── index.ts # Bot entry point, commands, setup wizard ├── markov.ts # Markov chain with trigram support -└── database.ts # SQLite persistence layer +├── database.ts # SQLite persistence layer +└── ai.ts # OpenAI-compatible client ``` -**Data flow:** +**Data flow (Markov):** 1. Message received → `chain.learn(text)` → SQLite 2. Trigger check (reply/mention/random) 3. If triggered → `chain.generate()` → reply +**Data flow (AI):** +1. `/ask-ai` or trigger word detected +2. Load last 50 messages as context +3. Build prompt (global + group prompt) +4. Call OpenAI-compatible API → reply + **Per-chat isolation:** - Each chat has separate Markov chain data -- Each chat has its own response probability setting -- In-memory cache (`chains` Map) loaded lazily from DB +- Each chat has separate AI settings +- Each chat has separate message context (50 messages) ## Key Implementation Details ### Markov Chain (markov.ts) - Uses **trigrams** (order=2): "word1 word2" → "word3" -- Better grammatical coherence than bigrams - Weighted random selection based on frequency - Generates sentences up to 20 words ### Database (database.ts) - SQLite with `better-sqlite3` (synchronous API) -- Three tables: `chat_settings`, `markov_transitions`, `markov_starts` -- Transactions for atomic updates +- Tables: + - `chat_settings` - Markov probability + - `markov_transitions` - Word transitions + - `markov_starts` - Sentence starts + - `ai_settings` - KI configuration per chat + - `message_context` - Last 50 messages per chat + - `setup_sessions` - Setup wizard state - Automatic cleanup every 24h -### Cleanup Mechanism -```typescript -const MAX_TRANSITIONS_PER_CHAT = 10000; // Upper limit -const MIN_TRANSITION_COUNT = 2; // Remove rare entries -const MIN_TRANSITIONS_TO_CLEANUP = 100; // Protect new chats -``` +### AI Client (ai.ts) +- OpenAI-compatible API (works with OpenAI, Ollama, OpenRouter, etc.) +- Prompt system: Global (.env) + Group-specific (DB) +- API key masking for security +- Context: Last 50 messages -### Response Triggers -1. Reply to bot's message → always respond -2. @username mention → always respond -3. Random probability (default 10%) in groups -4. Always respond in private chats +### Setup Wizard +- Started via `/ai setup` in group +- Continues in private chat for security +- API keys never shown in full +- Steps: Provider → API Key → Model → URL → Trigger/Prob → Group Prompt ## Environment -- `BOT_TOKEN` - Telegram bot token (required) -- Database: `data/ulfbot.db` (auto-created) - -## Docker - -- Uses `node:20-slim` for better-sqlite3 compatibility -- Volume `ulfbot-data` mounts to `/app/data` -- Build locally: `npm run build` then `docker compose up -d --build` - -## Common Modifications - -**Change response probability default:** -```typescript -// database.ts -return { probability: (row?.probability ?? 10) }; // Change default +```env +BOT_TOKEN= # Telegram bot token (required) +AI_DEFAULT_API_KEY= # Default API key (optional) +AI_DEFAULT_BASE_URL= # Default API URL (default: OpenAI) +AI_DEFAULT_MODEL= # Default model (default: gpt-4o-mini) +AI_SYSTEM_PROMPT= # Global system prompt ``` -**Change cleanup interval:** -```typescript -// index.ts -const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // Currently 24h +## Database Schema + +```sql +-- Markov +chat_settings (chat_id, probability) +markov_transitions (chat_id, key, next_word, count) +markov_starts (chat_id, key, count) + +-- AI +ai_settings (chat_id, enabled, trigger_word, random_prob, group_prompt, provider, base_url, model, api_key) +message_context (chat_id, role, content, timestamp) +setup_sessions (user_id, chat_id, step, data) ``` -**Change Markov order (affects grammar quality):** -```typescript -// markov.ts -constructor(order: number = 2) // Higher = better grammar, needs more data -``` \ No newline at end of file +## Response Triggers + +### Markov (always active) +1. Reply to bot's message → always respond +2. @username mention → always respond +3. Random probability (configurable per chat) + +### AI (when enabled) +1. `/ask-ai [text]` command +2. Trigger word (configurable, default: "ask-ai") +3. Random probability (configurable, default: 0%) + +## Security + +- API keys stored in database, never logged +- API keys masked in `/ai status` output: `sk-***...***xyz` +- Setup only via private chat +- Admin-only configuration commands \ No newline at end of file diff --git a/README.md b/README.md index d493e14..64b4a19 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ulfbot -Ein Telegram-Bot, der mithilfe von Markov Chains neue Sätze aus vorherigen Nachrichten generiert. +Ein Telegram-Bot, der mithilfe von Markov Chains neue Sätze aus vorherigen Nachrichten generiert. Optional mit KI/LLM-Integration. ## Features @@ -8,6 +8,7 @@ Ein Telegram-Bot, der mithilfe von Markov Chains neue Sätze aus vorherigen Nach - Generiert grammatikalisch sinnvolle Sätze mit Trigrammen - Antwortet bei Reply, Erwähnung (@username) oder zufällig - Pro-Chat-Wahrscheinlichkeit einstellbar (Admins) +- **KI/LLM-Integration** (OpenAI, Ollama, OpenRouter, etc.) - Persistente SQLite-Datenbank - Docker-Support @@ -62,6 +63,8 @@ npm run dev # Hot-Reload aktiv ## Befehle +### Markov (Standard) + | Befehl | Beschreibung | |--------|--------------| | `/start` | Begrüßung anzeigen | @@ -70,19 +73,75 @@ npm run dev # Hot-Reload aktiv | `/setprob <0-100>` | Wahrscheinlichkeit setzen (Admins) | | `/cleanup` | Datenbank bereinigen (Admins) | +### KI/LLM + +| Befehl | Beschreibung | +|--------|--------------| +| `/ai setup` | KI-Setup starten (Admins, Privat-Chat) | +| `/ai status` | Aktuelle KI-Konfiguration | +| `/ai on/off` | KI aktivieren/deaktivieren | +| `/ask-ai [text]` | KI-Antwort generieren | + ## Trigger für Antworten -Der Bot antwortet wenn: -- Auf eine seiner Nachrichten geantwortet wird (Reply) -- Sein @Username erwähnt wird -- Zufällig (basierend auf eingestellter Wahrscheinlichkeit) +### Markov (Standard) +- Reply auf Bot-Nachricht +- @Username-Erwähnung +- Zufällig (einstellbare Wahrscheinlichkeit) -In privaten Chats antwortet er immer. +### KI/LLM +- `/ask-ai [text]` Command +- Konfigurierbares Triggerwort (z.B. `ask-ai`) +- Zufällig (einstellbare Wahrscheinlichkeit) + +## KI-Integration + +### Setup + +1. `/ai setup` in einer Gruppe ausführen +2. Der Bot sendet eine private Nachricht mit Setup-Wizard +3. Provider, API-Key, Model konfigurieren +4. API-Keys werden sicher gespeichert und maskiert angezeigt + +### Unterstützte Provider + +| Provider | API-Format | +|----------|------------| +| OpenAI | OpenAI API | +| Ollama | OpenAI-kompatibel (lokal) | +| OpenRouter | OpenAI-kompatibel | +| Custom | OpenAI-kompatibel | + +### Umgebungsvariablen (.env) + +```env +# Bot Token (erforderlich) +BOT_TOKEN=your_telegram_bot_token + +# KI/LLM Settings (optional) +AI_DEFAULT_API_KEY=your_openai_api_key +AI_DEFAULT_BASE_URL=https://api.openai.com/v1 +AI_DEFAULT_MODEL=gpt-4o-mini +AI_SYSTEM_PROMPT=Du bist ein freundlicher Chat-Bot... +``` + +### Prompt-System + +- **Globaler System-Prompt** (.env) - Immer aktiv +- **Gruppen-Prompt** (pro Chat) - Ergänzt globalen Prompt ## Technisches ### Architektur +``` +src/ +├── index.ts # Bot-Logik, Commands, Setup-Wizard +├── markov.ts # Markov Chain Implementation +├── database.ts # SQLite Persistenz +└── ai.ts # OpenAI-kompatibler Client +``` + - **Markov Chain** mit Trigrammen für bessere Grammatik - **SQLite** für persistente Speicherung - Pro Chat separate Daten/Lernkurve @@ -94,15 +153,6 @@ In privaten Chats antwortet er immer. - Limitiert auf 10.000 Übergänge pro Chat - Schützt neue Chats (min. 100 Übergänge nötig) -### Projektstruktur - -``` -src/ -├── index.ts # Bot-Logik, Commands -├── markov.ts # Markov Chain Implementation -└── database.ts # SQLite Persistenz -``` - ## Lizenz ISC \ No newline at end of file diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..ced4f63 --- /dev/null +++ b/src/ai.ts @@ -0,0 +1,106 @@ +/** + * OpenAI-compatible AI client for multiple providers. + * Supports OpenAI, Ollama, OpenRouter, and any OpenAI-compatible API. + */ + +export interface AIConfig { + apiKey: string; + baseUrl: string; + model: string; + systemPrompt: string; + groupPrompt?: string; +} + +export interface AIMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +/** + * Mask an API key for safe display + * sk-proj-abc123xyz... → sk-***...***xyz + */ +export function maskApiKey(key: string): string { + if (!key || key.length < 8) return '***'; + const start = key.substring(0, 3); + const end = key.substring(key.length - 3); + return `${start}***...***${end}`; +} + +/** + * Generate AI response using OpenAI-compatible API + */ +export async function generateAIResponse( + config: AIConfig, + context: AIMessage[], + userMessage: string +): Promise { + const messages: AIMessage[] = []; + + // Build system prompt (global + group) + let systemPrompt = config.systemPrompt; + if (config.groupPrompt) { + systemPrompt += '\n\n' + config.groupPrompt; + } + messages.push({ role: 'system', content: systemPrompt }); + + // Add context messages (last 50) + messages.push(...context.slice(-50)); + + // Add user message + messages.push({ role: 'user', content: userMessage }); + + const response = await fetch(`${config.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + messages, + max_tokens: 500, + temperature: 0.7, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`AI API error: ${response.status} - ${error}`); + } + + const data = await response.json() as { choices: Array<{ message?: { content?: string } }> }; + return data.choices[0]?.message?.content || 'Keine Antwort erhalten.'; +} + +/** + * Get default base URL for a provider + */ +export function getDefaultBaseUrl(provider: string): string { + switch (provider) { + case 'openai': + return 'https://api.openai.com/v1'; + case 'ollama': + return 'http://localhost:11434/v1'; + case 'openrouter': + return 'https://openrouter.ai/api/v1'; + default: + return 'https://api.openai.com/v1'; + } +} + +/** + * Get available models for a provider + */ +export function getProviderModels(provider: string): string[] { + switch (provider) { + case 'openai': + return ['gpt-4o-mini', 'gpt-4o', 'gpt-3.5-turbo']; + case 'ollama': + return ['llama3', 'llama3.1', 'mistral', 'codellama']; + case 'openrouter': + return ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'meta-llama/llama-3.1-8b-instruct']; + default: + return ['gpt-4o-mini']; + } +} \ No newline at end of file diff --git a/src/database.ts b/src/database.ts index 48bfab3..817cea7 100644 --- a/src/database.ts +++ b/src/database.ts @@ -14,6 +14,24 @@ export interface ChatSettings { probability: number; } +export interface AISettings { + enabled: boolean; + triggerWord: string; + randomProb: number; + groupPrompt: string | null; + provider: string; + baseUrl: string; + model: string; + apiKey: string | null; +} + +export interface SetupSession { + userId: number; + chatId: number; + step: number; + data: Record; +} + export function initDatabase(): void { db = new Database(DB_FILE); @@ -37,6 +55,33 @@ export function initDatabase(): void { count INTEGER, PRIMARY KEY (chat_id, key) ); + + CREATE TABLE IF NOT EXISTS ai_settings ( + chat_id INTEGER PRIMARY KEY, + enabled INTEGER DEFAULT 0, + trigger_word TEXT DEFAULT 'ask-ai', + random_prob INTEGER DEFAULT 1, + group_prompt TEXT, + provider TEXT DEFAULT 'openai', + base_url TEXT, + model TEXT DEFAULT 'gpt-4o-mini', + api_key TEXT + ); + + CREATE TABLE IF NOT EXISTS message_context ( + chat_id INTEGER, + role TEXT, + content TEXT, + timestamp INTEGER, + PRIMARY KEY (chat_id, timestamp) + ); + + CREATE TABLE IF NOT EXISTS setup_sessions ( + user_id INTEGER PRIMARY KEY, + chat_id INTEGER, + step INTEGER, + data TEXT + ); `); // Create indexes for faster queries @@ -44,6 +89,7 @@ export function initDatabase(): void { CREATE INDEX IF NOT EXISTS idx_transitions_chat ON markov_transitions(chat_id); CREATE INDEX IF NOT EXISTS idx_starts_chat ON markov_starts(chat_id); CREATE INDEX IF NOT EXISTS idx_transitions_count ON markov_transitions(count); + CREATE INDEX IF NOT EXISTS idx_context ON message_context(chat_id, timestamp); `); } @@ -177,4 +223,171 @@ export function saveChain(chatId: number, chain: MarkovChain): void { export function closeDatabase(): void { db.close(); +} + +// ============ AI Settings ============ + +export function getAISettings(chatId: number): AISettings { + const row = db + .prepare('SELECT * FROM ai_settings WHERE chat_id = ?') + .get(chatId) as { + enabled: number; + trigger_word: string; + random_prob: number; + group_prompt: string | null; + provider: string; + base_url: string | null; + model: string; + api_key: string | null; + } | undefined; + + if (!row) { + return { + enabled: false, + triggerWord: 'ask-ai', + randomProb: 1, // Default 1% + groupPrompt: null, + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o-mini', + apiKey: null, + }; + } + + return { + enabled: row.enabled === 1, + triggerWord: row.trigger_word, + randomProb: row.random_prob, + groupPrompt: row.group_prompt, + provider: row.provider, + baseUrl: row.base_url || 'https://api.openai.com/v1', + model: row.model, + apiKey: row.api_key, + }; +} + +export function setAISettings(chatId: number, settings: Partial): void { + const existing = db.prepare('SELECT chat_id FROM ai_settings WHERE chat_id = ?').get(chatId); + + if (existing) { + const fields: string[] = []; + const values: (string | number | null)[] = []; + + if (settings.enabled !== undefined) { + fields.push('enabled = ?'); + values.push(settings.enabled ? 1 : 0); + } + if (settings.triggerWord !== undefined) { + fields.push('trigger_word = ?'); + values.push(settings.triggerWord); + } + if (settings.randomProb !== undefined) { + fields.push('random_prob = ?'); + values.push(settings.randomProb); + } + if (settings.groupPrompt !== undefined) { + fields.push('group_prompt = ?'); + values.push(settings.groupPrompt); + } + if (settings.provider !== undefined) { + fields.push('provider = ?'); + values.push(settings.provider); + } + if (settings.baseUrl !== undefined) { + fields.push('base_url = ?'); + values.push(settings.baseUrl); + } + if (settings.model !== undefined) { + fields.push('model = ?'); + values.push(settings.model); + } + if (settings.apiKey !== undefined) { + fields.push('api_key = ?'); + values.push(settings.apiKey); + } + + if (fields.length > 0) { + values.push(chatId); + db.prepare(`UPDATE ai_settings SET ${fields.join(', ')} WHERE chat_id = ?`).run(...values); + } + } else { + db.prepare(` + INSERT INTO ai_settings (chat_id, enabled, trigger_word, random_prob, group_prompt, provider, base_url, model, api_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + chatId, + settings.enabled ? 1 : 0, + settings.triggerWord || 'ask-ai', + settings.randomProb ?? 1, // Default 1% + settings.groupPrompt ?? null, + settings.provider || 'openai', + settings.baseUrl || 'https://api.openai.com/v1', + settings.model || 'gpt-4o-mini', + settings.apiKey ?? null + ); + } +} + +// ============ Message Context ============ + +export function addMessageContext(chatId: number, role: 'user' | 'assistant', content: string): void { + db.prepare(` + INSERT INTO message_context (chat_id, role, content, timestamp) + VALUES (?, ?, ?, ?) + `).run(chatId, role, content, Date.now()); +} + +export function getMessageContext(chatId: number, limit: number = 50): Array<{ role: string; content: string }> { + return db + .prepare(` + SELECT role, content FROM message_context + WHERE chat_id = ? + ORDER BY timestamp DESC + LIMIT ? + `) + .all(chatId, limit) as Array<{ role: string; content: string }>; +} + +export function cleanupMessageContext(chatId: number, keepLast: number = 50): void { + db.prepare(` + DELETE FROM message_context + WHERE chat_id = ? AND timestamp NOT IN ( + SELECT timestamp FROM message_context + WHERE chat_id = ? + ORDER BY timestamp DESC + LIMIT ? + ) + `).run(chatId, chatId, keepLast); +} + +// ============ Setup Sessions ============ + +export function getSetupSession(userId: number): SetupSession | null { + const row = db + .prepare('SELECT * FROM setup_sessions WHERE user_id = ?') + .get(userId) as { user_id: number; chat_id: number; step: number; data: string } | undefined; + + if (!row) return null; + + return { + userId: row.user_id, + chatId: row.chat_id, + step: row.step, + data: row.data ? JSON.parse(row.data) : {}, + }; +} + +export function setSetupSession(userId: number, chatId: number, step: number, data: Record): void { + db.prepare(` + INSERT INTO setup_sessions (user_id, chat_id, step, data) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + chat_id = excluded.chat_id, + step = excluded.step, + data = excluded.data + `).run(userId, chatId, step, JSON.stringify(data)); +} + +export function deleteSetupSession(userId: number): void { + db.prepare('DELETE FROM setup_sessions WHERE user_id = ?').run(userId); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b832f3b..a00f81b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,34 @@ import 'dotenv/config'; import { Telegraf, Context } from 'telegraf'; import { MarkovChain } from './markov.js'; -import { initDatabase, closeDatabase, getSettings, setProbability, loadChain, saveChain, cleanupDatabase } from './database.js'; +import { generateAIResponse, maskApiKey, getDefaultBaseUrl, getProviderModels } from './ai.js'; +import { + initDatabase, + closeDatabase, + getSettings, + setProbability, + loadChain, + saveChain, + cleanupDatabase, + getAISettings, + setAISettings, + addMessageContext, + getMessageContext, + cleanupMessageContext, + getSetupSession, + setSetupSession, + deleteSetupSession, + AISettings, +} from './database.js'; const bot = new Telegraf(process.env.BOT_TOKEN!); +// Global AI settings from .env +const AI_DEFAULT_API_KEY = process.env.AI_DEFAULT_API_KEY || ''; +const AI_DEFAULT_BASE_URL = process.env.AI_DEFAULT_BASE_URL || 'https://api.openai.com/v1'; +const AI_DEFAULT_MODEL = process.env.AI_DEFAULT_MODEL || 'gpt-4o-mini'; +const AI_SYSTEM_PROMPT = process.env.AI_SYSTEM_PROMPT || 'Du bist ein freundlicher Chat-Bot in einer Telegram-Gruppe. Antworte kurz und prägnant auf Deutsch.'; + // In-memory cache of chains const chains = new Map(); @@ -39,6 +63,319 @@ bot.use(async (ctx, next) => { return next(); }); +// ============ Setup Wizard ============ + +const SETUP_STEPS = 6; + +async function handleSetupStep(ctx: Context): Promise { + if (!ctx.from || !ctx.message || !('text' in ctx.message)) return; + + const text = ctx.message.text.trim(); + const userId = ctx.from.id; + + const session = getSetupSession(userId); + if (!session) return; + + const data = { ...session.data }; + let nextStep = session.step; + + switch (session.step) { + case 1: // Provider + const providers = ['openai', 'ollama', 'openrouter', 'custom']; + if (!providers.includes(text.toLowerCase())) { + ctx.reply('Bitte wähle einen gültigen Provider: openai, ollama, openrouter, custom'); + return; + } + data.provider = text.toLowerCase(); + data.baseUrl = getDefaultBaseUrl(data.provider); + nextStep = 2; + ctx.reply( + 'Schritt 2/6: API-Key eingeben\n' + + '⚠️ Der Key wird sicher gespeichert und nicht vollständig angezeigt.\n\n' + + 'Bitte sende deinen API-Key:' + ); + break; + + case 2: // API Key + data.apiKey = text; + nextStep = 3; + ctx.reply( + '✓ API-Key gespeichert (' + maskApiKey(text) + ')\n\n' + + 'Schritt 3/6: Model wählen\n' + + 'Verfügbare Models: ' + getProviderModels(data.provider || 'openai').join(', ') + '\n\n' + + 'Oder gib ein anderes Model ein:' + ); + break; + + case 3: // Model + data.model = text; + nextStep = 4; + ctx.reply( + 'Schritt 4/6: Base URL\n' + + 'Standard: ' + (data.baseUrl || 'https://api.openai.com/v1') + '\n\n' + + 'Sende "skip" für Standard oder gib eine andere URL ein:' + ); + break; + + case 4: // Base URL + if (text.toLowerCase() !== 'skip') { + data.baseUrl = text; + } + nextStep = 5; + ctx.reply( + 'Schritt 5/6: Trigger & Zufall\n' + + 'Triggerwort: ask-ai\n' + + 'Zufalls-Wahrscheinlichkeit (1-10%): 1%\n\n' + + 'Sende "skip" für Standard oder gib trigger wahrscheinlichkeit ein (z.B. "ask-ai 5"):' + ); + break; + + case 5: // Trigger & Zufall + if (text.toLowerCase() !== 'skip') { + const parts = text.split(' '); + if (parts[0]) data.triggerWord = parts[0]; + if (parts[1]) { + const prob = parseInt(parts[1], 10); + if (prob >= 1 && prob <= 10) { + data.randomProb = prob.toString(); + } else { + ctx.reply('Zufall muss zwischen 1 und 10 liegen. Verwende Standard (1%).'); + data.randomProb = '1'; + } + } + } + nextStep = 6; + ctx.reply( + 'Schritt 6/6: Gruppen-Prompt\n' + + 'Dieser Prompt ergänzt den globalen System-Prompt.\n' + + 'Beispiel: "In dieser Gruppe wird über Programmieren gesprochen."\n\n' + + 'Sende "skip" für keinen Gruppen-Prompt:' + ); + break; + + case 6: // Gruppen-Prompt + if (text.toLowerCase() !== 'skip') { + data.groupPrompt = text; + } + // Save all settings + setAISettings(session.chatId, { + enabled: true, + triggerWord: data.triggerWord || 'ask-ai', + randomProb: data.randomProb ? parseInt(data.randomProb, 10) : 0, + groupPrompt: data.groupPrompt || null, + provider: data.provider || 'openai', + baseUrl: data.baseUrl || getDefaultBaseUrl(data.provider || 'openai'), + model: data.model || 'gpt-4o-mini', + apiKey: data.apiKey || null, + }); + deleteSetupSession(userId); + ctx.reply( + '✅ Setup abgeschlossen!\n\n' + + 'KI ist jetzt für diese Gruppe aktiv.\n' + + 'Nutze /ask-ai oder das Triggerwort zum Testen.' + ); + return; + } + + setSetupSession(userId, session.chatId, nextStep, data); +} + +bot.command('ai', async (ctx) => { + if (!ctx.from || !ctx.message || !('text' in ctx.message)) return; + + const args = ctx.message.text.split(' ').slice(1); + const subcommand = args[0]?.toLowerCase(); + + // /ai setup - Start setup wizard in group, continue in private + if (subcommand === 'setup') { + if (ctx.chat?.type === 'private') { + ctx.reply('Bitte starte /ai setup in einer Gruppe, nicht im Privat-Chat.'); + return; + } + + if (!(await isAdmin(ctx, ctx.from.id))) { + ctx.reply('Nur Admins können das KI-Setup starten.'); + return; + } + + // Start private conversation for setup + setSetupSession(ctx.from.id, ctx.chat.id, 1, {}); + + ctx.reply( + '🤖 KI-Setup gestartet!\n' + + 'Ich sende dir eine private Nachricht zur Konfiguration.', + { reply_parameters: { message_id: ctx.message.message_id } } + ); + + // Send private message + try { + await ctx.telegram.sendMessage(ctx.from.id, + '🤖 KI-Setup für Gruppe\n\n' + + 'Schritt 1/6: Provider wählen\n' + + '[openai] [ollama] [openrouter] [custom]\n\n' + + 'Bitte sende den gewünschten Provider:' + ); + } catch { + ctx.reply('Bitte starte zuerst einen Privat-Chat mit mir (/start), dann führe /ai setup erneut aus.'); + deleteSetupSession(ctx.from.id); + } + return; + } + + // /ai status - Show current config + if (subcommand === 'status') { + const settings = getAISettings(ctx.chat.id); + const keyStatus = settings.apiKey ? maskApiKey(settings.apiKey) + ' ✓' : '✗ Nicht gesetzt'; + + ctx.reply( + '🤖 KI-Status\n' + + '─────────────────────────\n' + + `Status: ${settings.enabled ? '✓ Aktiviert' : '✗ Deaktiviert'}\n` + + `Provider: ${settings.provider}\n` + + `Model: ${settings.model}\n` + + `API-Key: ${keyStatus}\n` + + `Trigger: ${settings.triggerWord}\n` + + `Zufall: ${settings.randomProb}%\n` + + `Gruppen-Prompt: ${settings.groupPrompt || '(nicht gesetzt)'}\n\n` + + (settings.enabled ? '' : '→ /ai setup zum Konfigurieren') + ); + return; + } + + // /ai off - Disable AI + if (subcommand === 'off') { + if (!(await isAdmin(ctx, ctx.from.id))) { + ctx.reply('Nur Admins können die KI deaktivieren.'); + return; + } + setAISettings(ctx.chat.id, { enabled: false }); + ctx.reply('KI für diese Gruppe deaktiviert.'); + return; + } + + // /ai on - Enable AI + if (subcommand === 'on') { + if (!(await isAdmin(ctx, ctx.from.id))) { + ctx.reply('Nur Admins können die KI aktivieren.'); + return; + } + const settings = getAISettings(ctx.chat.id); + if (!settings.apiKey) { + ctx.reply('Bitte führe zuerst /ai setup aus.'); + return; + } + setAISettings(ctx.chat.id, { enabled: true }); + ctx.reply('KI für diese Gruppe aktiviert.'); + return; + } + + // /ai random - Set random probability (1-10%) + if (subcommand === 'random') { + if (!(await isAdmin(ctx, ctx.from.id))) { + ctx.reply('Nur Admins können die Zufallswahrscheinlichkeit ändern.'); + return; + } + const probStr = args[1]; + if (!probStr) { + const settings = getAISettings(ctx.chat.id); + ctx.reply(`Aktuelle Zufallswahrscheinlichkeit: ${settings.randomProb}%\n\n/ai random <1-10> - Setzen (Admins only)`); + return; + } + const prob = parseInt(probStr, 10); + if (isNaN(prob) || prob < 1 || prob > 10) { + ctx.reply('Bitte eine Zahl zwischen 1 und 10 angeben.'); + return; + } + setAISettings(ctx.chat.id, { randomProb: prob }); + ctx.reply(`Zufallswahrscheinlichkeit auf ${prob}% gesetzt.`); + return; + } + + // /ai help - Show detailed help + if (subcommand === 'help') { + ctx.reply( + '🤖 KI-Hilfe\n\n' + + 'Der Bot kann optional eine KI/LLM nutzen um auf Nachrichten zu antworten.\n\n' + + 'Trigger:\n' + + '• /ask-ai [text] - Expliziter Aufruf\n' + + '• Triggerwort (z.B. "ask-ai") - Im Text enthalten\n' + + '• Zufällig (1-10%, einstellbar)\n\n' + + 'Setup:\n' + + '1. /ai setup in Gruppe ausführen\n' + + '2. Private Nachricht folgt mit Wizard\n' + + '3. Provider, API-Key, Model konfigurieren\n\n' + + 'Provider:\n' + + '• openai - OpenAI (GPT-4, GPT-3.5)\n' + + '• ollama - Lokale Models\n' + + '• openrouter - Proxy für viele APIs\n' + + '• custom - Andere OpenAI-kompatible\n\n' + + 'Prompts:\n' + + '• Globaler Prompt (.env) - Immer aktiv\n' + + '• Gruppen-Prompt - Ergänzt globalen\n\n' + + 'Sicherheit:\n' + + '• API-Keys nur im Privat-Chat\n' + + '• API-Keys maskiert angezeigt\n' + + '• Admin-only Konfiguration' + ); + return; + } + + // Default: show help + ctx.reply( + '🤖 KI-Befehle\n\n' + + '/ai setup - KI konfigurieren (Admins)\n' + + '/ai status - Aktuelle Konfiguration\n' + + '/ai on/off - KI aktivieren/deaktivieren\n' + + '/ai random <1-10> - Zufallswahrscheinlichkeit\n' + + '/ai help - Detaillierte Hilfe\n' + + '/ask-ai [text] - KI-Antwort generieren' + ); +}); + +// /ask-ai command +bot.command('ask-ai', async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + + const settings = getAISettings(ctx.chat.id); + if (!settings.enabled || !settings.apiKey) { + ctx.reply('KI ist nicht aktiviert. Nutze /ai setup zum Konfigurieren.'); + return; + } + + const query = ctx.message.text.replace(/^\/ask-ai\s*/i, '').trim(); + if (!query) { + ctx.reply('Bitte gib eine Frage oder Nachricht ein. Beispiel: /ask-ai Wie geht es dir?'); + return; + } + + // Get context + const context = getMessageContext(ctx.chat.id, 50); + const messages = context.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })); + + try { + const response = await generateAIResponse( + { + apiKey: settings.apiKey, + baseUrl: settings.baseUrl, + model: settings.model, + systemPrompt: AI_SYSTEM_PROMPT, + groupPrompt: settings.groupPrompt || undefined, + }, + messages, + query + ); + + ctx.reply(response, { reply_parameters: { message_id: ctx.message.message_id } }); + + // Save to context + addMessageContext(ctx.chat.id, 'user', query); + addMessageContext(ctx.chat.id, 'assistant', response); + } catch (error) { + console.error('AI error:', error); + ctx.reply('Fehler bei der KI-Anfrage. Bitte überprüfe die Konfiguration.'); + } +}); + bot.command('start', (ctx) => { ctx.reply( 'Hallo! Ich lerne von Nachrichten und generiere neue Sätze.\n\n' + @@ -48,8 +385,7 @@ bot.command('start', (ctx) => { 'In Gruppen antworte ich zufällig mit eingestellter Wahrscheinlichkeit.\n\n' + 'Ich lerne nur aus Nachrichten der jeweiligen Gruppe - jede Gruppe hat ihre eigene "Persönlichkeit".\n\n' + '/hilfe - Diese Hilfe anzeigen\n' + - '/prob - Aktuelle Wahrscheinlichkeit anzeigen\n' + - '/setprob <0-100> - Antwortwahrscheinlichkeit setzen (Admins only)' + '/ai - KI-Befehle' ); }); @@ -62,7 +398,9 @@ bot.command('hilfe', (ctx) => { '/hilfe - Diese Hilfe\n' + '/prob - Antwortwahrscheinlichkeit anzeigen\n' + '/setprob <0-100> - Wahrscheinlichkeit setzen (Admins)\n' + - '/cleanup - Datenbank bereinigen (Admins)\n\n' + + '/cleanup - Datenbank bereinigen (Admins)\n' + + '/ai - KI-Befehle\n' + + '/ask-ai - KI-Antwort generieren\n\n' + 'Trigger für Antwort:\n' + '• Reply auf eine meiner Nachrichten\n' + '• @Erwähnung meines Namens\n' + @@ -110,6 +448,16 @@ bot.on('text', async (ctx) => { const text = ctx.message.text; + // Check if this is a private message for setup wizard + if (ctx.chat?.type === 'private') { + // Handle setup wizard steps + const session = getSetupSession(ctx.from.id); + if (session && !text.startsWith('/')) { + await handleSetupStep(ctx); + return; + } + } + // Skip commands if (text.startsWith('/')) return; @@ -123,7 +471,42 @@ bot.on('text', async (ctx) => { const isReplyToBot = ctx.message.reply_to_message?.from?.id === botId; const isMentioned = botUsername && text.toLowerCase().includes(`@${botUsername.toLowerCase()}`); - // Determine if should respond + // Check for AI trigger word + const aiSettings = getAISettings(ctx.chat.id); + const isAITrigger = aiSettings.enabled && aiSettings.triggerWord && text.toLowerCase().includes(aiSettings.triggerWord.toLowerCase()); + const isAIRandom = aiSettings.enabled && aiSettings.randomProb > 0 && Math.random() * 100 < aiSettings.randomProb; + + // Handle AI trigger + if ((isAITrigger || isAIRandom) && aiSettings.apiKey) { + const query = text.replace(new RegExp(aiSettings.triggerWord, 'gi'), '').trim() || text; + const context = getMessageContext(ctx.chat.id, 50); + const messages = context.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })); + + try { + const response = await generateAIResponse( + { + apiKey: aiSettings.apiKey, + baseUrl: aiSettings.baseUrl, + model: aiSettings.model, + systemPrompt: AI_SYSTEM_PROMPT, + groupPrompt: aiSettings.groupPrompt || undefined, + }, + messages, + query + ); + + ctx.reply(response, { reply_parameters: { message_id: ctx.message.message_id } }); + + // Save to context + addMessageContext(ctx.chat.id, 'user', query); + addMessageContext(ctx.chat.id, 'assistant', response); + } catch (error) { + console.error('AI error:', error); + } + return; + } + + // Determine if should respond (Markov) let shouldRespond = false; if (isReplyToBot || isMentioned) {