using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Cronos; using Gott.Models; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Gott.Services; public class CronjobService : BackgroundService { private readonly DatabaseService _db; private readonly ChatService _chatService; private readonly AnthropicService _anthropicService; private readonly OpenAiService _openAiService; private readonly TelegramService _telegramService; private readonly ILogger _logger; public CronjobService( DatabaseService db, ChatService chatService, AnthropicService anthropicService, OpenAiService openAiService, TelegramService telegramService, ILogger logger) { _db = db; _chatService = chatService; _anthropicService = anthropicService; _openAiService = openAiService; _telegramService = telegramService; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("CronjobService gestartet"); while (!stoppingToken.IsCancellationRequested) { try { await ProcessDueCronjobs(); } catch (Exception ex) { _logger.LogError(ex, "Fehler beim Verarbeiten der Cronjobs"); } // Jede Minute prüfen await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } } private async Task ProcessDueCronjobs() { var dueJobs = await _db.GetDueCronjobs(); foreach (var job in dueJobs) { _logger.LogInformation("Cronjob '{Name}' ({Id}) wird ausgeführt", job.Name, job.CronjobId); try { await ExecuteCronjob(job); var nextRun = CalculateNextRun(job.CronExpression); await _db.UpdateCronjobRun(job.CronjobId, "success", null, nextRun); _logger.LogInformation("Cronjob '{Name}' erfolgreich. Nächste Ausführung: {Next}", job.Name, nextRun); } catch (Exception ex) { _logger.LogError(ex, "Cronjob '{Name}' fehlgeschlagen", job.Name); var nextRun = CalculateNextRun(job.CronExpression); await _db.UpdateCronjobRun(job.CronjobId, "error", ex.Message, nextRun); } } } public async Task ExecuteCronjob(CronjobRecord job) { // Chat-Context vorbereiten (wie bei normalem Chat) var ctx = await _chatService.PrepareContext(job.SessionId, job.UserId); if (ctx == null) throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert"); // API-Key des Cronjob-Owners für diesen Agent auflösen var userApiKey = await _db.GetAgentApiKeyRecord(job.UserId, ctx.AgentId); bool useCliFallback = false; if (userApiKey == null) { // Kein User-Key – Verhalten exakt wie ChatEndpoint: // - Anthropic (Provider leer) → Claude-CLI-Fallback (sofern berechtigt/verfügbar) // - "openai" → harter Fehler (kein Server-Key, kein CLI-Äquivalent) // - nvidia/gemini/minimax/pollinations → durchlassen: ResolveProviderConfig löst den Server-Key auf if (string.IsNullOrEmpty(ctx.Provider)) { var jobOwner = await _db.GetUser(job.UserId); if (jobOwner != null && !jobOwner.CanUseCli && !jobOwner.IsAdmin) throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen und User {job.UserId} hat keine CLI-Berechtigung."); if (!_anthropicService.IsCliAvailable) throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen und Claude CLI nicht verfügbar."); useCliFallback = true; _logger.LogInformation("Cronjob '{Name}': Kein API-Key für Agent {AgentId}, verwende CLI-Fallback", job.Name, ctx.AgentId); } else if (ctx.Provider == "openai") { throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen (User {job.UserId}). OpenAI benötigt zwingend einen zugewiesenen API-Key."); } // sonst: nvidia/gemini/minimax/pollinations → ctx.ApiToken bleibt leer, // der Server-seitige Provider-Key wird in ResolveProviderConfig aufgelöst. } else { ctx.ApiToken = userApiKey.ApiKey; } // History aus DB laden. includeThinking=true für OpenAI-kompatible Provider → // ...-Prefix wird dem Assistant-Text vorangestellt. var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider)); // User-Nachricht hinzufügen var userContent = new List { new { type = "text", text = job.Prompt } }; history.Add(new Dictionary { ["role"] = "user", ["content"] = userContent }); // User-Nachricht in DB speichern await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = 0, Role = "user", Type = "text", Content = job.Prompt }); // Non-Streaming Chat ausführen – Provider-Weiche string responseText; if (!string.IsNullOrEmpty(ctx.Provider)) responseText = await _openAiService.ChatNonStreaming(ctx, history); else if (useCliFallback) responseText = await _anthropicService.ChatNonStreamingViaCli(ctx, history); else responseText = await _anthropicService.ChatNonStreaming(ctx, history); // Session-Timestamp aktualisieren await _db.UpdateSessionTimestamp(job.SessionId); // Telegram-Benachrichtigung auf der Arbeits-Session senden, falls sie Telegram hat if (!string.IsNullOrEmpty(responseText)) { await _telegramService.SendNotification(job.SessionId, $"⏰ Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}"); } // Benachrichtigungs-Session: Zusammenfassung in eine andere Session schreiben if (!string.IsNullOrEmpty(job.NotifySessionId) && job.NotifySessionId != job.SessionId) { await SendCronjobNotification(job, responseText); } } private async Task SendCronjobNotification(CronjobRecord job, string responseText) { try { var notifyCtx = await _chatService.PrepareContext(job.NotifySessionId, job.UserId); if (notifyCtx == null) { _logger.LogWarning("Notification-Session {SessionId} für Cronjob '{Name}' nicht gefunden", job.NotifySessionId, job.Name); return; } // Cronjob-Ergebnis als Nachricht in die Notify-Session schreiben (für die Web-History). var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}"; await _db.SaveMessage(new MessageRecord { ChainId = notifyCtx.ChainId, Iteration = 0, Role = "user", Type = "text", Content = summary }); await _db.UpdateSessionTimestamp(job.NotifySessionId); // ── Nachricht 1 (Pflicht): das echte Cronjob-Ergebnis ────────────────────────── // Geht IMMER raus, unabhängig vom Notify-Agent / API-Key. Früher lief hier nur der // Notify-Agent auf der Zusammenfassung; quittierte er bloß mit "👍", erreichte der // eigentliche Inhalt Telegram nie. Das Ergebnis ist jetzt die garantierte Nachricht. if (!string.IsNullOrEmpty(responseText)) { await _telegramService.SendNotification(job.NotifySessionId, $"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}"); } // ── Nachricht 2 (optional, best effort): die Reaktion des Notify-Agenten ──────── // Der Notify-Agent reagiert auf das Ergebnis; seine Antwort geht als SEPARATE // Telegram-Nachricht raus und wird (über ChatNonStreaming*) automatisch in die // Web-History persistiert. Schlägt der Lauf fehl (kein Key / OpenAI ohne Key / // CLI nicht verfügbar), wird die Reaktion still übersprungen – Nachricht 1 ist // dann längst zugestellt, es geht also nie wieder Information verloren. try { // API-Key des Cronjob-Owners für den Notify-Agenten auflösen (wie ChatEndpoint): // - Anthropic (Provider leer) → Claude-CLI-Fallback (sofern berechtigt/verfügbar) // - "openai" → überspringen (kein Server-Key, kein CLI-Äquivalent) // - nvidia/gemini/minimax/pollinations → durchlassen: Server-Key via ResolveProviderConfig var notifyApiKey = await _db.GetAgentApiKeyRecord(job.UserId, notifyCtx.AgentId); bool notifyUseCliFallback = false; bool canRunNotifyAgent = true; if (notifyApiKey == null) { if (string.IsNullOrEmpty(notifyCtx.Provider)) { var notifyOwner = await _db.GetUser(job.UserId); if ((notifyOwner != null && !notifyOwner.CanUseCli && !notifyOwner.IsAdmin) || !_anthropicService.IsCliAvailable) { _logger.LogWarning("Notify-Agent-Reaktion für Cronjob '{Name}' übersprungen: kein API-Key und CLI nicht verfügbar/berechtigt", job.Name); canRunNotifyAgent = false; } else { notifyUseCliFallback = true; } } else if (notifyCtx.Provider == "openai") { _logger.LogWarning("Notify-Agent-Reaktion für Cronjob '{Name}' übersprungen: OpenAI benötigt zwingend einen Key", job.Name); canRunNotifyAgent = false; } // sonst: nvidia/gemini/minimax/pollinations → notifyCtx.ApiToken bleibt leer (Server-Key-Auflösung). } else { notifyCtx.ApiToken = notifyApiKey.ApiKey; } if (canRunNotifyAgent) { // Nur die Benachrichtigung als History senden (nicht volle Session-History, // da diese mit einer assistant-Nachricht enden könnte → prefill-Fehler) var notifyHistory = new List { new Dictionary { ["role"] = "user", ["content"] = new List { new { type = "text", text = summary } } } }; string notifyResponse; if (!string.IsNullOrEmpty(notifyCtx.Provider)) notifyResponse = await _openAiService.ChatNonStreaming(notifyCtx, notifyHistory); else if (notifyUseCliFallback) notifyResponse = await _anthropicService.ChatNonStreamingViaCli(notifyCtx, notifyHistory); else notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory); if (!string.IsNullOrEmpty(notifyResponse)) { await _telegramService.SendNotification(job.NotifySessionId, $"💬 {notifyResponse}"); } } } catch (Exception reactEx) { // Reaktion ist rein optional – das Ergebnis (Nachricht 1) ist bereits zugestellt. _logger.LogWarning(reactEx, "Notify-Agent-Reaktion für Cronjob '{Name}' fehlgeschlagen (Ergebnis wurde dennoch gesendet)", job.Name); } } catch (Exception ex) { _logger.LogError(ex, "Fehler beim Senden der Cronjob-Benachrichtigung an Session {SessionId}", job.NotifySessionId); } } private static string Truncate(string text, int maxLength) { if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? ""; return text.Substring(0, maxLength) + "…"; } public static string CalculateNextRun(string cronExpression) { try { var cron = CronExpression.Parse(cronExpression, CronFormat.Standard); // Cron-Expression in Server-Lokalzeit interpretieren var next = cron.GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local); return next?.UtcDateTime.ToString("o") ?? ""; } catch { return ""; } } }