import os import re import json import sqlite3 import subprocess import imaplib import smtplib import email import threading import time import logging import io import qrcode from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import decode_header from flask import Flask, render_template, request, redirect, url_for, session, flash, Response, send_from_directory, jsonify from datetime import datetime, timedelta from functools import wraps from dotenv import load_dotenv from telegram import Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes load_dotenv() # ── Agent Konfiguration ─────────────────────────────────────────────────────── AGENT_CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'agent_config.json') AGENTS_BASE_DIR = os.path.join(os.path.dirname(__file__), 'agents') # Cache für verfügbare Modelle _available_models_cache = None _models_cache_time = None MODELS_CACHE_TTL = 3600 # Cache für 1 Stunde # ── Agent Memory System ──────────────────────────────────────────────────────── def ensure_agent_structure(agent_key): """Stellt sicher, dass die Ordnerstruktur für einen Agenten existiert.""" agent_dir = os.path.join(AGENTS_BASE_DIR, agent_key) work_dir = os.path.join(agent_dir, 'work') memory_dir = os.path.join(agent_dir, 'memory') os.makedirs(agent_dir, exist_ok=True) os.makedirs(work_dir, exist_ok=True) os.makedirs(memory_dir, exist_ok=True) return { 'agent_dir': agent_dir, 'work_dir': work_dir, 'memory_dir': memory_dir } def get_agent_memory(agent_key, memory_type='tasks'): """Lädt Erinnerungen eines Agenten aus JSON-Datei. memory_type kann sein: - 'tasks': Erledigte Tasks - 'notes': Notizen - 'conversations': Konversationen - 'research': Research-Ergebnisse """ dirs = ensure_agent_structure(agent_key) memory_file = os.path.join(dirs['memory_dir'], f'{memory_type}.json') if os.path.exists(memory_file): try: with open(memory_file, 'r', encoding='utf-8') as f: return json.load(f) except (json.JSONDecodeError, IOError, OSError) as e: logging.warning(f"Fehler beim Laden von {memory_file}: {e}") pass return [] def add_agent_memory(agent_key, memory_type, entry): """Fügt eine Erinnerung hinzu. entry sollte ein dict sein mit mindestens: - timestamp: ISO-Format - title: Kurze Beschreibung - content: Detaillierter Inhalt - metadata: Zusätzliche Infos (optional) """ dirs = ensure_agent_structure(agent_key) memory_file = os.path.join(dirs['memory_dir'], f'{memory_type}.json') memories = get_agent_memory(agent_key, memory_type) # Timestamp hinzufügen wenn nicht vorhanden if 'timestamp' not in entry: entry['timestamp'] = datetime.now().isoformat() # ID hinzufügen entry['id'] = len(memories) + 1 memories.append(entry) # Nur die letzten 100 Einträge behalten memories = memories[-100:] with open(memory_file, 'w', encoding='utf-8') as f: json.dump(memories, f, indent=2, ensure_ascii=False) return entry def get_agent_work_files(agent_key): """Gibt alle Dateien im work-Ordner eines Agenten zurück.""" dirs = ensure_agent_structure(agent_key) work_dir = dirs['work_dir'] files = [] if os.path.exists(work_dir): for filename in os.listdir(work_dir): filepath = os.path.join(work_dir, filename) if os.path.isfile(filepath): stat = os.stat(filepath) files.append({ 'name': filename, 'size': stat.st_size, 'modified': datetime.fromtimestamp(stat.st_mtime).isoformat(), 'path': filepath }) return sorted(files, key=lambda x: x['modified'], reverse=True) def get_agent_memory_summary(agent_key): """Generiert eine Zusammenfassung aller Erinnerungen für den Systemprompt.""" tasks = get_agent_memory(agent_key, 'tasks') notes = get_agent_memory(agent_key, 'notes') summary = [] if tasks: summary.append("## Letzte Tasks (letzte 5)") for task in tasks[-5:]: summary.append(f"- [{task.get('timestamp', 'N/A')}] {task.get('title', 'Unbekannt')}") if task.get('result'): summary.append(f" Ergebnis: {task.get('result', '')[:200]}") if notes: summary.append("\n## Wichtige Notizen") for note in notes[-5:]: summary.append(f"- {note.get('content', '')[:100]}") return '\n'.join(summary) if summary else "Keine Erinnerungen vorhanden." def get_available_models(force_refresh=False): """Lädt die verfügbaren KI-Modelle dynamisch von opencode.""" global _available_models_cache, _models_cache_time # Cache prüfen if not force_refresh and _available_models_cache is not None and _models_cache_time is not None: if (time.time() - _models_cache_time) < MODELS_CACHE_TTL: return _available_models_cache try: # opencode models ausführen result = subprocess.run( ['opencode', 'models'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: models = [] for line in result.stdout.strip().split('\n'): line = line.strip() if line: models.append(line) # Nach Anbieter gruppieren grouped = {} for model in models: if '/' in model: provider, name = model.split('/', 1) if provider not in grouped: grouped[provider] = [] grouped[provider].append(model) _available_models_cache = { 'models': models, 'grouped': grouped, 'count': len(models) } _models_cache_time = time.time() return _available_models_cache except Exception as e: logging.warning(f"[ModelLoader] Fehler beim Laden der Modelle: {e}") # Fallback auf hardcodierte Modelle wenn Laden fehlschlägt fallback = { 'models': [ 'opencode/big-pickle', 'opencode/gpt-5-nano', 'opencode/glm-5-free', 'opencode/minimax-m2.5-free', 'opencode/trinity-large-preview-free' ], 'grouped': { 'opencode': [ 'opencode/big-pickle', 'opencode/gpt-5-nano', 'opencode/glm-5-free', 'opencode/minimax-m2.5-free', 'opencode/trinity-large-preview-free' ] }, 'count': 5 } _available_models_cache = fallback _models_cache_time = time.time() return fallback def get_agent_config(): """Lädt die Agentenkonfiguration aus der JSON-Datei.""" if os.path.exists(AGENT_CONFIG_FILE): try: with open(AGENT_CONFIG_FILE, 'r', encoding='utf-8') as f: return json.load(f) except (json.JSONDecodeError, IOError, OSError) as e: logging.warning(f"Fehler beim Laden von {AGENT_CONFIG_FILE}: {e}") pass return {} def get_agent_model(agent_key): """Gibt das konfigurierte Modell für einen Agenten zurück.""" config = get_agent_config() return config.get(agent_key, {}).get('model', 'opencode/big-pickle') def save_agent_config(agent_key, model): """Speichert die Konfiguration für einen Agenten.""" config = get_agent_config() if agent_key not in config: config[agent_key] = {} config[agent_key]['model'] = model with open(AGENT_CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump(config, f, indent=2) # ── Email-Journal (SQLite) ────────────────────────────────────────────────── # Speichert jede gesehene Email mit Message-ID und Verarbeitungsstatus. # Verhindert, dass Emails verloren gehen wenn die App abstürzt. EMAIL_JOURNAL_DB = os.path.join(os.path.dirname(__file__), 'email_journal.db') def init_journal(): con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute(""" CREATE TABLE IF NOT EXISTS email_journal ( message_id TEXT PRIMARY KEY, imap_uid TEXT, sender TEXT, subject TEXT, received_at TEXT, status TEXT DEFAULT 'pending', agent_key TEXT, updated_at TEXT ) """) # Tasks-Tabelle für persistente Task-Speicherung con.execute(""" CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, assigned_agent TEXT, agent_key TEXT, status TEXT DEFAULT 'pending', created_at TEXT NOT NULL, completed_at TEXT, type TEXT, created_by TEXT, response TEXT, telegram_chat_id INTEGER, telegram_user TEXT, parent_task_id INTEGER ) """) # Team-Members Tabelle für reale Mitarbeiter con.execute(""" CREATE TABLE IF NOT EXISTS team_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, role TEXT NOT NULL, responsibilities TEXT, email TEXT, telegram_id INTEGER, phone TEXT, active INTEGER DEFAULT 1, created_at TEXT NOT NULL ) """) # App-Settings Tabelle für globale Einstellungen con.execute(""" CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT, updated_at TEXT ) """) # Default-Werte setzen falls nicht vorhanden con.execute(""" INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES ('app_name', 'Frankenbot', ?), ('theme', 'dark', ?) """, (datetime.now().isoformat(), datetime.now().isoformat())) con.commit() con.close() def get_app_setting(key: str, default=None): """Holt eine App-Einstellung aus der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) row = con.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() con.close() return row[0] if row else default def set_app_setting(key: str, value: str): """Setzt eine App-Einstellung in der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute(""" INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) """, (key, value, datetime.now().isoformat())) con.commit() con.close() def journal_seen(message_id: str) -> bool: """True wenn diese Message-ID bereits im Journal ist (egal welcher Status).""" con = sqlite3.connect(EMAIL_JOURNAL_DB) row = con.execute("SELECT status FROM email_journal WHERE message_id=?", (message_id,)).fetchone() con.close() return row is not None def journal_is_done(message_id: str) -> bool: """True wenn Status = 'completed', 'skipped' oder 'error'.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) row = con.execute("SELECT status FROM email_journal WHERE message_id=?", (message_id,)).fetchone() con.close() return row is not None and row[0] in ('completed', 'skipped', 'error') def journal_is_stale(message_id: str) -> bool: """True wenn Journal-Eintrag als 'queued' gilt UND älter als failsafe_window ist.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) row = con.execute("SELECT status, updated_at FROM email_journal WHERE message_id=?", (message_id,)).fetchone() con.close() if row is None or row[0] in ('completed', 'skipped', 'error'): return False try: updated = datetime.fromisoformat(row[1]) age = (datetime.now() - updated).total_seconds() return age > poller_settings['failsafe_window'] except Exception: return False def journal_insert(message_id: str, imap_uid: str, sender: str, subject: str, status: str, agent_key: str = ''): now = datetime.now().isoformat() con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute(""" INSERT OR IGNORE INTO email_journal (message_id, imap_uid, sender, subject, received_at, status, agent_key, updated_at) VALUES (?,?,?,?,?,?,?,?) """, (message_id, imap_uid, sender, subject, now, status, agent_key, now)) con.commit() con.close() def journal_update(message_id: str, status: str): now = datetime.now().isoformat() con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute("UPDATE email_journal SET status=?, updated_at=? WHERE message_id=?", (status, now, message_id)) con.commit() con.close() init_journal() # ──────────────────────────────────────────────────────────────────────────── app = Flask(__name__) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @app.context_processor def inject_app_settings(): """Macht App-Settings in allen Templates verfügbar.""" return { 'app_name': get_app_setting('app_name', 'Frankenbot'), 'theme': get_app_setting('theme', 'dark') } AGENT_KEYWORDS = { 'researcher': ['recherche', 'recherchieren', 'suchen', 'informationen', 'trends', 'forschung', 'web'], 'zusammenfasser': ['zusammenfassung', 'zusammenfassen', 'konsolidieren', 'übersicht'], 'tax_advisor': ['steuer', 'steuerrecht', 'umsatzsteuer', 'gemeinnützig', 'abgabe', 'finanzamt', '§4a', ' §4a', '§ 4a', 'mehrwertsteuer', 'mwst', 'ust', 'spenden'], 'location_manager': ['rathaus', 'location', 'ort', 'veranstaltungsort', 'raum', 'festsaal', 'wien'], 'program_manager': ['programm', 'ablauf', 'programmablauf', 'tanz', 'unterhaltung', 'awards', 'tombola', 'reden'], 'catering_manager': ['catering', 'essen', 'speisen', 'getränke', 'menü', 'küche', 'buffet', 'service', 'gastronomie'], 'musik_rechte_advisor': ['musik', 'akm', 'gema', 'lizenz', 'rechte', 'urheber', 'copyright', 'verwertungsgesellschaft'], 'document_editor': ['dokument', 'vertrag', 'brief', 'text', 'bearbeiten', 'erstellen', 'schreiben'], } app.secret_key = os.getenv('SECRET_KEY', 'agent-orchestration-secret-key-2026') app.config['UPLOAD_FOLDER'] = 'uploads' app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 app.permanent_session_lifetime = timedelta(days=7) # ── App Password ───────────────────────────────────────────────────────────── APP_PASSWORD = os.getenv('APP_PASSWORD', '') DEPLOY_SECRET = os.getenv('DEPLOY_SECRET', '') def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not session.get('authenticated'): return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function # Email Configuration - loaded AFTER load_dotenv() EMAIL_CONFIG = { 'imap_server': os.getenv('IMAP_SERVER', 'sslin.de'), 'smtp_server': os.getenv('SMTP_SERVER', 'sslout.de'), 'email_address': os.getenv('EMAIL_ADDRESS', ''), 'email_password': os.getenv('EMAIL_PASSWORD', '').strip('"').strip("'"), 'imap_port': int(os.getenv('IMAP_PORT', '993')), 'smtp_port': int(os.getenv('SMTP_PORT', '465')) } # Whitelist: Only these senders get processed by the poller EMAIL_WHITELIST = [ 'eric.fischer@signtime.media', 'p.dyderski@live.at', 'georg.tschare@gmail.com', 'georg.tschare@signtime.media', ] EMAIL_WHITELIST_DOMAINS = ['diversityball.at'] # ── Telegram Bot Configuration ──────────────────────────────────────────────── TELEGRAM_CONFIG = { 'bot_token': os.getenv('TELEGRAM_BOT_TOKEN', ''), 'bot_username': os.getenv('TELEGRAM_BOT_USERNAME', 'frankenbot'), 'allowed_users': [ int(uid.strip()) for uid in os.getenv('TELEGRAM_ALLOWED_USERS', '').split(',') if uid.strip().isdigit() ] } # Telegram Bot Application (globale Instanz) telegram_app = None telegram_thread = None # ── Poller-Einstellungen (zur Laufzeit änderbar via /settings) ─────────────── # POLLER_INTERVAL: Wie oft der IMAP-Poller läuft (Sekunden) # FAILSAFE_WINDOW: Wie lange ein Task laufen darf bevor Failsafe anschlägt (Sekunden) poller_settings = { 'poll_interval': 120, # 2 Minuten 'failsafe_window': 600, # 10 Minuten (großzügig – Agenten brauchen oft 3-5 Min) } # Email processing log (max 50 entries) email_log = [] email_log_lock = threading.Lock() # Task queue für Email-Tasks task_queue = [] task_queue_lock = threading.Lock() def get_agent_prompt(agent_key): """Liest den System-Prompt und Persönlichkeit eines Agenten aus den Dateien.""" agent_dir = os.path.join(os.path.dirname(__file__), 'agents', agent_key) prompt_file = os.path.join(agent_dir, 'systemprompt.md') personality_file = os.path.join(agent_dir, 'personality.md') prompt_content = "" personality_content = "" if os.path.exists(prompt_file): with open(prompt_file, 'r', encoding='utf-8') as f: prompt_content = f.read() if os.path.exists(personality_file): with open(personality_file, 'r', encoding='utf-8') as f: personality_content = f.read().strip() # Persönlichkeit vor dem System-Prompt einfügen, falls vorhanden if personality_content: return f"{personality_content}\n\n---\n\n{prompt_content}" return prompt_content def execute_agent_task(agent_key, user_prompt, extra_context=""): """ Führt einen echten Agenten-Task via opencode aus. System-Prompt wird als --prompt übergeben, User-Prompt als Message. """ system_prompt = get_agent_prompt(agent_key) if not system_prompt: return f"⚠️ Kein System-Prompt für Agent '{agent_key}' gefunden." # Agent-Struktur sicherstellen dirs = ensure_agent_structure(agent_key) work_dir = dirs['work_dir'] # Memory-Zusammenfassung laden memory_summary = get_agent_memory_summary(agent_key) # Wissensdatenbank-Pfad (im Orchestrator-Ordner) kb_file = os.path.join(os.path.dirname(__file__), 'agents', 'orchestrator', 'knowledge', 'diversityball_knowledge.md') # Team-Members laden (nur für Orchestrator) team_summary = "" if agent_key == 'orchestrator': team_summary = "\n\n" + get_team_member_summary() # System-Prompt = Agent-Rolle + Memory + Team + Kommandos (OHNE große Wissensdatenbank!) full_system = f"""{system_prompt} ## Deine Erinnerungen: {memory_summary}{team_summary} ## Wissensdatenbank: Die Wissensdatenbank liegt unter: {kb_file} Wenn du spezifische Informationen brauchst: @READ_KNOWLEDGE Topic: [Thema, z.B. "Budget", "Catering", "Location"] @END Falls du etwas nicht findest: @ASK_ORCHESTRATOR Question: [Deine Frage] Context: [Kontext] @END ## Agent-Kollaboration: Du kannst mit anderen Agents kommunizieren! Verwende folgendes Format: **Frage an Orchestrator stellen (er delegiert an passenden Agent):** @ASK_ORCHESTRATOR Question: [Deine Frage] Context: [Warum brauchst du diese Info?] @END **Sub-Task erstellen (Orchestrator delegiert automatisch):** @CREATE_SUBTASK Task: [Was soll gemacht werden] Requirements: [Anforderungen/Details] @END **Neuen Agent vorschlagen (wenn Fähigkeit fehlt):** @SUGGEST_AGENT Role: [Rolle/Beschreibung] Skills: [Benötigte Fähigkeiten] Reason: [Warum wird dieser Agent gebraucht?] @END **Team-Member per Email kontaktieren:** @SEND_EMAIL To: [Email-Adresse des Team-Members] Subject: [Betreff] Body: [Nachricht] @END **Team-Member per Telegram kontaktieren:** @SEND_TELEGRAM TelegramID: [Telegram-ID des Team-Members] Message: [Nachricht] @END **Team-Member Informationen aktualisieren:** @UPDATE_TEAM_MEMBER Identifier: [Email oder Name des Team-Members] Role: [Neue Rolle] (optional) Responsibilities: [Neue Verantwortlichkeiten] (optional) TelegramID: [Telegram-ID] (optional) Phone: [Telefon] (optional) @END **Neuen Team-Member hinzufügen:** @ADD_TEAM_MEMBER Name: [Vollständiger Name] Role: [Rolle/Position] Responsibilities: [Verantwortlichkeiten] Email: [Email-Adresse] @END Der Orchestrator kümmert sich um die Zuweisung und Kommunikation! ## Wichtig: - Du hast Zugriff auf das Internet via WebFetch-Tool - nutze es aktiv! - Du kannst Emails versenden - nutze send_email wenn beauftragt - Dein Arbeitsverzeichnis: {work_dir} - Speichere ALLE erstellten Dateien in diesem Verzeichnis! - Verwende absolute Pfade für Dateien: {work_dir}/dateiname.ext - Liefere immer eine vollständige, direkt verwertbare Antwort {extra_context}""" # System-Prompt + User-Prompt zusammen als eine Message # (--prompt flag gibt leere Antwort, daher alles in eine Message) combined_message = f"{full_system}\n\n---\n\n{user_prompt}" # Modell aus Konfiguration holen model = get_agent_model(agent_key) try: result = subprocess.run( ['opencode', 'run', '--model', model, '--format', 'json', combined_message], capture_output=True, text=True, timeout=600, # 10 Minuten für komplexe Tasks cwd=work_dir # Agent arbeitet in seinem work-Verzeichnis ) if result.returncode == 0: lines = result.stdout.strip().split('\n') response_text = "" for line in lines: try: data = json.loads(line) if data.get('part', {}).get('type') == 'text': response_text += data.get('part', {}).get('text', '') except (json.JSONDecodeError, KeyError): # Ignore invalid JSON lines in streaming output pass # Agent-Kommandos parsen (Task-Delegation, Fragen, Agent-Erstellung) if response_text: parse_agent_commands(agent_key, response_text) return response_text if response_text else "⚠️ Keine Antwort erhalten." else: return f"⚠️ Fehler: {result.stderr}" except FileNotFoundError: return "⚠️ OpenCode CLI nicht gefunden. Bitte installiere opencode." except subprocess.TimeoutExpired: return "⚠️ Timeout - Agentenantwort dauert zu lange." except Exception as e: return f"⚠️ Fehler: {str(e)}" os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) def load_agents_from_directories(): """Lädt Agenten dynamisch aus dem agents/ Verzeichnis.""" agents = {} agents_dir = os.path.join(os.path.dirname(__file__), 'agents') if not os.path.exists(agents_dir): return {} for agent_name in os.listdir(agents_dir): agent_path = os.path.join(agents_dir, agent_name) if os.path.isdir(agent_path): prompt_file = os.path.join(agent_path, 'systemprompt.md') description = f"Agent: {agent_name}" if os.path.exists(prompt_file): with open(prompt_file, 'r', encoding='utf-8') as f: content = f.read() first_line = content.split('\n')[0] if content else "" description = first_line.replace('#', '').strip() or f"Agent: {agent_name}" agents[agent_name] = { 'name': agent_name.replace('_', ' ').title(), 'description': description, 'status': 'active' } return agents AGENTS = load_agents_from_directories() # Legacy - tasks werden jetzt in DB gespeichert, aber wir brauchen Kompatibilität tasks = [] # Wird bei get_tasks() aus DB geladen chat_history = [] orchestrator_chat = [] # ── Task Database Functions ───────────────────────────────────────────────── def get_tasks(status=None, limit=None): """Lädt Tasks aus der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) con.row_factory = sqlite3.Row if status: query = "SELECT * FROM tasks WHERE status = ? ORDER BY id DESC" params = (status,) else: query = "SELECT * FROM tasks ORDER BY id DESC" params = () if limit: query += f" LIMIT {limit}" rows = con.execute(query, params).fetchall() con.close() return [dict(row) for row in rows] def add_orchestrator_notification(message_type, message, agent_key=None): """Fügt eine System-Benachrichtigung zum Orchestrator-Chat hinzu.""" try: if 'orchestrator_chat' not in session: session['orchestrator_chat'] = [] session['orchestrator_chat'].insert(0, { # Insert at top (newest first) 'timestamp': datetime.now().strftime('%d.%m.%Y %H:%M:%S'), 'type': message_type, 'message': message, 'agent': AGENTS.get(agent_key, {}).get('name', agent_key) if agent_key else 'System', 'agent_key': agent_key, 'is_notification': True }) session['orchestrator_chat'] = session['orchestrator_chat'][:50] # Keep last 50 session.modified = True except: pass # Session might not be available in background threads def create_task(title, description, agent_key='orchestrator', task_type='manual', created_by='user', **kwargs): """Erstellt einen neuen Task in der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) assigned_agent = AGENTS.get(agent_key, {}).get('name', agent_key) if agent_key else 'Nicht zugewiesen' created_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') cursor = con.execute(""" INSERT INTO tasks ( title, description, assigned_agent, agent_key, status, created_at, type, created_by, telegram_chat_id, telegram_user, parent_task_id ) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?) """, ( title, description, assigned_agent, agent_key, created_at, task_type, created_by, kwargs.get('telegram_chat_id'), kwargs.get('telegram_user'), kwargs.get('parent_task_id') )) task_id = cursor.lastrowid con.commit() con.close() logging.info(f"[DB] Task #{task_id} erstellt: {title[:50]}") # Orchestrator-Notification hinzufügen if created_by != 'user': # Nur bei Agent-erstellten Tasks add_orchestrator_notification( 'task_created', f'📋 Task #{task_id} erstellt: {title}', agent_key=created_by ) return task_id def update_task_db(task_id, **kwargs): """Aktualisiert einen Task in der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) updates = [] params = [] for key, value in kwargs.items(): if key in ['status', 'response', 'agent_key', 'assigned_agent', 'completed_at']: updates.append(f"{key} = ?") params.append(value) if 'status' in kwargs and kwargs['status'] == 'completed' and 'completed_at' not in kwargs: updates.append("completed_at = ?") params.append(datetime.now().strftime('%Y-%m-%d %H:%M:%S')) if not updates: con.close() return params.append(task_id) query = f"UPDATE tasks SET {', '.join(updates)} WHERE id = ?" con.execute(query, params) con.commit() con.close() def delete_task(task_id): """Löscht einen Task aus der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) con.commit() con.close() logging.info(f"[DB] Task #{task_id} gelöscht") def cleanup_old_tasks(days=7): """Löscht completed Tasks älter als X Tage.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) cutoff = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) cutoff = cutoff.replace(day=cutoff.day - days) cutoff_str = cutoff.strftime('%Y-%m-%d %H:%M:%S') cursor = con.execute( "DELETE FROM tasks WHERE status = 'completed' AND completed_at < ?", (cutoff_str,) ) deleted = cursor.rowcount con.commit() con.close() if deleted > 0: logging.info(f"[DB] {deleted} alte Tasks gelöscht (älter als {days} Tage)") return deleted # ── Team Members Database Functions ───────────────────────────────────────── def get_team_members(active_only=True): """Lädt Team-Members aus der Datenbank.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) con.row_factory = sqlite3.Row if active_only: rows = con.execute("SELECT * FROM team_members WHERE active = 1 ORDER BY name").fetchall() else: rows = con.execute("SELECT * FROM team_members ORDER BY name").fetchall() con.close() return [dict(row) for row in rows] def add_team_member(name, role, responsibilities='', email='', telegram_id=None, phone=''): """Fügt ein Team-Mitglied hinzu.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) created_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') cursor = con.execute(""" INSERT INTO team_members (name, role, responsibilities, email, telegram_id, phone, active, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?) """, (name, role, responsibilities, email, telegram_id, phone, created_at)) member_id = cursor.lastrowid con.commit() con.close() logging.info(f"[DB] Team-Member '{name}' hinzugefügt") return member_id def update_team_member(identifier, **kwargs): """Aktualisiert ein Team-Mitglied (per Email oder Name).""" con = sqlite3.connect(EMAIL_JOURNAL_DB) # Finde Member per Email oder Name member = con.execute( "SELECT id FROM team_members WHERE email = ? OR name = ? LIMIT 1", (identifier, identifier) ).fetchone() if not member: con.close() logging.warning(f"[DB] Team-Member '{identifier}' nicht gefunden für Update") return False member_id = member[0] # Baue UPDATE query updates = [] params = [] for key, value in kwargs.items(): if key in ['name', 'role', 'responsibilities', 'email', 'telegram_id', 'phone', 'active']: updates.append(f"{key} = ?") params.append(value) if not updates: con.close() return False params.append(member_id) query = f"UPDATE team_members SET {', '.join(updates)} WHERE id = ?" con.execute(query, params) con.commit() con.close() logging.info(f"[DB] Team-Member '{identifier}' aktualisiert") return True def get_team_member_summary(): """Erstellt eine Text-Zusammenfassung aller Team-Members für den Orchestrator.""" members = get_team_members(active_only=True) if not members: return "Keine Team-Mitglieder definiert." summary = "## Reale Team-Mitglieder:\n\n" summary += "Du kannst Team-Member-Informationen jederzeit mit @UPDATE_TEAM_MEMBER aktualisieren!\n\n" for member in members: summary += f"**{member['name']}** ({member['role']})\n" if member['responsibilities']: summary += f" Verantwortlich für: {member['responsibilities']}\n" contact_methods = [] if member['email']: contact_methods.append(f"Email: {member['email']}") if member['telegram_id']: contact_methods.append(f"Telegram-ID: {member['telegram_id']}") if member['phone']: contact_methods.append(f"Tel: {member['phone']}") if contact_methods: summary += f" Kontakt: {', '.join(contact_methods)}\n" summary += "\n" return summary # ── Agent Message Queue ───────────────────────────────────────────────────── # Ermöglicht Agent-zu-Agent Kommunikation agent_messages = [] # Liste von {id, from_agent, to_agent, message_type, content, status, created, response} def send_agent_message(from_agent, to_agent, message_type, content): """Sendet eine Nachricht von einem Agent an einen anderen. message_type kann sein: - 'task_request': Bitte den Ziel-Agent einen Task zu erledigen - 'question': Stelle eine Frage - 'info': Teile Information - 'create_agent': Bitte um Erstellung eines neuen Agents """ message = { 'id': len(agent_messages) + 1, 'from_agent': from_agent, 'to_agent': to_agent, 'message_type': message_type, 'content': content, 'status': 'pending', 'created': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'response': None } agent_messages.append(message) logger.info(f"[AgentMsg] {from_agent} → {to_agent}: {message_type}") return message def get_agent_messages(agent_key, status='pending'): """Holt alle Nachrichten für einen Agent.""" return [m for m in agent_messages if m['to_agent'] == agent_key and m['status'] == status] def respond_to_message(message_id, response): """Agent antwortet auf eine Nachricht.""" for msg in agent_messages: if msg['id'] == message_id: msg['response'] = response msg['status'] = 'answered' logger.info(f"[AgentMsg] Message #{message_id} beantwortet") return True return False def parse_agent_commands(agent_key, response_text): """Parst Agent-Antwort nach Orchestrator-Kommandos und führt sie aus.""" import re # ASK_ORCHESTRATOR: Agent stellt Frage an Orchestrator ask_requests = re.findall( r'@ASK_ORCHESTRATOR\s*\nQuestion:\s*([^\n]+)\s*\nContext:\s*([^@]+)@END', response_text, re.DOTALL ) for question, context in ask_requests: # Erstelle Task für Orchestrator um die Frage zu beantworten task_id = create_task( title=f"Frage von {agent_key}: {question.strip()[:80]}", description=f"""Ein Agent braucht Hilfe! **Von:** {agent_key} **Frage:** {question.strip()} **Kontext:** {context.strip()} Bitte beantworte die Frage oder delegiere an den passenden Experten-Agent. Die Antwort wird an {agent_key} zurückgegeben.""", agent_key='orchestrator', task_type='agent_question', created_by=agent_key, from_agent=agent_key, return_to=agent_key ) logger.info(f"[AgentCmd] {agent_key} fragt Orchestrator (Task #{task_id}): {question.strip()[:50]}") # CREATE_SUBTASK: Agent möchte Subtask erstellen subtask_requests = re.findall( r'@CREATE_SUBTASK\s*\nTask:\s*([^\n]+)\s*\nRequirements:\s*([^@]+)@END', response_text, re.DOTALL ) for task_desc, requirements in subtask_requests: task_id = create_task( title=task_desc.strip()[:100], description=f"Von {agent_key} angefordert:\n{requirements.strip()}", agent_key='orchestrator', task_type='agent_subtask', created_by=agent_key, from_agent=agent_key ) logger.info(f"[AgentCmd] {agent_key} erstellt Subtask #{task_id} via Orchestrator") # SUGGEST_AGENT: Agent schlägt neuen Agent vor suggest_requests = re.findall( r'@SUGGEST_AGENT\s*\nRole:\s*([^\n]+)\s*\nSkills:\s*([^\n]+)\s*\nReason:\s*([^@]+)@END', response_text, re.DOTALL ) for role, skills, reason in suggest_requests: # Agent-Key aus Role ableiten agent_key_suggestion = role.lower().replace(' ', '_').replace('-', '_') # Task für Orchestrator um Agent zu erstellen task_id = create_task( title=f"Agent-Vorschlag: {role.strip()}", description=f"""Agent {agent_key} schlägt vor, einen neuen Agent zu erstellen: **Rolle:** {role.strip()} **Fähigkeiten:** {skills.strip()} **Begründung:** {reason.strip()} Bitte entscheide ob dieser Agent erstellt werden soll.""", agent_key='orchestrator', task_type='agent_suggestion', created_by=agent_key, from_agent=agent_key, suggested_agent=agent_key_suggestion, suggested_role=role.strip(), suggested_skills=skills.strip() ) logger.info(f"[AgentCmd] {agent_key} schlägt neuen Agent vor (Task #{task_id}): {role.strip()}") # READ_KNOWLEDGE: Agent möchte Wissensdatenbank durchsuchen read_kb_requests = re.findall( r'@READ_KNOWLEDGE\s*\nTopic:\s*([^@]+)@END', response_text, re.DOTALL ) # Wenn Agent Wissensdatenbank lesen will, füge relevante Sektion zur Antwort hinzu # (wird im Response-Text nicht sichtbar, aber Agent bekommt es als Context) if read_kb_requests: kb_file = os.path.join(os.path.dirname(__file__), 'agents', 'orchestrator', 'knowledge', 'diversityball_knowledge.md') if os.path.exists(kb_file): with open(kb_file, 'r', encoding='utf-8') as f: kb_content = f.read() for topic in read_kb_requests: topic_clean = topic.strip().lower() logger.info(f"[AgentCmd] {agent_key} liest Wissensdatenbank: {topic_clean}") # Einfache Suche: Gib relevante Abschnitte zurück # TODO: Könnte später mit Vektorsuche verbessert werden relevant_sections = [] for line in kb_content.split('\n'): if topic_clean in line.lower(): relevant_sections.append(line) if relevant_sections: logger.info(f"[AgentCmd] {len(relevant_sections)} relevante Zeilen gefunden") # SEND_EMAIL: Orchestrator sendet Email an Team-Member send_email_requests = re.findall( r'@SEND_EMAIL\s*\nTo:\s*([^\n]+)\s*\nSubject:\s*([^\n]+)\s*\nBody:\s*([^@]+)@END', response_text, re.DOTALL ) for to_addr, subject, body in send_email_requests: to_clean = to_addr.strip() subject_clean = subject.strip() body_clean = body.strip() # Versuche Email zu senden success, message = send_email(to_clean, subject_clean, body_clean) if success: logger.info(f"[AgentCmd] Email gesendet an {to_clean}: {subject_clean}") else: logger.error(f"[AgentCmd] Email-Fehler: {message}") # SEND_TELEGRAM: Orchestrator sendet Telegram-Nachricht send_telegram_requests = re.findall( r'@SEND_TELEGRAM\s*\nTo:\s*([^\n]+)\s*\nMessage:\s*([^@]+)@END', response_text, re.DOTALL ) for recipient, message in send_telegram_requests: recipient_clean = recipient.strip() message_clean = message.strip() # Telegram-Integration (wenn aktiviert) if TELEGRAM_CONFIG.get('bot_token') and TELEGRAM_CONFIG.get('telegram_bot'): try: # Finde Chat-ID für Recipient (basierend auf Team-Member) con = sqlite3.connect(EMAIL_JOURNAL_DB) result = con.execute( "SELECT telegram_chat_id FROM team_members WHERE name = ? OR email = ?", (recipient_clean, recipient_clean) ).fetchone() con.close() if result and result[0]: chat_id = result[0] import asyncio asyncio.run(TELEGRAM_CONFIG['telegram_bot'].bot.send_message( chat_id=chat_id, text=message_clean )) logger.info(f"[AgentCmd] Telegram gesendet an {recipient_clean}") else: logger.warning(f"[AgentCmd] Keine Telegram Chat-ID für {recipient_clean}") except Exception as e: logger.error(f"[AgentCmd] Telegram-Fehler: {str(e)}") else: logger.warning("[AgentCmd] Telegram nicht konfiguriert") # ADD_TEAM_MEMBER: Füge neues Team-Mitglied hinzu add_member_requests = re.findall( r'@ADD_TEAM_MEMBER\s*\nName:\s*([^\n]+)\s*\nEmail:\s*([^\n]+)\s*\nRole:\s*([^\n]+)\s*\nResponsibilities:\s*([^@]+)@END', response_text, re.DOTALL ) for name, email, role, resp in add_member_requests: name_clean = name.strip() email_clean = email.strip() role_clean = role.strip() resp_clean = resp.strip() success = add_team_member(name_clean, role_clean, email_clean, resp_clean) if success: logger.info(f"[AgentCmd] Team-Member hinzugefügt: {name_clean} ({role_clean})") else: logger.warning(f"[AgentCmd] Team-Member konnte nicht hinzugefügt werden: {name_clean}") # UPDATE_TEAM_MEMBER: Aktualisiere Team-Mitglied update_member_requests = re.findall( r'@UPDATE_TEAM_MEMBER\s*\nEmail:\s*([^\n]+)\s*\nField:\s*([^\n]+)\s*\nValue:\s*([^@]+)@END', response_text, re.DOTALL ) for email, field, value in update_member_requests: email_clean = email.strip() field_clean = field.strip().lower() value_clean = value.strip() # Hole aktuelles Team-Member con = sqlite3.connect(EMAIL_JOURNAL_DB) member = con.execute( "SELECT name, role, email, responsibilities FROM team_members WHERE LOWER(email) = ?", (email_clean.lower(),) ).fetchone() if member: # Update je nach Field updates = { 'name': member[0], 'role': member[1], 'email': member[2], 'responsibilities': member[3] } if field_clean in updates: updates[field_clean] = value_clean success = update_team_member( email_clean, updates['name'], updates['role'], updates['responsibilities'] ) if success: logger.info(f"[AgentCmd] Team-Member aktualisiert: {email_clean} - {field_clean}") else: logger.error(f"[AgentCmd] Update fehlgeschlagen für {email_clean}") else: logger.warning(f"[AgentCmd] Unbekanntes Field: {field_clean}") else: logger.warning(f"[AgentCmd] Team-Member nicht gefunden: {email_clean}") con.close() def create_new_agent(agent_key, role, skills): """Erstellt dynamisch einen neuen Agenten.""" agent_dir = os.path.join(AGENTS_BASE_DIR, agent_key) if os.path.exists(agent_dir): logger.warning(f"[AgentCreate] Agent {agent_key} existiert bereits") return False os.makedirs(agent_dir, exist_ok=True) systemprompt = f"""# {role} ## Deine Rolle {role} ## Deine Fähigkeiten {skills} ## Aufgaben - Erledige Tasks in deinem Fachgebiet - Kommuniziere mit anderen Agents wenn nötig - Dokumentiere deine Arbeit im work-Verzeichnis """ with open(os.path.join(agent_dir, 'systemprompt.md'), 'w', encoding='utf-8') as f: f.write(systemprompt) open(os.path.join(agent_dir, 'personality.md'), 'w').close() with open(os.path.join(agent_dir, 'reminders.md'), 'w', encoding='utf-8') as f: f.write(f"# Erinnerungen - {agent_key.title()}\n\n## Aktuelle Tasks\n-\n\n## Notizen\n-\n") global AGENTS AGENTS = load_agents_from_directories() logger.info(f"[AgentCreate] Neuer Agent erstellt: {agent_key}") return True def load_knowledge_base(): kb_path = os.path.join(os.path.dirname(__file__), 'agents', 'orchestrator', 'knowledge', 'diversityball_knowledge.md') if os.path.exists(kb_path): with open(kb_path, 'r', encoding='utf-8') as f: content = f.read() return content[:1500] return "" def load_agent_prompts(): prompts = {} agents_dir = os.path.join(os.path.dirname(__file__), 'agents') if os.path.exists(agents_dir): for agent_name in os.listdir(agents_dir): prompt_file = os.path.join(agents_dir, agent_name, 'systemprompt.md') if os.path.exists(prompt_file): with open(prompt_file, 'r', encoding='utf-8') as f: prompts[agent_name] = f.read()[:500] return prompts def init_orchestrator_session(): if 'orchestrator_chat' not in session: session['orchestrator_chat'] = [] def delegate_to_agent(prompt): prompt_lower = prompt.lower() for agent, keywords in AGENT_KEYWORDS.items(): for keyword in keywords: if keyword in prompt_lower: return agent return 'researcher' def get_uploaded_files(): files = [] upload_dir = app.config['UPLOAD_FOLDER'] if os.path.exists(upload_dir): for f in os.listdir(upload_dir): filepath = os.path.join(upload_dir, f) if os.path.isfile(filepath): files.append({ 'name': f, 'size': os.path.getsize(filepath), 'modified': datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %H:%M') }) return sorted(files, key=lambda x: x['modified'], reverse=True) def get_email_folder_files(): """Liefert Dateien aus dem emails/ Verzeichnis.""" files = [] email_dir = os.path.join(os.path.dirname(__file__), 'emails') if os.path.exists(email_dir): for f in sorted(os.listdir(email_dir)): filepath = os.path.join(email_dir, f) if os.path.isfile(filepath): files.append({ 'name': f, 'size': os.path.getsize(filepath), 'modified': datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %H:%M') }) return files def get_project_files(): """Liefert .md und .docx Dateien aus dem Arbeitsverzeichnis.""" files = [] base_dir = os.path.dirname(__file__) allowed_ext = ('.md', '.docx', '.txt') for f in sorted(os.listdir(base_dir)): filepath = os.path.join(base_dir, f) if os.path.isfile(filepath) and f.lower().endswith(allowed_ext): files.append({ 'name': f, 'size': os.path.getsize(filepath), 'modified': datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %H:%M') }) return files # Email Functions def get_emails(): """Ruft Emails von IMAP-Server ab""" try: if not EMAIL_CONFIG['email_address'] or not EMAIL_CONFIG['email_password']: return [] mail = imaplib.IMAP4_SSL(EMAIL_CONFIG['imap_server'], EMAIL_CONFIG['imap_port']) mail.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password']) mail.select('INBOX') status, messages = mail.search(None, 'ALL') email_ids = messages[0].split()[-10:] # Last 10 emails emails = [] for email_id in reversed(email_ids): status, msg_data = mail.fetch(email_id, '(RFC822)') msg = email.message_from_bytes(msg_data[0][1]) emails.append({ 'id': email_id.decode(), 'subject': msg.get('Subject', '(Kein Betreff)'), 'from': msg.get('From', '(Unbekannt)'), 'date': msg.get('Date', ''), 'preview': get_email_preview(msg) }) mail.close() mail.logout() return emails except Exception as e: return [{'error': str(e)}] def get_email_preview(msg): """Extrahiert Email-Vorschau aus Nachricht""" try: if msg.is_multipart(): for part in msg.get_payload(): if part.get_content_type() == 'text/plain': return part.get_payload(decode=True).decode('utf-8', errors='ignore')[:100] else: return msg.get_payload(decode=True).decode('utf-8', errors='ignore')[:100] except (AttributeError, TypeError, UnicodeDecodeError) as e: logging.debug(f"Email preview extraction failed: {e}") return '(Vorschau nicht verfügbar)' return '' def get_email_body(email_id): """Ruft vollständigen Email-Body ab""" try: if not EMAIL_CONFIG['email_address'] or not EMAIL_CONFIG['email_password']: return 'Email-Konfiguration erforderlich' mail = imaplib.IMAP4_SSL(EMAIL_CONFIG['imap_server'], EMAIL_CONFIG['imap_port']) mail.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password']) mail.select('INBOX') status, msg_data = mail.fetch(email_id, '(RFC822)') msg = email.message_from_bytes(msg_data[0][1]) body = "" if msg.is_multipart(): for part in msg.get_payload(): if part.get_content_type() == 'text/plain': body = part.get_payload(decode=True).decode('utf-8', errors='ignore') break else: body = msg.get_payload(decode=True).decode('utf-8', errors='ignore') mail.close() mail.logout() return body except Exception as e: return f'Fehler beim Abrufen der Email: {str(e)}' def send_email(to_address, subject, body): """Sendet eine Email via SMTP""" try: if not EMAIL_CONFIG['email_address'] or not EMAIL_CONFIG['email_password']: return False, 'Email-Konfiguration erforderlich' msg = MIMEMultipart() msg['From'] = EMAIL_CONFIG['email_address'] msg['To'] = to_address msg['Subject'] = subject msg.attach(MIMEText(body, 'plain', 'utf-8')) smtp_port = EMAIL_CONFIG['smtp_port'] if smtp_port == 465: server = smtplib.SMTP_SSL(EMAIL_CONFIG['smtp_server'], smtp_port, timeout=10) else: server = smtplib.SMTP(EMAIL_CONFIG['smtp_server'], smtp_port, timeout=10) server.starttls() server.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password']) server.send_message(msg) server.quit() return True, 'Email erfolgreich versendet' except Exception as e: return False, f'Fehler beim Versenden: {str(e)}' def is_whitelisted(sender_address): """Prüft ob Absender auf der Whitelist steht.""" match = re.search(r'<([^>]+)>', sender_address) addr = match.group(1).lower() if match else sender_address.lower().strip() if addr in [w.lower() for w in EMAIL_WHITELIST]: return True domain = addr.split('@')[-1] if '@' in addr else '' if domain in [d.lower() for d in EMAIL_WHITELIST_DOMAINS]: return True return False def is_known_team_member(sender_address): """Prüft ob Absender ein bekanntes Team-Mitglied ist.""" match = re.search(r'<([^>]+)>', sender_address) addr = match.group(1).lower() if match else sender_address.lower().strip() # Prüfe ob Email in Team-Members DB con = sqlite3.connect(EMAIL_JOURNAL_DB) result = con.execute( "SELECT name, role FROM team_members WHERE LOWER(email) = ? AND active = 1", (addr,) ).fetchone() con.close() return result # Returns (name, role) or None def decode_email_header_value(value): """Dekodiert Email-Header (z.B. encoded Subject/From).""" if not value: return '' decoded_parts = decode_header(value) result = '' for part, charset in decoded_parts: if isinstance(part, bytes): result += part.decode(charset or 'utf-8', errors='ignore') else: result += part return result def extract_body_from_msg(msg): """Extrahiert Text-Body aus einem Email-Message-Objekt.""" body = '' if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() content_disp = str(part.get('Content-Disposition', '')) if content_type == 'text/plain' and 'attachment' not in content_disp: try: charset = part.get_content_charset() or 'utf-8' payload = part.get_payload(decode=True) if isinstance(payload, bytes): body = payload.decode(charset, errors='ignore') break except Exception: pass else: try: charset = msg.get_content_charset() or 'utf-8' payload = msg.get_payload(decode=True) if isinstance(payload, bytes): body = payload.decode(charset, errors='ignore') except Exception: body = '' return body def add_to_email_log(entry): """Fügt Eintrag zum Email-Log hinzu (max 50 Einträge).""" with email_log_lock: email_log.append(entry) while len(email_log) > 50: email_log.pop(0) # ── Telegram Bot Functions ──────────────────────────────────────────────────── async def telegram_start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handler für /start Kommando.""" user_id = update.effective_user.id username = update.effective_user.username or update.effective_user.first_name # Whitelist-Check if TELEGRAM_CONFIG['allowed_users'] and user_id not in TELEGRAM_CONFIG['allowed_users']: await update.message.reply_text( f"⛔ Zugriff verweigert.\n\n" f"Deine User-ID: {user_id}\n" f"Bitte füge diese ID zu TELEGRAM_ALLOWED_USERS in der .env Datei hinzu." ) logging.warning(f"[Telegram] Unauthorized access attempt from {username} (ID: {user_id})") return await update.message.reply_text( f"👋 Willkommen bei Frankenbot, {username}!\n\n" f"🤖 Ich bin dein Multi-Agent Event-Management-Assistent.\n\n" f"Sende mir einfach eine Nachricht und ich erstelle einen Task, " f"der von unseren spezialisierten Agents bearbeitet wird:\n\n" f"• 💰 Budget Manager\n" f"• 🍽️ Catering Manager\n" f"• 📍 Location Manager\n" f"• 📅 Program Manager\n" f"• 🔍 Researcher\n" f"• und mehr!\n\n" f"Der Orchestrator wählt automatisch den besten Agent für deine Anfrage." ) logging.info(f"[Telegram] User {username} (ID: {user_id}) started bot") async def telegram_message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handler für eingehende Nachrichten.""" user_id = update.effective_user.id username = update.effective_user.username or update.effective_user.first_name message_text = update.message.text # Whitelist-Check if TELEGRAM_CONFIG['allowed_users'] and user_id not in TELEGRAM_CONFIG['allowed_users']: await update.message.reply_text("⛔ Zugriff verweigert. Verwende /start für Details.") return # Task erstellen task_id = create_task( title=f"Telegram: {message_text[:50]}{'...' if len(message_text) > 50 else ''}", description=message_text, agent_key='orchestrator', task_type='telegram', created_by=f'telegram_user_{user_id}', telegram_chat_id=update.effective_chat.id, telegram_user=username ) await update.message.reply_text( f"✅ Task #{task_id} erstellt!\n\n" f"📝 {message_text[:100]}{'...' if len(message_text) > 100 else ''}\n\n" f"⏳ Der Orchestrator wird deine Anfrage verarbeiten.\n" f"Ich benachrichtige dich, sobald die Antwort bereit ist." ) logging.info(f"[Telegram] Task #{task_id} created by {username} (ID: {user_id})") def send_telegram_message(chat_id: int, message: str): """Sendet eine Nachricht an einen Telegram-Chat.""" global telegram_app if not telegram_app or not TELEGRAM_CONFIG['bot_token']: logging.warning("[Telegram] Cannot send message: Bot not configured") return False try: # Async-Funktion in sync Context ausführen import asyncio loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(telegram_app.bot.send_message( chat_id=chat_id, text=message )) loop.close() return True except Exception as e: logging.error(f"[Telegram] Error sending message: {e}") return False def init_telegram_bot(): """Initialisiert den Telegram Bot.""" global telegram_app if not TELEGRAM_CONFIG['bot_token']: logging.info("[Telegram] Bot token not configured, skipping initialization") return try: # Application erstellen telegram_app = Application.builder().token(TELEGRAM_CONFIG['bot_token']).build() # Handler registrieren telegram_app.add_handler(CommandHandler("start", telegram_start_command)) telegram_app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, telegram_message_handler)) logging.info("[Telegram] Bot initialized successfully") logging.info(f"[Telegram] Allowed users: {TELEGRAM_CONFIG['allowed_users']}") except Exception as e: logging.error(f"[Telegram] Failed to initialize bot: {e}") telegram_app = None def run_telegram_bot(): """Startet den Telegram Bot in einem separaten Thread.""" global telegram_app if not telegram_app: logging.warning("[Telegram] Bot not initialized, cannot start") return try: logging.info("[Telegram] Starting bot polling...") telegram_app.run_polling(drop_pending_updates=True) except Exception as e: logging.error(f"[Telegram] Bot polling error: {e}") def start_telegram_thread(): """Startet den Telegram Bot im Hintergrund.""" global telegram_thread if not TELEGRAM_CONFIG['bot_token']: logging.info("[Telegram] No bot token configured, skipping bot start") return init_telegram_bot() if telegram_app: telegram_thread = threading.Thread(target=run_telegram_bot, daemon=True, name="TelegramBot") telegram_thread.start() logging.info("[Telegram] Background thread started") def poll_emails(): """ Hintergrund-Thread: Checkt alle 2 Minuten den IMAP-Posteingang. Nutzt SQLite-Journal als Failsafe: - UNSEEN → noch nie gesehen → verarbeiten - SEEN → aber nicht im Journal → wurde von externem Client gelesen → ignorieren - Im Journal als 'pending'/'queued' → App-Absturz → erneut verarbeiten (IMAP als UNSEEN zurücksetzen) IMAP Seen-Flag wird erst NACH erfolgreichem Task-Abschluss gesetzt (durch TaskWorker). """ logger.info("[EmailPoller] Hintergrund-Thread gestartet.") while True: try: addr = EMAIL_CONFIG.get('email_address', '') pwd = EMAIL_CONFIG.get('email_password', '') if not addr or not pwd: logger.warning("[EmailPoller] Keine Email-Konfiguration – überspringe Durchlauf.") time.sleep(poller_settings['poll_interval']) continue logger.info("[EmailPoller] Verbinde mit IMAP %s:%d …", EMAIL_CONFIG['imap_server'], EMAIL_CONFIG['imap_port']) mail = imaplib.IMAP4_SSL(EMAIL_CONFIG['imap_server'], EMAIL_CONFIG['imap_port']) mail.login(addr, pwd) mail.select('INBOX') # Alle Emails holen (UNSEEN + Journal-pending als Failsafe) status, messages = mail.search(None, 'ALL') if status != 'OK': mail.close() mail.logout() time.sleep(poller_settings['poll_interval']) continue all_ids = messages[0].split() # Kandidaten: UNSEEN oder im Journal als noch nicht abgeschlossen candidates = [] for email_id in all_ids: fetch_status, flag_data = mail.fetch(email_id, '(FLAGS RFC822.HEADER)') if fetch_status != 'OK' or not flag_data or flag_data[0] is None: continue flags_raw = flag_data[0][0].decode() if isinstance(flag_data[0][0], bytes) else str(flag_data[0][0]) is_seen = '\\Seen' in flags_raw raw_header = flag_data[0][1] if not isinstance(raw_header, bytes): continue hdr = email.message_from_bytes(raw_header) message_id = (hdr.get('Message-ID') or hdr.get('Message-Id') or '').strip() # Fallback falls kein Message-ID Header vorhanden if not message_id: message_id = f"no-msgid-uid-{email_id.decode()}" if not is_seen: # Noch ungelesen → immer verarbeiten candidates.append((email_id, message_id, is_seen)) elif journal_is_stale(message_id): # Gelesen, aber Journal-Eintrag hängt länger als failsafe_window → Absturz-Recovery logger.warning("[EmailPoller] Failsafe: Email %s hängt seit >%ds – erneut verarbeiten.", message_id, poller_settings['failsafe_window']) mail.store(email_id, '-FLAGS', '\\Seen') candidates.append((email_id, message_id, False)) logger.info("[EmailPoller] %d Email(s) zur Verarbeitung.", len(candidates)) for email_id, message_id, _ in candidates: try: fetch_status, msg_data = mail.fetch(email_id, '(RFC822)') if fetch_status != 'OK' or not msg_data or msg_data[0] is None: continue raw_bytes = msg_data[0][1] if not isinstance(raw_bytes, bytes): continue msg = email.message_from_bytes(raw_bytes) sender_raw = msg.get('From', '') sender = decode_email_header_value(sender_raw) subject_raw = msg.get('Subject', '(Kein Betreff)') subject = decode_email_header_value(subject_raw) body = extract_body_from_msg(msg) log_entry = { 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'from': sender, 'subject': subject, 'agent': None, 'status': 'skipped', 'response_preview': '' } if not is_whitelisted(sender): # Nicht-whitelisted: sofort als gelesen markieren, im Journal als 'skipped' mail.store(email_id, '+FLAGS', '\\Seen') journal_insert(message_id, email_id.decode(), sender, subject, 'skipped') logger.info("[EmailPoller] Absender nicht auf Whitelist: %s – wird ignoriert.", sender) add_to_email_log(log_entry) continue # Bereits vollständig verarbeitet? (z.B. nach Neustart) if journal_is_done(message_id): mail.store(email_id, '+FLAGS', '\\Seen') logger.info("[EmailPoller] Email %s bereits verarbeitet – überspringe.", message_id) continue logger.info("[EmailPoller] Email von %s | Betreff: %s → in Queue", sender, subject) # Absender-Email extrahieren für Reply-Adresse addr_match = re.search(r'<([^>]+)>', sender) sender_email = addr_match.group(1) if addr_match else sender.strip() # Prüfe ob Absender bekannt ist (in Team-Members DB) known_member = is_known_team_member(sender) # Wenn unbekannt: Bitte um Vorstellung extra_context = "" if not known_member: extra_context = f""" ## ⚠️ WICHTIG - Neuer Absender: Diese Email kommt von **{sender}** - diese Person ist noch nicht in der Team-Datenbank! Bitte in deiner Antwort: 1. Freundlich begrüßen 2. Um Vorstellung bitten: Name, Rolle, Verantwortlichkeiten 3. Fragen was sie im Projekt macht 4. Dann ihre eigentliche Anfrage beantworten Der Orchestrator wird die Informationen dann mit @ADD_TEAM_MEMBER oder @UPDATE_TEAM_MEMBER in die Datenbank eintragen. """ else: extra_context = f""" ## Team-Member erkannt: Diese Email kommt von **{known_member[0]}** ({known_member[1]}) """ # Agent bestimmen - bei unbekannten Sendern immer Orchestrator if not known_member: agent_key = 'orchestrator' logger.info("[EmailPoller] Unbekannter Absender → Orchestrator übernimmt") else: full_prompt_for_routing = f"{subject} {body}" agent_key = delegate_to_agent(full_prompt_for_routing) # Journal: Status 'queued' – \Seen wird NICHT gesetzt (erst nach Verarbeitung) journal_insert(message_id, email_id.decode(), sender, subject, 'queued', agent_key) # Task erstellen in DB task_id = create_task( title=f"📧 Email: {subject[:50]}", description=body[:500], agent_key=agent_key, task_type='email', created_by=sender_email, reply_to=sender_email, reply_subject=f"Re: {subject}" if not subject.startswith('Re:') else subject, original_sender=sender, original_subject=subject, original_body=body, message_id=message_id, imap_uid=email_id.decode(), extra_context=extra_context ) # Für task_queue brauchen wir Task als Dict (Legacy-Kompatibilität) task = { 'id': task_id, 'title': f"📧 Email: {subject[:50]}", 'description': body[:500], 'assigned_agent': AGENTS.get(agent_key, {}).get('name', agent_key), 'agent_key': agent_key, 'status': 'pending', 'created': datetime.now().strftime('%Y-%m-%d %H:%M'), 'type': 'email', 'reply_to': sender_email, 'reply_subject': f"Re: {subject}" if not subject.startswith('Re:') else subject, 'original_sender': sender, 'original_subject': subject, 'original_body': body, 'message_id': message_id, 'imap_uid': email_id.decode(), 'extra_context': extra_context, } with task_queue_lock: task_queue.append(task) log_entry['agent'] = agent_key log_entry['status'] = 'queued' add_to_email_log(log_entry) logger.info("[EmailPoller] Task #%d für Email von %s in Queue eingereiht.", task_id, sender_email) except Exception as e: logger.error("[EmailPoller] Fehler bei Email-ID %s: %s", email_id, str(e)) mail.close() mail.logout() except Exception as e: logger.error("[EmailPoller] Verbindungsfehler: %s", str(e)) time.sleep(poller_settings['poll_interval']) def process_email_tasks(): """ Hintergrund-Thread: Verarbeitet Email-Tasks aus der task_queue. Prüft alle 10 Sekunden auf pending Tasks, führt Agenten aus und sendet Antworten. """ logger.info("[TaskWorker] Hintergrund-Thread gestartet.") while True: try: with task_queue_lock: pending = [t for t in task_queue if t.get('status') == 'pending' and t.get('type') == 'email'] for task in pending: try: task['status'] = 'in_progress' logger.info("[TaskWorker] Verarbeite Task #%d – Agent: %s", task['id'], task['agent_key']) agent_key = task['agent_key'] sender_email = task['reply_to'] reply_subject = task['reply_subject'] sender = task['original_sender'] subject = task['original_subject'] body = task['original_body'] full_prompt = f"""Eine Email ist eingegangen und muss beantwortet werden. Von: {sender} Betreff: {subject} Inhalt: {body} Bitte bearbeite diese Anfrage vollständig: 1. Nutze WebFetch wenn du Informationen aus dem Internet brauchst 2. Wenn die Email bittet eine Antwort an jemand anderen zu schicken, tue das 3. Formuliere eine vollständige, hilfreiche Antwort 4. Die Antwort wird automatisch an {sender_email} zurückgeschickt""" email_context = f""" ## Email-Kontext: - Diese Anfrage kommt per Email von: {sender_email} - Die Antwort wird automatisch per Email zurückgeschickt - Formuliere die Antwort daher als Email-Text (freundlich, professionell) - Wenn gebeten wird, eine Email an jemanden zu schicken: gib die Adresse in der Form "An: adresse@example.com" oder "To: adresse@example.com" an""" # Kombiniere Email-Kontext mit Task-Kontext (z.B. unbekannter Absender) combined_context = email_context if task.get('extra_context'): combined_context += "\n\n" + task['extra_context'] agent_response = execute_agent_task(agent_key, full_prompt, extra_context=combined_context) # Zusätzliche Empfänger aus der Agenten-Antwort extrahieren extra_recipients = re.findall( r'(?:An|To):\s*([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})', agent_response ) send_errors = [] # An zusätzliche Empfänger senden for recipient in extra_recipients: if recipient.lower() != sender_email.lower(): fwd_success, fwd_msg = send_email(recipient, reply_subject, agent_response) if fwd_success: logger.info("[TaskWorker] Email an Zusatz-Empfänger %s gesendet.", recipient) else: logger.error("[TaskWorker] Fehler bei Zusatz-Empfänger %s: %s", recipient, fwd_msg) send_errors.append(f"{recipient}: {fwd_msg}") # Immer an den Original-Absender antworten success, send_msg = send_email(sender_email, reply_subject, agent_response) if success: logger.info("[TaskWorker] Auto-Reply an %s gesendet.", sender_email) else: logger.error("[TaskWorker] Fehler beim Reply an %s: %s", sender_email, send_msg) send_errors.append(f"{sender_email}: {send_msg}") final_status = 'completed' if not send_errors else 'error' task['status'] = final_status task['response_preview'] = agent_response[:200] # Journal aktualisieren + IMAP \Seen erst jetzt setzen msg_id = task.get('message_id', '') imap_uid = task.get('imap_uid', '') if msg_id: journal_update(msg_id, final_status) if imap_uid and final_status in ('completed', 'error'): try: mail_done = imaplib.IMAP4_SSL(EMAIL_CONFIG['imap_server'], EMAIL_CONFIG['imap_port']) mail_done.login(EMAIL_CONFIG['email_address'], EMAIL_CONFIG['email_password']) mail_done.select('INBOX') mail_done.store(imap_uid.encode(), '+FLAGS', '\\Seen') mail_done.close() mail_done.logout() logger.info("[TaskWorker] IMAP \\Seen für UID %s gesetzt.", imap_uid) except Exception as imap_err: logger.error("[TaskWorker] IMAP \\Seen konnte nicht gesetzt werden: %s", imap_err) # Log-Eintrag aktualisieren with email_log_lock: for entry in reversed(email_log): if entry.get('from') == sender and entry.get('subject') == subject and entry.get('status') == 'queued': entry['status'] = final_status entry['response_preview'] = agent_response[:200] break logger.info("[TaskWorker] Task #%d abgeschlossen mit Status: %s", task['id'], final_status) except Exception as e: task['status'] = 'error' logger.error("[TaskWorker] Fehler bei Task #%d: %s", task['id'], str(e)) except Exception as e: logger.error("[TaskWorker] Unerwarteter Fehler: %s", str(e)) time.sleep(10) def start_email_poller(): """Startet den Email-Poller und den Task-Worker als Daemon-Threads.""" poller_thread = threading.Thread(target=poll_emails, name='EmailPoller', daemon=True) poller_thread.start() logger.info("[EmailPoller] Daemon-Thread gestartet.") worker_thread = threading.Thread(target=process_email_tasks, name='TaskWorker', daemon=True) worker_thread.start() logger.info("[TaskWorker] Daemon-Thread gestartet.") def process_beat_tasks(): """Background-Beat: Verarbeitet offene Tasks automatisch.""" logger.info("[TaskBeat] Hintergrund-Thread gestartet.") while True: try: # Lade pending Tasks aus Datenbank db_tasks = get_tasks(status='pending') # Konvertiere zu Dict-Format für Legacy-Kompatibilität pending_tasks = [] for db_task in db_tasks: if db_task.get('type') in ('agent_created', 'manual', 'orchestrated', 'agent_delegated', 'telegram'): pending_tasks.append(db_task) for task in pending_tasks: agent_key = task.get('agent_key') or task.get('assigned_agent', '') if task.get('agent_key') == 'orchestrator': # Update in DB update_task_db(task['id'], status='in_progress') task['status'] = 'in_progress' logger.info("[TaskBeat] Planungsphase für Task #%d", task['id']) sub_tasks = task.get('sub_tasks', []) available_agents = task.get('available_agents', list(AGENTS.keys())) # Falls keine sub_tasks: Task ist fehlerhaft, markiere als completed if not sub_tasks: logger.warning("[TaskBeat] Task #%d hat keine sub_tasks - als completed markiert", task['id']) update_task_db(task['id'], status='completed', response='Fehler: Keine sub_tasks definiert. Dieser Task wurde wahrscheinlich über eine veraltete API erstellt.') continue prompt = f"""Du bist der Master-Orchestrator. Analysiere folgende Tasks und weise sie den richtigen Agenten zu: Tasks: {chr(10).join(['- ' + t for t in sub_tasks])} Verfügbare Agenten: {', '.join(available_agents)} Agent-Beschreibungen: """ for a_key, a_info in AGENTS.items(): prompt += f"- {a_key}: {a_info.get('description', 'Keine Beschreibung')[:100]}\n" prompt += "\nAntworte in diesem Format (einen Agent pro Task):\n" for i, t in enumerate(sub_tasks): prompt += f"Task {i+1}: [Agent-Key] - Kurze Begründung\n" response = execute_agent_task('orchestrator', prompt) task['plan_response'] = response import re agent_assignments = re.findall(r'Task \d+: (\w+)', response) created_sub_tasks = [] for i, t in enumerate(sub_tasks): assigned = agent_assignments[i] if i < len(agent_assignments) else available_agents[i % len(available_agents)] if assigned not in AGENTS: assigned = available_agents[0] # Sub-Task mit KOMPLETTEM Kontext sub_task_id = create_task( title=t[:80], description=f"""**Original-Aufgabe:** {task.get('title', '')} {task.get('description', '')} **Orchestrator-Analyse:** {response} **Dein spezifischer Teil:** {t} Arbeite diesen Teil ab und liefere ein vollständiges Ergebnis.""", agent_key=assigned, task_type='orchestrated', created_by='orchestrator', parent_task_id=task['id'] ) created_sub_tasks.append(sub_task_id) logger.info("[TaskBeat] Sub-Task #%d zugewiesen an %s", sub_task_id, assigned) # Update in DB update_task_db(task['id'], status='completed') task['status'] = 'completed' task['sub_task_ids'] = created_sub_tasks logger.info("[TaskBeat] Planungs-Task #%d abgeschlossen. %d Sub-Tasks erstellt.", task['id'], len(created_sub_tasks)) continue if not agent_key: available_agents = list(AGENTS.keys()) if available_agents: agent_key = available_agents[0] task['agent_key'] = agent_key update_task_db(task['id'], agent_key=agent_key) if agent_key and agent_key in AGENTS: # Update in DB update_task_db(task['id'], status='in_progress') task['status'] = 'in_progress' logger.info("[TaskBeat] Verarbeite Task #%d – Agent: %s", task['id'], agent_key) response = execute_agent_task(agent_key, task.get('title', '') + '\n\n' + task.get('description', '')) # Update in DB update_task_db(task['id'], response=response) task['response'] = response # Neues Memory-System: Task als strukturierte Erinnerung speichern try: add_agent_memory(agent_key, 'tasks', { 'task_id': task['id'], 'title': task.get('title', 'Unbekannt'), 'description': task.get('description', ''), 'result': response, 'status': 'completed', 'metadata': { 'assigned_by': task.get('agent', 'system'), 'duration': None } }) except Exception as e: logger.warning("[TaskBeat] Konnte Erinnerung nicht speichern: %s", str(e)) # Update in DB update_task_db(task['id'], status='completed') task['status'] = 'completed' logger.info("[TaskBeat] Task #%d abgeschlossen.", task['id']) # Telegram-Benachrichtigung wenn Task von Telegram kam if task.get('type') == 'telegram' and task.get('telegram_chat_id'): try: telegram_msg = ( f"✅ Task #{task['id']} abgeschlossen!\n\n" f"📝 Anfrage: {task.get('title', 'N/A')}\n\n" f"💬 Antwort:\n{response[:4000]}" # Telegram limit: 4096 chars ) send_telegram_message(task['telegram_chat_id'], telegram_msg) logger.info("[TaskBeat] Telegram-Antwort gesendet für Task #%d", task['id']) except Exception as e: logger.error("[TaskBeat] Fehler beim Senden der Telegram-Antwort: %s", str(e)) except Exception as e: logger.error("[TaskBeat] Fehler: %s", str(e)) # Beat-Intervall: 10 Sekunden (statt 30) time.sleep(10) def start_task_beat(): """Startet den Task-Beat als Daemon-Thread.""" beat_thread = threading.Thread(target=process_beat_tasks, name='TaskBeat', daemon=True) beat_thread.start() logger.info("[TaskBeat] Daemon-Thread gestartet.") # ── Orchestrator Beat ─────────────────────────────────────────────────────── def orchestrator_beat(): """ Orchestrator Beat: Läuft alle 30 Minuten und prüft: - Offene Tasks ohne Fortschritt - Blockierte Tasks die Aufmerksamkeit brauchen - Wichtige Entscheidungen die ausstehen - Team-Member Benachrichtigungen """ logger.info("[OrchestratorBeat] Hintergrund-Thread gestartet.") while True: try: # Alle 30 Minuten prüfen time.sleep(1800) logger.info("[OrchestratorBeat] Starte Überprüfung...") # 1. Finde Tasks die länger als 2 Stunden pending sind pending_tasks = get_tasks(status='pending') old_pending = [] for task in pending_tasks: try: created = datetime.strptime(task['created_at'], '%Y-%m-%d %H:%M:%S') age_hours = (datetime.now() - created).total_seconds() / 3600 if age_hours > 2: old_pending.append((task, age_hours)) except: pass # 2. Finde in_progress Tasks die länger als 4 Stunden laufen in_progress = get_tasks(status='in_progress') stuck_tasks = [] for task in in_progress: try: created = datetime.strptime(task['created_at'], '%Y-%m-%d %H:%M:%S') age_hours = (datetime.now() - created).total_seconds() / 3600 if age_hours > 4: stuck_tasks.append((task, age_hours)) except: pass # 3. Wenn es Probleme gibt, frage den Orchestrator if old_pending or stuck_tasks: summary = "## Orchestrator Beat - Status-Check\n\n" if old_pending: summary += f"### ⏳ {len(old_pending)} Alte Pending Tasks:\n" for task, hours in old_pending[:5]: # Max 5 summary += f"- Task #{task['id']}: {task['title'][:60]} ({hours:.1f}h alt)\n" summary += "\n" if stuck_tasks: summary += f"### 🚫 {len(stuck_tasks)} Blockierte Tasks:\n" for task, hours in stuck_tasks[:5]: summary += f"- Task #{task['id']}: {task['title'][:60]} ({hours:.1f}h in Bearbeitung)\n" summary += "\n" summary += """ ### Aufgabe: 1. Analysiere die Situation 2. Entscheide ob Tasks eskaliert werden müssen 3. Wenn ja: Kontaktiere relevante Team-Members via Email oder Telegram 4. Verwende @SEND_EMAIL oder @SEND_TELEGRAM um Benachrichtigungen zu senden Nutze @READ_KNOWLEDGE um Kontext zu holen falls nötig. """ # Orchestrator fragen logger.info("[OrchestratorBeat] Frage Orchestrator zu %d Problemen", len(old_pending) + len(stuck_tasks)) response = execute_agent_task('orchestrator', summary) # Response loggen und Kommandos parsen if response: logger.info("[OrchestratorBeat] Orchestrator-Antwort: %s", response[:200]) # Parse Orchestrator-Kommandos (Email, Telegram, etc.) parse_agent_commands('orchestrator', response) except Exception as e: logger.error("[OrchestratorBeat] Fehler: %s", str(e)) def start_orchestrator_beat(): """Startet den Orchestrator Beat als Daemon-Thread.""" beat_thread = threading.Thread(target=orchestrator_beat, name='OrchestratorBeat', daemon=True) beat_thread.start() logger.info("[OrchestratorBeat] Daemon-Thread gestartet.") # Poller beim App-Start starten start_email_poller() start_task_beat() start_orchestrator_beat() @app.route('/login', methods=['GET', 'POST']) def login(): if session.get('authenticated'): return redirect(url_for('index')) error = None if request.method == 'POST': if request.form.get('password') == APP_PASSWORD: session['authenticated'] = True session.permanent = True return redirect(url_for('index')) error = 'Falsches Passwort' return render_template('login.html', error=error) @app.route('/logout') def logout(): session.clear() return redirect(url_for('login')) @app.route('/') @login_required def index(): # Hole die 5 neuesten Tasks aus DB all_tasks = get_tasks() recent_tasks = all_tasks[:5] if all_tasks else [] return render_template('index.html', agents=AGENTS, recent_tasks=recent_tasks) @app.route('/chat', methods=['GET', 'POST']) @login_required def chat(): # Chat-Verlauf aus Session laden if 'chat_history' not in session: session['chat_history'] = [] chat_display = session.get('chat_history', []) return render_template('chat.html', agents=AGENTS, chat_history=chat_display) @app.route('/chat/send', methods=['POST']) @login_required def chat_send(): """Führt einen Agent aus und gibt die Antwort per Server-Sent Events LIVE zurück.""" data = request.get_json() prompt = data.get('prompt', '').strip() agent_key = data.get('agent', '').strip() # Validierung vor dem Generator if not prompt or not agent_key: return jsonify({'type': 'error', 'message': 'Fehlende Eingabe'}), 400 if agent_key not in AGENTS: return jsonify({'type': 'error', 'message': 'Agent nicht gefunden'}), 404 agent_info = AGENTS.get(agent_key, {}) agent_name = agent_info.get('name', agent_key) def generate(): # Agent-Info senden yield f"data: {json.dumps({'type': 'agent_selected', 'agent': agent_name, 'agent_key': agent_key})}\n\n" yield f"data: {json.dumps({'type': 'processing', 'message': f'⏳ {agent_name} denkt nach...'})}\n\n" try: # Agent live ausführen mit Streaming response_text = "" # System-Prompt vorbereiten system_prompt = get_agent_prompt(agent_key) if not system_prompt: yield f"data: {json.dumps({'type': 'error', 'message': f'Kein System-Prompt für Agent {agent_key}'})}\n\n" return dirs = ensure_agent_structure(agent_key) work_dir = dirs['work_dir'] memory_summary = get_agent_memory_summary(agent_key) kb_file = os.path.join(os.path.dirname(__file__), 'agents', 'orchestrator', 'knowledge', 'diversityball_knowledge.md') team_summary = "" if agent_key == 'orchestrator': team_summary = "\n\n" + get_team_member_summary() full_system = f"""{system_prompt} ## Deine Erinnerungen: {memory_summary}{team_summary} ## Wissensdatenbank: Die Wissensdatenbank liegt unter: {kb_file} - Du hast Zugriff auf das Internet via WebFetch-Tool - Dein Arbeitsverzeichnis: {work_dir}""" combined_message = f"{full_system}\n\n---\n\n{prompt}" model = get_agent_model(agent_key) # OpenCode mit Streaming aufrufen process = subprocess.Popen( ['opencode', 'run', '--model', model, '--format', 'json', combined_message], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=work_dir ) # Live-Output lesen und streamen for line in process.stdout: try: data_json = json.loads(line.strip()) if data_json.get('part', {}).get('type') == 'text': chunk = data_json.get('part', {}).get('text', '') response_text += chunk # Chunk live an Frontend senden yield f"data: {json.dumps({'type': 'chunk', 'text': chunk})}\n\n" except (json.JSONDecodeError, KeyError): pass process.wait() # Agent-Kommandos parsen if response_text: parse_agent_commands(agent_key, response_text) # Erfolg melden yield f"data: {json.dumps({'type': 'complete', 'message': '✓ Fertig', 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M'), 'response': response_text})}\n\n" except Exception as e: logger.error(f"[Chat] Fehler beim Ausführen von {agent_key}: {str(e)}") yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" return Response(generate(), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) @app.route('/chat/save', methods=['POST']) @login_required def chat_save(): """Speichert eine Chat-Nachricht in der Session.""" data = request.get_json() if 'chat_history' not in session: session['chat_history'] = [] session['chat_history'].append({ 'timestamp': data.get('timestamp'), 'agent': data.get('agent'), 'agent_key': data.get('agent_key'), 'prompt': data.get('prompt'), 'response': data.get('response') }) session['chat_history'] = session['chat_history'][-30:] session.modified = True return jsonify({'success': True}) @app.route('/tasks', methods=['GET', 'POST']) @login_required def task_list(): if request.method == 'POST': title = request.form.get('title', '').strip() description = request.form.get('description', '').strip() assigned_agent = request.form.get('assigned_agent', '') if title: task_id = create_task( title=title, description=description, agent_key=assigned_agent if assigned_agent else None, task_type='manual', created_by='user' ) flash(f'Task #{task_id} erstellt!', 'success') # Alle Tasks aus Datenbank holen – Neueste zuerst all_tasks = get_tasks() return render_template('tasks.html', agents=AGENTS, tasks=all_tasks) @app.route('/tasks/update//') @login_required def update_task(task_id, status): # Update in Datenbank update_task_db(task_id, status=status) # Auch in-memory array aktualisieren (Legacy-Kompatibilität für task_queue) for task in tasks: if task['id'] == task_id: task['status'] = status break return redirect(url_for('task_list')) @app.route('/api/agent-stream', methods=['POST']) @login_required def agent_stream(): """Server-Sent Events Endpoint – echtes Streaming direkt aus opencode JSON-Output.""" data = request.get_json() prompt = data.get('prompt', '').strip() def generate(): if not prompt: yield f"data: {json.dumps({'type': 'error', 'message': 'Leere Anfrage'})}\n\n" return selected_agent = delegate_to_agent(prompt) agent_info = AGENTS.get(selected_agent, {}) agent_name = agent_info.get('name', selected_agent) system_prompt = get_agent_prompt(selected_agent) kb_file = os.path.join(os.path.dirname(__file__), 'agents', 'orchestrator', 'knowledge', 'diversityball_knowledge.md') kb_content = "" if os.path.exists(kb_file): with open(kb_file, 'r', encoding='utf-8') as f: kb_content = f.read() full_prompt = f"""## Wissensdatenbank (Diversity-Ball): {kb_content} ## System-Prompt des Agenten ({agent_name}): {system_prompt} ## Deine Aufgabe: {prompt}""" # Sofort Agent-Info senden yield f"data: {json.dumps({'type': 'agent_selected', 'agent': agent_name, 'agent_key': selected_agent})}\n\n" yield f"data: {json.dumps({'type': 'processing', 'message': f'⏳ {agent_name} arbeitet...'})}\n\n" try: # Agent work directory sicherstellen dirs = ensure_agent_structure(selected_agent) work_dir = dirs['work_dir'] model = get_agent_model(selected_agent) proc = subprocess.Popen( ['opencode', 'run', '--model', model, '--format', 'json', full_prompt], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=work_dir # Agent arbeitet in seinem work-Verzeichnis ) # Jede Zeile sofort aus opencode lesen und streamen for line in proc.stdout: line = line.strip() if not line: continue try: event_data = json.loads(line) if event_data.get('part', {}).get('type') == 'text': text = event_data['part'].get('text', '') if text: yield f"data: {json.dumps({'type': 'response_chunk', 'text': text})}\n\n" except Exception: pass proc.wait() yield f"data: {json.dumps({'type': 'complete', 'message': '✓ Fertig'})}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" return Response(generate(), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) @app.route('/orchestrator', methods=['GET', 'POST']) @login_required def orchestrator(): init_orchestrator_session() if request.method == 'POST': prompt = request.form.get('prompt', '').strip() if prompt: kb = load_knowledge_base() agent_prompts = load_agent_prompts() selected_agent = delegate_to_agent(prompt) agent_info = AGENTS.get(selected_agent, {}) agent_name = agent_info.get('name', selected_agent) response = execute_agent_task(selected_agent, prompt) orchestrator_chat = session.get('orchestrator_chat', []) orchestrator_chat.insert(0, { # Insert at top (newest first) 'timestamp': datetime.now().strftime('%d.%m.%Y %H:%M:%S'), 'user_prompt': prompt, 'agent': agent_name, 'agent_key': selected_agent, 'response': response, 'is_notification': False }) session['orchestrator_chat'] = orchestrator_chat[:30] # Keep first 30 chat_display = session.get('orchestrator_chat', []) return render_template('orchestrator.html', agents=AGENTS, chat_history=chat_display, knowledge_loaded=bool(load_knowledge_base())) @app.route('/orchestrator/clear', methods=['POST']) def orchestrator_clear(): """Löscht den Orchestrator Chat-Verlauf.""" session['orchestrator_chat'] = [] session.modified = True return jsonify({'success': True}) @app.route('/agents', methods=['GET', 'POST']) @login_required def agents(): agents_dir = os.path.join(os.path.dirname(__file__), 'agents') agents_list = [] if os.path.exists(agents_dir): for agent_name in sorted(os.listdir(agents_dir)): agent_path = os.path.join(agents_dir, agent_name) prompt_file = os.path.join(agent_path, 'systemprompt.md') reminders_file = os.path.join(agent_path, 'reminders.md') personality_file = os.path.join(agent_path, 'personality.md') if os.path.isdir(agent_path): prompt_content = '' reminders_content = '' personality_content = '' if os.path.exists(prompt_file): with open(prompt_file, 'r', encoding='utf-8') as f: prompt_content = f.read() if os.path.exists(reminders_file): with open(reminders_file, 'r', encoding='utf-8') as f: reminders_content = f.read() else: reminders_content = '# Erinnerungen - ' + agent_name.title() + '\n\n## Aktuelle Tasks\n-\n\n## Notizen\n- \n\n## Letzte Aktionen\n- ' if os.path.exists(personality_file): with open(personality_file, 'r', encoding='utf-8') as f: personality_content = f.read() agents_list.append({ 'name': agent_name, 'prompt': prompt_content, 'reminders': reminders_content, 'personality': personality_content }) edit_agent = request.args.get('edit') if not edit_agent and agents_list: return redirect(url_for('agents', edit=agents_list[0]['name'])) edit_prompt = '' edit_reminders = '' edit_personality = '' edit_model = 'opencode/big-pickle' if edit_agent: for agent in agents_list: if agent['name'] == edit_agent: edit_prompt = agent['prompt'] edit_reminders = agent['reminders'] edit_personality = agent['personality'] break edit_model = get_agent_model(edit_agent) if request.method == 'POST': agent_name = request.form.get('agent_name', '').strip() prompt_content = request.form.get('prompt_content', '') reminders_content = request.form.get('reminders_content', '') personality_content = request.form.get('personality_content', '') if agent_name: agent_path = os.path.join(agents_dir, agent_name) if prompt_content is not None: prompt_file = os.path.join(agent_path, 'systemprompt.md') with open(prompt_file, 'w', encoding='utf-8') as f: f.write(prompt_content) if reminders_content is not None: reminders_file = os.path.join(agent_path, 'reminders.md') with open(reminders_file, 'w', encoding='utf-8') as f: f.write(reminders_content) if personality_content is not None: personality_file = os.path.join(agent_path, 'personality.md') with open(personality_file, 'w', encoding='utf-8') as f: f.write(personality_content) flash(f'Daten für "{agent_name}" gespeichert!', 'success') return redirect(url_for('agents', edit=agent_name)) # Verfügbare Modelle laden available_models = get_available_models() return render_template('agents.html', agents=AGENTS, agents_list=agents_list, edit_agent=edit_agent, edit_prompt=edit_prompt, edit_reminders=edit_reminders, edit_personality=edit_personality, edit_model=edit_model, available_models=available_models) @app.route('/files', methods=['GET', 'POST']) @login_required def files(): if request.method == 'POST': if 'file' not in request.files: flash('Keine Datei ausgewählt', 'danger') else: file = request.files['file'] if file.filename == '': flash('Keine Datei ausgewählt', 'danger') else: filepath = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) file.save(filepath) flash('Datei hochgeladen!', 'success') file_list = get_uploaded_files() email_files = get_email_folder_files() project_files = get_project_files() # Agent Work Folders sammeln agent_work_folders = {} for agent_key in AGENTS.keys(): work_files = get_agent_work_files(agent_key) if work_files: # Nur Agenten mit Dateien anzeigen agent_work_folders[agent_key] = work_files return render_template('files.html', files=file_list, email_files=email_files, project_files=project_files, agent_work_folders=agent_work_folders) @app.route('/files/delete/') @login_required def delete_file(filename): filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) if os.path.exists(filepath): os.remove(filepath) flash('Datei gelöscht!', 'success') return redirect(url_for('files')) @app.route('/files/download/') @login_required def download_file(filename): """Liefert eine hochgeladene Datei zum Download/Anzeige.""" return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=False) @app.route('/files/agent//') @login_required def download_agent_file(agent_key, filename): """Liefert eine Datei aus dem Work-Ordner eines Agenten.""" if agent_key not in AGENTS: return jsonify({'error': 'Agent nicht gefunden'}), 404 dirs = ensure_agent_structure(agent_key) work_dir = dirs['work_dir'] filepath = os.path.join(work_dir, filename) # Security: Stelle sicher, dass die Datei im work_dir ist if not os.path.abspath(filepath).startswith(os.path.abspath(work_dir)): return jsonify({'error': 'Zugriff verweigert'}), 403 if not os.path.isfile(filepath): return jsonify({'error': 'Datei nicht gefunden'}), 404 # Force download wenn download=1 Parameter as_attachment = request.args.get('download') == '1' return send_from_directory(work_dir, filename, as_attachment=as_attachment) @app.route('/files/agent//view/') @login_required def view_agent_file(agent_key, filename): """Gibt Inhalt einer Agent-Datei als JSON zurück.""" if agent_key not in AGENTS: return jsonify({'error': 'Agent nicht gefunden'}), 404 dirs = ensure_agent_structure(agent_key) work_dir = dirs['work_dir'] filepath = os.path.join(work_dir, filename) # Security: Stelle sicher, dass die Datei im work_dir ist if not os.path.abspath(filepath).startswith(os.path.abspath(work_dir)): return jsonify({'error': 'Zugriff verweigert'}), 403 if not os.path.isfile(filepath): return jsonify({'error': 'Datei nicht gefunden'}), 404 try: with open(filepath, 'r', encoding='utf-8') as f: content = f.read() if request.args.get('json'): return jsonify({'content': content}) return content except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/files/agent//delete/') @login_required def delete_agent_file(agent_key, filename): """Löscht eine Datei aus dem Work-Ordner eines Agenten.""" if agent_key not in AGENTS: flash('Agent nicht gefunden', 'danger') return redirect(url_for('files')) dirs = ensure_agent_structure(agent_key) work_dir = dirs['work_dir'] filepath = os.path.join(work_dir, filename) # Security: Stelle sicher, dass die Datei im work_dir ist if not os.path.abspath(filepath).startswith(os.path.abspath(work_dir)): flash('Zugriff verweigert', 'danger') return redirect(url_for('files')) if not os.path.isfile(filepath): flash('Datei nicht gefunden', 'warning') return redirect(url_for('files')) try: os.remove(filepath) flash(f'Agent-Datei "{filename}" gelöscht', 'success') except Exception as e: flash(f'Fehler beim Löschen: {str(e)}', 'danger') return redirect(url_for('files')) @app.route('/files/email/view/') @login_required def view_email_file(filename): """Gibt Inhalt einer Email-Vorlage als JSON oder direkten Text zurück.""" email_dir = os.path.join(os.path.dirname(__file__), 'emails') filepath = os.path.join(email_dir, filename) # Security: stay inside emails/ dir if not os.path.abspath(filepath).startswith(os.path.abspath(email_dir)): return jsonify({'error': 'Zugriff verweigert'}), 403 if not os.path.isfile(filepath): return jsonify({'error': 'Datei nicht gefunden'}), 404 try: with open(filepath, 'r', encoding='utf-8', errors='replace') as f: content = f.read() if request.args.get('json'): return jsonify({'content': content}) return content, 200, {'Content-Type': 'text/plain; charset=utf-8'} except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/files/email/save/', methods=['POST']) @login_required def save_email_file(filename): """Speichert den Inhalt einer Email-Vorlage (JSON POST).""" email_dir = os.path.join(os.path.dirname(__file__), 'emails') filepath = os.path.join(email_dir, filename) if not os.path.abspath(filepath).startswith(os.path.abspath(email_dir)): return jsonify({'ok': False, 'error': 'Zugriff verweigert'}), 403 try: data = request.get_json() content = data.get('content', '') if data else '' with open(filepath, 'w', encoding='utf-8') as f: f.write(content) return jsonify({'ok': True}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @app.route('/files/email/delete/') @login_required def delete_email_file(filename): """Löscht eine Email-Vorlage.""" email_dir = os.path.join(os.path.dirname(__file__), 'emails') filepath = os.path.join(email_dir, filename) if not os.path.abspath(filepath).startswith(os.path.abspath(email_dir)): flash('Zugriff verweigert', 'danger') return redirect(url_for('files')) if os.path.isfile(filepath): os.remove(filepath) flash(f'Email-Vorlage "{filename}" gelöscht!', 'success') else: flash('Datei nicht gefunden', 'warning') return redirect(url_for('files')) @app.route('/files/project/view/') @login_required def view_project_file(filename): """Gibt Inhalt einer Projektdatei als JSON zurück.""" base_dir = os.path.dirname(__file__) filepath = os.path.join(base_dir, filename) # Security: stay in base dir (no subdirs) if os.path.dirname(os.path.abspath(filepath)) != os.path.abspath(base_dir): return jsonify({'error': 'Zugriff verweigert'}), 403 allowed_ext = ('.md', '.txt', '.docx') if not filename.lower().endswith(allowed_ext): return jsonify({'error': 'Dateityp nicht unterstützt'}), 400 if not os.path.isfile(filepath): return jsonify({'error': 'Datei nicht gefunden'}), 404 try: if filename.lower().endswith('.docx'): return jsonify({'content': '(DOCX-Vorschau nicht verfügbar – Datei herunterladen)'}), 200 with open(filepath, 'r', encoding='utf-8', errors='replace') as f: content = f.read() if request.args.get('json'): return jsonify({'content': content}) return content, 200, {'Content-Type': 'text/plain; charset=utf-8'} except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/files/project/') @login_required def download_project_file(filename): """Liefert eine Projektdatei zum Download.""" base_dir = os.path.dirname(__file__) filepath = os.path.join(base_dir, filename) # Security: stay in base dir if os.path.dirname(os.path.abspath(filepath)) != os.path.abspath(base_dir): return jsonify({'error': 'Zugriff verweigert'}), 403 allowed_ext = ('.md', '.txt', '.docx') if not filename.lower().endswith(allowed_ext): return jsonify({'error': 'Dateityp nicht unterstützt'}), 400 if not os.path.isfile(filepath): return jsonify({'error': 'Datei nicht gefunden'}), 404 # Force download wenn download=1 Parameter as_attachment = request.args.get('download') == '1' return send_from_directory(base_dir, filename, as_attachment=as_attachment) @app.route('/files/project/delete/') @login_required def delete_project_file(filename): """Löscht eine Projektdatei.""" base_dir = os.path.dirname(__file__) filepath = os.path.join(base_dir, filename) # Security: stay in base dir if os.path.dirname(os.path.abspath(filepath)) != os.path.abspath(base_dir): flash('Zugriff verweigert', 'danger') return redirect(url_for('files')) allowed_ext = ('.md', '.txt', '.docx') if not filename.lower().endswith(allowed_ext): flash('Dateityp nicht unterstützt', 'warning') return redirect(url_for('files')) if not os.path.isfile(filepath): flash('Datei nicht gefunden', 'warning') return redirect(url_for('files')) try: os.remove(filepath) flash(f'Projektdokument "{filename}" gelöscht', 'success') except Exception as e: flash(f'Fehler beim Löschen: {str(e)}', 'danger') return redirect(url_for('files')) @app.route('/emails', methods=['GET', 'POST']) @login_required def emails(): """Email Management Interface""" if request.method == 'POST': action = request.form.get('action') if action == 'send': to_address = request.form.get('to_address', '').strip() subject = request.form.get('subject', '').strip() body = request.form.get('body', '').strip() if to_address and subject and body: success, message = send_email(to_address, subject, body) if success: flash('Email erfolgreich versendet!', 'success') else: flash(f'Fehler: {message}', 'danger') else: flash('Bitte alle Felder ausfüllen', 'warning') email_config_valid = bool(EMAIL_CONFIG['email_address'] and EMAIL_CONFIG['email_password']) emails_list = get_emails() if email_config_valid else [] return render_template('emails.html', emails=emails_list, email_config_valid=email_config_valid, current_email=EMAIL_CONFIG['email_address']) @app.route('/emails/') @login_required def view_email(email_id): """View single email content""" if not (EMAIL_CONFIG['email_address'] and EMAIL_CONFIG['email_password']): return 'Email-Konfiguration erforderlich', 400 body = get_email_body(email_id) return {'content': body} @app.route('/email-log') @login_required def email_log_view(): """Zeigt das Email-Verarbeitungs-Log.""" with email_log_lock: log_entries = list(reversed(email_log)) # Neueste zuerst return render_template('email_log.html', agents=AGENTS, log_entries=log_entries) @app.route('/settings', methods=['GET', 'POST']) @login_required def settings(): """App-Einstellungen & Poller-Einstellungen zur Laufzeit ändern.""" if request.method == 'POST': # App-Name & Theme Einstellungen if 'app_name' in request.form: app_name = request.form.get('app_name', 'Frankenbot').strip() theme = request.form.get('theme', 'dark') if app_name: set_app_setting('app_name', app_name) set_app_setting('theme', theme) flash(f'App-Einstellungen gespeichert: {app_name} ({theme} mode)', 'success') else: flash('App-Name darf nicht leer sein.', 'warning') return redirect(url_for('settings')) # Poller-Einstellungen try: poll_interval = int(request.form.get('poll_interval', 120)) failsafe_window = int(request.form.get('failsafe_window', 600)) if poll_interval < 10: flash('Poll-Intervall muss mindestens 10 Sekunden betragen.', 'warning') elif failsafe_window < poll_interval: flash('Failsafe-Fenster muss größer als das Poll-Intervall sein.', 'warning') else: poller_settings['poll_interval'] = poll_interval poller_settings['failsafe_window'] = failsafe_window flash(f'Einstellungen gespeichert: Poll alle {poll_interval}s, Failsafe nach {failsafe_window}s.', 'success') except ValueError: flash('Ungültige Eingabe – bitte nur ganze Zahlen eingeben.', 'danger') return redirect(url_for('settings')) # Journal-Statistik für Anzeige con = sqlite3.connect(EMAIL_JOURNAL_DB) journal_rows = con.execute( "SELECT status, COUNT(*) FROM email_journal GROUP BY status" ).fetchall() con.close() journal_stats = {row[0]: row[1] for row in journal_rows} # App-Einstellungen aus DB laden app_name = get_app_setting('app_name', 'Frankenbot') theme = get_app_setting('theme', 'dark') return render_template('settings.html', agents=AGENTS, poller_settings=poller_settings, journal_stats=journal_stats, telegram_config=TELEGRAM_CONFIG, app_name=app_name, theme=theme) @app.route('/settings/journal-clear', methods=['POST']) @login_required def journal_clear(): """Löscht abgeschlossene Journal-Einträge (completed, skipped, error).""" con = sqlite3.connect(EMAIL_JOURNAL_DB) deleted = con.execute( "DELETE FROM email_journal WHERE status IN ('completed','skipped','error')" ).rowcount con.commit() con.close() flash(f'{deleted} abgeschlossene Journal-Einträge gelöscht.', 'success') return redirect(url_for('settings')) @app.route('/team') @login_required def team(): """Zeigt alle Team-Members an.""" team_members = get_team_members(active_only=False) return render_template('team.html', team_members=team_members, agents=AGENTS) @app.route('/team/add', methods=['POST']) @login_required def team_add(): """Fügt ein neues Team-Mitglied hinzu.""" name = request.form.get('name', '').strip() email = request.form.get('email', '').strip() role = request.form.get('role', '').strip() responsibilities = request.form.get('responsibilities', '').strip() telegram_chat_id = request.form.get('telegram_chat_id', '').strip() if not name or not email or not role or not responsibilities: flash('Alle Felder außer Telegram Chat ID sind Pflichtfelder!', 'danger') return redirect(url_for('team')) # Optional: Telegram Chat ID als Integer chat_id = None if telegram_chat_id: try: chat_id = int(telegram_chat_id) except ValueError: flash('Telegram Chat ID muss eine Zahl sein!', 'warning') success = add_team_member(name, role, responsibilities, email, telegram_id=chat_id) if success: flash(f'✅ Team-Member "{name}" erfolgreich hinzugefügt!', 'success') logger.info(f"[Team] Neues Mitglied hinzugefügt: {name} ({email})") else: flash(f'❌ Team-Member konnte nicht hinzugefügt werden (Email evtl. bereits vorhanden).', 'danger') return redirect(url_for('team')) @app.route('/team//activate', methods=['POST']) @login_required def team_activate(member_id): """Aktiviert ein Team-Mitglied.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute("UPDATE team_members SET active = 1 WHERE id = ?", (member_id,)) con.commit() con.close() logger.info(f"[Team] Member #{member_id} aktiviert") return jsonify({'success': True}) @app.route('/team//deactivate', methods=['POST']) @login_required def team_deactivate(member_id): """Deaktiviert ein Team-Mitglied.""" con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute("UPDATE team_members SET active = 0 WHERE id = ?", (member_id,)) con.commit() con.close() logger.info(f"[Team] Member #{member_id} deaktiviert") return jsonify({'success': True}) @app.route('/team/edit', methods=['POST']) @login_required def team_edit(): """Bearbeitet ein Team-Mitglied.""" member_id = request.form.get('member_id') name = request.form.get('name', '').strip() email = request.form.get('email', '').strip() role = request.form.get('role', '').strip() responsibilities = request.form.get('responsibilities', '').strip() telegram_chat_id = request.form.get('telegram_chat_id', '').strip() if not member_id or not name or not email or not role or not responsibilities: flash('Alle Felder außer Telegram Chat ID sind Pflichtfelder!', 'danger') return redirect(url_for('team')) # Optional: Telegram Chat ID als Integer chat_id = None if telegram_chat_id: try: chat_id = int(telegram_chat_id) except ValueError: flash('Telegram Chat ID muss eine Zahl sein!', 'warning') chat_id = None # Update in Datenbank con = sqlite3.connect(EMAIL_JOURNAL_DB) con.execute(""" UPDATE team_members SET name = ?, email = ?, role = ?, responsibilities = ?, telegram_id = ? WHERE id = ? """, (name, email, role, responsibilities, chat_id, member_id)) con.commit() con.close() flash(f'✅ Team-Member "{name}" erfolgreich aktualisiert!', 'success') logger.info(f"[Team] Member #{member_id} aktualisiert: {name} ({email})") return redirect(url_for('team')) @app.route('/api/telegram-qr') @login_required def telegram_qr(): """Generiert QR-Code für Telegram Bot.""" if not TELEGRAM_CONFIG['bot_token'] or not TELEGRAM_CONFIG['bot_username']: return "Telegram Bot nicht konfiguriert", 404 # Bot-Link erstellen bot_link = f"https://t.me/{TELEGRAM_CONFIG['bot_username']}?start=connect" # QR-Code generieren qr = qrcode.QRCode(version=1, box_size=10, border=2) qr.add_data(bot_link) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") # In BytesIO speichern img_io = io.BytesIO() img.save(img_io, 'PNG') img_io.seek(0) return Response(img_io.getvalue(), mimetype='image/png') # ── Task API ──────────────────────────────────────────────────────────────── @app.route('/api/tasks', methods=['GET', 'POST']) @login_required def api_tasks(): """API zum Erstellen und Abrufen von Tasks.""" if request.method == 'POST': data = request.get_json() title = data.get('title', '').strip() description = data.get('description', '') assigned_agent = data.get('assigned_agent', '') agent_key = data.get('agent_key', '') if not title: return jsonify({'error': 'Kein Titel übergeben'}), 400 task_id = create_task( title=title, description=description, agent_key=agent_key or assigned_agent or 'orchestrator', task_type='agent_created', created_by=agent_key or 'api' ) # Task aus DB holen für Response con = sqlite3.connect(EMAIL_JOURNAL_DB) task_row = con.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() con.close() new_task = { 'id': task_id, 'title': title, 'description': description, 'assigned_agent': AGENTS.get(agent_key or assigned_agent, {}).get('name', agent_key or assigned_agent) if (agent_key or assigned_agent) else 'Nicht zugewiesen', 'agent_key': agent_key or assigned_agent or 'orchestrator', 'status': 'pending', 'created': datetime.now().strftime('%Y-%m-%d %H:%M'), 'type': 'agent_created', 'created_by': agent_key or 'api' } return jsonify({'success': True, 'task': new_task}) # GET: Alle Tasks aus DB task_list = get_tasks() return jsonify({'tasks': task_list}) @app.route('/api/tasks/', methods=['PUT']) @login_required def update_task_api(task_id): """API zum Aktualisieren eines Tasks.""" data = request.get_json() new_status = data.get('status') # Update in DB update_task_db(task_id, status=new_status) # Auch in-memory aktualisieren (Legacy) for task in tasks: if task['id'] == task_id: task['status'] = new_status return jsonify({'success': True, 'task': task}) # Falls nur in DB vorhanden return jsonify({'success': True, 'task_id': task_id, 'status': new_status}) @app.route('/api/models', methods=['GET']) @login_required def get_models(): """Gibt die Liste der verfügbaren KI-Modelle zurück.""" force_refresh = request.args.get('refresh', 'false').lower() == 'true' models_data = get_available_models(force_refresh=force_refresh) return jsonify(models_data) @app.route('/api/agent//model', methods=['POST']) @login_required def set_agent_model(agent_name): """Setzt das Modell für einen Agenten.""" data = request.get_json() if data and 'model' in data: save_agent_config(agent_name, data['model']) return jsonify({'success': True, 'message': f'Modell für {agent_name} gesetzt auf {data["model"]}'}) return jsonify({'error': 'Kein Modell übergeben'}), 400 @app.route('/api/agent//delete', methods=['DELETE']) @login_required def delete_agent(agent_name): """Löscht einen Agenten (den gesamten Ordner).""" import shutil agents_dir = os.path.join(os.path.dirname(__file__), 'agents') agent_path = os.path.join(agents_dir, agent_name) if not os.path.isdir(agent_path): return jsonify({'error': 'Agent nicht gefunden'}), 404 try: shutil.rmtree(agent_path) config = get_agent_config() if agent_name in config: del config[agent_name] with open(AGENT_CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump(config, f, indent=2) # AGENTS Dictionary neu laden global AGENTS AGENTS = load_agents_from_directories() logger.info(f"[AgentDelete] Agent gelöscht: {agent_name}") return jsonify({'success': True, 'message': f'Agent "{agent_name}" wurde gelöscht.'}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/agent//reminders', methods=['GET', 'POST']) @login_required def agent_reminders(agent_name): agents_dir = os.path.join(os.path.dirname(__file__), 'agents') agent_path = os.path.join(agents_dir, agent_name) reminders_file = os.path.join(agent_path, 'reminders.md') if not os.path.isdir(agent_path): return jsonify({'error': 'Agent nicht gefunden'}), 404 if request.method == 'GET': if os.path.exists(reminders_file): with open(reminders_file, 'r', encoding='utf-8') as f: content = f.read() else: content = '' return jsonify({'reminders': content}) if request.method == 'POST': data = request.get_json() if data and 'reminders' in data: with open(reminders_file, 'w', encoding='utf-8') as f: f.write(data['reminders']) return jsonify({'success': True, 'message': 'Erinnerungen gespeichert'}) return jsonify({'error': 'Keine Daten übergeben'}), 400 @app.route('/api/orchestrator-distribute', methods=['POST']) @login_required def distribute_tasks(): """Erstellt einen Planungs-Task für den Orchestrator - dieser weist dann die richtigen Agenten zu.""" data = request.get_json() tasks_list = data.get('tasks', []) selected_agents = data.get('agents', []) if not tasks_list: return jsonify({'error': 'Keine Tasks übergeben'}), 400 tasks_text = '\n'.join([f"- {t}" for t in tasks_list]) agents_text = ', '.join(selected_agents) if selected_agents else 'alle verfügbaren Agenten' planning_task_id = create_task( title=f"Planungsphase: {tasks_list[0][:50]}{'...' if len(tasks_list[0]) > 50 else ''}", description=f"Tasks:\n{tasks_text}\n\nVerfügbare Agenten: {agents_text}\n\nDer Orchestrator soll diese Tasks analysieren und den richtigen Agenten zuweisen.", agent_key='orchestrator', task_type='orchestrated', created_by='orchestrator', sub_tasks=tasks_list, available_agents=selected_agents ) return jsonify({ 'success': True, 'message': f'Planungs-Task erstellt. Der Orchestrator wird die richtigen Agenten zuweisen.', 'tasks': [planning_task_id] }) for i, task_text in enumerate(tasks_list): agent_key = selected_agents[i % len(selected_agents)] task_id = create_task( title=task_text[:80], description='Automatisch erstellt durch Orchestrator', agent_key=agent_key, task_type='orchestrated', created_by='orchestrator' ) # Setze Status sofort auf in_progress update_task_db(task_id, status='in_progress') created_tasks.append(task_id) executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(selected_agents)) for i, task_text in enumerate(tasks_list): agent_key = selected_agents[i % len(selected_agents)] task_id = created_tasks[i] executor.submit(run_task_async, agent_key, task_text, task_id) return jsonify({ 'success': True, 'message': f'{len(created_tasks)} Tasks erstellt und werden im Hintergrund ausgeführt', 'tasks': created_tasks }) @app.route('/api/webhook/deploy', methods=['POST']) def webhook_deploy(): """Gitea Webhook: git pull + restart service on push to main.""" if not DEPLOY_SECRET: return jsonify({'error': 'Deploy not configured'}), 403 # Verify secret from Gitea webhook token = request.headers.get('Authorization', '').replace('Bearer ', '') if not token: # Gitea can also send secret in payload data = request.get_json(silent=True) or {} token = data.get('secret', '') if token != DEPLOY_SECRET: return jsonify({'error': 'Invalid secret'}), 403 # Run git pull, then restart service in background (detached so response returns first) repo_dir = os.path.dirname(os.path.abspath(__file__)) subprocess.Popen( ['bash', '-c', f'cd {repo_dir} && git pull && sleep 1 && sudo systemctl restart frankenbot'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) return jsonify({'success': True, 'message': 'Deploy triggered'}) def init_default_team_members(): """Fügt Standard-Team-Members hinzu, falls keine existieren.""" existing = get_team_members(active_only=False) if len(existing) == 0: add_team_member( name="Eric Fischer", role="Programmer", responsibilities="Funktionserweiterung", email="eric.fischer@signtime.media", telegram_id=None, phone="" ) add_team_member( name="Georg Tschare", role="CEO", responsibilities="Personalmanagement, Kundenkommunikation, Kommunikation mit Behörden", email="georg.tschare@signtime.media", telegram_id=None, phone="" ) add_team_member( name="Piotr Dyderski", role="Tech, 3D Art, RnD", responsibilities="Technische Infrastruktur, AI-Agenten, Automatisierung, 3D Avatare, Research and Development", email="p.dyderski@live.at", telegram_id=None, phone="" ) logging.info("[DB] Standard Team-Members initialisiert") if __name__ == '__main__': # Standard Team-Members initialisieren init_default_team_members() # Alte Tasks aufräumen (älter als 7 Tage) cleanup_old_tasks(days=7) # Telegram Bot starten start_telegram_thread() # Flask App starten app.run(debug=False, host='0.0.0.0', port=5050, threaded=True)