User-spezifische API-Key-Auswahl, globales is_active entfernt

Backend:
- is_active komplett entfernt, durch is_public (Admin-Toggle) ersetzt
- Neues selected_api_key_id auf users-Tabelle
- AnthropicService: globaler _token entfernt, apiToken pro Request
- Token-Durchreichung: StreamChat → ProcessStream → RunAgentChat →
  RunPreparer/RunReview/RunFinalizer, ExecuteTool, send_to_session
- ChatEndpoint: User-Key aus DB laden, an StreamChat übergeben
- TelegramService: Key per bot.UserId auflösen
- CronjobService: Key per job.UserId auflösen
- Neue Endpoints: GET /api/api-keys/available, PUT /api/api-keys/select/{keyId},
  GET /api/api-keys/selected, PUT /api/admin/api-keys/{keyId}/public,
  POST /api/admin/api-keys/check-all
- Program.cs: AnthropicService ohne Token-Parameter

Frontend:
- API-Keys-Page: Public-Toggle (Globe/Lock) statt Activate/Deactivate
- Alle prüfen Button für Batch-Key-Check
- Chat: Key-Auswahl für alle User (nicht nur Admin)
- Dummy-Badge wenn kein Key ausgewählt
- Fallback-Badge bei Key ohne Usage-Daten
- Key-Switch-Modal: apiGetAvailableKeys + apiSelectApiKey
This commit is contained in:
Meleki committed 2026-03-13 12:27:03 +01:00
1 parent cc46cf2bbc
commit dde0fede7d
18 files changed
+340 -193

No files matched your search

