V16 / 6df58cd3ed86
CurrentWeatherTool.cs
Lilith/Core/Tools/CurrentWeatherTool.cs
Unchanged from the preceding versionCS
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 });
}