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