diff --git a/Endpoints/TelegramEndpoint.cs b/Endpoints/TelegramEndpoint.cs new file mode 100644 index 0000000..9ffa50f --- /dev/null +++ b/Endpoints/TelegramEndpoint.cs @@ -0,0 +1,131 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Gott.Extensions; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Gott.Endpoints; + +public static class TelegramEndpoint +{ + public static void MapTelegramEndpoints(this IEndpointRouteBuilder app) + { + // Webhook (von Telegram aufgerufen – keine Cookie-Auth!) + app.MapPost("/api/telegram/webhook", HandleWebhook); + + // Link-Code generieren (aus Web-UI, braucht Auth) + app.MapPost("/api/telegram/link", HandleCreateLink); + + // Webhook registrieren (Admin) + app.MapPost("/api/telegram/register-webhook", HandleRegisterWebhook); + + // Webhook-Info (Admin) + app.MapGet("/api/telegram/webhook-info", HandleWebhookInfo); + } + + private static async Task HandleWebhook(HttpRequest req, HttpResponse res, TelegramService telegram) + { + if (!telegram.IsConfigured) + { + res.StatusCode = 404; + return; + } + + // Secret-Token prüfen + var secret = req.Headers["X-Telegram-Bot-Api-Secret-Token"].ToString(); + if (secret != telegram.WebhookSecret) + { + res.StatusCode = 403; + return; + } + + try + { + var body = await JsonSerializer.DeserializeAsync(req.Body); + // 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}"); } + }); + } + catch + { + // Malformed JSON – ignorieren + } + + res.StatusCode = 200; + } + + private static async Task HandleCreateLink(HttpRequest req, HttpResponse res, DatabaseService db, TelegramService telegram) + { + 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) + { + res.StatusCode = 404; + await res.WriteAsJsonAsync(new { error = "Session nicht gefunden" }); + return; + } + + var agentId = session.AgentId; + var code = await db.CreateTelegramLinkCode(userId, sessionId, agentId); + + await res.WriteAsJsonAsync(new { code, expiresInSeconds = 300 }); + } + + private static async Task HandleRegisterWebhook(HttpRequest req, HttpResponse res, TelegramService telegram) + { + if (!req.HttpContext.IsAdmin()) + { + res.StatusCode = 403; + await res.WriteAsJsonAsync(new { error = "Admin required" }); + return; + } + + var body = await JsonSerializer.DeserializeAsync(req.Body); + if (!body.TryGetProperty("baseUrl", out var urlEl)) + { + res.StatusCode = 400; + await res.WriteAsJsonAsync(new { error = "baseUrl required" }); + return; + } + + var result = await telegram.RegisterWebhook(urlEl.GetString()); + await res.WriteAsJsonAsync(new { result }); + } + + 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(); + res.ContentType = "application/json"; + await res.WriteAsync(result); + } +} diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index d6dec9a..37ecb9f 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -17,8 +17,8 @@ public class AuthMiddleware { var path = ctx.Request.Path.Value ?? ""; - // Static files and login endpoint don't require auth - if (!path.StartsWith("/api/") || path == "/api/auth/login") + // Static files, login and telegram webhook don't require auth + if (!path.StartsWith("/api/") || path == "/api/auth/login" || path == "/api/telegram/webhook") { await _next(ctx); return; diff --git a/Models/TelegramLinkRecord.cs b/Models/TelegramLinkRecord.cs new file mode 100644 index 0000000..8d9386b --- /dev/null +++ b/Models/TelegramLinkRecord.cs @@ -0,0 +1,14 @@ +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 2602445..3599fa9 100644 --- a/Program.cs +++ b/Program.cs @@ -1,9 +1,11 @@ using System; using System.IO; +using System.Net.Http; using Gott.Endpoints; using Gott.Middleware; using Gott.Services; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); @@ -20,8 +22,21 @@ builder.Services.AddSingleton(dbService); builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new StreamingTracker()); -builder.Services.AddSingleton(new MemoryService(dataDir)); -builder.Services.AddSingleton(new SkillService(dataDir)); +var memoryService = new MemoryService(dataDir); +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"); +var anthropicToken = builder.Configuration["AnthropicToken"] ?? ""; +builder.Services.AddSingleton(sp => + new TelegramService( + telegramToken, telegramSecret, anthropicToken, + sp.GetRequiredService(), + dbService, new ToolService(), memoryService, skillService, + builder.Configuration.GetValue("MaxIterations", 100))); var app = builder.Build(); app.UseDefaultFiles(); @@ -38,5 +53,6 @@ app.MapInfoEndpoint(); app.MapBackupEndpoint(); app.MapSkillEndpoints(); app.MapProfileEndpoints(); +app.MapTelegramEndpoints(); app.Run("http://0.0.0.0:19000"); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index edeca08..8c91e19 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -183,6 +183,22 @@ public class DatabaseService updated_at TEXT NOT NULL ); "); + + conn.Execute(@" + CREATE TABLE IF NOT EXISTS telegram_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_chat_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT, + link_code TEXT, + expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(telegram_chat_id) + ); + CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code); + "); } private void TryAddColumn(SqliteConnection conn, string table, string column, string type) @@ -742,4 +758,87 @@ public class DatabaseService "UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId", new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") }); } + + // ── Telegram ── + + public async Task CreateTelegramLinkCode(string userId, string sessionId, string agentId) + { + 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"); + + // 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) + 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; + } + + public async Task RedeemTelegramLinkCode(string code, long 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 + 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; + } + + 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) + { + 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 }); + } + + public async Task> GetUserSessions(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", + new { userId }); + } } diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs new file mode 100644 index 0000000..000a56d --- /dev/null +++ b/Services/TelegramService.cs @@ -0,0 +1,840 @@ +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; + +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 int _maxIterations; + + public TelegramService( + string botToken, + string webhookSecret, + string anthropicToken, + IHttpClientFactory httpFactory, + DatabaseService db, + ToolService toolService, + MemoryService memoryService, + SkillService skillService, + int maxIterations = 100) + { + _botToken = botToken; + _webhookSecret = webhookSecret; + _anthropicToken = anthropicToken; + _httpFactory = httpFactory; + _db = db; + _toolService = toolService; + _memoryService = memoryService; + _skillService = skillService; + _maxIterations = maxIterations; + } + + public string WebhookSecret => _webhookSecret; + public bool IsConfigured => !string.IsNullOrEmpty(_botToken); + + // ── Webhook-Update verarbeiten ── + + public async Task HandleUpdate(JsonElement update) + { + if (!update.TryGetProperty("message", out var message)) return; + if (!message.TryGetProperty("chat", out var chat)) return; + if (!message.TryGetProperty("text", out var textEl)) return; + + var chatId = chat.GetProperty("id").GetInt64(); + var text = textEl.GetString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(text)) return; + + // Commands + if (text.StartsWith("/")) + { + await HandleCommand(chatId, text); + return; + } + + // Normale Nachricht → an Claude weiterleiten + await HandleMessage(chatId, text); + } + + // ── Commands ── + + private async Task HandleCommand(long chatId, string text) + { + 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() : ""; + + 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); + break; + + case "/status": + await HandleStatus(chatId); + break; + + case "/unlink": + await HandleUnlink(chatId); + break; + + default: + await SendMessage(chatId, "❓ Unbekannter Befehl. Sende /start für Hilfe."); + break; + } + } + + private async Task HandleLink(long chatId, string code) + { + if (string.IsNullOrEmpty(code)) + { + await SendMessage(chatId, "Bitte sende: `/link CODE`\n\nDen Code findest du im Web\\-UI unter 🔗 Telegram\\.", parseMode: "MarkdownV2"); + return; + } + + var link = await _db.RedeemTelegramLinkCode(code.ToUpperInvariant(), chatId); + if (link == null) + { + await SendMessage(chatId, "❌ Code ungültig oder abgelaufen. Bitte generiere einen neuen im Web-UI."); + 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"); + + try + { + var reply = await ChatNonStreaming(session, agent, userText, chatId); + await SendLongMessage(chatId, reply); + } + catch (Exception ex) + { + await SendMessage(chatId, $"❌ Fehler: {ex.Message}"); + } + } + + // ── Non-Streaming Claude Chat ── + + private async Task ChatNonStreaming(SessionRecord session, AgentRecord agent, string userText, long chatId) + { + var modelId = agent.Model switch + { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-4-5", + _ => "claude-sonnet-4-6" + }; + + // 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."; + } + + // ── History aus DB aufbauen ── + + 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) + { + var payload = new Dictionary + { + ["chat_id"] = chatId, + ["text"] = text + }; + if (!string.IsNullOrEmpty(parseMode)) + payload["parse_mode"] = parseMode; + + await CallTelegramApi("sendMessage", payload); + } + + private async Task SendLongMessage(long chatId, string text) + { + // Telegram-Limit: 4096 Zeichen + const int maxLen = 4096; + + if (text.Length <= maxLen) + { + await SendMessage(chatId, text); + return; + } + + // An Zeilengrenzen splitten + var remaining = text; + while (remaining.Length > 0) + { + 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 + } + } + + 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() + { + var client = _httpFactory.CreateClient(); + client.DefaultRequestHeaders.Add("x-api-key", _anthropicToken); + client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + return client; + } + + private async Task SaveError(string chainId, int iteration, string error) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iteration, + Role = "system", Type = "error", + Content = error, IsError = true + }); + } + + private async Task FinalizeChain(string chainId, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) + { + 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 + }); + } + + 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(); + } +} + +// TokenUsage und ToolResult werden aus AnthropicService.cs verwendet diff --git a/appsettings.json b/appsettings.json index e70debb..0ea800a 100755 --- a/appsettings.json +++ b/appsettings.json @@ -6,5 +6,8 @@ } }, "AllowedHosts": "*", - "MaxIterations": 100 + "AnthropicToken": "", + "MaxIterations": 100, + "TelegramBotToken": "", + "TelegramWebhookSecret": "" }