449 lines
20 KiB
Python
449 lines
20 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)
|
|
|
|
# Forum Topics: Ausnahlmeliste (nicht zu reopening)
|
|
NOT_REOPEN_TOPICS = config.get('forum_topics', {}).get('not_reopen_topics', [])
|
|
|
|
# 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_profile (Refaktoriert: first_name, last_name, is_bot, kein message_count)
|
|
self.cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS user_profile (
|
|
user_id INTEGER PRIMARY KEY,
|
|
first_name TEXT,
|
|
last_name TEXT,
|
|
username TEXT,
|
|
is_bot INTEGER DEFAULT 0,
|
|
lastseen_timestamp TEXT NULL
|
|
)
|
|
""")
|
|
|
|
# 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 get_user_profile(self, user_id: int) -> dict:
|
|
"""Lädt das gespeicherte Profil eines Benutzers aus der Datenbank."""
|
|
try:
|
|
self.cursor.execute("""
|
|
SELECT first_name, last_name, username, is_bot FROM user_profile WHERE user_id = ?
|
|
""", (user_id,))
|
|
result = self.cursor.fetchone()
|
|
if result:
|
|
return {
|
|
'first_name': result[0],
|
|
'last_name': result[1],
|
|
'username': result[2],
|
|
'is_bot': result[3]
|
|
}
|
|
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, first_name: str, last_name: str, username: str, is_bot: bool = False):
|
|
"""Aktualisiert nur die Profilinformationen eines Benutzers (ohne Nachrichtenstatistiken).
|
|
|
|
Erstellt den Benutzer automatisch, falls er noch nicht existiert.
|
|
"""
|
|
try:
|
|
self.cursor.execute("""
|
|
INSERT INTO user_profile (user_id, first_name, last_name, username, is_bot, lastseen_timestamp)
|
|
VALUES (?, ?, ?, ?, ?, NULL)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
first_name = excluded.first_name,
|
|
last_name = excluded.last_name,
|
|
username = excluded.username,
|
|
is_bot = excluded.is_bot
|
|
""", (user_id, first_name, last_name, username, int(is_bot)))
|
|
self.conn.commit()
|
|
except Exception as e:
|
|
logging.error(f"Fehler beim Aktualisieren des Benutzerprofils {user_id}: {e}")
|
|
|
|
def update_user_stats(self, user_id: int, lastseen_timestamp: str, first_name: str = None, last_name: str = None, username: str = None, is_bot: bool = False):
|
|
"""Aktualisiert die Statistiken und das letzte bekannte Profil des Benutzers."""
|
|
try:
|
|
self.cursor.execute("""
|
|
INSERT INTO user_profile (user_id, first_name, last_name, username, is_bot, lastseen_timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
lastseen_timestamp = excluded.lastseen_timestamp,
|
|
first_name = COALESCE(excluded.first_name, user_profile.first_name),
|
|
last_name = COALESCE(excluded.last_name, user_profile.last_name),
|
|
username = COALESCE(excluded.username, user_profile.username),
|
|
is_bot = COALESCE(NULLIF(excluded.is_bot, 0), user_profile.is_bot)
|
|
""", (user_id, first_name, last_name, username, int(is_bot), lastseen_timestamp))
|
|
self.conn.commit()
|
|
except Exception as e:
|
|
logging.error(f"Fehler beim Aktualisieren der User Stats für {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 1B: FORUM TOPIC GESCHLOSSEN ---
|
|
async def handle_forum_topic_closed(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
"""Überwacht das Schließen von Forum-Topics und öffnet sie wieder (außer in der Ausnahlmeliste)."""
|
|
if not update or not update.message or not update.effective_chat:
|
|
return
|
|
|
|
chat_id = update.effective_chat.id
|
|
|
|
# Prüfe, ob ein Topic-Close-Event vorliegt
|
|
if hasattr(update.message, 'forum_topic_closed') and update.message.forum_topic_closed:
|
|
topic_id = self._get_topic_id(update)
|
|
|
|
details = f"Forum-Topic wurde geschlossen (Topic ID: {topic_id})"
|
|
self._log_event(
|
|
client=update, context=context, chat_id=chat_id, topic_id=topic_id,
|
|
activity_type="TOPIC_CLOSED", user_id=0, username="system",
|
|
details=details
|
|
)
|
|
|
|
# Prüfe, ob dieses Topic in der Ausnahlmeliste ist
|
|
should_skip = any(
|
|
item['group_id'] == chat_id and str(item['topic_id']) == topic_id
|
|
for item in NOT_REOPEN_TOPICS
|
|
)
|
|
|
|
if should_skip:
|
|
skip_details = f"Topic-Reopening übersprungen (in Ausnahmeliste)"
|
|
self._log_event(
|
|
client=update, context=context, chat_id=chat_id, topic_id=topic_id,
|
|
activity_type="TOPIC_REOPEN_SKIPPED", user_id=0, username="system",
|
|
details=skip_details
|
|
)
|
|
logging.info(f"⏭️ Topic {topic_id} in Chat {chat_id} wird nicht wiedereröffnet (Ausnahlmeliste)")
|
|
return
|
|
|
|
# Versuche das Topic wieder zu öffnen
|
|
try:
|
|
await context.bot.reopen_forum_topic(chat_id=chat_id, message_thread_id=int(topic_id))
|
|
|
|
reopen_details = f"Forum-Topic automatisch wiedereröffnet (Topic ID: {topic_id})"
|
|
self._log_event(
|
|
client=update, context=context, chat_id=chat_id, topic_id=topic_id,
|
|
activity_type="TOPIC_REOPENED", user_id=0, username="system",
|
|
details=reopen_details
|
|
)
|
|
logging.info(f"✅ Topic {topic_id} in Chat {chat_id} wurde wiedereröffnet")
|
|
|
|
except Exception as e:
|
|
error_details = f"Fehler beim Wiedereröffnen von Topic {topic_id}: {str(e)}"
|
|
self._log_event(
|
|
client=update, context=context, chat_id=chat_id, topic_id=topic_id,
|
|
activity_type="TOPIC_REOPEN_FAILED", user_id=0, username="system",
|
|
details=error_details
|
|
)
|
|
logging.error(f"❌ {error_details}")
|
|
|
|
|
|
|
|
# --- 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)
|
|
|
|
# Extrahiere current profile
|
|
current_first_name = user.first_name or ""
|
|
current_last_name = user.last_name or ""
|
|
current_username = getattr(user, 'username', None)
|
|
current_is_bot = user.is_bot
|
|
|
|
# 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_first_name = old_profile['first_name'] or ""
|
|
old_last_name = old_profile['last_name'] or ""
|
|
old_username = old_profile['username']
|
|
old_is_bot = old_profile['is_bot']
|
|
|
|
# Prüfe auf Änderungen
|
|
has_name_changed = (old_first_name != current_first_name) or (old_last_name != current_last_name)
|
|
has_username_changed = old_username != current_username
|
|
has_is_bot_changed = old_is_bot != current_is_bot
|
|
|
|
if has_name_changed or has_username_changed or has_is_bot_changed:
|
|
details = f"Profiländerung erkannt"
|
|
if has_name_changed:
|
|
old_full = f"{old_first_name} {old_last_name}".strip()
|
|
new_full = f"{current_first_name} {current_last_name}".strip()
|
|
details += f" | Name: '{old_full}' → '{new_full}'"
|
|
if has_username_changed:
|
|
details += f" | Username: '{old_username}' → '{current_username}'"
|
|
if has_is_bot_changed:
|
|
details += f" | is_bot: {old_is_bot} → {current_is_bot}"
|
|
|
|
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=f"{old_first_name} {old_last_name}".strip() or "N/A",
|
|
new_name=f"{current_first_name} {current_last_name}".strip(),
|
|
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,
|
|
first_name=current_first_name,
|
|
last_name=current_last_name,
|
|
username=current_username,
|
|
is_bot=current_is_bot
|
|
)
|
|
|
|
|
|
# --- 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
|
|
|
|
# -1. Überwache Forum-Topic-Closes
|
|
await self.handle_forum_topic_closed(update, context)
|
|
|
|
# 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: Profildaten speichern und Timestamp aktualisieren
|
|
self._update_user_stats(
|
|
user_id=user.id,
|
|
lastseen_timestamp=timestamp_str,
|
|
first_name=user.first_name,
|
|
last_name=user.last_name or None,
|
|
username=getattr(user, 'username', None),
|
|
is_bot=user.is_bot
|
|
)
|
|
|
|
# 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
|
|
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
|
|
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, lastseen_timestamp: str, first_name: str = None, last_name: str = None, username: str = None, is_bot: bool = False):
|
|
"""Wrapper für DatabaseManager.update_user_stats()."""
|
|
self.db.update_user_stats(user_id, lastseen_timestamp, first_name, last_name, username, is_bot)
|
|
|
|
|
|
# --- 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() |