Files
gott/Services/CronjobService.cs
T
MelekiandClaude Opus 4.8 e395927a67 Send cronjob result and notify-agent reaction to Telegram
The cronjob notification ran the notify-agent on the result summary and
sent only its reaction to Telegram. When the agent merely acknowledged the
summary (e.g. "👍"), the actual cronjob result never reached Telegram —
the user got a header plus an emoji and no information.

Send the real result as the guaranteed first Telegram message, then run
the notify-agent best-effort and send its reaction as a separate second
message. The agent run is wrapped in try/catch with a canRunNotifyAgent
guard instead of the old return-aborts, so a missing key / OpenAI without
key / unavailable CLI only skips the reaction — the result is already
delivered. The notify-agent still persists its reaction to the web history
automatically (ChatNonStreaming / ChatNonStreamingViaCli).

TelegramService.SendNotification now uses SendLongMessage so long results
are chunked into 4096-char messages instead of being rejected whole.

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

310 lines
13 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;
}
// 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<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);
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 "";
}
}
}