+28 -32
View File
@@ -16,7 +16,6 @@ namespace Gott.Services;
public class AnthropicService
{
private readonly IHttpClientFactory _httpFactory;
private string _token;
private readonly ToolService _toolService;
private readonly DatabaseService _db;
private readonly MemoryService _memoryService;
@@ -27,10 +26,9 @@ public class AnthropicService
private ChatService _chatService;
private TelegramService _telegramService;
public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
{
_httpFactory = httpFactory;
_token = token;
_toolService = toolService;
_db = db;
_memoryService = memoryService;
@@ -54,25 +52,17 @@ public class AnthropicService
_telegramService = telegramService;
}
/// <summary>
/// Aktualisiert den API-Token zur Laufzeit (z.B. wenn ein anderer Key aktiviert wird).
/// </summary>
public void UpdateToken(string token)
{
_token = token ?? "";
}
/// <summary>
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
/// </summary>
private void TryUpdateKeyMeta(HttpResponseMessage httpResp)
private void TryUpdateKeyMeta(HttpResponseMessage httpResp, string apiToken)
{
try
{
var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers);
if (meta == null || string.IsNullOrEmpty(_token)) return;
_ = _db.UpdateApiKeyMetaByToken(_token, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
if (meta == null || string.IsNullOrEmpty(apiToken)) return;
_ = _db.UpdateApiKeyMetaByToken(apiToken, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
}
catch { /* silent */ }
}
@@ -84,6 +74,7 @@ public class AnthropicService
List<object> messages,
string chainId,
string workspace,
string apiToken,
string sessionId = null,
string agentId = null,
string userId = null,
@@ -181,7 +172,7 @@ public class AnthropicService
var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null;
AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote, includeGitHubSearch: useGitHubSearch && _toolService.HasGitHubToken, includeSerperSearch: useSerperSearch && _toolService.HasSerperKey);
var client = CreateClient();
var client = CreateClient(apiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, sessionId, chainId, iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
@@ -192,7 +183,7 @@ public class AnthropicService
workCt
);
TryUpdateKeyMeta(httpResp);
TryUpdateKeyMeta(httpResp, apiToken);
if (!httpResp.IsSuccessStatusCode)
{
@@ -227,7 +218,7 @@ public class AnthropicService
return;
}
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId);
var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId, apiToken);
lastStopReason = stopReason;
promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten
@@ -311,7 +302,7 @@ public class AnthropicService
// ── Review-Agent Phase ──
if (review != null && stopReason == "end_turn" && reviewRound < review.MaxReviewIterations)
{
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, usage, iterations, reviewRound, workCt, sseCt);
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, apiToken, usage, iterations, reviewRound, workCt, sseCt);
iterations += reviewResult.ReviewIterationCount;
reviewRound++;
@@ -336,7 +327,7 @@ public class AnthropicService
// ── Finalizer-Agent Phase ──
if (finalizer != null && stopReason == "end_turn")
{
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, usage, iterations, workCt, sseCt);
var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, apiToken, usage, iterations, workCt, sseCt);
iterations += finalizerIterations;
}
@@ -467,6 +458,7 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId,
string apiToken,
CancellationToken cancellationToken)
{
// Preparer läuft VOR dem Worker und nutzt den Request-CancellationToken direkt.
@@ -487,7 +479,7 @@ public class AnthropicService
var (text, iterations) = await RunAgentChat(
response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId,
preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion,
preparer.ToolChoice, preparerMessages, chainId, sessionId, usage,
preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage,
0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent",
cancellationToken, cancellationToken);
@@ -555,6 +547,7 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
int reviewRound,
@@ -579,7 +572,7 @@ public class AnthropicService
var (reviewText, reviewIterations) = await RunAgentChat(
response, review.ModelId, review.SystemPrompt, review.AgentId,
workspace, review.UseMemory, review.WebSearchVersion,
review.ToolChoice, reviewMessages, chainId, sessionId, usage,
review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage,
baseIterations, "review", review.Agent?.Name ?? "Review-Agent",
workCt, sseCt);
@@ -630,6 +623,7 @@ public class AnthropicService
string chainId,
string workspace,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
CancellationToken workCt,
@@ -647,7 +641,7 @@ public class AnthropicService
var (text, iterations) = await RunAgentChat(
response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId,
finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, usage,
finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage,
baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent",
workCt, sseCt);
@@ -794,6 +788,7 @@ public class AnthropicService
List<object> messages,
string chainId,
string sessionId,
string apiToken,
TokenUsage usage,
int baseIterations,
string agentType,
@@ -825,7 +820,7 @@ public class AnthropicService
AddToolsToPayload(payload, agentWorkspace, agentId, useMemory, webSearchVersion, agentToolChoice, iterations);
var client = CreateClient();
var client = CreateClient(apiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, sessionId, chainId, baseIterations + iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
@@ -834,7 +829,7 @@ public class AnthropicService
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
cancellationToken: workCt);
TryUpdateKeyMeta(httpResp);
TryUpdateKeyMeta(httpResp, apiToken);
var respBody = await httpResp.Content.ReadAsStringAsync(workCt);
@@ -925,7 +920,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, telegramService: _telegramService); }
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, telegramService: _telegramService, apiToken: apiToken); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1047,7 +1042,7 @@ public class AnthropicService
AddToolsToPayload(payload, ctx.Workspace, ctx.AgentId, ctx.UseMemory, ctx.WebSearchVersion, ctx.ToolChoice, iterations);
var client = CreateClient();
var client = CreateClient(ctx.ApiToken);
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
LogPayload(json, ctx.SessionId, ctx.ChainId, iterations);
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
@@ -1055,7 +1050,7 @@ public class AnthropicService
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent });
TryUpdateKeyMeta(httpResp);
TryUpdateKeyMeta(httpResp, ctx.ApiToken);
var respBody = await httpResp.Content.ReadAsStringAsync();
@@ -1133,7 +1128,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: ctx.Session?.UserId, telegramService: _telegramService); }
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1243,7 +1238,8 @@ public class AnthropicService
CancellationToken sseCt = default,
RemoteConnectionManager remoteMgr = null,
string sessionId = null,
string userId = null)
string userId = null,
string apiToken = null)
{
var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
// Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht.
@@ -1425,7 +1421,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: userId, telegramService: _telegramService); }
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: userId, telegramService: _telegramService, apiToken: apiToken); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1694,10 +1690,10 @@ public class AnthropicService
return 0;
}
private HttpClient CreateClient()
private HttpClient CreateClient(string apiToken)
{
var client = _httpFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,code-execution-2025-08-25");
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
+15
View File
@@ -86,6 +86,12 @@ public class CronjobService : BackgroundService
if (ctx == null)
throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert");
// API-Key des Cronjob-Owners auflösen
var userApiKey = await _db.GetUserSelectedApiKey(job.UserId);
if (userApiKey == null)
throw new Exception($"Kein API-Key für User {job.UserId} ausgewählt");
ctx.ApiToken = userApiKey.ApiKey;
// History aus DB laden
var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks);
@@ -134,6 +140,15 @@ public class CronjobService : BackgroundService
return;
}
// API-Key des Cronjob-Owners auflösen
var notifyApiKey = await _db.GetUserSelectedApiKey(job.UserId);
if (notifyApiKey == null)
{
_logger.LogWarning("Kein API-Key für User {UserId} (Cronjob '{Name}' Notification)", job.UserId, job.Name);
return;
}
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)}";
+10 -24
View File
@@ -324,7 +324,7 @@ public partial class DatabaseService
{
var now = DateTime.UtcNow.ToString("o");
conn.Execute(@"
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at)
INSERT INTO api_keys (key_id, provider, name, api_key, is_public, created_at, updated_at)
VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)",
new { keyId = Guid.NewGuid().ToString("N"), token, now });
Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert");
@@ -351,12 +351,11 @@ public partial class DatabaseService
new { provider });
}
public async Task<ApiKeyRecord> GetActiveApiKey(string provider)
public async Task<IEnumerable<ApiKeyRecord>> GetPublicApiKeys()
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
"SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1",
new { provider });
return await conn.QueryAsync<ApiKeyRecord>(
"SELECT * FROM api_keys WHERE is_public = 1 ORDER BY provider, name");
}
public async Task<ApiKeyRecord> GetApiKey(string keyId)
@@ -372,8 +371,8 @@ public partial class DatabaseService
{
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at)
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)",
INSERT INTO api_keys (key_id, provider, name, api_key, is_public, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at)
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsPublic, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)",
record);
return await conn.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
}
@@ -409,29 +408,16 @@ public partial class DatabaseService
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
UPDATE api_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now, updated_at = @now
WHERE api_key = @apiKey AND is_active = 1",
WHERE api_key = @apiKey",
new { apiKey, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") });
}
public async Task SetActiveApiKey(string keyId, string provider)
{
using var conn = CreateConnection();
// Erst alle deaktivieren
await conn.ExecuteAsync(
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider",
new { provider, now = DateTime.UtcNow.ToString("o") });
// Dann den gewählten aktivieren
await conn.ExecuteAsync(
"UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId",
new { keyId, now = DateTime.UtcNow.ToString("o") });
}
public async Task DeactivateApiKey(string keyId)
public async Task SetApiKeyPublic(string keyId, bool isPublic)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId",
new { keyId, now = DateTime.UtcNow.ToString("o") });
"UPDATE api_keys SET is_public = @isPublic, updated_at = @now WHERE key_id = @keyId",
new { keyId, isPublic, now = DateTime.UtcNow.ToString("o") });
}
public async Task DeleteApiKey(string keyId)
+28
View File
@@ -187,4 +187,32 @@ public partial class DatabaseService
await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId });
}
// ── User API-Key Auswahl ──
public async Task SetUserSelectedApiKey(string userId, string keyId)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"UPDATE users SET selected_api_key_id = @keyId, updated_at = @now WHERE user_id = @userId",
new { userId, keyId = (object)keyId ?? DBNull.Value, now = DateTime.UtcNow.ToString("o") });
}
public async Task<string> GetUserSelectedApiKeyId(string userId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<string>(
"SELECT selected_api_key_id FROM users WHERE user_id = @userId",
new { userId });
}
public async Task<ApiKeyRecord> GetUserSelectedApiKey(string userId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(@"
SELECT k.* FROM api_keys k
INNER JOIN users u ON u.selected_api_key_id = k.key_id
WHERE u.user_id = @userId",
new { userId });
}
}
+4
View File
@@ -341,6 +341,10 @@ public partial class DatabaseService
TryAddColumn(conn, "api_keys", "usage_5h", "REAL");
TryAddColumn(conn, "api_keys", "checked_at", "TEXT");
TryAddColumn(conn, "api_keys", "source_url", "TEXT");
TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0");
// users: ausgewählter API-Key
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
MigrateImagesToWebP(conn);
+12 -3
View File
@@ -80,7 +80,7 @@ public class TelegramService
return;
}
await HandleMessage(chatId, text, bot.BotToken, session);
await HandleMessage(chatId, text, bot.BotToken, bot.UserId, session);
}
private async Task HandleStart(long chatId, TelegramBotRecord bot, SessionRecord session)
@@ -159,15 +159,24 @@ public class TelegramService
// ── Nachricht an Claude weiterleiten ──
private async Task HandleMessage(long chatId, string userText, string botToken, SessionRecord session)
private async Task HandleMessage(long chatId, string userText, string botToken, string botUserId, SessionRecord session)
{
var ctx = await _chatService.PrepareContext(session.SessionId, null, _maxIterations);
var ctx = await _chatService.PrepareContext(session.SessionId, botUserId, _maxIterations);
if (ctx == null)
{
await SendMessage(chatId, botToken, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu.");
return;
}
// API-Key des Bot-Owners auflösen
var userApiKey = await _db.GetUserSelectedApiKey(botUserId);
if (userApiKey == null)
{
await SendMessage(chatId, botToken, "❌ Kein API-Key ausgewählt. Der Bot-Besitzer muss im Web-UI einen Key auswählen.");
return;
}
ctx.ApiToken = userApiKey.ApiKey;
await SendChatAction(chatId, botToken, "typing");
try
+3 -2
View File
@@ -391,7 +391,7 @@ public class ToolService
};
}
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, string userId = null, TelegramService telegramService = null)
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, string userId = null, TelegramService telegramService = null, string apiToken = null)
{
switch (name)
{
@@ -765,9 +765,10 @@ public class ToolService
return "Fehler: Kann keine Nachricht an die eigene Session senden";
// Context für Ziel-Session vorbereiten (analog zu CronjobService)
var ctx = await chatService.PrepareContext(targetSessionId, userId: null);
var ctx = await chatService.PrepareContext(targetSessionId, userId: userId);
if (ctx == null)
return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)";
ctx.ApiToken = apiToken;
// History aufbauen – User-Message NICHT in DB speichern (wie Cronjob)
// Stattdessen als kontextualisierte Nachricht in die History injizieren