← All source files

V13 / 7879e8b5740b

CurrentWeatherTool.cs

Lilith/Core/Tools/CurrentWeatherTool.cs

Changed in this version
CS
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Lilith.Core.Tools;

/// <summary>Bounded, explicit-city weather lookup. Never infers a location from IP.</summary>
public sealed class CurrentWeatherTool(HttpClient? client = null) : ILilithTool
{
    private static readonly HttpClient Shared = new(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }) { Timeout = TimeSpan.FromSeconds(15) };
    public string Name => "get_current_weather";
    public string Description => "Read current weather for an explicitly supplied city from wttr.in, in Celsius. Requires internet. Use the actual returned weather; report unavailable honestly. Never infer a city from the computer's IP or timezone.";
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new {
        type = "object", properties = new { city = new { type = "string", description = "City, optionally with country/region, e.g. London, UK or New York, USA." } },
        required = new[] { "city" }, additionalProperties = false
    });
    public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default)
    {
        if (arguments.ValueKind != JsonValueKind.Object || arguments.EnumerateObject().Any(p => p.Name != "city") ||
            !arguments.TryGetProperty("city", out var value) || value.ValueKind != JsonValueKind.String) throw new ArgumentException("get_current_weather requires a city string only.");
        string city = value.GetString()!.Trim();
        if (city.Length is < 1 or > 100 || !city.Any(char.IsLetter) || !Regex.IsMatch(city, "^[\\p{L}\\p{M}\\p{N} .,'’()-]+$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)))
            throw new ArgumentException("Supply a city name, optionally including its region/country.");
        cancellationToken.ThrowIfCancellationRequested();
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(TimeSpan.FromSeconds(12));
        try
        {
            string url = "https://wttr.in/" + Uri.EscapeDataString(city) + "?format=3&m";
            using var request = new HttpRequestMessage(HttpMethod.Get, url);
            request.Headers.UserAgent.ParseAdd("Lilith/1.0");
            using var response = await (client ?? Shared).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token);
            if (!response.IsSuccessStatusCode) return Unavailable(city, "Weather service returned HTTP " + (int)response.StatusCode + ".");
            await using var stream = await response.Content.ReadAsStreamAsync(deadline.Token);
            byte[] buffer = new byte[4097]; int length = 0;
            while (length < buffer.Length)
            {
                int read = await stream.ReadAsync(buffer.AsMemory(length), deadline.Token);
                if (read == 0) break;
                length += read;
            }
            if (length > 4096) return Unavailable(city, "Weather response exceeded its limit.");
            string weather = new UTF8Encoding(false, true).GetString(buffer, 0, length).Trim();
            if (weather.Length is < 1 or > 500 || weather.Any(char.IsControl) || weather.Contains('<') ||
                !Regex.IsMatch(weather, @"[-+]?\d+(?:\.\d+)?\s*°C", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)))
                return Unavailable(city, "Weather service did not return a usable current-weather line.");
            return JsonSerializer.Serialize(new { status = "completed", city, weather, units = "Celsius", source = "https://wttr.in", retrieved_at = DateTimeOffset.UtcNow.ToString("O") });
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return Unavailable(city, "Weather request timed out."); }
        catch (Exception ex) when (ex is HttpRequestException or IOException or DecoderFallbackException) { return Unavailable(city, "Weather service could not be reached or read."); }
    }
    private static string Unavailable(string city, string error) => JsonSerializer.Serialize(new { status = "unavailable", city, source = "https://wttr.in", error });
}