first commit
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
*.md
|
||||||
|
.git/
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
BOT_TOKEN=your_telegram_bot_token_here
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
data/*.db
|
||||||
93
CLAUDE.md
Normal file
93
CLAUDE.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Development with hot-reload (tsx watch)
|
||||||
|
npm run build # Compile TypeScript to dist/
|
||||||
|
npm start # Run production build
|
||||||
|
docker compose up -d --build # Docker deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # Bot entry point, commands, message handling
|
||||||
|
├── markov.ts # Markov chain with trigram support
|
||||||
|
└── database.ts # SQLite persistence layer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
1. Message received → `chain.learn(text)` → SQLite
|
||||||
|
2. Trigger check (reply/mention/random)
|
||||||
|
3. If triggered → `chain.generate()` → 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change cleanup interval:**
|
||||||
|
```typescript
|
||||||
|
// index.ts
|
||||||
|
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // Currently 24h
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change Markov order (affects grammar quality):**
|
||||||
|
```typescript
|
||||||
|
// markov.ts
|
||||||
|
constructor(order: number = 2) // Higher = better grammar, needs more data
|
||||||
|
```
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY dist ./dist
|
||||||
|
|
||||||
|
# Persistent data directory
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Ulfbot
|
||||||
|
|
||||||
|
Ein Telegram-Bot, der mithilfe von Markov Chains neue Sätze aus vorherigen Nachrichten generiert.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Lernt von allen Text-Nachrichten in einer Gruppe
|
||||||
|
- Generiert grammatikalisch sinnvolle Sätze mit Trigrammen
|
||||||
|
- Antwortet bei Reply, Erwähnung (@username) oder zufällig
|
||||||
|
- Pro-Chat-Wahrscheinlichkeit einstellbar (Admins)
|
||||||
|
- Persistente SQLite-Datenbank
|
||||||
|
- Docker-Support
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### Mit Docker (empfohlen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
git clone <repo-url>
|
||||||
|
cd ulfbot
|
||||||
|
|
||||||
|
# .env erstellen
|
||||||
|
cp .env.example .env
|
||||||
|
# BOT_TOKEN in .env eintragen
|
||||||
|
|
||||||
|
# Bauen und starten
|
||||||
|
npm run build
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ohne Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abhängigkeiten installieren
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# .env erstellen
|
||||||
|
cp .env.example .env
|
||||||
|
# BOT_TOKEN in .env eintragen
|
||||||
|
|
||||||
|
# Kompilieren
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Hot-Reload aktiv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bot erstellen
|
||||||
|
|
||||||
|
1. [@BotFather](https://t.me/BotFather) auf Telegram öffnen
|
||||||
|
2. `/newbot` senden
|
||||||
|
3. Namen und Username wählen
|
||||||
|
4. Den erhaltenen Token in `.env` als `BOT_TOKEN` eintragen
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `/start` | Begrüßung anzeigen |
|
||||||
|
| `/hilfe` | Hilfe anzeigen |
|
||||||
|
| `/prob` | Aktuelle Antwortwahrscheinlichkeit |
|
||||||
|
| `/setprob <0-100>` | Wahrscheinlichkeit setzen (Admins) |
|
||||||
|
| `/cleanup` | Datenbank bereinigen (Admins) |
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
In privaten Chats antwortet er immer.
|
||||||
|
|
||||||
|
## Technisches
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
|
||||||
|
- **Markov Chain** mit Trigrammen für bessere Grammatik
|
||||||
|
- **SQLite** für persistente Speicherung
|
||||||
|
- Pro Chat separate Daten/Lernkurve
|
||||||
|
|
||||||
|
### Datenbank-Cleanup
|
||||||
|
|
||||||
|
- Automatisch alle 24 Stunden
|
||||||
|
- Entfernt seltene Übergänge (nur einmal gesehen)
|
||||||
|
- 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
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
ulfbot:
|
||||||
|
build: .
|
||||||
|
container_name: ulfbot
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- BOT_TOKEN=${BOT_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ulfbot-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ulfbot-data:
|
||||||
1243
package-lock.json
generated
Normal file
1243
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "ulfbot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Telegram bot",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "echo \"No tests yet\" && exit 0"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"telegraf": "^4.16.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/database.ts
Normal file
180
src/database.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { MarkovChain } from './markov.js';
|
||||||
|
|
||||||
|
const DB_FILE = 'data/ulfbot.db';
|
||||||
|
|
||||||
|
// Cleanup settings
|
||||||
|
const MAX_TRANSITIONS_PER_CHAT = 10000; // Max transitions per chat
|
||||||
|
const MIN_TRANSITION_COUNT = 2; // Remove transitions seen only once
|
||||||
|
const MIN_TRANSITIONS_TO_CLEANUP = 100; // Don't cleanup chats with fewer transitions
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
|
||||||
|
export interface ChatSettings {
|
||||||
|
probability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initDatabase(): void {
|
||||||
|
db = new Database(DB_FILE);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_settings (
|
||||||
|
chat_id INTEGER PRIMARY KEY,
|
||||||
|
probability INTEGER DEFAULT 10
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS markov_transitions (
|
||||||
|
chat_id INTEGER,
|
||||||
|
key TEXT,
|
||||||
|
next_word TEXT,
|
||||||
|
count INTEGER,
|
||||||
|
PRIMARY KEY (chat_id, key, next_word)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS markov_starts (
|
||||||
|
chat_id INTEGER,
|
||||||
|
key TEXT,
|
||||||
|
count INTEGER,
|
||||||
|
PRIMARY KEY (chat_id, key)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for faster queries
|
||||||
|
db.exec(`
|
||||||
|
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);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove low-frequency transitions and limit total size per chat
|
||||||
|
* Only cleans up chats with enough data (MIN_TRANSITIONS_TO_CLEANUP)
|
||||||
|
*/
|
||||||
|
export function cleanupDatabase(): void {
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
// Only cleanup chats that have enough transitions
|
||||||
|
// Chats with fewer transitions are still learning - don't delete their data
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM markov_transitions
|
||||||
|
WHERE count < ? AND chat_id IN (
|
||||||
|
SELECT chat_id FROM markov_transitions
|
||||||
|
GROUP BY chat_id HAVING COUNT(*) >= ?
|
||||||
|
)
|
||||||
|
`).run(MIN_TRANSITION_COUNT, MIN_TRANSITIONS_TO_CLEANUP);
|
||||||
|
|
||||||
|
// Get chats that exceed the limit
|
||||||
|
const chats = db.prepare(`
|
||||||
|
SELECT chat_id, COUNT(*) as cnt FROM markov_transitions
|
||||||
|
GROUP BY chat_id HAVING cnt > ?
|
||||||
|
`).all(MAX_TRANSITIONS_PER_CHAT) as { chat_id: number; cnt: number }[];
|
||||||
|
|
||||||
|
for (const { chat_id } of chats) {
|
||||||
|
// Keep only the most frequent transitions
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM markov_transitions
|
||||||
|
WHERE chat_id = ? AND (key, next_word) NOT IN (
|
||||||
|
SELECT key, next_word FROM markov_transitions
|
||||||
|
WHERE chat_id = ?
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
`).run(chat_id, chat_id, MAX_TRANSITIONS_PER_CHAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove orphaned starts (no matching transitions)
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM markov_starts
|
||||||
|
WHERE key NOT IN (SELECT DISTINCT key FROM markov_transitions)
|
||||||
|
`).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
const stats = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM markov_transitions) as transitions,
|
||||||
|
(SELECT COUNT(*) FROM markov_starts) as starts
|
||||||
|
`).get() as { transitions: number; starts: number };
|
||||||
|
|
||||||
|
console.log(`DB cleanup done: ${stats.transitions} transitions, ${stats.starts} starts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettings(chatId: number): ChatSettings {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT probability FROM chat_settings WHERE chat_id = ?')
|
||||||
|
.get(chatId);
|
||||||
|
|
||||||
|
return { probability: (row as { probability: number } | undefined)?.probability ?? 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProbability(chatId: number, probability: number): void {
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO chat_settings (chat_id, probability)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(chat_id) DO UPDATE SET probability = excluded.probability
|
||||||
|
`)
|
||||||
|
.run(chatId, probability);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadChain(chatId: number): MarkovChain {
|
||||||
|
const chain = new MarkovChain(2);
|
||||||
|
|
||||||
|
// Load transitions
|
||||||
|
const transitions = db
|
||||||
|
.prepare('SELECT key, next_word, count FROM markov_transitions WHERE chat_id = ?')
|
||||||
|
.all(chatId) as { key: string; next_word: string; count: number }[];
|
||||||
|
|
||||||
|
for (const row of transitions) {
|
||||||
|
chain.addTransition(row.key, row.next_word, row.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load starts
|
||||||
|
const starts = db
|
||||||
|
.prepare('SELECT key, count FROM markov_starts WHERE chat_id = ?')
|
||||||
|
.all(chatId) as { key: string; count: number }[];
|
||||||
|
|
||||||
|
for (const row of starts) {
|
||||||
|
chain.addStart(row.key, row.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveChain(chatId: number, chain: MarkovChain): void {
|
||||||
|
const data = chain.export();
|
||||||
|
|
||||||
|
// Use transaction for atomicity
|
||||||
|
const saveTransaction = db.transaction(() => {
|
||||||
|
// Clear existing data for this chat
|
||||||
|
db.prepare('DELETE FROM markov_transitions WHERE chat_id = ?').run(chatId);
|
||||||
|
db.prepare('DELETE FROM markov_starts WHERE chat_id = ?').run(chatId);
|
||||||
|
|
||||||
|
// Insert transitions
|
||||||
|
const insertTransition = db.prepare(
|
||||||
|
'INSERT INTO markov_transitions (chat_id, key, next_word, count) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [key, words] of Object.entries(data.transitions)) {
|
||||||
|
for (const [nextWord, count] of Object.entries(words as Record<string, number>)) {
|
||||||
|
insertTransition.run(chatId, key, nextWord, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert starts
|
||||||
|
const insertStart = db.prepare(
|
||||||
|
'INSERT INTO markov_starts (chat_id, key, count) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [key, count] of Object.entries(data.starts)) {
|
||||||
|
insertStart.run(chatId, key, count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
saveTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDatabase(): void {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
173
src/index.ts
Normal file
173
src/index.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Telegraf, Context } from 'telegraf';
|
||||||
|
import { MarkovChain } from './markov.js';
|
||||||
|
import { initDatabase, closeDatabase, getSettings, setProbability, loadChain, saveChain, cleanupDatabase } from './database.js';
|
||||||
|
|
||||||
|
const bot = new Telegraf(process.env.BOT_TOKEN!);
|
||||||
|
|
||||||
|
// In-memory cache of chains
|
||||||
|
const chains = new Map<number, MarkovChain>();
|
||||||
|
|
||||||
|
// Get or create chain for a chat (loads from DB if not cached)
|
||||||
|
function getChain(chatId: number): MarkovChain {
|
||||||
|
if (!chains.has(chatId)) {
|
||||||
|
chains.set(chatId, loadChain(chatId));
|
||||||
|
}
|
||||||
|
return chains.get(chatId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is admin in group
|
||||||
|
async function isAdmin(ctx: Context, userId: number): Promise<boolean> {
|
||||||
|
if (ctx.chat?.type === 'private') return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admins = await ctx.getChatAdministrators();
|
||||||
|
return admins.some((admin) => admin.user.id === userId);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bot info to extract username
|
||||||
|
let botUsername: string | null = null;
|
||||||
|
|
||||||
|
bot.use(async (ctx, next) => {
|
||||||
|
if (!botUsername) {
|
||||||
|
const me = await ctx.telegram.getMe();
|
||||||
|
botUsername = me.username ?? null;
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('start', (ctx) => {
|
||||||
|
ctx.reply(
|
||||||
|
'Hallo! Ich lerne von Nachrichten und generiere neue Sätze.\n\n' +
|
||||||
|
'Ich antworte wenn:\n' +
|
||||||
|
'• Du mich per Reply markierst\n' +
|
||||||
|
'• Du meinen @namen erwähnst\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' +
|
||||||
|
'/hilfe - Diese Hilfe anzeigen\n' +
|
||||||
|
'/prob - Aktuelle Wahrscheinlichkeit anzeigen\n' +
|
||||||
|
'/setprob <0-100> - Antwortwahrscheinlichkeit setzen (Admins only)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('hilfe', (ctx) => {
|
||||||
|
ctx.reply(
|
||||||
|
'📚 Hilfe\n\n' +
|
||||||
|
'Ich generiere Sätze basierend auf vorherigen Nachrichten in dieser Gruppe.\n\n' +
|
||||||
|
'Befehle:\n' +
|
||||||
|
'/start - Begrüßung anzeigen\n' +
|
||||||
|
'/hilfe - Diese Hilfe\n' +
|
||||||
|
'/prob - Antwortwahrscheinlichkeit anzeigen\n' +
|
||||||
|
'/setprob <0-100> - Wahrscheinlichkeit setzen (Admins)\n' +
|
||||||
|
'/cleanup - Datenbank bereinigen (Admins)\n\n' +
|
||||||
|
'Trigger für Antwort:\n' +
|
||||||
|
'• Reply auf eine meiner Nachrichten\n' +
|
||||||
|
'• @Erwähnung meines Namens\n' +
|
||||||
|
'• Zufällig (je nach Wahrscheinlichkeit)\n\n' +
|
||||||
|
'Ich lerne nur aus Nachrichten dieser Gruppe.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('prob', (ctx) => {
|
||||||
|
const settings = getSettings(ctx.chat.id);
|
||||||
|
ctx.reply(`Aktuelle Antwortwahrscheinlichkeit: ${settings.probability}%`);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('setprob', async (ctx) => {
|
||||||
|
if (!ctx.from || !(await isAdmin(ctx, ctx.from.id))) {
|
||||||
|
ctx.reply('Nur Admins können die Wahrscheinlichkeit ändern.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ctx.message?.text?.split(' ')[1];
|
||||||
|
const prob = parseInt(args ?? '', 10);
|
||||||
|
|
||||||
|
if (isNaN(prob) || prob < 0 || prob > 100) {
|
||||||
|
ctx.reply('Bitte eine Zahl zwischen 0 und 100 angeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProbability(ctx.chat.id, prob);
|
||||||
|
ctx.reply(`Wahrscheinlichkeit auf ${prob}% gesetzt.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('cleanup', async (ctx) => {
|
||||||
|
if (!ctx.from || !(await isAdmin(ctx, ctx.from.id))) {
|
||||||
|
ctx.reply('Nur Admins können Cleanup ausführen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupDatabase();
|
||||||
|
ctx.reply('Datenbank bereinigt: Seltene Einträge entfernt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle text messages - learn and potentially respond
|
||||||
|
bot.on('text', async (ctx) => {
|
||||||
|
if (!ctx.message || !ctx.from) return;
|
||||||
|
|
||||||
|
const text = ctx.message.text;
|
||||||
|
|
||||||
|
// Skip commands
|
||||||
|
if (text.startsWith('/')) return;
|
||||||
|
|
||||||
|
// Learn from message
|
||||||
|
const chain = getChain(ctx.chat.id);
|
||||||
|
chain.learn(text);
|
||||||
|
saveChain(ctx.chat.id, chain);
|
||||||
|
|
||||||
|
// Check if bot is mentioned or replied to
|
||||||
|
const botId = (await ctx.telegram.getMe()).id;
|
||||||
|
const isReplyToBot = ctx.message.reply_to_message?.from?.id === botId;
|
||||||
|
const isMentioned = botUsername && text.toLowerCase().includes(`@${botUsername.toLowerCase()}`);
|
||||||
|
|
||||||
|
// Determine if should respond
|
||||||
|
let shouldRespond = false;
|
||||||
|
|
||||||
|
if (isReplyToBot || isMentioned) {
|
||||||
|
shouldRespond = true;
|
||||||
|
} else if (ctx.chat.type !== 'private') {
|
||||||
|
const settings = getSettings(ctx.chat.id);
|
||||||
|
if (Math.random() * 100 < settings.probability) {
|
||||||
|
shouldRespond = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldRespond = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRespond && chain.hasLearned()) {
|
||||||
|
const sentence = chain.generate();
|
||||||
|
ctx.reply(sentence, { reply_parameters: { message_id: ctx.message.message_id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.once('SIGINT', () => {
|
||||||
|
bot.stop('SIGINT');
|
||||||
|
closeDatabase();
|
||||||
|
});
|
||||||
|
process.once('SIGTERM', () => {
|
||||||
|
bot.stop('SIGTERM');
|
||||||
|
closeDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic cleanup (every 24 hours)
|
||||||
|
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function startPeriodicCleanup(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
console.log('Running periodic cleanup...');
|
||||||
|
cleanupDatabase();
|
||||||
|
// Clear in-memory cache to free memory
|
||||||
|
chains.clear();
|
||||||
|
}, CLEANUP_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start bot
|
||||||
|
initDatabase();
|
||||||
|
cleanupDatabase();
|
||||||
|
bot.launch();
|
||||||
|
startPeriodicCleanup();
|
||||||
|
console.log('Bot is running...');
|
||||||
170
src/markov.ts
Normal file
170
src/markov.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Markov chain for generating sentences from learned text.
|
||||||
|
* Uses trigrams (word triplets) for better grammatical coherence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ChainData {
|
||||||
|
// Map of "word1 word2" -> possible next words with frequency
|
||||||
|
transitions: Map<string, Map<string, number>>;
|
||||||
|
// All sentence start patterns
|
||||||
|
starts: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkovChain {
|
||||||
|
private order: number;
|
||||||
|
private chain: ChainData;
|
||||||
|
|
||||||
|
constructor(order: number = 2) {
|
||||||
|
this.order = order;
|
||||||
|
this.chain = {
|
||||||
|
transitions: new Map(),
|
||||||
|
starts: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize text into words, preserving sentence boundaries
|
||||||
|
*/
|
||||||
|
private tokenize(text: string): string[][] {
|
||||||
|
const sentences: string[][] = [];
|
||||||
|
|
||||||
|
// Split into sentences (rough but works for most cases)
|
||||||
|
const sentencePatterns = text.split(/[.!?]+/);
|
||||||
|
|
||||||
|
for (const sentence of sentencePatterns) {
|
||||||
|
const words = sentence
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\wäöüß\s]/g, '') // Keep German characters
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(w => w.length > 0);
|
||||||
|
|
||||||
|
if (words.length > 0) {
|
||||||
|
sentences.push(words);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learn from a text, adding it to the chain
|
||||||
|
*/
|
||||||
|
learn(text: string): void {
|
||||||
|
const sentences = this.tokenize(text);
|
||||||
|
|
||||||
|
for (const words of sentences) {
|
||||||
|
if (words.length < this.order + 1) continue;
|
||||||
|
|
||||||
|
// Mark sentence start
|
||||||
|
const startKey = words.slice(0, this.order).join(' ');
|
||||||
|
this.chain.starts.set(startKey, (this.chain.starts.get(startKey) || 0) + 1);
|
||||||
|
|
||||||
|
// Build transitions
|
||||||
|
for (let i = 0; i < words.length - this.order; i++) {
|
||||||
|
const key = words.slice(i, i + this.order).join(' ');
|
||||||
|
const next = words[i + this.order];
|
||||||
|
|
||||||
|
if (!this.chain.transitions.has(key)) {
|
||||||
|
this.chain.transitions.set(key, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions = this.chain.transitions.get(key)!;
|
||||||
|
transitions.set(next, (transitions.get(next) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a weighted random item from a map of {item: weight}
|
||||||
|
*/
|
||||||
|
private weightedRandom<T>(items: Map<T, number>): T | null {
|
||||||
|
const entries = Array.from(items.entries());
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
const total = entries.reduce((sum, [, w]) => sum + w, 0);
|
||||||
|
let random = Math.random() * total;
|
||||||
|
|
||||||
|
for (const [item, weight] of entries) {
|
||||||
|
random -= weight;
|
||||||
|
if (random <= 0) return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries[0]![0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a sentence from the learned chain
|
||||||
|
*/
|
||||||
|
generate(maxWords: number = 20): string {
|
||||||
|
if (this.chain.starts.size === 0) {
|
||||||
|
return 'Ich brauche erst mehr Nachrichten zum Lernen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick random start
|
||||||
|
const startKey = this.weightedRandom(this.chain.starts);
|
||||||
|
if (!startKey) return '...';
|
||||||
|
|
||||||
|
const words: string[] = startKey.split(' ');
|
||||||
|
let currentKey = startKey;
|
||||||
|
|
||||||
|
while (words.length < maxWords) {
|
||||||
|
const transitions = this.chain.transitions.get(currentKey);
|
||||||
|
|
||||||
|
if (!transitions || transitions.size === 0) break;
|
||||||
|
|
||||||
|
const next = this.weightedRandom(transitions);
|
||||||
|
if (!next) break;
|
||||||
|
|
||||||
|
words.push(next);
|
||||||
|
|
||||||
|
// Update key (slide window)
|
||||||
|
const keyWords = words.slice(-this.order);
|
||||||
|
currentKey = keyWords.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter
|
||||||
|
const result = words.join(' ');
|
||||||
|
return result.charAt(0).toUpperCase() + result.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if chain has learned anything
|
||||||
|
*/
|
||||||
|
hasLearned(): boolean {
|
||||||
|
return this.chain.starts.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single transition (for loading from DB)
|
||||||
|
*/
|
||||||
|
addTransition(key: string, nextWord: string, count: number): void {
|
||||||
|
if (!this.chain.transitions.has(key)) {
|
||||||
|
this.chain.transitions.set(key, new Map());
|
||||||
|
}
|
||||||
|
this.chain.transitions.get(key)!.set(nextWord, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single start (for loading from DB)
|
||||||
|
*/
|
||||||
|
addStart(key: string, count: number): void {
|
||||||
|
this.chain.starts.set(key, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export chain data for persistence
|
||||||
|
*/
|
||||||
|
export(): { transitions: Record<string, Record<string, number>>; starts: Record<string, number> } {
|
||||||
|
const transitions: Record<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
for (const [key, words] of this.chain.transitions) {
|
||||||
|
transitions[key] = Object.fromEntries(words);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transitions,
|
||||||
|
starts: Object.fromEntries(this.chain.starts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user