Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
18 KiB
C#
393 lines
18 KiB
C#
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";
|
|
|
|
/// <summary>#715: Brings text from outside into the context — answers of outside APIs.</summary>
|
|
public bool ReadsForeignContent => true;
|
|
|
|
public string Description => """
|
|
Retrieves real-time financial data from verified APIs.
|
|
All responses contain fetchedAt and dataAsOf timestamps.
|
|
Actions: 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=current price, history=price history, crypto=crypto, forex=exchange rate, search=search symbol"
|
|
},
|
|
"symbol": { "type": "string", "description": "e.g. NVDA, BTC, EUR/USD" },
|
|
"provider": { "type": "string", "description": "optional: twelvedata|alphavantage|massive|coingecko|yahoo" },
|
|
"interval": { "type": "string", "description": "for history: 1min|5min|1h|1day" },
|
|
"outputsize":{ "type": "integer","description": "for history: number of data points, max 500" }
|
|
}
|
|
}
|
|
""").RootElement.Clone();
|
|
|
|
public async Task<ToolResult> ExecuteAsync(
|
|
JsonElement input,
|
|
AgentToolContext context,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var action = input.GetProperty("action").GetString()!;
|
|
var symbol = input.GetProperty("symbol").GetString()!;
|
|
|
|
// Load config
|
|
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("DirectAPI", out var c) && c is JsonElement je
|
|
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
|
|
: context.ToolConfig;
|
|
|
|
if (config == null) return ToolResult.Fail("DirectAPI configuration is missing.");
|
|
|
|
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<ToolResult> 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}";
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (json.TryGetProperty("code", out var code) && code.GetInt32() != 200)
|
|
return ToolResult.Fail(json.GetProperty("message").GetString() ?? "API Fehler");
|
|
|
|
DateTime? dataAsOf = null;
|
|
if (json.TryGetProperty("timestamp", out var ts))
|
|
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
|
|
|
return CreateSuccessResult(fetchedAt, dataAsOf, url, json);
|
|
}
|
|
else if (provider == "alphavantage")
|
|
{
|
|
var apiKey = GetApiKey(providers, "alphavantage");
|
|
var url = $"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={apiKey}";
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (json.TryGetProperty("Note", out var note))
|
|
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
|
|
if (json.TryGetProperty("Error Message", out var err))
|
|
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
|
|
|
|
return CreateSuccessResult(fetchedAt, fetchedAt, url, json);
|
|
}
|
|
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}");
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
return CreateSuccessResult(fetchedAt, fetchedAt, url, json);
|
|
}
|
|
else if (provider == "yahoo")
|
|
{
|
|
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d";
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (!json.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<ToolResult> 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}";
|
|
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (json.TryGetProperty("status", out var status) && status.GetString() != "ok")
|
|
return ToolResult.Fail(json.GetProperty("message").GetString() ?? "API Fehler");
|
|
|
|
return CreateSuccessResult(fetchedAt, null, url, json);
|
|
}
|
|
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}";
|
|
|
|
if (!(await SafeGetJsonAsync(http, urlBase, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (json.TryGetProperty("Note", out var note))
|
|
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
|
|
if (json.TryGetProperty("Error Message", out var err))
|
|
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
|
|
|
|
return CreateSuccessResult(fetchedAt, null, urlBase, json);
|
|
}
|
|
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}");
|
|
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
return CreateSuccessResult(fetchedAt, null, url, json);
|
|
}
|
|
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}";
|
|
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (!json.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<ToolResult> 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";
|
|
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
if (!json.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<ToolResult> 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}";
|
|
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
|
|
DateTime? dataAsOf = null;
|
|
if (json.TryGetProperty("timestamp", out var ts))
|
|
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
|
|
|
return CreateSuccessResult(fetchedAt, dataAsOf, url, json);
|
|
}
|
|
|
|
private async Task<ToolResult> 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
|
|
|
|
if (!(await SafeGetJsonAsync(http, url, ct)).TryGet(out var json, out var error)) return error;
|
|
return CreateSuccessResult(fetchedAt, null, url, json);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The result of an API call: <b>either</b> data <b>or</b> a refusal —
|
|
/// never both, never neither.
|
|
///
|
|
/// <para>Previously this was a tuple <c>(JsonElement? json, ToolResult? error)</c>, and
|
|
/// each of the eleven call sites then read <c>json.Value</c>. That the value is always there after
|
|
/// <c>if (error != null) return error;</c> was only known to the human —
|
|
/// the compiler pointed out with eleven CS8629 that it could not follow it.
|
|
/// Eleven warnings you learn to look away from hide the twelfth that
|
|
/// is right.</para>
|
|
///
|
|
/// <para><see cref="TryGet"/> says the same in a way that can be followed: whoever
|
|
/// gets <c>true</c> has the data; whoever gets <c>false</c> has the refusal
|
|
/// they should return.</para>
|
|
/// </summary>
|
|
private readonly struct JsonFetch
|
|
{
|
|
private readonly JsonElement _json;
|
|
private readonly ToolResult? _error;
|
|
|
|
private JsonFetch(JsonElement json, ToolResult? error)
|
|
{
|
|
_json = json;
|
|
_error = error;
|
|
}
|
|
|
|
public static JsonFetch Ok(JsonElement json) => new(json, null);
|
|
public static JsonFetch Fail(ToolResult error) => new(default, error);
|
|
|
|
public bool TryGet(out JsonElement json, out ToolResult error)
|
|
{
|
|
json = _json;
|
|
// The fallback never applies: Fail demands a refusal, Ok sets none. It stands
|
|
// here so the promise gets by without an exclamation mark.
|
|
error = _error ?? ToolResult.Fail("Unbekannter Fehler beim API-Aufruf.");
|
|
return _error is null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetch a JSON response from an API safely.
|
|
/// Checks the HTTP status and whether the response actually is JSON before parsing.
|
|
/// </summary>
|
|
private static async Task<JsonFetch> SafeGetJsonAsync(
|
|
HttpClient http, string url, CancellationToken ct)
|
|
{
|
|
// Error messages go to the model too — the key must not appear in them.
|
|
var safeUrl = UrlSanitizer.Sanitize(url);
|
|
|
|
HttpResponseMessage response;
|
|
try
|
|
{
|
|
response = await http.GetAsync(url, ct);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return JsonFetch.Fail(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 JsonFetch.Fail(ToolResult.Fail(
|
|
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}"));
|
|
}
|
|
|
|
// Check whether the response is JSON at all
|
|
var trimmed = body.TrimStart();
|
|
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
|
|
{
|
|
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
|
return JsonFetch.Fail(ToolResult.Fail(
|
|
$"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}"));
|
|
}
|
|
|
|
try
|
|
{
|
|
var json = JsonDocument.Parse(body).RootElement.Clone();
|
|
return JsonFetch.Ok(json);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
|
return JsonFetch.Fail(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,
|
|
// Without sanitizing, the API key in the query string would go to the model,
|
|
// into the persisted context and into the logs.
|
|
source = UrlSanitizer.Sanitize(source),
|
|
data = data
|
|
};
|
|
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
}
|