Files
loggingbot/bot.py
T
admin 2af4603ba3 Implement profile change detection and persistence
- Enhance topic detection with message_thread_id (primary) and reply_to_topic_id (fallback)
- Add DatabaseManager.get_user_profile() to load stored user profiles
- Add DatabaseManager.update_user_profile() for profile-only updates (without affecting message_count)
- Refactor handle_profile_update() to compare stored vs current profiles
- Automatically detect name and username changes in handle_message()
- Persist detected profile changes to user_stats and profile_history tables
- Log profile changes with before/after details in activity logging
2026-06-09 20:32:35 +02:00

371 lines
17 KiB
Python

import logging
import sqlite3
import os
import yaml
from pathlib import Path
from telegram import Update
from telegram.ext import Application, MessageHandler, filters, ContextTypes, CommandHandler
from datetime import datetime
# --- KONFIGURATION LADEN ---
def load_config(config_path: str = 'config.yaml') -> dict:
"""Lädt die Konfiguration aus einer YAML-Datei."""
if not os.path.exists(config_path):
raise FileNotFoundError(
f"Konfigurationsdatei '{config_path}' nicht gefunden.\n"
f"Bitte kopiere 'config_example.yaml' zu '{config_path}' und passe die Werte an."
)
with open(config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
return config
# Config laden
try:
config = load_config('config.yaml')
except FileNotFoundError as e:
print(f"❌ Fehler: {e}")
exit(1)
# Konfigurationsvariablen extrahieren
BOT_TOKEN = config['bot']['token']
ENABLE_DEBUG_MODE = config['bot']['debug_mode']
DATABASE_NAME = config['database']['name']
WRITE_ACTIVITY_TO_FILE = config['logging']['write_to_file']
LOGGING_FILE = config['logging']['file_name']
LOG_LEVEL = getattr(logging, config['logging']['level'], logging.INFO)
# Logging konfigurieren
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
class DatabaseManager:
"""Kapselt alle Datenbankinteraktionen und verwaltet Benutzer-/Gruppenzustände."""
def __init__(self):
# ACHTUNG: Führen Sie vor dem Start einmal manuell die Migration durch,
# oder lassen Sie Python das Schema anpassen (siehe unten).
self.conn = sqlite3.connect(DATABASE_NAME)
self.cursor = self.conn.cursor()
self._create_tables()
def _create_tables(self):
"""Stellt sicher, dass alle notwendigen Tabellen und Spalten existieren."""
# 1. activity_log (Bleibt gleich)
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_chat_id TEXT NOT NULL,
topic_id TEXT DEFAULT 'MAIN',
timestamp TEXT NOT NULL,
activity_type TEXT NOT NULL,
user_id INTEGER NOT NULL,
username TEXT,
details TEXT
)
""")
# 2. user_stats (Angepasst mit Name/Username)
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS user_stats (
user_id INTEGER PRIMARY KEY,
message_count INTEGER DEFAULT 0,
last_message_timestamp TEXT NULL,
full_name TEXT, -- NEU: Benutzername speichern
username TEXT -- NEU: Benutzernamen speichern
)
""")
# 3. NEUE TABELLE: Profile History Log (Protokolliert Änderungen)
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS profile_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
group_chat_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
activity_type TEXT NOT NULL,
old_full_name TEXT, -- Name vor der Änderung
new_full_name TEXT, -- Neuer Name
old_username TEXT, -- Username vor der Änderung
new_username TEXT, -- Neuer Username
details TEXT
);
""")
print(f"✅ Datenbank '{DATABASE_NAME}' erfolgreich initialisiert/überprüft.")
def log_activity(self, group_chat_id: int, topic_id: str, activity_type: str, user_id: int, username: str, details: str = None):
"""Speichert eine allgemeine Aktivität im Protokoll."""
timestamp = datetime.now().isoformat()
try:
self.cursor.execute("""
INSERT INTO activity_log (group_chat_id, topic_id, timestamp, activity_type, user_id, username, details)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (str(group_chat_id), topic_id, timestamp, activity_type, user_id, username or None, details))
self.conn.commit()
except Exception as e:
logging.error(f"Fehler beim Logging der Aktivität: {e}")
# NEU: Funktion zur Speicherung von Profiländerungen im History Log
def log_profile_change(self, group_chat_id: int, user_id: int, topic_id: str, old_name: str, new_name: str, old_user: str, new_user: str, details: str):
"""Speichert die detaillierte Historie einer Profiländerung."""
timestamp = datetime.now().isoformat()
try:
self.cursor.execute("""
INSERT INTO profile_history (user_id, group_chat_id, timestamp, activity_type,
old_full_name, new_full_name, old_username, new_username, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user_id, str(group_chat_id), timestamp, "PROFILE_CHANGE",
old_name, new_name, old_user, new_user, details))
self.conn.commit()
except Exception as e:
logging.error(f"Fehler beim Protokollieren der Profiländerung: {e}")
def update_user_stats(self, user_id: int, message_count: int, last_message_timestamp: str, full_name: str = None, username: str = None):
"""Aktualisiert die Statistiken und das letzte bekannte Profil des Benutzers."""
try:
# Die Spaltenliste muss genau den Reihenfolge der Argumente entsprechen.
self.cursor.execute("""
INSERT INTO user_stats (user_id, message_count, last_message_timestamp, full_name, username)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
message_count = excluded.message_count,
last_message_timestamp = excluded.last_message_timestamp,
full_name = (CASE WHEN user_stats.full_name IS NULL THEN excluded.full_name ELSE user_stats.full_name END),
username = (CASE WHEN user_stats.username IS NULL THEN excluded.username ELSE user_stats.username END)
""", (user_id, message_count, last_message_timestamp, full_name, username))
self.conn.commit()
except Exception as e:
logging.error(f"Fehler beim Aktualisieren der User Stats für {user_id}: {e}")
def get_user_profile(self, user_id: int) -> dict:
"""Lädt das gespeicherte Profil eines Benutzers aus der Datenbank."""
try:
self.cursor.execute("""
SELECT full_name, username FROM user_stats WHERE user_id = ?
""", (user_id,))
result = self.cursor.fetchone()
if result:
return {'full_name': result[0], 'username': result[1]}
return None
except Exception as e:
logging.error(f"Fehler beim Laden des Benutzerprofils {user_id}: {e}")
return None
def update_user_profile(self, user_id: int, full_name: str, username: str):
"""Aktualisiert nur die Profilinformationen (Name/Username) eines Benutzers.
Erstellt den Benutzer automatisch, falls er noch nicht existiert.
"""
try:
self.cursor.execute("""
INSERT INTO user_stats (user_id, full_name, username, message_count, last_message_timestamp)
VALUES (?, ?, ?, 0, NULL)
ON CONFLICT(user_id) DO UPDATE SET full_name = excluded.full_name, username = excluded.username
""", (user_id, full_name, username))
self.conn.commit()
except Exception as e:
logging.error(f"Fehler beim Aktualisieren des Benutzerprofils {user_id}: {e}")
def close(self):
"""Schließt die Datenbankverbindung."""
self.conn.close()
class MonitoringBot:
"""Der Hauptbot-Klasse, erweitert um Topic- und Profilprotokollierung."""
def __init__(self):
self.db = DatabaseManager()
# Start-Methode (Muss auf der Hauptebene des Klassenblocks sein)
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Verarbeitet den /start Befehl."""
await update.message.reply_text("Monitoring-Bot ist online und protokolliert Aktivitäten.")
# --- HILFSFUNKTIONEN ZUR TOPIC-ERKENNUNG ---
def _get_topic_id(self, update: Update) -> str:
"""Extrahiert die Topic ID aus dem Update (Topic Aware).
Versucht verschiedene Attribute je nach Telegram PTB-Version:
- message_thread_id: Für Nachrichten in Topics/Threads
- forum_topic_created: Wenn ein neues Topic erstellt wurde
- reply_to_topic_id: Fallback für explizite Topic-Antworten
"""
if not update.message:
return 'MAIN'
# Versuche message_thread_id (Standard für Topics)
if hasattr(update.message, 'message_thread_id') and update.message.message_thread_id:
return str(update.message.message_thread_id)
# Versuche reply_to_topic_id (Fallback)
if hasattr(update.message, 'reply_to_topic_id') and update.message.reply_to_topic_id:
return str(update.message.reply_to_topic_id)
# Fallback: Hauptthema (MAIN)
return 'MAIN'
# --- EVENT HANDLER 1: BEITRITT/AUSSTIEG ---
async def handle_member_update(self, client: Update, context: ContextTypes.DEFAULT_TYPE):
"""Verarbeitet JOIN- und LEAVE-Ereignisse."""
if not client or not context.message: return
new_member = client.effective_user
chat_id = client.effective_chat.id
topic_id = self._get_topic_id(client.update)
self._log_event(client, context, chat_id, topic_id, "JOIN", new_member.id, new_member.full_name, f"Beitritt von {new_member.full_name}")
# --- EVENT HANDLER 2: PROFILDÄNDERUNG (Profilvergleich mit DB) ---
async def handle_profile_update(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Erkennt Profiländerungen durch Vergleich des gespeicherten Profils mit dem aktuellen."""
#if not update or not update.effective_user or not update.effective_chat:
# return
user = update.effective_user
chat_id = update.effective_chat.id
topic_id = self._get_topic_id(update)
current_full_name = user.full_name
current_username = getattr(user, 'username', None)
# Lade das alte Profil aus der Datenbank
old_profile = self.db.get_user_profile(user.id)
# Vergleiche mit dem aktuellen Profil
if old_profile:
old_full_name = old_profile['full_name']
old_username = old_profile['username']
# Prüfe auf Änderungen
has_name_changed = old_full_name != current_full_name
has_username_changed = old_username != current_username
if has_name_changed or has_username_changed:
details = f"Profiländerung erkannt"
if has_name_changed:
details += f" | Name: '{old_full_name}''{current_full_name}'"
if has_username_changed:
details += f" | Username: '{old_username}''{current_username}'"
self._log_event(
client=update, context=context, chat_id=chat_id, topic_id=topic_id,
activity_type="PROFILE_CHANGE", user_id=user.id,
username=current_username or user.id, details=details
)
# Speichere die Änderung im History Log
self.db.log_profile_change(
group_chat_id=chat_id,
user_id=user.id,
topic_id=topic_id,
old_name=old_full_name or "N/A",
new_name=current_full_name,
old_user=old_username or "N/A",
new_user=current_username or "N/A",
details=details
)
# Persistiere die neuen Profilinformationen in user_stats
self.db.update_user_profile(
user_id=user.id,
full_name=current_full_name,
username=current_username
)
# --- EVENT HANDLER 3: MESSAGE (Statistik & Topic Aware & Profiländerungserkennung) ---
async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Verarbeitet jede Nachricht und aktualisiert die Statistiken des ABSENDERS.
Erkennt auch automatisch Profiländerungen durch Vergleich mit der Datenbank.
"""
if not update or not update.effective_chat or not update.effective_user:
return
# 0. Erkenne Profiländerungen BEVOR die Stats aktualisiert werden
await self.handle_profile_update(update, context)
# 1. TOPIC ID EXTRAHIEREN
topic_id = self._get_topic_id(update)
chat_id = update.effective_chat.id
message_text = update.message.text if update.message else None
user = update.effective_user
timestamp_str = datetime.now().isoformat()
# 2. Statistik-Update: User ID, Count erhöhen und aktuelle Profildaten speichern
self._update_user_stats(
user_id=user.id,
message_count=+1,
last_message_timestamp=timestamp_str,
full_name=user.full_name, # Aktueller Name vom Telegram Update
username=getattr(user, 'username', None) # Optional: Username
)
# General Logging (Protokollierung des Ereignisses)
if message_text and not message_text.startswith('/'):
self._log_event(
client=update, context=context, chat_id=chat_id, topic_id=topic_id,
activity_type="MESSAGE", user_id=user.id, username=user.username, details=f"Nachricht gesendet: '{message_text[:50]}...'"
)
# --- INTERNE HILFSFUNKTIONEN (General Logging bleibt stabil) ---
def _log_event(self, client, context, chat_id, topic_id, activity_type, user_id, username, details):
"""Interne Funktion zur Durchführung des Datenbank- und File-Logging."""
self.db.log_activity(group_chat_id=chat_id, topic_id=topic_id, activity_type=activity_type, user_id=user_id, username=username, details=details)
# Debug Output (Bleibt stabil)
if ENABLE_DEBUG_MODE:
logging.info(f"[LOG] Aktivität erfasst ({activity_type}): User {user_id} | Topic '{topic_id}'")
if WRITE_ACTIVITY_TO_FILE:
print(f"--- DEBUG LOG ENTRY --- Type: {activity_type}, Topic: {topic_id}, User: {username or user_id}")
# Optional File Logging (Bleibt stabil)
if WRITE_ACTIVITY_TO_FILE:
log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] | CHAT_ID={chat_id} | TOPIC={topic_id} | TYPE={activity_type} | USER={user_id} ({username}) | DETAILS={details}\n"
with open(LOGGING_FILE, 'a', encoding='utf-8') as f:
f.write(log_message)
# Optional File Logging
if WRITE_ACTIVITY_TO_FILE:
log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] | CHAT_ID={chat_id} | TOPIC={topic_id} | TYPE={activity_type} | USER={user_id} ({username}) | DETAILS={details}\n"
with open(LOGGING_FILE, 'a', encoding='utf-8') as f:
f.write(log_message)
def _update_user_stats(self, user_id: int, message_count: int, last_message_timestamp: str, full_name: str = None, username: str = None):
"""Aktualisiert die Statistiken und das Profil des Benutzers via DatabaseManager."""
# Ruft die korrigierte Methode aus dem Manager auf.
self.db.update_user_stats(user_id, message_count, last_message_timestamp, full_name=full_name, username=username)
# --- HAUPT-RUN FUNKTION (Bleibt gleich) ---
def run(self):
print("\n=============================================")
print(" 🚀 Starting Monitoring Bot...")
print("=============================================\n")
application = Application.builder().token(BOT_TOKEN).build()
# Handlers registrieren
application.add_handler(CommandHandler("start", self.start_command))
application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self.handle_message))
print("🤖 Bot wird gestartet. Warte auf Updates...")
try:
application.run_polling()
except KeyboardInterrupt:
pass
finally:
self.db.close()
logging.info("Bot wurde manuell gestoppt und Datenbankverbindung geschlossen.")
if __name__ == '__main__':
monitor = MonitoringBot()
monitor.run()