From 0120bb8f7350a4ddb343163545545025ce16a3cc Mon Sep 17 00:00:00 2001 From: Meleki Date: Tue, 3 Mar 2026 21:46:59 +0100 Subject: [PATCH] Telegram: Bot=Session statt User-Level, Chat-Code-Refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Telegram-Config (Bot-Token, Webhook-Secret, ChatId) von User-Settings in sessions-Tabelle verschoben → jede Session hat ihren eigenen Bot - Link-Code-System komplett entfernt (TelegramLinkRecord gelöscht) - /start verknüpft automatisch, kein /link mehr nötig - Alle Endpoints session-basiert: /api/sessions/{id}/telegram/* - Webhook-URL pro Session: /api/telegram/webhook/{sessionId} Refactoring: Doppelten Chat-Code eliminiert - Neuer ChatService: PrepareContext, BuildHistoryFromDb, ResolveModelId - Neues ChatContext-Model als gemeinsame Datenstruktur - AnthropicService.ChatNonStreaming() für Telegram (statt eigenem Loop) - TelegramService um ~180 Zeilen reduziert, kein eigener Claude-Loop mehr - ChatEndpoint nutzt ChatService.PrepareContext() statt eigener Setup-Logik - Model-Mapping nur noch an einer Stelle (3x → 1x) Frontend - Telegram-Section von Settings-Page in Session-Bearbeitungsseite verschoben - Webhook-Secret readonly, wird einmalig generiert und persistent gespeichert - Status wird automatisch geladen, Register/Delete je nach Webhook-Status - Toast-Notification-System (showToast) in ui.js - Link-Code-Modal und Telegram-Menüpunkt im Dashboard entfernt - Telegram-Badge auf Session-Cards (konfiguriert/verbunden) Bugfix: Bearer statt x-api-key für OAuth-Tokens im TelegramService --- Endpoints/ChatEndpoint.cs | 98 +--- Endpoints/TelegramEndpoint.cs | 138 ++--- Middleware/AuthMiddleware.cs | 2 +- Models/ChatContext.cs | 20 + Models/SessionRecord.cs | 3 + Models/TelegramLinkRecord.cs | 14 - Program.cs | 18 +- Services/AnthropicService.cs | 179 ++++++ Services/ChatService.cs | 234 ++++++++ Services/DatabaseService.cs | 89 +-- Services/TelegramService.cs | 877 ++++++------------------------ appsettings.json | 4 +- wwwroot/css/dashboard.css | 120 ---- wwwroot/css/form.css | 21 +- wwwroot/css/main.css | 39 ++ wwwroot/index.html | 46 +- wwwroot/js/api.js | 38 +- wwwroot/js/pages/dashboard.js | 59 +- wwwroot/js/pages/session-page.js | 144 +++++ wwwroot/js/pages/settings-page.js | 93 +--- wwwroot/js/ui.js | 30 + 21 files changed, 994 insertions(+), 1272 deletions(-) create mode 100644 Models/ChatContext.cs delete mode 100644 Models/TelegramLinkRecord.cs create mode 100644 Services/ChatService.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 9b1f9ad..2c00980 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; @@ -13,7 +12,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; namespace Gott.Endpoints; @@ -24,7 +22,7 @@ public static class ChatEndpoint app.MapPost("/api/chat", HandleChat); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, SkillService skillService, CancellationToken cancellationToken) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, ToolService toolService, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null || body.Messages == null || body.Messages.Count == 0) @@ -50,91 +48,27 @@ public static class ChatEndpoint return; } - // Agent aus Session auflösen var userId = req.HttpContext.GetUserId(); - var session = await db.GetSession(sessionId); - if (session == null || session.UserId != userId || string.IsNullOrEmpty(session.AgentId)) + var maxIterations = config.GetValue("MaxIterations", 100); + + var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations); + if (ctx == null) { res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "Kein Agent ausgewählt" }); + await res.WriteAsJsonAsync(new { error = "Session/Agent nicht gefunden oder kein Agent ausgewählt" }); return; } - var agent = await db.GetAgent(session.AgentId); - if (agent == null) - { - res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "Agent nicht gefunden" }); - return; - } - - var modelId = agent.Model switch - { - "sonnet" => "claude-sonnet-4-6", - "sonnet-4-5" => "claude-sonnet-4-5", - "sonnet-4-6" => "claude-sonnet-4-6", - "opus" => "claude-opus-4-6", - "opus-4-5" => "claude-opus-4-5", - "opus-4-6" => "claude-opus-4-6", - "haiku" => "claude-haiku-4-5", - "haiku-4-5" => "claude-haiku-4-5", - _ => "claude-sonnet-4-6" - }; - res.Headers["Content-Type"] = "text/event-stream"; res.Headers["Cache-Control"] = "no-cache"; res.Headers["Connection"] = "keep-alive"; - var markedMessages = await db.GetMarkedMessages(sessionId); - var agentMemoryDir = memoryService.GetAgentMemoryDir(agent.AgentId); - var agentSkills = await db.GetAgentSkills(agent.AgentId); - var user = await db.GetUser(userId); - var systemPromptText = SystemPromptBuilder.Build(agent, session, markedMessages, memoryService, agentMemoryDir, agentSkills, skillService, userPersona: user?.GetEffectivePersona()); - - var chainId = Guid.NewGuid().ToString("N"); - - var workspace = session.Workspace; - - // Skills brauchen Tool-Use (execute) – wenn Skills aktiv aber kein Workspace gesetzt, - // /tmp als Fallback-Workspace setzen damit die Tool-Definitionen generiert werden. - if (string.IsNullOrEmpty(workspace) && agentSkills.Any()) - workspace = "/tmp"; - - var toolChoice = agent.ToolChoice ?? "any"; - var hasWorkspace = !string.IsNullOrEmpty(workspace); - var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId); - var toolDefs = (hasWorkspace || includeMemory) - ? toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory) - : Array.Empty(); - var toolNames = toolDefs.Length > 0 - ? JsonSerializer.Serialize(toolDefs.Select(td => - { - var json = JsonSerializer.Serialize(td); - var el = JsonSerializer.Deserialize(json); - return el.GetProperty("name").GetString(); - }).ToArray()) - : null; - - await db.CreateChain(new ChainRecord - { - ChainId = chainId, - SessionId = sessionId, - AgentId = agent.AgentId, - Model = modelId, - SystemPrompt = systemPromptText, - ToolChoice = (hasWorkspace || includeMemory) ? toolChoice : null, - ToolNames = toolNames - }); - - // Session-Timestamp aktualisieren - await db.UpdateSessionTimestamp(sessionId); - + // Messages aus dem Request aufbereiten var messages = new List(); foreach (var msg in body.Messages) { - // Content bereinigen: interne Felder (_file_name) entfernen JsonElement content = msg.Content; - if (msg.Content.ValueKind == System.Text.Json.JsonValueKind.Array) + if (msg.Content.ValueKind == JsonValueKind.Array) { var node = System.Text.Json.Nodes.JsonNode.Parse(msg.Content.GetRawText()); foreach (var block in node.AsArray()) @@ -157,7 +91,6 @@ public static class ChatEndpoint if (lastUserMsg != null) { var textContent = lastUserMsg.GetTextContent(); - var attachmentInfos = lastUserMsg.GetAttachments(); var attachmentBlocks = lastUserMsg.GetAttachmentBlocks(); var attachmentRecords = attachmentBlocks.Select(b => new AttachmentRecord @@ -170,7 +103,7 @@ public static class ChatEndpoint await db.SaveMessageWithAttachments(new MessageRecord { - ChainId = chainId, + ChainId = ctx.ChainId, Iteration = 0, Role = "user", Type = "text", @@ -178,20 +111,11 @@ public static class ChatEndpoint }, attachmentRecords); } - var systemPrompt = string.IsNullOrWhiteSpace(systemPromptText) - ? new List() - : new List { new { type = "text", text = systemPromptText } }; - - if (!string.IsNullOrEmpty(workspace)) - Directory.CreateDirectory(workspace); - - var maxIterations = config.GetValue("MaxIterations", 100); - var anthropic = new AnthropicService(httpFactory, token, toolService, db, memoryService); - streamingTracker.Start(sessionId, chainId); + streamingTracker.Start(sessionId, ctx.ChainId); try { - await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace, sessionId, agentId: agent.AgentId, useMemory: agent.UseMemory, webSearchVersion: agent.WebSearchVersion, maxIterations: maxIterations, toolChoice: toolChoice, cancellationToken: cancellationToken); + await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: ctx.ToolChoice, cancellationToken: cancellationToken); } finally { diff --git a/Endpoints/TelegramEndpoint.cs b/Endpoints/TelegramEndpoint.cs index f7dc212..6d382d5 100644 --- a/Endpoints/TelegramEndpoint.cs +++ b/Endpoints/TelegramEndpoint.cs @@ -13,28 +13,29 @@ public static class TelegramEndpoint { public static void MapTelegramEndpoints(this IEndpointRouteBuilder app) { - // Webhook (von Telegram aufgerufen – keine Cookie-Auth!) - app.MapPost("/api/telegram/webhook", HandleWebhook); + // Webhook pro Session (von Telegram aufgerufen – keine Cookie-Auth!) + app.MapPost("/api/telegram/webhook/{sessionId}", HandleWebhook); - // Link-Code generieren (aus Web-UI, braucht Auth) - app.MapPost("/api/telegram/link", HandleCreateLink); + // Session Telegram-Config speichern + app.MapPut("/api/sessions/{sessionId}/telegram", HandleSaveTelegram); - // Webhook registrieren (Admin) - app.MapPost("/api/telegram/register-webhook", HandleRegisterWebhook); + // Session Telegram-Config abrufen + app.MapGet("/api/sessions/{sessionId}/telegram", HandleGetTelegram); - // Telegram-Status (ob konfiguriert – für Frontend) - app.MapGet("/api/telegram/status", HandleStatus); + // Webhook registrieren (pro Session) + app.MapPost("/api/sessions/{sessionId}/telegram/register-webhook", HandleRegisterWebhook); - // Webhook-Info (Admin) - app.MapGet("/api/telegram/webhook-info", HandleWebhookInfo); + // Webhook-Info (pro Session) + app.MapGet("/api/sessions/{sessionId}/telegram/webhook-info", HandleWebhookInfo); - // Webhook löschen (Admin) - app.MapDelete("/api/telegram/webhook", HandleDeleteWebhook); + // Webhook löschen (pro Session) + app.MapDelete("/api/sessions/{sessionId}/telegram/webhook", HandleDeleteWebhook); } - private static async Task HandleWebhook(HttpRequest req, HttpResponse res, TelegramService telegram) + private static async Task HandleWebhook(string sessionId, HttpRequest req, HttpResponse res, TelegramService telegram, DatabaseService db) { - if (!telegram.IsConfigured) + var session = await db.GetSession(sessionId); + if (session == null || string.IsNullOrEmpty(session.TelegramBotToken)) { res.StatusCode = 404; return; @@ -42,7 +43,7 @@ public static class TelegramEndpoint // Secret-Token prüfen var secret = req.Headers["X-Telegram-Bot-Api-Secret-Token"].ToString(); - if (secret != telegram.WebhookSecret) + if (!string.IsNullOrEmpty(session.TelegramWebhookSecret) && secret != session.TelegramWebhookSecret) { res.StatusCode = 403; return; @@ -54,8 +55,8 @@ public static class TelegramEndpoint // Fire-and-forget – Telegram erwartet schnelle 200 OK Antwort _ = Task.Run(async () => { - try { await telegram.HandleUpdate(body); } - catch (Exception ex) { Console.Error.WriteLine($"[Telegram] Fehler: {ex.Message}"); } + try { await telegram.HandleUpdate(body, session); } + catch (Exception ex) { Console.Error.WriteLine($"[Telegram] Fehler für Session {sessionId}: {ex.Message}"); } }); } catch @@ -66,26 +67,9 @@ public static class TelegramEndpoint res.StatusCode = 200; } - private static async Task HandleCreateLink(HttpRequest req, HttpResponse res, DatabaseService db, TelegramService telegram) + private static async Task HandleSaveTelegram(string sessionId, HttpRequest req, HttpResponse res, DatabaseService db) { - if (!telegram.IsConfigured) - { - res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "Telegram Bot nicht konfiguriert" }); - return; - } - var userId = req.HttpContext.GetUserId(); - var body = await JsonSerializer.DeserializeAsync(req.Body); - - if (!body.TryGetProperty("sessionId", out var sidEl)) - { - res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "sessionId required" }); - return; - } - - var sessionId = sidEl.GetString(); var session = await db.GetSession(sessionId); if (session == null || session.UserId != userId) { @@ -94,18 +78,42 @@ public static class TelegramEndpoint return; } - var agentId = session.AgentId; - var code = await db.CreateTelegramLinkCode(userId, sessionId, agentId); + var body = await JsonSerializer.DeserializeAsync(req.Body); + var botToken = body.TryGetProperty("botToken", out var t) ? t.GetString() : null; + var webhookSecret = body.TryGetProperty("webhookSecret", out var s) ? s.GetString() : null; - await res.WriteAsJsonAsync(new { code, expiresInSeconds = 300 }); + await db.UpdateSessionTelegram(sessionId, botToken, webhookSecret, session.TelegramChatId); + await res.WriteAsJsonAsync(new { ok = true }); } - private static async Task HandleRegisterWebhook(HttpRequest req, HttpResponse res, TelegramService telegram) + private static async Task HandleGetTelegram(string sessionId, HttpRequest req, HttpResponse res, DatabaseService db) { - if (!req.HttpContext.IsAdmin()) + var userId = req.HttpContext.GetUserId(); + var session = await db.GetSession(sessionId); + if (session == null || session.UserId != userId) { - res.StatusCode = 403; - await res.WriteAsJsonAsync(new { error = "Admin required" }); + res.StatusCode = 404; + await res.WriteAsJsonAsync(new { error = "Session nicht gefunden" }); + return; + } + + await res.WriteAsJsonAsync(new + { + botToken = session.TelegramBotToken ?? "", + webhookSecret = session.TelegramWebhookSecret ?? "", + chatId = session.TelegramChatId ?? "", + configured = !string.IsNullOrEmpty(session.TelegramBotToken) + }); + } + + private static async Task HandleRegisterWebhook(string sessionId, HttpRequest req, HttpResponse res, DatabaseService db, TelegramService telegram) + { + var userId = req.HttpContext.GetUserId(); + var session = await db.GetSession(sessionId); + if (session == null || session.UserId != userId) + { + res.StatusCode = 404; + await res.WriteAsJsonAsync(new { error = "Session nicht gefunden" }); return; } @@ -117,39 +125,37 @@ public static class TelegramEndpoint return; } - var result = await telegram.RegisterWebhook(urlEl.GetString()); - await res.WriteAsJsonAsync(new { result }); - } - - private static async Task HandleStatus(HttpResponse res, TelegramService telegram) - { - await res.WriteAsJsonAsync(new { configured = telegram.IsConfigured }); - } - - private static async Task HandleWebhookInfo(HttpRequest req, HttpResponse res, TelegramService telegram) - { - if (!req.HttpContext.IsAdmin()) - { - res.StatusCode = 403; - await res.WriteAsJsonAsync(new { error = "Admin required" }); - return; - } - - var result = await telegram.GetWebhookInfo(); + var result = await telegram.RegisterWebhook(urlEl.GetString(), sessionId); res.ContentType = "application/json"; await res.WriteAsync(result); } - private static async Task HandleDeleteWebhook(HttpRequest req, HttpResponse res, TelegramService telegram) + private static async Task HandleWebhookInfo(string sessionId, HttpRequest req, HttpResponse res, DatabaseService db, TelegramService telegram) { - if (!req.HttpContext.IsAdmin()) + var userId = req.HttpContext.GetUserId(); + var session = await db.GetSession(sessionId); + if (session == null || session.UserId != userId) { - res.StatusCode = 403; - await res.WriteAsJsonAsync(new { error = "Admin required" }); + res.StatusCode = 404; return; } - var result = await telegram.DeleteWebhook(); + var result = await telegram.GetWebhookInfo(sessionId); + res.ContentType = "application/json"; + await res.WriteAsync(result); + } + + private static async Task HandleDeleteWebhook(string sessionId, HttpRequest req, HttpResponse res, DatabaseService db, TelegramService telegram) + { + var userId = req.HttpContext.GetUserId(); + var session = await db.GetSession(sessionId); + if (session == null || session.UserId != userId) + { + res.StatusCode = 404; + return; + } + + var result = await telegram.DeleteWebhook(sessionId); res.ContentType = "application/json"; await res.WriteAsync(result); } diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index aea8f89..f3f8fb4 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -18,7 +18,7 @@ public class AuthMiddleware var path = ctx.Request.Path.Value ?? ""; // Static files, login and telegram webhook don't require auth - var isTelegramWebhook = path == "/api/telegram/webhook" && ctx.Request.Method == "POST"; + var isTelegramWebhook = path.StartsWith("/api/telegram/webhook/") && ctx.Request.Method == "POST"; if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook) { await _next(ctx); diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs new file mode 100644 index 0000000..880ede4 --- /dev/null +++ b/Models/ChatContext.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Gott.Models; + +public class ChatContext +{ + public string ChainId { get; set; } + public string SessionId { get; set; } + public string ModelId { get; set; } + public string Workspace { get; set; } + public string AgentId { get; set; } + public string SystemPromptText { get; set; } + public List SystemPrompt { get; set; } + public string ToolChoice { get; set; } + public bool UseMemory { get; set; } + public string WebSearchVersion { get; set; } + public int MaxIterations { get; set; } + public AgentRecord Agent { get; set; } + public SessionRecord Session { get; set; } +} diff --git a/Models/SessionRecord.cs b/Models/SessionRecord.cs index 5b5668d..e15352c 100644 --- a/Models/SessionRecord.cs +++ b/Models/SessionRecord.cs @@ -13,6 +13,9 @@ public class SessionRecord public string CustomCommands { get; set; } public string UserId { get; set; } public bool IsDeleted { get; set; } + public string TelegramBotToken { get; set; } + public string TelegramWebhookSecret { get; set; } + public string TelegramChatId { get; set; } public string CreatedAt { get; set; } public string UpdatedAt { get; set; } } diff --git a/Models/TelegramLinkRecord.cs b/Models/TelegramLinkRecord.cs deleted file mode 100644 index 8d9386b..0000000 --- a/Models/TelegramLinkRecord.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Gott.Models; - -public class TelegramLinkRecord -{ - public long Id { get; set; } - public long TelegramChatId { get; set; } - public string UserId { get; set; } - public string SessionId { get; set; } - public string AgentId { get; set; } - public string LinkCode { get; set; } - public string ExpiresAt { get; set; } - public string CreatedAt { get; set; } - public string UpdatedAt { get; set; } -} diff --git a/Program.cs b/Program.cs index 5440c14..10a2ac6 100644 --- a/Program.cs +++ b/Program.cs @@ -20,7 +20,6 @@ dbService.EnsureDefaultUser(authService); builder.Services.AddSingleton(authService); builder.Services.AddSingleton(dbService); -builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new StreamingTracker()); var memoryService = new MemoryService(dataDir); @@ -28,16 +27,21 @@ var skillService = new SkillService(dataDir); builder.Services.AddSingleton(memoryService); builder.Services.AddSingleton(skillService); -// Telegram Bot (optional – nur wenn Token konfiguriert) -var telegramToken = builder.Configuration["TelegramBotToken"] ?? ""; -var telegramSecret = builder.Configuration["TelegramWebhookSecret"] ?? Guid.NewGuid().ToString("N"); +// Shared Services +var toolService = new ToolService(); +builder.Services.AddSingleton(toolService); +var chatService = new ChatService(dbService, toolService, memoryService, skillService); +builder.Services.AddSingleton(chatService); + +// Telegram Bot (pro Session – Token aus Session-Config) var anthropicToken = builder.Configuration["AnthropicToken"] ?? ""; +var maxIterations = builder.Configuration.GetValue("MaxIterations", 100); builder.Services.AddSingleton(sp => new TelegramService( - telegramToken, telegramSecret, anthropicToken, + anthropicToken, sp.GetRequiredService(), - dbService, new ToolService(), memoryService, skillService, - builder.Configuration.GetValue("MaxIterations", 100))); + dbService, toolService, memoryService, chatService, + maxIterations)); var app = builder.Build(); app.UseDefaultFiles(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 683b8df..70427e0 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -235,6 +235,185 @@ public class AnthropicService } } + /// + /// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück. + /// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator). + /// + public async Task ChatNonStreaming( + ChatContext ctx, + List messages, + Func onIteration = null) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var usage = new TokenUsage(); + int iterations = 0; + var finalText = new StringBuilder(); + + var chain = new ChainRecord + { + ChainId = ctx.ChainId, + SessionId = ctx.SessionId, + Model = ctx.ModelId + }; + + try + { + while (iterations < ctx.MaxIterations) + { + iterations++; + onIteration?.Invoke(iterations); + + var payload = new Dictionary + { + ["model"] = ctx.ModelId, + ["max_tokens"] = 16384, + ["messages"] = messages, + ["stream"] = false + }; + + if (ctx.SystemPrompt != null && ctx.SystemPrompt.Count > 0) + payload["system"] = ctx.SystemPrompt; + + var hasWorkspace = !string.IsNullOrEmpty(ctx.Workspace); + var includeMemory = ctx.UseMemory && _memoryService != null && !string.IsNullOrEmpty(ctx.AgentId); + var hasTools = hasWorkspace || includeMemory; + var hasWebSearch = !string.IsNullOrEmpty(ctx.WebSearchVersion); + + if (hasTools || hasWebSearch) + { + var tools = new List(); + if (hasTools) + tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory)); + if (hasWebSearch) + tools.Add(new { type = ctx.WebSearchVersion, name = "web_search", max_uses = 5 }); + payload["tools"] = tools; + var effectiveChoice = (ctx.ToolChoice == "any" && iterations == 1) ? "any" : "auto"; + payload["tool_choice"] = new { type = effectiveChoice }; + } + + var client = CreateClient(); + var json = JsonSerializer.Serialize(payload); + var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); + + var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }); + + var respBody = await httpResp.Content.ReadAsStringAsync(); + + if (!httpResp.IsSuccessStatusCode) + { + var errMsg = $"API {(int)httpResp.StatusCode}: {respBody}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await FinalizeChain(chain, usage, iterations, "error", sw); + throw new Exception($"Claude API Fehler: {(int)httpResp.StatusCode}"); + } + + var resp = JsonSerializer.Deserialize(respBody); + + // Usage tracken + if (resp.TryGetProperty("usage", out var u)) + { + if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64(); + if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64(); + if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64(); + if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64(); + } + + var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : ""; + var content = resp.GetProperty("content"); + + var assistantContent = new List(); + var toolResults = new List(); + + foreach (var block in content.EnumerateArray()) + { + var blockType = block.GetProperty("type").GetString() ?? ""; + + if (blockType == "text") + { + var text = block.GetProperty("text").GetString() ?? ""; + assistantContent.Add(new { type = "text", text }); + finalText.Append(text); + + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "text", + Content = text + }); + } + else if (blockType == "tool_use") + { + var toolId = block.GetProperty("id").GetString() ?? ""; + var toolName = block.GetProperty("name").GetString() ?? ""; + var toolInput = block.GetProperty("input"); + + assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput }); + + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "tool_use", + Content = toolInput.GetRawText(), + ToolName = toolName, ToolCallId = toolId + }); + + string toolResult; + bool isError = false; + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId); } + catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } + + // Tool-Result in der History kürzen (wie bei StreamChat) + const int MaxToolResultChars = 20_000; + var truncatedResult = toolResult.Length > MaxToolResultChars + ? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]" + : toolResult; + + toolResults.Add(new + { + type = "tool_result", + tool_use_id = toolId, + content = truncatedResult, + is_error = isError + }); + + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "user", Type = "tool_result", + Content = toolResult, + ToolName = toolName, ToolCallId = toolId, + IsError = isError + }); + } + } + + messages.Add(new Dictionary { ["role"] = "assistant", ["content"] = assistantContent }); + + if (stopReason == "end_turn" || stopReason == "stop_sequence" || toolResults.Count == 0) + { + await FinalizeChain(chain, usage, iterations, stopReason, sw); + break; + } + + messages.Add(new Dictionary { ["role"] = "user", ["content"] = toolResults }); + } + } + catch + { + if (iterations > 0) + await FinalizeChain(chain, usage, iterations, "error", sw); + throw; + } + + return finalText.Length > 0 ? finalText.ToString() : "(Keine Textantwort)"; + } + private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null) { chain.InputTokens = usage.InputTokens; diff --git a/Services/ChatService.cs b/Services/ChatService.cs new file mode 100644 index 0000000..ec99028 --- /dev/null +++ b/Services/ChatService.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Gott.Models; + +namespace Gott.Services; + +public class ChatService +{ + private readonly DatabaseService _db; + private readonly ToolService _toolService; + private readonly MemoryService _memoryService; + private readonly SkillService _skillService; + + public ChatService(DatabaseService db, ToolService toolService, MemoryService memoryService, SkillService skillService) + { + _db = db; + _toolService = toolService; + _memoryService = memoryService; + _skillService = skillService; + } + + public static string ResolveModelId(string model) + { + return model switch + { + "sonnet" => "claude-sonnet-4-6", + "sonnet-4-5" => "claude-sonnet-4-5", + "sonnet-4-6" => "claude-sonnet-4-6", + "opus" => "claude-opus-4-6", + "opus-4-5" => "claude-opus-4-5", + "opus-4-6" => "claude-opus-4-6", + "haiku" => "claude-haiku-4-5", + "haiku-4-5" => "claude-haiku-4-5", + _ => "claude-sonnet-4-6" + }; + } + + /// + /// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc. + /// + public async Task PrepareContext(string sessionId, string userId, int maxIterations = 100) + { + var session = await _db.GetSession(sessionId); + if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId)) + return null; + + var agent = await _db.GetAgent(session.AgentId); + if (agent == null) + return null; + + var modelId = ResolveModelId(agent.Model); + + var markedMessages = await _db.GetMarkedMessages(sessionId); + var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId); + var agentSkills = await _db.GetAgentSkills(agent.AgentId); + var user = userId != null ? await _db.GetUser(userId) : null; + var systemPromptText = SystemPromptBuilder.Build( + agent, session, markedMessages, _memoryService, agentMemoryDir, + agentSkills, _skillService, userPersona: user?.GetEffectivePersona()); + + var workspace = session.Workspace; + if (string.IsNullOrEmpty(workspace) && agentSkills.Any()) + workspace = "/tmp"; + + var toolChoice = agent.ToolChoice ?? "any"; + var hasWorkspace = !string.IsNullOrEmpty(workspace); + var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId); + + var toolDefs = (hasWorkspace || includeMemory) + ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory) + : Array.Empty(); + var toolNames = toolDefs.Length > 0 + ? JsonSerializer.Serialize(toolDefs.Select(td => + { + var json = JsonSerializer.Serialize(td); + var el = JsonSerializer.Deserialize(json); + return el.GetProperty("name").GetString(); + }).ToArray()) + : null; + + var chainId = Guid.NewGuid().ToString("N"); + + await _db.CreateChain(new ChainRecord + { + ChainId = chainId, + SessionId = sessionId, + AgentId = agent.AgentId, + Model = modelId, + SystemPrompt = systemPromptText, + ToolChoice = (hasWorkspace || includeMemory) ? toolChoice : null, + ToolNames = toolNames + }); + + await _db.UpdateSessionTimestamp(sessionId); + + if (!string.IsNullOrEmpty(workspace)) + Directory.CreateDirectory(workspace); + + var systemPrompt = new List(); + if (!string.IsNullOrWhiteSpace(systemPromptText)) + systemPrompt.Add(new { type = "text", text = systemPromptText }); + + return new ChatContext + { + ChainId = chainId, + SessionId = sessionId, + ModelId = modelId, + Workspace = workspace, + AgentId = agent.AgentId, + SystemPromptText = systemPromptText, + SystemPrompt = systemPrompt, + ToolChoice = toolChoice, + UseMemory = agent.UseMemory, + WebSearchVersion = agent.WebSearchVersion, + MaxIterations = maxIterations, + Agent = agent, + Session = session + }; + } + + /// + /// Baut die Message-History aus der DB (aktive Chains). + /// + public async Task> BuildHistoryFromDb(string sessionId) + { + var chains = await _db.GetActiveChains(sessionId); + var history = new List(); + + foreach (var chain in chains) + { + var msgs = await _db.GetChainMessages(chain.ChainId); + var grouped = new Dictionary>(); + foreach (var msg in msgs) + { + if (!grouped.ContainsKey(msg.Iteration)) + grouped[msg.Iteration] = new List(); + grouped[msg.Iteration].Add(msg); + } + + foreach (var kvp in grouped.OrderBy(x => x.Key)) + { + foreach (var msg in kvp.Value) + { + if (msg.Role == "user" && msg.Type == "text") + { + var content = new List(); + var attachments = await _db.GetAttachments(msg.Id); + foreach (var att in attachments) + { + if (att.Type == "image") + { + content.Add(new + { + type = "image", + source = new { type = "base64", media_type = att.MediaType, data = att.Data } + }); + } + else if (att.Type == "document") + { + content.Add(new + { + type = "document", + source = new { type = "base64", media_type = att.MediaType, data = att.Data } + }); + } + } + content.Add(new { type = "text", text = msg.Content ?? "" }); + history.Add(new Dictionary { ["role"] = "user", ["content"] = content }); + } + else if (msg.Role == "assistant" && msg.Type == "text") + { + history.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = new List { new { type = "text", text = msg.Content ?? "" } } + }); + } + else if (msg.Role == "assistant" && msg.Type == "tool_use") + { + JsonElement inputEl; + try { inputEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + + var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; + if (lastAssistant is Dictionary dict && + dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && + dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) + { + contentList.Add(new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }); + } + else + { + history.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = new List + { + new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl } + } + }); + } + } + else if (msg.Role == "user" && msg.Type == "tool_result") + { + var lastUser = history.Count > 0 ? history[history.Count - 1] : null; + if (lastUser is Dictionary dict && + dict.TryGetValue("role", out var role) && role.ToString() == "user" && + dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) + { + contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = msg.Content ?? "", is_error = msg.IsError }); + } + else + { + history.Add(new Dictionary + { + ["role"] = "user", + ["content"] = new List + { + new { type = "tool_result", tool_use_id = msg.ToolCallId, content = msg.Content ?? "", is_error = msg.IsError } + } + }); + } + } + } + } + } + + return history; + } +} diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 52a245a..402930f 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -159,6 +159,9 @@ public class DatabaseService // User-Zuordnung TryAddColumn(conn, "sessions", "user_id", "TEXT"); + TryAddColumn(conn, "sessions", "telegram_bot_token", "TEXT"); + TryAddColumn(conn, "sessions", "telegram_webhook_secret", "TEXT"); + TryAddColumn(conn, "sessions", "telegram_chat_id", "TEXT"); TryAddColumn(conn, "agents", "user_id", "TEXT"); TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "users", "persona", "TEXT"); @@ -331,7 +334,9 @@ public class DatabaseService s.CreatedAt, s.UpdatedAt, MessageCount = activeCount, - TotalMessageCount = totalCount + TotalMessageCount = totalCount, + TelegramConfigured = !string.IsNullOrEmpty(s.TelegramBotToken), + TelegramLinked = !string.IsNullOrEmpty(s.TelegramChatId) }); } return results; @@ -809,79 +814,45 @@ public class DatabaseService new { userId, settings, now = DateTime.UtcNow.ToString("o") }); } - // ── Telegram ── + // ── Telegram (Session-basiert) ── - public async Task CreateTelegramLinkCode(string userId, string sessionId, string agentId) + public async Task GetSessionByTelegramChatId(long chatId) { using var conn = CreateConnection(); - var code = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant(); - var now = DateTime.UtcNow.ToString("o"); - var expires = DateTime.UtcNow.AddMinutes(5).ToString("o"); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM sessions WHERE telegram_chat_id = @chatId AND is_deleted = 0", + new { chatId = chatId.ToString() }); + } - // Alten Code für diese Session löschen - await conn.ExecuteAsync( - "DELETE FROM telegram_links WHERE link_code IS NOT NULL AND user_id = @userId AND session_id = @sessionId", - new { userId, sessionId }); - - // Neuen Pending-Link-Code erstellen (noch ohne telegram_chat_id) + public async Task UpdateSessionTelegram(string sessionId, string botToken, string webhookSecret, string chatId) + { + using var conn = CreateConnection(); await conn.ExecuteAsync(@" - INSERT INTO telegram_links (telegram_chat_id, user_id, session_id, agent_id, link_code, expires_at, created_at, updated_at) - VALUES (0, @userId, @sessionId, @agentId, @code, @expires, @now, @now)", - new { userId, sessionId, agentId, code, expires, now }); - - return code; + UPDATE sessions SET telegram_bot_token = @botToken, telegram_webhook_secret = @webhookSecret, + telegram_chat_id = @chatId, updated_at = @now + WHERE session_id = @sessionId", + new { sessionId, botToken, webhookSecret, chatId, now = DateTime.UtcNow.ToString("o") }); } - public async Task RedeemTelegramLinkCode(string code, long chatId) + public async Task SetSessionTelegramChatId(string sessionId, string chatId) { using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - - var link = await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM telegram_links WHERE link_code = @code AND telegram_chat_id = 0", - new { code }); - - if (link == null) return null; - if (string.Compare(link.ExpiresAt, now, StringComparison.Ordinal) < 0) return null; - - // Bestehende Verknüpfung für diese chatId entfernen + // Erst alte Verknüpfung mit dieser chatId entfernen (ein Telegram-Chat = eine Session) await conn.ExecuteAsync( - "DELETE FROM telegram_links WHERE telegram_chat_id = @chatId", - new { chatId }); - - // Pending-Eintrag aktivieren - await conn.ExecuteAsync(@" - UPDATE telegram_links SET telegram_chat_id = @chatId, link_code = NULL, expires_at = NULL, updated_at = @now - WHERE id = @id", - new { chatId, now, id = link.Id }); - - link.TelegramChatId = chatId; - link.LinkCode = null; - return link; + "UPDATE sessions SET telegram_chat_id = NULL, updated_at = @now WHERE telegram_chat_id = @chatId", + new { chatId, now = DateTime.UtcNow.ToString("o") }); + // Dann diese Session verknüpfen + await conn.ExecuteAsync( + "UPDATE sessions SET telegram_chat_id = @chatId, updated_at = @now WHERE session_id = @sessionId", + new { sessionId, chatId, now = DateTime.UtcNow.ToString("o") }); } - public async Task GetTelegramLink(long chatId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM telegram_links WHERE telegram_chat_id = @chatId", - new { chatId }); - } - - public async Task UpdateTelegramSession(long chatId, string sessionId, string agentId) + public async Task ClearSessionTelegramChatId(string sessionId) { using var conn = CreateConnection(); await conn.ExecuteAsync( - "UPDATE telegram_links SET session_id = @sessionId, agent_id = @agentId, updated_at = @now WHERE telegram_chat_id = @chatId", - new { chatId, sessionId, agentId, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task DeleteTelegramLink(long chatId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "DELETE FROM telegram_links WHERE telegram_chat_id = @chatId", - new { chatId }); + "UPDATE sessions SET telegram_chat_id = NULL, updated_at = @now WHERE session_id = @sessionId", + new { sessionId, now = DateTime.UtcNow.ToString("o") }); } public async Task> GetUserSessions(string userId) diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs index 9296757..e4cc6be 100644 --- a/Services/TelegramService.cs +++ b/Services/TelegramService.cs @@ -1,12 +1,8 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using Gott.Models; @@ -14,44 +10,47 @@ namespace Gott.Services; public class TelegramService { - private readonly string _botToken; - private readonly string _webhookSecret; private readonly string _anthropicToken; private readonly IHttpClientFactory _httpFactory; private readonly DatabaseService _db; private readonly ToolService _toolService; private readonly MemoryService _memoryService; - private readonly SkillService _skillService; + private readonly ChatService _chatService; private readonly int _maxIterations; public TelegramService( - string botToken, - string webhookSecret, string anthropicToken, IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, MemoryService memoryService, - SkillService skillService, + ChatService chatService, int maxIterations = 100) { - _botToken = botToken; - _webhookSecret = webhookSecret; _anthropicToken = anthropicToken; _httpFactory = httpFactory; _db = db; _toolService = toolService; _memoryService = memoryService; - _skillService = skillService; + _chatService = chatService; _maxIterations = maxIterations; } - public string WebhookSecret => _webhookSecret; - public bool IsConfigured => !string.IsNullOrEmpty(_botToken); + // ── Bot-Token / Secret aus Session laden ── + + public string GetBotTokenForSession(SessionRecord session) + { + return session?.TelegramBotToken; + } + + public string GetWebhookSecretForSession(SessionRecord session) + { + return session?.TelegramWebhookSecret; + } // ── Webhook-Update verarbeiten ── - public async Task HandleUpdate(JsonElement update) + public async Task HandleUpdate(JsonElement update, SessionRecord session) { if (!update.TryGetProperty("message", out var message)) return; if (!message.TryGetProperty("chat", out var chat)) return; @@ -62,636 +61,142 @@ public class TelegramService if (string.IsNullOrEmpty(text)) return; - // Commands - if (text.StartsWith("/")) + var botToken = session.TelegramBotToken; + if (string.IsNullOrEmpty(botToken)) return; + + if (text.StartsWith("/start")) { - await HandleCommand(chatId, text); + await _db.SetSessionTelegramChatId(session.SessionId, chatId.ToString()); + var sessionName = session.Name ?? session.SessionId; + await SendMessage(chatId, botToken, + $"✅ *Verbunden mit Session: {EscapeMd(sessionName)}*\n\n" + + "Du kannst jetzt einfach drauflos schreiben\\!\n\n" + + "Befehle:\n" + + "/clear – History archivieren\n" + + "/status – Aktuelle Session\\-Info\n" + + "/unlink – Verknüpfung aufheben", + parseMode: "MarkdownV2"); return; } - // Normale Nachricht → an Claude weiterleiten - await HandleMessage(chatId, text); + if (text.StartsWith("/")) + { + await HandleCommand(chatId, text, botToken, session); + return; + } + + await HandleMessage(chatId, text, botToken, session); } // ── Commands ── - private async Task HandleCommand(long chatId, string text) + private async Task HandleCommand(long chatId, string text, string botToken, SessionRecord session) { var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - var cmd = parts[0].ToLowerInvariant().Split('@')[0]; // /command@botname → /command - var arg = parts.Length > 1 ? parts[1].Trim() : ""; + var cmd = parts[0].ToLowerInvariant().Split('@')[0]; switch (cmd) { - case "/start": - await SendMessage(chatId, - "👋 *Willkommen bei Gott\\!*\n\n" + - "Verknüpfe mich mit einer Session:\n" + - "1\\. Öffne im Web\\-UI eine Session\n" + - "2\\. Klicke auf 🔗 *Telegram*\n" + - "3\\. Sende mir den Code: `/link CODE`\n\n" + - "Befehle:\n" + - "/link CODE – Session verknüpfen\n" + - "/session – Session wechseln\n" + - "/new – Neue Session erstellen\n" + - "/clear – History archivieren\n" + - "/status – Aktuelle Verknüpfung\n" + - "/unlink – Verknüpfung aufheben", - parseMode: "MarkdownV2"); - break; - - case "/link": - await HandleLink(chatId, arg); - break; - - case "/session": - await HandleSessionSwitch(chatId, arg); - break; - - case "/new": - await HandleNewSession(chatId, arg); - break; - case "/clear": - await HandleClear(chatId); + var archive = await _db.CreateArchive(session.SessionId); + await SendMessage(chatId, botToken, archive == null + ? "History ist bereits leer." + : $"✅ History archiviert ({archive.MessageCount} Nachrichten)."); break; case "/status": - await HandleStatus(chatId); + var agent = !string.IsNullOrEmpty(session.AgentId) ? await _db.GetAgent(session.AgentId) : null; + var sb = new StringBuilder("📊 *Status:*\n\n"); + sb.AppendLine($"Session: *{EscapeMd(session.Name ?? session.SessionId)}*"); + if (agent != null) + sb.AppendLine($"Agent: *{EscapeMd(agent.Name)}* \\({EscapeMd(agent.Model)}\\)"); + await SendMessage(chatId, botToken, sb.ToString(), parseMode: "MarkdownV2"); break; case "/unlink": - await HandleUnlink(chatId); + await _db.ClearSessionTelegramChatId(session.SessionId); + await SendMessage(chatId, botToken, "✅ Verknüpfung aufgehoben. Sende /start um erneut zu verbinden."); break; default: - await SendMessage(chatId, "❓ Unbekannter Befehl. Sende /start für Hilfe."); + await SendMessage(chatId, botToken, "❓ Unbekannter Befehl. Sende /start für Hilfe."); break; } } - private async Task HandleLink(long chatId, string code) + // ── Nachricht an Claude weiterleiten ── + + private async Task HandleMessage(long chatId, string userText, string botToken, SessionRecord session) { - if (string.IsNullOrEmpty(code)) + if (session.TelegramChatId != chatId.ToString()) { - await SendMessage(chatId, "Bitte sende: `/link CODE`\n\nDen Code findest du im Web\\-UI unter 🔗 Telegram\\.", parseMode: "MarkdownV2"); + await SendMessage(chatId, botToken, "❌ Bitte sende zuerst /start um dich mit dieser Session zu verbinden."); return; } - var link = await _db.RedeemTelegramLinkCode(code.ToUpperInvariant(), chatId); - if (link == null) + // ChatService für Setup nutzen (userId = null → kein Auth-Check, Session ist bereits authentifiziert via Webhook-Secret) + var ctx = await _chatService.PrepareContext(session.SessionId, null, _maxIterations); + if (ctx == null) { - await SendMessage(chatId, "❌ Code ungültig oder abgelaufen. Bitte generiere einen neuen im Web-UI."); + await SendMessage(chatId, botToken, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu."); return; } - var session = await _db.GetSession(link.SessionId); - var sessionName = session?.Name ?? link.SessionId; - await SendMessage(chatId, $"✅ Verknüpft mit Session: *{EscapeMd(sessionName)}*\n\nDu kannst jetzt einfach drauflos schreiben\\!", parseMode: "MarkdownV2"); - } - - private async Task HandleSessionSwitch(long chatId, string arg) - { - var link = await _db.GetTelegramLink(chatId); - if (link == null) - { - await SendMessage(chatId, "❌ Nicht verknüpft. Sende /link CODE um zu starten."); - return; - } - - // Wenn eine Session-Nummer angegeben wurde - if (!string.IsNullOrEmpty(arg)) - { - var sessions = (await _db.GetUserSessions(link.UserId)).ToList(); - if (int.TryParse(arg, out var idx) && idx >= 1 && idx <= sessions.Count) - { - var target = sessions[idx - 1]; - var agent = !string.IsNullOrEmpty(target.AgentId) ? await _db.GetAgent(target.AgentId) : null; - await _db.UpdateTelegramSession(chatId, target.SessionId, target.AgentId); - await SendMessage(chatId, $"✅ Gewechselt zu: *{EscapeMd(target.Name ?? target.SessionId)}*", parseMode: "MarkdownV2"); - return; - } - - // Vielleicht ein Name? - var match = sessions.FirstOrDefault(s => - (s.Name ?? "").Contains(arg, StringComparison.OrdinalIgnoreCase)); - if (match != null) - { - await _db.UpdateTelegramSession(chatId, match.SessionId, match.AgentId); - await SendMessage(chatId, $"✅ Gewechselt zu: *{EscapeMd(match.Name ?? match.SessionId)}*", parseMode: "MarkdownV2"); - return; - } - - await SendMessage(chatId, "❌ Session nicht gefunden."); - return; - } - - // Ohne Argument: Liste anzeigen - var allSessions = (await _db.GetUserSessions(link.UserId)).ToList(); - if (allSessions.Count == 0) - { - await SendMessage(chatId, "Keine Sessions vorhanden. Erstelle eine mit /new"); - return; - } - - var sb = new StringBuilder("📋 *Deine Sessions:*\n\n"); - for (int i = 0; i < allSessions.Count && i < 20; i++) - { - var s = allSessions[i]; - var marker = s.SessionId == link.SessionId ? " ◀️" : ""; - sb.AppendLine($"`{i + 1}` {EscapeMd(s.Name ?? s.SessionId)}{marker}"); - } - sb.AppendLine($"\nWechseln: `/session NUMMER`"); - await SendMessage(chatId, sb.ToString(), parseMode: "MarkdownV2"); - } - - private async Task HandleNewSession(long chatId, string name) - { - var link = await _db.GetTelegramLink(chatId); - if (link == null) - { - await SendMessage(chatId, "❌ Nicht verknüpft. Sende /link CODE um zu starten."); - return; - } - - if (string.IsNullOrWhiteSpace(name)) - name = $"Telegram {DateTime.UtcNow:dd.MM.yyyy HH:mm}"; - - var session = await _db.CreateSession(link.UserId, name: name, agentId: link.AgentId); - await _db.UpdateTelegramSession(chatId, session.SessionId, link.AgentId); - await SendMessage(chatId, $"✅ Neue Session erstellt: *{EscapeMd(name)}*", parseMode: "MarkdownV2"); - } - - private async Task HandleClear(long chatId) - { - var link = await _db.GetTelegramLink(chatId); - if (link == null) - { - await SendMessage(chatId, "❌ Nicht verknüpft."); - return; - } - - var archive = await _db.CreateArchive(link.SessionId); - if (archive == null) - { - await SendMessage(chatId, "History ist bereits leer."); - return; - } - await SendMessage(chatId, $"✅ History archiviert ({archive.MessageCount} Nachrichten)."); - } - - private async Task HandleStatus(long chatId) - { - var link = await _db.GetTelegramLink(chatId); - if (link == null) - { - await SendMessage(chatId, "❌ Nicht verknüpft. Sende /link CODE um zu starten."); - return; - } - - var session = await _db.GetSession(link.SessionId); - var agent = !string.IsNullOrEmpty(link.AgentId) ? await _db.GetAgent(link.AgentId) : null; - var sb = new StringBuilder("📊 *Status:*\n\n"); - sb.AppendLine($"Session: *{EscapeMd(session?.Name ?? link.SessionId)}*"); - if (agent != null) - sb.AppendLine($"Agent: *{EscapeMd(agent.Name)}* \\({EscapeMd(agent.Model)}\\)"); - await SendMessage(chatId, sb.ToString(), parseMode: "MarkdownV2"); - } - - private async Task HandleUnlink(long chatId) - { - await _db.DeleteTelegramLink(chatId); - await SendMessage(chatId, "✅ Verknüpfung aufgehoben. Sende /link CODE um erneut zu verknüpfen."); - } - - // ── Nachricht an Claude weiterleiten (Non-Streaming) ── - - private async Task HandleMessage(long chatId, string userText) - { - var link = await _db.GetTelegramLink(chatId); - if (link == null) - { - await SendMessage(chatId, "❌ Nicht verknüpft. Sende /start für Hilfe."); - return; - } - - var session = await _db.GetSession(link.SessionId); - if (session == null) - { - await SendMessage(chatId, "❌ Session nicht mehr verfügbar. Nutze /session zum Wechseln."); - return; - } - - var agent = !string.IsNullOrEmpty(link.AgentId) ? await _db.GetAgent(link.AgentId) : null; - if (agent == null && !string.IsNullOrEmpty(session.AgentId)) - agent = await _db.GetAgent(session.AgentId); - - if (agent == null) - { - await SendMessage(chatId, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu."); - return; - } - - // "Typing" anzeigen - await SendChatAction(chatId, "typing"); + await SendChatAction(chatId, botToken, "typing"); try { - var reply = await ChatNonStreaming(session, agent, userText, chatId); - await SendLongMessage(chatId, reply); + // History aus DB laden + User-Nachricht anhängen + var history = await _chatService.BuildHistoryFromDb(session.SessionId); + history.Add(new Dictionary + { + ["role"] = "user", + ["content"] = userText + }); + + // User-Nachricht in DB speichern + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, + Iteration = 0, + Role = "user", + Type = "text", + Content = userText + }); + + // 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) => + { + if (iteration % 3 == 0) + await SendChatAction(chatId, botToken, "typing"); + }); + + await SendLongMessage(chatId, botToken, reply); } catch (Exception ex) { - await SendMessage(chatId, $"❌ Fehler: {ex.Message}"); + await SendMessage(chatId, botToken, $"❌ Fehler: {ex.Message}"); } } - // ── Non-Streaming Claude Chat ── + // ── Notification senden (für Cronjobs) ── - private async Task ChatNonStreaming(SessionRecord session, AgentRecord agent, string userText, long chatId) + public async Task SendNotification(string sessionId, string text) { - var modelId = agent.Model switch - { - "sonnet" => "claude-sonnet-4-6", - "sonnet-4-5" => "claude-sonnet-4-5", - "sonnet-4-6" => "claude-sonnet-4-6", - "opus" => "claude-opus-4-6", - "opus-4-5" => "claude-opus-4-5", - "opus-4-6" => "claude-opus-4-6", - "haiku" => "claude-haiku-4-5", - "haiku-4-5" => "claude-haiku-4-5", - _ => "claude-sonnet-4-6" - }; + var session = await _db.GetSession(sessionId); + if (session == null || string.IsNullOrEmpty(session.TelegramBotToken) || string.IsNullOrEmpty(session.TelegramChatId)) + return; - // History aus DB laden (aktive Chains) - var history = await BuildHistory(session.SessionId); - - // User-Nachricht anhängen - history.Add(new Dictionary - { - ["role"] = "user", - ["content"] = userText - }); - - // System-Prompt - var markedMessages = await _db.GetMarkedMessages(session.SessionId); - var agentMemoryDir = _memoryService.GetAgentMemoryDir(agent.AgentId); - var agentSkills = await _db.GetAgentSkills(agent.AgentId); - var user = await _db.GetUser(session.UserId); - var systemPromptText = SystemPromptBuilder.Build( - agent, session, markedMessages, _memoryService, agentMemoryDir, - agentSkills, _skillService, userPersona: user?.GetEffectivePersona()); - - var systemPrompt = new List(); - if (!string.IsNullOrWhiteSpace(systemPromptText)) - systemPrompt.Add(new { type = "text", text = systemPromptText }); - - // Chain erstellen - var chainId = Guid.NewGuid().ToString("N"); - var workspace = session.Workspace; - - if (string.IsNullOrEmpty(workspace) && agentSkills.Any()) - workspace = "/tmp"; - - var toolChoice = agent.ToolChoice ?? "any"; - var hasWorkspace = !string.IsNullOrEmpty(workspace); - var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId); - - var toolDefs = (hasWorkspace || includeMemory) - ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory) - : Array.Empty(); - var toolNames = toolDefs.Length > 0 - ? JsonSerializer.Serialize(toolDefs.Select(td => - { - var json = JsonSerializer.Serialize(td); - var el = JsonSerializer.Deserialize(json); - return el.GetProperty("name").GetString(); - }).ToArray()) - : null; - - await _db.CreateChain(new ChainRecord - { - ChainId = chainId, - SessionId = session.SessionId, - AgentId = agent.AgentId, - Model = modelId, - SystemPrompt = systemPromptText, - ToolChoice = (hasWorkspace || includeMemory) ? toolChoice : null, - ToolNames = toolNames - }); - - // User-Nachricht speichern - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, - Iteration = 0, - Role = "user", - Type = "text", - Content = userText - }); - - await _db.UpdateSessionTimestamp(session.SessionId); - - if (!string.IsNullOrEmpty(workspace)) - Directory.CreateDirectory(workspace); - - // Iterativer Claude-Loop (Non-Streaming) - var sw = System.Diagnostics.Stopwatch.StartNew(); - var usage = new TokenUsage(); - int iterations = 0; - var finalText = new StringBuilder(); - - try - { - while (iterations < _maxIterations) - { - iterations++; - - var payload = new Dictionary - { - ["model"] = modelId, - ["max_tokens"] = 16384, - ["messages"] = history, - ["stream"] = false - }; - - if (systemPrompt.Count > 0) - payload["system"] = systemPrompt; - - var hasTools = hasWorkspace || includeMemory; - var hasWebSearch = !string.IsNullOrEmpty(agent.WebSearchVersion); - - if (hasTools || hasWebSearch) - { - var tools = new List(); - if (hasTools) - tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory)); - if (hasWebSearch) - tools.Add(new { type = agent.WebSearchVersion, name = "web_search", max_uses = 5 }); - payload["tools"] = tools; - var effectiveChoice = (toolChoice == "any" && iterations == 1) ? "any" : "auto"; - payload["tool_choice"] = new { type = effectiveChoice }; - } - - var client = CreateAnthropicClient(); - var json = JsonSerializer.Serialize(payload); - var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - - var httpResp = await client.SendAsync( - new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }); - - var respBody = await httpResp.Content.ReadAsStringAsync(); - - if (!httpResp.IsSuccessStatusCode) - { - await SaveError(chainId, iterations, $"API {(int)httpResp.StatusCode}: {respBody}"); - await FinalizeChain(chainId, usage, iterations, "error", sw); - throw new Exception($"Claude API Fehler: {(int)httpResp.StatusCode}"); - } - - var resp = JsonSerializer.Deserialize(respBody); - - // Usage tracken - if (resp.TryGetProperty("usage", out var u)) - { - if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64(); - if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64(); - if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64(); - if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64(); - } - - var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : ""; - var content = resp.GetProperty("content"); - - // Content verarbeiten - var assistantContent = new List(); - var toolResults = new List(); - var executedTools = new List(); - - foreach (var block in content.EnumerateArray()) - { - var blockType = block.GetProperty("type").GetString() ?? ""; - - if (blockType == "text") - { - var text = block.GetProperty("text").GetString() ?? ""; - assistantContent.Add(new { type = "text", text }); - finalText.Append(text); - - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = iterations, - Role = "assistant", Type = "text", - Content = text - }); - } - else if (blockType == "tool_use") - { - var toolId = block.GetProperty("id").GetString() ?? ""; - var toolName = block.GetProperty("name").GetString() ?? ""; - var toolInput = block.GetProperty("input"); - - assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput }); - - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = iterations, - Role = "assistant", Type = "tool_use", - ToolName = toolName, ToolCallId = toolId, - Content = toolInput.ToString() - }); - - // Tool ausführen - string toolResult; - bool isError = false; - try - { - toolResult = await _toolService.ExecuteTool(toolName, toolInput, session.Workspace, _memoryService, agent.AgentId); - } - catch (Exception ex) - { - toolResult = $"Fehler: {ex.Message}"; - isError = true; - } - - executedTools.Add(new ToolResult(toolId, toolResult, isError)); - - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = iterations, - Role = "user", Type = "tool_result", - ToolCallId = toolId, Content = toolResult, IsError = isError - }); - - const int MaxToolResultChars = 20_000; - var truncResult = toolResult.Length > MaxToolResultChars - ? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]" - : toolResult; - - toolResults.Add(new - { - type = "tool_result", - tool_use_id = toolId, - content = truncResult, - is_error = isError - }); - } - } - - // Assistant-Antwort in History aufnehmen - history.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = assistantContent - }); - - if (stopReason != "tool_use") - { - await FinalizeChain(chainId, usage, iterations, stopReason, sw); - break; - } - - // Tool-Results zurücksenden - history.Add(new Dictionary - { - ["role"] = "user", - ["content"] = toolResults - }); - - // Typing-Indikator aufrechterhalten - await SendChatAction(chatId, "typing"); - } - - if (iterations >= _maxIterations) - { - await FinalizeChain(chainId, usage, iterations, "max_iterations", sw); - finalText.Append("\n\n⚠️ Max Iterationen erreicht."); - } - } - catch (Exception) when (finalText.Length > 0) - { - // Partial result - } - - return finalText.Length > 0 ? finalText.ToString() : "🤷 Keine Antwort erhalten."; + var chatId = long.Parse(session.TelegramChatId); + await SendMessage(chatId, session.TelegramBotToken, text); } - // ── History aus DB aufbauen ── + // ── Telegram Bot API ── - private async Task>> BuildHistory(string sessionId) - { - var chains = await _db.GetActiveChains(sessionId); - var messages = new List>(); - - foreach (var chain in chains.Reverse()) - { - var chainMsgs = (await _db.GetChainMessages(chain.ChainId)).ToList(); - - foreach (var msg in chainMsgs) - { - if (msg.Role == "user" && msg.Type == "text") - { - messages.Add(new Dictionary - { - ["role"] = "user", - ["content"] = msg.Content ?? "" - }); - } - else if (msg.Role == "assistant" && msg.Type == "text") - { - // Aufeinanderfolgende Assistant-Text-Blöcke zusammenfassen - if (messages.Count > 0 && messages[^1]["role"] as string == "assistant") - { - var existing = messages[^1]["content"]; - if (existing is List blocks) - { - blocks.Add(new { type = "text", text = msg.Content ?? "" }); - } - else if (existing is string s) - { - messages[^1]["content"] = new List - { - new { type = "text", text = s }, - new { type = "text", text = msg.Content ?? "" } - }; - } - } - else - { - messages.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = msg.Content ?? "" - }); - } - } - else if (msg.Role == "assistant" && msg.Type == "tool_use") - { - JsonElement inputEl; - try { inputEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } - catch { inputEl = JsonSerializer.Deserialize("{}"); } - - var toolBlock = new { type = "tool_use", id = msg.ToolCallId, name = msg.ToolName, input = inputEl }; - - if (messages.Count > 0 && messages[^1]["role"] as string == "assistant") - { - var existing = messages[^1]["content"]; - if (existing is List blocks) - { - blocks.Add(toolBlock); - } - else if (existing is string s) - { - messages[^1]["content"] = new List - { - new { type = "text", text = s }, - toolBlock - }; - } - } - else - { - messages.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = new List { toolBlock } - }); - } - } - else if (msg.Role == "user" && msg.Type == "tool_result") - { - var toolResultBlock = new - { - type = "tool_result", - tool_use_id = msg.ToolCallId, - content = msg.Content ?? "", - is_error = msg.IsError - }; - - if (messages.Count > 0 && messages[^1]["role"] as string == "user" && - messages[^1]["content"] is List existingResults) - { - existingResults.Add(toolResultBlock); - } - else - { - messages.Add(new Dictionary - { - ["role"] = "user", - ["content"] = new List { toolResultBlock } - }); - } - } - } - } - - return messages; - } - - // ── Telegram API ── - - public async Task SendMessage(long chatId, string text, string parseMode = null) + public async Task SendMessage(long chatId, string botToken, string text, string parseMode = null) { + var client = _httpFactory.CreateClient(); var payload = new Dictionary { ["chat_id"] = chatId, @@ -700,155 +205,93 @@ public class TelegramService if (!string.IsNullOrEmpty(parseMode)) payload["parse_mode"] = parseMode; - await CallTelegramApi("sendMessage", payload); + await client.PostAsync( + $"https://api.telegram.org/bot{botToken}/sendMessage", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); } - private async Task SendLongMessage(long chatId, string text) + public async Task SendLongMessage(long chatId, string botToken, string text) { - // Telegram-Limit: 4096 Zeichen const int maxLen = 4096; - if (text.Length <= maxLen) { - await SendMessage(chatId, text); + await SendMessage(chatId, botToken, text); return; } - // An Zeilengrenzen splitten - var remaining = text; - while (remaining.Length > 0) + for (var i = 0; i < text.Length; i += maxLen) { - string chunk; - if (remaining.Length <= maxLen) - { - chunk = remaining; - remaining = ""; - } - else - { - var splitAt = remaining.LastIndexOf('\n', maxLen - 1); - if (splitAt < maxLen / 2) splitAt = maxLen; - chunk = remaining[..splitAt]; - remaining = remaining[splitAt..].TrimStart('\n'); - } - - await SendMessage(chatId, chunk); - if (remaining.Length > 0) - await Task.Delay(100); // Rate-Limiting + var chunk = text.Substring(i, Math.Min(maxLen, text.Length - i)); + await SendMessage(chatId, botToken, chunk); + await Task.Delay(100); } } - private async Task SendChatAction(long chatId, string action) - { - await CallTelegramApi("sendChatAction", new Dictionary - { - ["chat_id"] = chatId, - ["action"] = action ?? "typing" - }); - } - - private async Task CallTelegramApi(string method, object payload) - { - try - { - var client = _httpFactory.CreateClient(); - var json = JsonSerializer.Serialize(payload); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - await client.PostAsync($"https://api.telegram.org/bot{_botToken}/{method}", content); - } - catch - { - // Telegram API Fehler nicht propagieren - } - } - - // ── Helpers ── - - private HttpClient CreateAnthropicClient() + public async Task SendChatAction(long chatId, string botToken, string action) { var client = _httpFactory.CreateClient(); - client.DefaultRequestHeaders.Add("x-api-key", _anthropicToken); - client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); - return client; + await client.PostAsync( + $"https://api.telegram.org/bot{botToken}/sendChatAction", + new StringContent(JsonSerializer.Serialize(new { chat_id = chatId, action }), Encoding.UTF8, "application/json")); } - private async Task SaveError(string chainId, int iteration, string error) + // ── Webhook-Verwaltung (pro Session) ── + + public async Task RegisterWebhook(string baseUrl, string sessionId) { - await _db.SaveMessage(new MessageRecord + var session = await _db.GetSession(sessionId); + if (session == null || string.IsNullOrEmpty(session.TelegramBotToken)) + return JsonSerializer.Serialize(new { ok = false, error = "Kein Bot-Token für diese Session konfiguriert" }); + + var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/telegram/webhook/{sessionId}"; + var client = _httpFactory.CreateClient(); + var payload = new Dictionary { - ChainId = chainId, Iteration = iteration, - Role = "system", Type = "error", - Content = error, IsError = true - }); + ["url"] = webhookUrl, + ["allowed_updates"] = new[] { "message" } + }; + + if (!string.IsNullOrEmpty(session.TelegramWebhookSecret)) + payload["secret_token"] = session.TelegramWebhookSecret; + + var resp = await client.PostAsync( + $"https://api.telegram.org/bot{session.TelegramBotToken}/setWebhook", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + return await resp.Content.ReadAsStringAsync(); } - private async Task FinalizeChain(string chainId, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) + public async Task GetWebhookInfo(string sessionId) { - await _db.UpdateChain(new ChainRecord - { - ChainId = chainId, - InputTokens = usage.InputTokens, - OutputTokens = usage.OutputTokens, - CacheCreationTokens = usage.CacheCreationTokens, - CacheReadTokens = usage.CacheReadTokens, - Iterations = iterations, - StopReason = stopReason, - DurationMs = sw.ElapsedMilliseconds - }); + var session = await _db.GetSession(sessionId); + if (session == null || string.IsNullOrEmpty(session.TelegramBotToken)) + return JsonSerializer.Serialize(new { ok = false, error = "Kein Bot-Token konfiguriert" }); + + var client = _httpFactory.CreateClient(); + var resp = await client.GetAsync($"https://api.telegram.org/bot{session.TelegramBotToken}/getWebhookInfo"); + return await resp.Content.ReadAsStringAsync(); } + public async Task DeleteWebhook(string sessionId) + { + var session = await _db.GetSession(sessionId); + if (session == null || string.IsNullOrEmpty(session.TelegramBotToken)) + return JsonSerializer.Serialize(new { ok = false, error = "Kein Bot-Token konfiguriert" }); + + var client = _httpFactory.CreateClient(); + var resp = await client.PostAsync( + $"https://api.telegram.org/bot{session.TelegramBotToken}/deleteWebhook", + new StringContent("{}", Encoding.UTF8, "application/json")); + return await resp.Content.ReadAsStringAsync(); + } + + // ── Helper ── + private static string EscapeMd(string text) { if (string.IsNullOrEmpty(text)) return ""; - // MarkdownV2 escape: _ * [ ] ( ) ~ ` > # + - = | { } . ! - var chars = new[] { '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!' }; - var sb = new StringBuilder(text.Length); - foreach (var c in text) - { - if (Array.IndexOf(chars, c) >= 0) sb.Append('\\'); - sb.Append(c); - } - return sb.ToString(); - } - - // ── Webhook registrieren ── - - public async Task RegisterWebhook(string baseUrl) - { - if (!IsConfigured) return "Telegram Bot nicht konfiguriert."; - - var url = $"{baseUrl.TrimEnd('/')}/api/telegram/webhook"; - var payload = new Dictionary - { - ["url"] = url, - ["secret_token"] = _webhookSecret, - ["allowed_updates"] = new[] { "message" } - }; - - var client = _httpFactory.CreateClient(); - var json = JsonSerializer.Serialize(payload); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - var resp = await client.PostAsync($"https://api.telegram.org/bot{_botToken}/setWebhook", content); - return await resp.Content.ReadAsStringAsync(); - } - - public async Task GetWebhookInfo() - { - if (!IsConfigured) return "Telegram Bot nicht konfiguriert."; - - var client = _httpFactory.CreateClient(); - var resp = await client.GetAsync($"https://api.telegram.org/bot{_botToken}/getWebhookInfo"); - return await resp.Content.ReadAsStringAsync(); - } - - public async Task DeleteWebhook() - { - if (!IsConfigured) return "Telegram Bot nicht konfiguriert."; - - var client = _httpFactory.CreateClient(); - var resp = await client.PostAsync($"https://api.telegram.org/bot{_botToken}/deleteWebhook", null); - return await resp.Content.ReadAsStringAsync(); + foreach (var c in new[] { '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!' }) + text = text.Replace(c.ToString(), "\\" + c); + return text; } } - -// TokenUsage und ToolResult werden aus AnthropicService.cs verwendet diff --git a/appsettings.json b/appsettings.json index 0ea800a..309ec2f 100755 --- a/appsettings.json +++ b/appsettings.json @@ -7,7 +7,5 @@ }, "AllowedHosts": "*", "AnthropicToken": "", - "MaxIterations": 100, - "TelegramBotToken": "", - "TelegramWebhookSecret": "" + "MaxIterations": 100 } diff --git a/wwwroot/css/dashboard.css b/wwwroot/css/dashboard.css index ff3648f..8368a69 100644 --- a/wwwroot/css/dashboard.css +++ b/wwwroot/css/dashboard.css @@ -255,123 +255,3 @@ background: rgb(from var(--text) r g b / .15); color: var(--text); } - -/* Telegram Link Modal */ - -.modal-overlay { - position: fixed; - inset: 0; - background: rgb(from var(--f1-bg) r g b / var(--semi-transparent)); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.telegram-link-modal { - background: var(--f2-bg); - border: var(--border-width) solid var(--f2-border); - border-radius: var(--border-radius); - width: 90vw; - max-width: 28em; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.telegram-link-modal .modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--box-padding); - border-bottom: var(--border-width) solid var(--f1-border); - background: var(--f1-bg); -} - -.telegram-link-modal .modal-header h3 { - margin: 0; - display: flex; - align-items: center; - gap: var(--badge-spacing); -} - -.telegram-link-modal .modal-body { - padding: var(--box-padding); - display: flex; - flex-direction: column; - gap: var(--box-spacing); -} - -.telegram-code-display { - display: flex; - align-items: center; - justify-content: center; - gap: var(--box-spacing); - padding: var(--box-padding); - background: var(--f1-bg); - border: var(--border-width) solid var(--f1-border); - border-radius: var(--border-radius); -} - -.telegram-code-display code { - font-size: calc(var(--font-size) * 1.5); - font-weight: bold; - letter-spacing: .1em; - color: var(--primary); - user-select: all; -} - -.telegram-code-hint { - opacity: var(--semi-transparent); - text-align: center; - display: flex; - align-items: center; - justify-content: center; - gap: var(--badge-spacing); -} - -/* ── Telegram Settings Status Box ── */ - -.telegram-status-box { - margin: 0 0 var(--box-spacing) 0; - background: var(--f3-bg); - border: var(--border-width) solid var(--f3-border); - border-radius: var(--border-radius); - padding: var(--box-padding); - display: flex; - flex-direction: column; - gap: var(--badge-spacing); -} - -.tg-status-row { - display: flex; - gap: var(--badge-spacing); -} - -.tg-status-row.tg-error { - background: color-mix(in srgb, var(--danger) 8%, transparent); -} - -.tg-label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tg-value { - flex: 3; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tg-badge-ok { - background: color-mix(in srgb, var(--success) 15%, transparent); - color: var(--success); -} - -.tg-badge-off { - background: color-mix(in srgb, var(--warning) 15%, transparent); - color: var(--warning); -} diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index cb98a0c..7360eb5 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -51,7 +51,7 @@ .form-actions { display: flex; gap: var(--badge-spacing); - margin-top: var(--box-spacing); + margin-block: var(--box-spacing) calc(var(--page-spacing) * 4); } /* ── Custom Commands ── */ @@ -272,3 +272,22 @@ label.skill-item { transform: translateX(1em); } +/* ── Telegram Section ── */ + +.telegram-status-box { + padding: var(--box-padding); + background: var(--f3-bg); + border: var(--border-width) solid var(--f2-border); + border-radius: var(--border-radius); + display: flex; + flex-direction: column; + gap: var(--badge-spacing); +} + +.telegram-status-box .tg-error { + color: var(--dangerous); +} + +.telegram-actions { + margin-top: var(--badge-spacing); +} diff --git a/wwwroot/css/main.css b/wwwroot/css/main.css index 1508286..46d8ab9 100644 --- a/wwwroot/css/main.css +++ b/wwwroot/css/main.css @@ -56,6 +56,45 @@ background: rgb(from var(--dangerous) r g b / .1); } +/* Toast Notifications */ + +#toastContainer { + position: fixed; + left: 50%; + translate: -50% 0; + bottom: calc(var(--page-spacing) * 2); + z-index: 9999; + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: var(--badge-spacing); + pointer-events: none; + width: calc(min(100%, var(--page-width)) - var(--page-spacing)); +} + +.toast { + pointer-events: auto; + padding: var(--box-padding); + border-radius: var(--border-radius); + background: color-mix(in srgb, var(--f1-bg), transparent 10%); + color: var(--text-color); + cursor: pointer; + opacity: 0; + transition: opacity var(--transition-duration); +} + +.toast-visible { + opacity: 1; +} + +.toast i { + margin-right: var(--badge-spacing); +} + +.toast-success i { color: var(--primary); } +.toast-error i { color: var(--dangerous); } +.toast-info i { color: var(--secondary); } + /* Layout */ header { diff --git a/wwwroot/index.html b/wwwroot/index.html index 21c50f9..829532b 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -107,6 +107,20 @@
+ + + Erstelle einen Bot via @BotFather + + + + + +
Kein Status verfügbar
+
+ + +
+
@@ -300,37 +314,7 @@
- +
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index e467b17..c1764a7 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -263,36 +263,30 @@ async function apiChangePassword(currentPassword, newPassword) { return resp.json(); } -async function apiTelegramStatus() { - const resp = await fetch('/api/telegram/status'); +// ── Telegram API (pro Session) ── + +async function apiSessionTelegramGet(sessionId) { + const resp = await fetch(`/api/sessions/${sessionId}/telegram`); if (!resp.ok) return { configured: false }; return resp.json(); } -async function apiTelegramLink(sessionId) { - const resp = await fetch('/api/telegram/link', { - method: 'POST', - credentials: 'include', +async function apiSessionTelegramSave(sessionId, botToken, webhookSecret) { + const resp = await fetch(`/api/sessions/${sessionId}/telegram`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }) + body: JSON.stringify({ botToken, webhookSecret }) }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Telegram-Link fehlgeschlagen'); + throw new Error(err.error || 'Telegram-Config speichern fehlgeschlagen'); } return resp.json(); } -async function apiTelegramWebhookInfo() { - const resp = await fetch('/api/telegram/webhook-info', { credentials: 'include' }); - if (!resp.ok) throw new Error('Webhook-Info fehlgeschlagen'); - return resp.json(); -} - -async function apiTelegramRegisterWebhook(baseUrl) { - const resp = await fetch('/api/telegram/register-webhook', { +async function apiSessionTelegramRegisterWebhook(sessionId, baseUrl) { + const resp = await fetch(`/api/sessions/${sessionId}/telegram/register-webhook`, { method: 'POST', - credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl }) }); @@ -300,8 +294,14 @@ async function apiTelegramRegisterWebhook(baseUrl) { return resp.json(); } -async function apiTelegramDeleteWebhook() { - const resp = await fetch('/api/telegram/webhook', { method: 'DELETE', credentials: 'include' }); +async function apiSessionTelegramWebhookInfo(sessionId) { + const resp = await fetch(`/api/sessions/${sessionId}/telegram/webhook-info`); + if (!resp.ok) throw new Error('Webhook-Info fehlgeschlagen'); + return resp.json(); +} + +async function apiSessionTelegramDeleteWebhook(sessionId) { + const resp = await fetch(`/api/sessions/${sessionId}/telegram/webhook`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Webhook-Löschung fehlgeschlagen'); return resp.json(); } diff --git a/wwwroot/js/pages/dashboard.js b/wwwroot/js/pages/dashboard.js index 3b84bca..8ea6383 100644 --- a/wwwroot/js/pages/dashboard.js +++ b/wwwroot/js/pages/dashboard.js @@ -17,8 +17,7 @@ registerPage('dashboard', { // Globaler Click-Handler zum Schließen aller Card-Menüs document.addEventListener('click', this._closeCardMenus = () => closeAllCardMenus()); - // Telegram-Status parallel laden - const telegramStatusPromise = apiTelegramStatus().catch(() => ({ configured: false })); + // Telegram-Config parallel pro Session laden (sessions werden gleich geladen) const agents = await apiGetAgents(); const agentMap = {}; @@ -41,6 +40,7 @@ registerPage('dashboard', { const sessionBadges = []; if (agent) sessionBadges.push(`${esc(agent.name)}`); if (s.context) sessionBadges.push(``); + if (s.telegramConfigured) sessionBadges.push(``); card.innerHTML = `
${s.image ? `` : ''}
@@ -52,7 +52,6 @@ registerPage('dashboard', { @@ -98,18 +97,6 @@ registerPage('dashboard', { menuDropdown.hidden = true; navigateTo('chat', { id: s.sessionId }); }); - const telegramBtn = menuDropdown.querySelector('[data-action="telegram"]'); - telegramBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - menuDropdown.hidden = true; - try { - const result = await apiTelegramLink(s.sessionId); - showTelegramLinkCode(result.code, s.name || 'Unbenannt'); - } catch (err) { - showToast(err.message, 'error'); - } - }); - menuDropdown.querySelector('[data-action="edit"]').addEventListener('click', (e) => { e.stopPropagation(); menuDropdown.hidden = true; @@ -156,12 +143,7 @@ registerPage('dashboard', { } } - // Telegram-Buttons einblenden wenn konfiguriert - telegramStatusPromise.then(status => { - if (status.configured) { - grid.querySelectorAll('[data-action="telegram"]').forEach(btn => btn.hidden = false); - } - }); + // Neue Session Button document.getElementById('newSessionBtn').addEventListener('click', () => navigateTo('session')); @@ -262,42 +244,7 @@ registerPage('dashboard', { } }); -function showTelegramLinkCode(code, sessionName) { - // Bestehendes Modal entfernen - const existing = document.getElementById('telegramLinkModal'); - if (existing) existing.remove(); - const modal = document.createElement('div'); - modal.id = 'telegramLinkModal'; - modal.className = 'modal-overlay'; - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - modal.querySelector('.modal-close').addEventListener('click', () => modal.remove()); - modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); - - document.getElementById('copyTelegramCode').addEventListener('click', () => { - navigator.clipboard.writeText('/link ' + code).then(() => { - showToast('In Zwischenablage kopiert', 'success'); - }); - }); -} function closeAllCardMenus() { document.querySelectorAll('.card-menu-dropdown').forEach(d => d.hidden = true); diff --git a/wwwroot/js/pages/session-page.js b/wwwroot/js/pages/session-page.js index 7aa44b2..116421f 100644 --- a/wwwroot/js/pages/session-page.js +++ b/wwwroot/js/pages/session-page.js @@ -55,6 +55,11 @@ registerPage('session', { // Befehl hinzufügen addCommandBtn.addEventListener('click', () => createCommandRow(commandsList)); + // Telegram-Section nur für existierende Sessions + if (!isNew) { + initSessionTelegram(sessionId); + } + // Agents laden const agents = await apiGetAgents(); agentSelect.innerHTML = ''; @@ -135,6 +140,17 @@ registerPage('session', { await apiCreateSession(data); } else { await apiUpdateSession(sessionId, data); + // Telegram-Config mitspeichern (falls Section sichtbar) + var tgToken = document.getElementById('sessionTelegramToken'); + var tgSecret = document.getElementById('sessionTelegramSecret'); + if (tgToken) { + try { + await saveTelegramConfig(sessionId, tgToken, tgSecret); + } catch(e) { + showToast('Session gespeichert, aber Telegram-Fehler: ' + e.message, 'error'); + return; + } + } } navigateTo('dashboard'); }); @@ -157,3 +173,131 @@ registerPage('session', { _sessionImageData = null; } }); + +function initSessionTelegram(sessionId) { + var tokenInput = document.getElementById('sessionTelegramToken'); + var secretInput = document.getElementById('sessionTelegramSecret'); + var registerBtn = document.getElementById('sessionTelegramRegisterBtn'); + var deleteBtn = document.getElementById('sessionTelegramDeleteBtn'); + var statusBox = document.getElementById('sessionTelegramStatusBox'); + + // Vorhandene Config laden + Status abrufen + apiSessionTelegramGet(sessionId).then(async function(config) { + if (config.botToken) tokenInput.value = config.botToken; + if (config.webhookSecret) { + secretInput.value = config.webhookSecret; + } else { + // Secret einmalig generieren und sofort persistent speichern + var newSecret = crypto.randomUUID().replace(/-/g, ''); + secretInput.value = newSecret; + await apiSessionTelegramSave(sessionId, config.botToken || '', newSecret); + } + // Status automatisch abrufen wenn Token konfiguriert ist + if (config.botToken) { + refreshTelegramStatus(sessionId, statusBox, registerBtn, deleteBtn); + } else { + showTelegramUnconfigured(statusBox, registerBtn, deleteBtn); + } + }).catch(function() { + showTelegramUnconfigured(statusBox, registerBtn, deleteBtn); + }); + + // Webhook registrieren + registerBtn.addEventListener('click', async function() { + var baseUrl = prompt('Base-URL der App:', window.location.origin); + if (!baseUrl) return; + registerBtn.disabled = true; + registerBtn.textContent = 'Wird registriert...'; + try { + // Erst Token + Secret speichern (falls geändert) + await saveTelegramConfig(sessionId, tokenInput, secretInput); + var res = await apiSessionTelegramRegisterWebhook(sessionId, baseUrl.trim()); + if (res.ok) { + showToast('Webhook registriert!', 'success'); + } else { + showToast('Antwort: ' + JSON.stringify(res), 'error'); + } + } catch(e) { + showToast('Fehler: ' + e.message, 'error'); + } finally { + registerBtn.disabled = false; + registerBtn.textContent = 'Webhook registrieren'; + refreshTelegramStatus(sessionId, statusBox, registerBtn, deleteBtn); + } + }); + + // Webhook entfernen + deleteBtn.addEventListener('click', async function() { + if (!confirm('Webhook wirklich entfernen?')) return; + deleteBtn.disabled = true; + deleteBtn.textContent = 'Wird entfernt...'; + try { + var res = await apiSessionTelegramDeleteWebhook(sessionId); + if (res.ok) { + showToast('Webhook entfernt', 'success'); + } else { + showToast('Antwort: ' + JSON.stringify(res), 'error'); + } + } catch(e) { + showToast('Fehler: ' + e.message, 'error'); + } finally { + deleteBtn.disabled = false; + deleteBtn.textContent = 'Webhook entfernen'; + refreshTelegramStatus(sessionId, statusBox, registerBtn, deleteBtn); + } + }); +} + +function showTelegramUnconfigured(statusBox, registerBtn, deleteBtn) { + statusBox.innerHTML = '

⚠️ Kein Bot-Token konfiguriert. Speichere zuerst die Session mit einem Token.

'; + statusBox.hidden = false; + registerBtn.hidden = true; + deleteBtn.hidden = true; +} + +async function refreshTelegramStatus(sessionId, statusBox, registerBtn, deleteBtn) { + statusBox.hidden = true; + try { + var info = await apiSessionTelegramWebhookInfo(sessionId); + var result = info.result || {}; + var url = result.url || ''; + var pending = result.pending_update_count || 0; + var lastError = result.last_error_message; + var active = !!url; + + var html = ''; + if (active) { + html += '

✅ Webhook aktiv

'; + html += '

URL: ' + escapeHtml(url) + '

'; + html += '

Ausstehend: ' + pending + '

'; + if (lastError) html += '

Letzter Fehler: ' + escapeHtml(lastError) + '

'; + } else { + html += '

⚠️ Webhook nicht registriert

'; + } + statusBox.innerHTML = html; + statusBox.hidden = false; + + // Buttons ein/ausblenden je nach Status + registerBtn.hidden = active; + deleteBtn.hidden = !active; + } catch(e) { + statusBox.innerHTML = '

Status konnte nicht abgerufen werden: ' + escapeHtml(e.message) + '

'; + statusBox.hidden = false; + registerBtn.hidden = false; + deleteBtn.hidden = true; + } +} + +async function saveTelegramConfig(sessionId, tokenInput, secretInput) { + var token = tokenInput.value.trim(); + var secret = secretInput.value.trim(); + await apiSessionTelegramSave(sessionId, token, secret); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/wwwroot/js/pages/settings-page.js b/wwwroot/js/pages/settings-page.js index 36702a8..770e7c6 100644 --- a/wwwroot/js/pages/settings-page.js +++ b/wwwroot/js/pages/settings-page.js @@ -9,12 +9,10 @@ registerPage('settings', { const showDeletedToggle = document.getElementById('settingShowDeletedMessages'); const resetBtn = document.getElementById('settingsResetBtn'); const backupSection = document.getElementById('settingsBackupSection'); - const telegramSection = document.getElementById('settingsTelegramSection'); - // Backup + Telegram nur für Admins + // Backup nur für Admins if (currentUser && currentUser.isAdmin) { backupSection.style.display = ''; - initTelegramSection(); } const s = loadSettings(); @@ -77,96 +75,9 @@ registerPage('settings', { showDeletedToggle.checked = SETTINGS_DEFAULTS.showDeletedMessages; }); - function initTelegramSection() { - // Erst prüfen ob Telegram überhaupt konfiguriert ist - apiTelegramStatus().then(s => { - if (!s.configured) return; // Bot-Token nicht gesetzt → Section versteckt lassen - telegramSection.style.display = ''; - const statusBtn = document.getElementById('settingsTelegramStatusBtn'); - const statusBox = document.getElementById('settingsTelegramStatusBox'); - const registerBtn = document.getElementById('settingsTelegramRegisterBtn'); - const deleteBtn = document.getElementById('settingsTelegramDeleteBtn'); - - // Status abrufen - statusBtn.addEventListener('click', async () => { - statusBtn.disabled = true; - statusBtn.textContent = 'Wird abgerufen...'; - statusBox.style.display = 'none'; - try { - const info = await apiTelegramWebhookInfo(); - const result = info.result || {}; - const url = result.url || '(kein Webhook registriert)'; - const pending = result.pending_update_count ?? 0; - const lastError = result.last_error_message; - let html = `
Webhook-URL${escapeHtml(url)}
`; - html += `
Ausstehende Updates${pending}
`; - if (lastError) { - html += `
Letzter Fehler${escapeHtml(lastError)}
`; - } - const active = !!result.url; - html += `
Status${active ? '✅ Aktiv' : '⚠️ Nicht registriert'}
`; - statusBox.innerHTML = html; - statusBox.style.display = ''; - } catch (e) { - statusBox.innerHTML = `
Fehler: ${escapeHtml(e.message)}
`; - statusBox.style.display = ''; - } finally { - statusBtn.disabled = false; - statusBtn.textContent = 'Status abrufen'; - } - }); - - // Webhook registrieren - registerBtn.addEventListener('click', async () => { - const baseUrl = prompt('Base-URL der App (z.B. ' + window.location.origin + '):', window.location.origin); - if (!baseUrl) return; - registerBtn.disabled = true; - registerBtn.textContent = 'Wird registriert...'; - try { - const res = await apiTelegramRegisterWebhook(baseUrl.trim()); - if (res.result && res.result.ok) { - alert('✅ Webhook erfolgreich registriert!'); - } else { - alert('Antwort: ' + JSON.stringify(res.result)); - } - } catch (e) { - alert('Fehler: ' + e.message); - } finally { - registerBtn.disabled = false; - registerBtn.textContent = 'Registrieren'; - } - }); - - // Webhook löschen - deleteBtn.addEventListener('click', async () => { - if (!confirm('Webhook wirklich entfernen? Der Bot empfängt dann keine Nachrichten mehr.')) return; - deleteBtn.disabled = true; - deleteBtn.textContent = 'Wird entfernt...'; - try { - const res = await apiTelegramDeleteWebhook(); - if (res.ok) { - alert('✅ Webhook erfolgreich entfernt.'); - } else { - alert('Antwort: ' + JSON.stringify(res)); - } - } catch (e) { - alert('Fehler: ' + e.message); - } finally { - deleteBtn.disabled = false; - deleteBtn.textContent = 'Webhook entfernen'; - } - }); - }); - } }, destroy() {} }); -function escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} + diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 4b69380..c99e7bd 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -1,3 +1,33 @@ +// ── Toast Notifications ── + +function showToast(message, type) { + type = type || 'info'; + var container = document.getElementById('toastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'toastContainer'; + document.body.appendChild(container); + } + var toast = document.createElement('div'); + toast.className = 'toast toast-' + type; + var icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-times-circle' : 'fa-info-circle'; + toast.innerHTML = ' ' + (message || ''); + container.appendChild(toast); + // Einblenden + requestAnimationFrame(function() { toast.classList.add('toast-visible'); }); + // Automatisch ausblenden + var timeout = setTimeout(function() { removeToast(toast); }, 4000); + toast.addEventListener('click', function() { + clearTimeout(timeout); + removeToast(toast); + }); +} + +function removeToast(toast) { + toast.classList.remove('toast-visible'); + toast.addEventListener('transitionend', function() { toast.remove(); }); +} + // ── DOM Helpers ── const $ = (sel) => document.querySelector(sel);