Files
gott/Services/CronjobService.cs
T
MelekiandClaude Opus 4.8 e3f89043c1 Fix cronjob/telegram fallback for keyless server-key providers
Cronjobs and Telegram bots threw a hard error when an agent had no
assigned user API key, even for providers (nvidia/gemini/minimax/
pollinations) whose server-side key ChatEndpoint resolves automatically
via ResolveProviderConfig. The guard blocked every non-empty provider
instead of only "openai", so e.g. the nvidia agent failed on "Jetzt
ausführen" although the normal chat worked.

Mirror ChatEndpoint's three-way handling at all three sites
(ExecuteCronjob, SendCronjobNotification, TelegramService.HandleMessage):
- Anthropic (empty provider): Claude CLI fallback when permitted/available
- "openai": hard error (no server key, no CLI equivalent)
- other providers: pass through with empty token; server key resolved later

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:25:05 +02:00

283 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CronjobService> _logger;
public CronjobService(
DatabaseService db,
ChatService chatService,
AnthropicService anthropicService,
OpenAiService openAiService,
TelegramService telegramService,
ILogger<CronjobService> 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 →
// <think>...</think>-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<object> { new { type = "text", text = job.Prompt } };
history.Add(new Dictionary<string, object> { ["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;
}
// API-Key des Cronjob-Owners für Notify-Agent auflösen
var notifyApiKey = await _db.GetAgentApiKeyRecord(job.UserId, notifyCtx.AgentId);
bool notifyUseCliFallback = false;
if (notifyApiKey == null)
{
// Kein User-Key – Verhalten wie ExecuteCronjob/ChatEndpoint:
// - Anthropic (Provider leer) → Claude-CLI-Fallback (sofern berechtigt/verfügbar)
// - "openai" → abbrechen (kein Server-Key, kein CLI-Äquivalent)
// - nvidia/gemini/minimax/pollinations → durchlassen: Server-Key via ResolveProviderConfig
if (string.IsNullOrEmpty(notifyCtx.Provider))
{
var notifyOwner = await _db.GetUser(job.UserId);
if ((notifyOwner != null && !notifyOwner.CanUseCli && !notifyOwner.IsAdmin) || !_anthropicService.IsCliAvailable)
{
_logger.LogWarning("Kein API-Key für Agent {AgentId} (Cronjob '{Name}' Notification) und CLI nicht verfügbar/berechtigt", notifyCtx.AgentId, job.Name);
return;
}
notifyUseCliFallback = true;
}
else if (notifyCtx.Provider == "openai")
{
_logger.LogWarning("Kein API-Key für Agent {AgentId} (Cronjob '{Name}' Notification, OpenAI benötigt zwingend einen Key)", notifyCtx.AgentId, job.Name);
return;
}
// sonst: nvidia/gemini/minimax/pollinations → notifyCtx.ApiToken bleibt leer (Server-Key-Auflösung).
}
else
{
notifyCtx.ApiToken = notifyApiKey.ApiKey;
}
// Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
// In Notify-Session als Nachricht speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = notifyCtx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = summary
});
// 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<object>
{
new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { 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);
await _db.UpdateSessionTimestamp(job.NotifySessionId);
// Telegram auf Notify-Session senden
if (!string.IsNullOrEmpty(notifyResponse))
{
await _telegramService.SendNotification(job.NotifySessionId,
$"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{notifyResponse}");
}
}
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 "";
}
}
}