using System.Net.Http.Json; using System.Text.Json; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.DirectAPI; public sealed class DirectApiTool : IAgentTool { public string Name => "DirectAPI"; public string Description => """ Ruft Echtzeit-Finanzdaten von verifizierten APIs ab. Alle Antworten enthalten fetchedAt und dataAsOf Timestamps. Aktionen: quote, history, crypto, forex, search """; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "required": ["action", "symbol"], "properties": { "action": { "type": "string", "enum": ["quote", "history", "crypto", "forex", "search"], "description": "quote=aktueller Kurs, history=Kursverlauf, crypto=Krypto, forex=Wechselkurs, search=Symbol suchen" }, "symbol": { "type": "string", "description": "z.B. NVDA, BTC, EUR/USD" }, "provider": { "type": "string", "description": "optional: twelvedata|alphavantage|massive|coingecko|yahoo" }, "interval": { "type": "string", "description": "für history: 1min|5min|1h|1day" }, "outputsize":{ "type": "integer","description": "für history: Anzahl Datenpunkte, max 500" } } } """).RootElement.Clone(); public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { try { var action = input.GetProperty("action").GetString()!; var symbol = input.GetProperty("symbol").GetString()!; // Config laden IReadOnlyDictionary? config = context.ToolConfig.TryGetValue("DirectAPI", out var c) && c is JsonElement je ? JsonSerializer.Deserialize>(je.GetRawText()) : context.ToolConfig; if (config == null) return ToolResult.Fail("DirectAPI Konfiguration fehlt."); var providers = config.GetValueOrDefault("providers") as JsonElement?; var defaultProvider = config.GetValueOrDefault("defaultProvider")?.ToString() ?? "twelvedata"; var provider = input.TryGetProperty("provider", out var p) ? p.GetString() : defaultProvider; return action switch { "quote" => await FetchQuoteAsync(symbol, provider!, providers, context, ct), "history" => await FetchHistoryAsync(symbol, input, provider!, providers, context, ct), "crypto" => await FetchCryptoAsync(symbol, providers, context, ct), "forex" => await FetchForexAsync(symbol, provider!, providers, context, ct), "search" => await SearchSymbolAsync(symbol, provider!, providers, context, ct), _ => ToolResult.Fail($"Unbekannte Aktion: {action}") }; } catch (Exception ex) { context.Logger.LogError(ex, "Fehler in DirectAPI"); return ToolResult.Fail($"Fehler: {ex.Message}"); } } private async Task FetchQuoteAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct) { using var http = new HttpClient(); http.DefaultRequestHeaders.Add("Cache-Control", "no-cache"); var fetchedAt = DateTime.UtcNow; if (provider == "twelvedata") { var apiKey = GetApiKey(providers, "twelvedata"); var url = $"https://api.twelvedata.com/quote?symbol={symbol}&apikey={apiKey}"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; if (json.Value.TryGetProperty("code", out var code) && code.GetInt32() != 200) return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler"); DateTime? dataAsOf = null; if (json.Value.TryGetProperty("timestamp", out var ts)) dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime; return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value); } else if (provider == "alphavantage") { var apiKey = GetApiKey(providers, "alphavantage"); var url = $"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={apiKey}"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; if (json.Value.TryGetProperty("Note", out var note)) return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}"); if (json.Value.TryGetProperty("Error Message", out var err)) return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}"); return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value); } else if (provider == "massive") { var apiKey = GetApiKey(providers, "massive"); var url = $"https://api.massive.com/v3/snapshot?ticker={symbol}"; http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value); } else if (provider == "yahoo") { var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; if (!json.Value.TryGetProperty("chart", out var chartProp) || !chartProp.TryGetProperty("result", out var resultArr) || resultArr.GetArrayLength() == 0) return ToolResult.Fail($"Yahoo Finance: Keine Daten für '{symbol}' erhalten."); var chart = resultArr[0]; var meta = chart.GetProperty("meta"); DateTime? dataAsOf = null; if (meta.TryGetProperty("regularMarketTime", out var ts)) dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime; return CreateSuccessResult(fetchedAt, dataAsOf, url, chart); } return ToolResult.Fail($"Provider '{provider}' wird für 'quote' noch nicht unterstützt."); } private async Task FetchHistoryAsync(string symbol, JsonElement input, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct) { var interval = input.TryGetProperty("interval", out var i) ? i.GetString() : "1day"; var outputSize = input.TryGetProperty("outputsize", out var o) ? o.GetInt32() : 30; using var http = new HttpClient(); var fetchedAt = DateTime.UtcNow; if (provider == "twelvedata") { var apiKey = GetApiKey(providers, "twelvedata"); var url = $"https://api.twelvedata.com/time_series?symbol={symbol}&interval={interval}&outputsize={outputSize}&apikey={apiKey}"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; if (json.Value.TryGetProperty("status", out var status) && status.GetString() != "ok") return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler"); return CreateSuccessResult(fetchedAt, null, url, json.Value); } else if (provider == "alphavantage") { var apiKey = GetApiKey(providers, "alphavantage"); var function_ = interval switch { "1min" or "5min" or "15min" or "30min" or "1h" => "TIME_SERIES_INTRADAY", _ => "TIME_SERIES_DAILY" }; var avInterval = interval switch { "1min" => "1min", "5min" => "5min", "15min" => "15min", "30min" => "30min", "1h" => "60min", _ => "" }; var urlBase = $"https://www.alphavantage.co/query?function={function_}&symbol={symbol}&apikey={apiKey}&outputsize=compact"; if (!string.IsNullOrEmpty(avInterval)) urlBase += $"&interval={avInterval}"; var (json, error) = await SafeGetJsonAsync(http, urlBase, ct); if (error != null) return error; if (json.Value.TryGetProperty("Note", out var note)) return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}"); if (json.Value.TryGetProperty("Error Message", out var err)) return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}"); return CreateSuccessResult(fetchedAt, null, urlBase, json.Value); } else if (provider == "massive") { var apiKey = GetApiKey(providers, "massive"); var timespan = interval switch { "1min" => "minute", "5min" => "minute", "15min" => "minute", "1h" => "hour", "1day" => "day", _ => "day" }; var multiplier = interval switch { "5min" => 5, "15min" => 15, _ => 1 }; var from = DateTime.UtcNow.AddDays(-outputSize).ToString("yyyy-MM-dd"); var to = DateTime.UtcNow.ToString("yyyy-MM-dd"); var url = $"https://api.massive.com/v2/aggs/ticker/{symbol}/range/{multiplier}/{timespan}/{from}/{to}?limit={outputSize}"; http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; return CreateSuccessResult(fetchedAt, null, url, json.Value); } else if (provider == "yahoo") { var yahooInterval = interval switch { "1min" => "1m", "5min" => "5m", "15min" => "15m", "1h" => "1h", "1day" => "1d", _ => "1d" }; var range = interval is "1min" or "5min" or "15min" ? "1d" : $"{outputSize}d"; var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval={yahooInterval}&range={range}"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; if (!json.Value.TryGetProperty("chart", out var chartProp) || !chartProp.TryGetProperty("result", out var resultArr) || resultArr.GetArrayLength() == 0) return ToolResult.Fail($"Yahoo Finance: Keine Historien-Daten für '{symbol}' erhalten."); var chart = resultArr[0]; return CreateSuccessResult(fetchedAt, null, url, chart); } return ToolResult.Fail($"Provider '{provider}' wird für 'history' noch nicht unterstützt."); } private async Task FetchCryptoAsync(string symbol, JsonElement? providers, AgentToolContext context, CancellationToken ct) { using var http = new HttpClient(); var fetchedAt = DateTime.UtcNow; var url = $"https://api.coingecko.com/api/v3/simple/price?ids={symbol.ToLowerInvariant()}&vs_currencies=usd&include_last_updated_at=true"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; if (!json.Value.TryGetProperty(symbol.ToLowerInvariant(), out var data)) return ToolResult.Fail($"Symbol '{symbol}' nicht gefunden."); DateTime? dataAsOf = null; if (data.TryGetProperty("last_updated_at", out var ts)) dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime; return CreateSuccessResult(fetchedAt, dataAsOf, url, data); } private async Task FetchForexAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct) { if (provider != "twelvedata") return ToolResult.Fail("Forex wird aktuell nur über twelvedata unterstützt."); var apiKey = GetApiKey(providers, "twelvedata"); using var http = new HttpClient(); var fetchedAt = DateTime.UtcNow; var url = $"https://api.twelvedata.com/exchange_rate?symbol={symbol}&apikey={apiKey}"; var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; DateTime? dataAsOf = null; if (json.Value.TryGetProperty("timestamp", out var ts)) dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime; return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value); } private async Task SearchSymbolAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct) { if (provider != "twelvedata") return ToolResult.Fail("Search wird aktuell nur über twelvedata unterstützt."); var apiKey = GetApiKey(providers, "twelvedata"); using var http = new HttpClient(); var fetchedAt = DateTime.UtcNow; var url = $"https://api.twelvedata.com/symbol_search?symbol={symbol}"; // search often doesn't need key or uses same var (json, error) = await SafeGetJsonAsync(http, url, ct); if (error != null) return error; return CreateSuccessResult(fetchedAt, null, url, json.Value); } /// /// Sichere JSON-Antwort von einer API abrufen. /// Prüft HTTP-Status und ob die Antwort tatsächlich JSON ist, bevor geparst wird. /// private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync( HttpClient http, string url, CancellationToken ct) { // Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen. var safeUrl = UrlSanitizer.Sanitize(url); HttpResponseMessage response; try { response = await http.GetAsync(url, ct); } catch (HttpRequestException ex) { return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}")); } var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { var preview = body.Length > 300 ? body[..300] + "…" : body; return (null, ToolResult.Fail( $"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}")); } // Prüfen ob die Antwort überhaupt JSON ist var trimmed = body.TrimStart(); if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '[')) { var preview = body.Length > 300 ? body[..300] + "…" : body; return (null, ToolResult.Fail( $"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}")); } try { var json = JsonDocument.Parse(body).RootElement.Clone(); return (json, null); } catch (JsonException ex) { var preview = body.Length > 300 ? body[..300] + "…" : body; return (null, ToolResult.Fail( $"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}")); } } private string? GetApiKey(JsonElement? providers, string name) { if (providers == null) return null; if (providers.Value.TryGetProperty(name, out var p) && p.TryGetProperty("apiKey", out var k)) return k.GetString(); return null; } private ToolResult CreateSuccessResult(DateTime fetchedAt, DateTime? dataAsOf, string source, object data) { var result = new { fetchedAt = fetchedAt, dataAsOf = dataAsOf, // Ohne Bereinigung ginge der API-Schlüssel im Query-String an das Modell, // in den persistierten Kontext und in die Logs. source = UrlSanitizer.Sanitize(source), data = data }; return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); } }