Cronjobs + AnthropicService Singleton Refactoring

Cronjobs:
- CronjobService (BackgroundService) prüft jede Minute auf fällige Jobs
- Cron-Expression in Server-Lokalzeit via Cronos (CronFormat.Standard)
- Cronjob-Ausführung: Prompt → Session → Agent-Antwort in Chat-History
- Optionale Benachrichtigungs-Session (notify_session_id): Zusammenfassung
  wird in separate Session geschrieben (z.B. mit Telegram)
- Telegram-Benachrichtigung mit Cronjob-Name + Ursprungs-Session
- REST API: CRUD + toggle + manuelles Ausführen (/run)
- Frontend: Dashboard-Cards, Cronjob-Formular mit Cron-Vorschau,
  Session-Dropdown, Notify-Session-Dropdown, Status-Box

Refactoring:
- AnthropicService als Singleton in DI registriert
- ChatEndpoint + TelegramService nutzen injizierte Instanz
- Redundante Dependencies (Token, ToolService, MemoryService) entfernt

Neue Dateien:
- Models/CronjobRecord.cs
- Services/CronjobService.cs
- Endpoints/CronjobEndpoint.cs
- wwwroot/js/pages/cronjob-page.js
- PLAN-Cronjob.md
This commit is contained in:
Meleki committed 2026-03-03 22:44:46 +01:00
1 parent 0120bb8f73
commit b645625b56
16 files changed
+1132 -28

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
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 TelegramService _telegramService;
private readonly ILogger<CronjobService> _logger;
public CronjobService(
DatabaseService db,
ChatService chatService,
AnthropicService anthropicService,
TelegramService telegramService,
ILogger<CronjobService> logger)
{
_db = db;
_chatService = chatService;
_anthropicService = anthropicService;
_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");
// History aus DB laden
var history = await _chatService.BuildHistoryFromDb(job.SessionId);
// 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
var 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;
}
// 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 } }
}
};
var 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 "";
}
}
}
+106
View File
@@ -166,6 +166,7 @@ public class DatabaseService
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
TryAddColumn(conn, "users", "persona", "TEXT");
TryAddColumn(conn, "users", "settings", "TEXT");
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills (
@@ -205,6 +206,28 @@ public class DatabaseService
);
CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS cronjobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cronjob_id TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
name TEXT NOT NULL,
cron_expression TEXT NOT NULL,
prompt TEXT NOT NULL,
notify_session_id TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
next_run_at TEXT,
last_run_status TEXT,
last_run_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id);
CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at);
");
}
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
@@ -862,4 +885,87 @@ public class DatabaseService
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC",
new { userId });
}
// ── Cronjobs ──
public async Task<CronjobRecord> CreateCronjob(CronjobRecord cronjob)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
cronjob.CronjobId = Guid.NewGuid().ToString("N");
cronjob.CreatedAt = now;
cronjob.UpdatedAt = now;
await conn.ExecuteAsync(@"
INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at)
VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt)
", cronjob);
return cronjob;
}
public async Task<IEnumerable<CronjobRecord>> GetCronjobs(string userId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<CronjobRecord>(@"
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
FROM cronjobs c
LEFT JOIN sessions s ON c.session_id = s.session_id
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
WHERE c.user_id = @userId
ORDER BY c.name",
new { userId });
}
public async Task<CronjobRecord> GetCronjob(string cronjobId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<CronjobRecord>(@"
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
FROM cronjobs c
LEFT JOIN sessions s ON c.session_id = s.session_id
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
WHERE c.cronjob_id = @cronjobId",
new { cronjobId });
}
public async Task UpdateCronjob(CronjobRecord cronjob)
{
using var conn = CreateConnection();
cronjob.UpdatedAt = DateTime.UtcNow.ToString("o");
await conn.ExecuteAsync(@"
UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression,
prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt
WHERE cronjob_id = @CronjobId AND user_id = @UserId",
cronjob);
}
public async Task DeleteCronjob(string cronjobId, string userId)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId",
new { cronjobId, userId });
}
public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
UPDATE cronjobs SET last_run_at = @now, last_run_status = @status,
last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now
WHERE cronjob_id = @cronjobId",
new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") });
}
public async Task<IEnumerable<CronjobRecord>> GetDueCronjobs()
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
return await conn.QueryAsync<CronjobRecord>(@"
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
FROM cronjobs c
LEFT JOIN sessions s ON c.session_id = s.session_id
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now",
new { now });
}
}
+4 -11
View File
@@ -10,29 +10,23 @@ namespace Gott.Services;
public class TelegramService
{
private readonly string _anthropicToken;
private readonly IHttpClientFactory _httpFactory;
private readonly DatabaseService _db;
private readonly ToolService _toolService;
private readonly MemoryService _memoryService;
private readonly ChatService _chatService;
private readonly AnthropicService _anthropic;
private readonly int _maxIterations;
public TelegramService(
string anthropicToken,
IHttpClientFactory httpFactory,
DatabaseService db,
ToolService toolService,
MemoryService memoryService,
ChatService chatService,
AnthropicService anthropic,
int maxIterations = 100)
{
_anthropicToken = anthropicToken;
_httpFactory = httpFactory;
_db = db;
_toolService = toolService;
_memoryService = memoryService;
_chatService = chatService;
_anthropic = anthropic;
_maxIterations = maxIterations;
}
@@ -165,8 +159,7 @@ public class TelegramService
});
// AnthropicService für Non-Streaming-Chat nutzen
var anthropic = new AnthropicService(_httpFactory, _anthropicToken, _toolService, _db, _memoryService);
var reply = await anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
var reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{
if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing");