Add KI/LLM integration with OpenAI-compatible API support
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 30s

Features:
- /ai setup - Setup wizard via private chat for security
- /ai status - Show current config (API key masked)
- /ai on/off - Enable/disable AI per chat
- /ai random <1-10> - Set random trigger probability (admins)
- /ai help - Detailed help
- /ask-ai [text] - Generate AI response

Security:
- API keys only entered in private chat
- API keys masked in status display
- Admin-only configuration

Architecture:
- OpenAI-compatible client (OpenAI, Ollama, OpenRouter)
- Global system prompt (.env) + group prompt (DB)
- 50 message context per chat
- SQLite tables: ai_settings, message_context, setup_sessions

Defaults:
- AI disabled by default
- Trigger word: ask-ai
- Random probability: 1%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 10:40:51 +01:00
parent 42b2971cd8
commit 3b310d612d
6 changed files with 844 additions and 64 deletions

View File

@@ -1 +1,7 @@
BOT_TOKEN=your_telegram_bot_token_here 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.

110
CLAUDE.md
View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## Commands
@@ -19,75 +19,97 @@ docker compose up -d --build # Docker deployment
``` ```
src/ src/
├── index.ts # Bot entry point, commands, message handling ├── index.ts # Bot entry point, commands, setup wizard
├── markov.ts # Markov chain with trigram support ├── 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 1. Message received → `chain.learn(text)` → SQLite
2. Trigger check (reply/mention/random) 2. Trigger check (reply/mention/random)
3. If triggered → `chain.generate()` → reply 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:** **Per-chat isolation:**
- Each chat has separate Markov chain data - Each chat has separate Markov chain data
- Each chat has its own response probability setting - Each chat has separate AI settings
- In-memory cache (`chains` Map) loaded lazily from DB - Each chat has separate message context (50 messages)
## Key Implementation Details ## Key Implementation Details
### Markov Chain (markov.ts) ### Markov Chain (markov.ts)
- Uses **trigrams** (order=2): "word1 word2" → "word3" - Uses **trigrams** (order=2): "word1 word2" → "word3"
- Better grammatical coherence than bigrams
- Weighted random selection based on frequency - Weighted random selection based on frequency
- Generates sentences up to 20 words - Generates sentences up to 20 words
### Database (database.ts) ### Database (database.ts)
- SQLite with `better-sqlite3` (synchronous API) - SQLite with `better-sqlite3` (synchronous API)
- Three tables: `chat_settings`, `markov_transitions`, `markov_starts` - Tables:
- Transactions for atomic updates - `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 - Automatic cleanup every 24h
### Cleanup Mechanism ### AI Client (ai.ts)
```typescript - OpenAI-compatible API (works with OpenAI, Ollama, OpenRouter, etc.)
const MAX_TRANSITIONS_PER_CHAT = 10000; // Upper limit - Prompt system: Global (.env) + Group-specific (DB)
const MIN_TRANSITION_COUNT = 2; // Remove rare entries - API key masking for security
const MIN_TRANSITIONS_TO_CLEANUP = 100; // Protect new chats - Context: Last 50 messages
```
### Response Triggers ### Setup Wizard
1. Reply to bot's message → always respond - Started via `/ai setup` in group
2. @username mention → always respond - Continues in private chat for security
3. Random probability (default 10%) in groups - API keys never shown in full
4. Always respond in private chats - Steps: Provider → API Key → Model → URL → Trigger/Prob → Group Prompt
## Environment ## Environment
- `BOT_TOKEN` - Telegram bot token (required) ```env
- Database: `data/ulfbot.db` (auto-created) BOT_TOKEN= # Telegram bot token (required)
AI_DEFAULT_API_KEY= # Default API key (optional)
## Docker AI_DEFAULT_BASE_URL= # Default API URL (default: OpenAI)
AI_DEFAULT_MODEL= # Default model (default: gpt-4o-mini)
- Uses `node:20-slim` for better-sqlite3 compatibility AI_SYSTEM_PROMPT= # Global system prompt
- 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
``` ```
**Change cleanup interval:** ## Database Schema
```typescript
// index.ts ```sql
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // Currently 24h -- 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):** ## Response Triggers
```typescript
// markov.ts ### Markov (always active)
constructor(order: number = 2) // Higher = better grammar, needs more data 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

View File

@@ -1,6 +1,6 @@
# Ulfbot # 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 ## 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 - Generiert grammatikalisch sinnvolle Sätze mit Trigrammen
- Antwortet bei Reply, Erwähnung (@username) oder zufällig - Antwortet bei Reply, Erwähnung (@username) oder zufällig
- Pro-Chat-Wahrscheinlichkeit einstellbar (Admins) - Pro-Chat-Wahrscheinlichkeit einstellbar (Admins)
- **KI/LLM-Integration** (OpenAI, Ollama, OpenRouter, etc.)
- Persistente SQLite-Datenbank - Persistente SQLite-Datenbank
- Docker-Support - Docker-Support
@@ -62,6 +63,8 @@ npm run dev # Hot-Reload aktiv
## Befehle ## Befehle
### Markov (Standard)
| Befehl | Beschreibung | | Befehl | Beschreibung |
|--------|--------------| |--------|--------------|
| `/start` | Begrüßung anzeigen | | `/start` | Begrüßung anzeigen |
@@ -70,19 +73,75 @@ npm run dev # Hot-Reload aktiv
| `/setprob <0-100>` | Wahrscheinlichkeit setzen (Admins) | | `/setprob <0-100>` | Wahrscheinlichkeit setzen (Admins) |
| `/cleanup` | Datenbank bereinigen (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 ## Trigger für Antworten
Der Bot antwortet wenn: ### Markov (Standard)
- Auf eine seiner Nachrichten geantwortet wird (Reply) - Reply auf Bot-Nachricht
- Sein @Username erwähnt wird - @Username-Erwähnung
- Zufällig (basierend auf eingestellter Wahrscheinlichkeit) - 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 ## Technisches
### Architektur ### 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 - **Markov Chain** mit Trigrammen für bessere Grammatik
- **SQLite** für persistente Speicherung - **SQLite** für persistente Speicherung
- Pro Chat separate Daten/Lernkurve - Pro Chat separate Daten/Lernkurve
@@ -94,15 +153,6 @@ In privaten Chats antwortet er immer.
- Limitiert auf 10.000 Übergänge pro Chat - Limitiert auf 10.000 Übergänge pro Chat
- Schützt neue Chats (min. 100 Übergänge nötig) - 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 ## Lizenz
ISC ISC

106
src/ai.ts Normal file
View File

@@ -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<string> {
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'];
}
}

View File

@@ -14,6 +14,24 @@ export interface ChatSettings {
probability: number; 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<string, string>;
}
export function initDatabase(): void { export function initDatabase(): void {
db = new Database(DB_FILE); db = new Database(DB_FILE);
@@ -37,6 +55,33 @@ export function initDatabase(): void {
count INTEGER, count INTEGER,
PRIMARY KEY (chat_id, key) 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 // 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_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_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_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 { export function closeDatabase(): void {
db.close(); 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<AISettings>): 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<string, string>): 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);
} }

View File

@@ -1,10 +1,34 @@
import 'dotenv/config'; import 'dotenv/config';
import { Telegraf, Context } from 'telegraf'; import { Telegraf, Context } from 'telegraf';
import { MarkovChain } from './markov.js'; 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!); 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 // In-memory cache of chains
const chains = new Map<number, MarkovChain>(); const chains = new Map<number, MarkovChain>();
@@ -39,6 +63,319 @@ bot.use(async (ctx, next) => {
return next(); return next();
}); });
// ============ Setup Wizard ============
const SETUP_STEPS = 6;
async function handleSetupStep(ctx: Context): Promise<void> {
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) => { bot.command('start', (ctx) => {
ctx.reply( ctx.reply(
'Hallo! Ich lerne von Nachrichten und generiere neue Sätze.\n\n' + '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' + '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' + 'Ich lerne nur aus Nachrichten der jeweiligen Gruppe - jede Gruppe hat ihre eigene "Persönlichkeit".\n\n' +
'/hilfe - Diese Hilfe anzeigen\n' + '/hilfe - Diese Hilfe anzeigen\n' +
'/prob - Aktuelle Wahrscheinlichkeit anzeigen\n' + '/ai - KI-Befehle'
'/setprob <0-100> - Antwortwahrscheinlichkeit setzen (Admins only)'
); );
}); });
@@ -62,7 +398,9 @@ bot.command('hilfe', (ctx) => {
'/hilfe - Diese Hilfe\n' + '/hilfe - Diese Hilfe\n' +
'/prob - Antwortwahrscheinlichkeit anzeigen\n' + '/prob - Antwortwahrscheinlichkeit anzeigen\n' +
'/setprob <0-100> - Wahrscheinlichkeit setzen (Admins)\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 <text> - KI-Antwort generieren\n\n' +
'Trigger für Antwort:\n' + 'Trigger für Antwort:\n' +
'• Reply auf eine meiner Nachrichten\n' + '• Reply auf eine meiner Nachrichten\n' +
'• @Erwähnung meines Namens\n' + '• @Erwähnung meines Namens\n' +
@@ -110,6 +448,16 @@ bot.on('text', async (ctx) => {
const text = ctx.message.text; 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 // Skip commands
if (text.startsWith('/')) return; if (text.startsWith('/')) return;
@@ -123,7 +471,42 @@ bot.on('text', async (ctx) => {
const isReplyToBot = ctx.message.reply_to_message?.from?.id === botId; const isReplyToBot = ctx.message.reply_to_message?.from?.id === botId;
const isMentioned = botUsername && text.toLowerCase().includes(`@${botUsername.toLowerCase()}`); 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; let shouldRespond = false;
if (isReplyToBot || isMentioned) { if (isReplyToBot || isMentioned) {