Add OpenWeather-backed weather reports
This commit is contained in:
@@ -21,6 +21,14 @@
|
||||
"WhisperLanguage": "en",
|
||||
"TempDirectory": "/tmp/openjibo-stt",
|
||||
"CleanupTempFiles": false
|
||||
},
|
||||
"Weather": {
|
||||
"OpenWeather": {
|
||||
"BaseUrl": "https://api.openweathermap.org",
|
||||
"ApiKey": "723667c9ab0318142227c5389900d087",
|
||||
"DefaultLocation": "Boston,US",
|
||||
"UseCelsius": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ public interface IPersonalMemoryStore
|
||||
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
|
||||
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
|
||||
string? GetPreference(PersonalMemoryTenantScope tenantScope, string category);
|
||||
void SetName(PersonalMemoryTenantScope tenantScope, string name);
|
||||
string? GetName(PersonalMemoryTenantScope tenantScope);
|
||||
void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value);
|
||||
string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label);
|
||||
void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity);
|
||||
PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item);
|
||||
IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope);
|
||||
}
|
||||
|
||||
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);
|
||||
|
||||
public enum PersonalAffinity
|
||||
{
|
||||
Like,
|
||||
Love,
|
||||
Dislike
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
public interface IWeatherReportProvider
|
||||
{
|
||||
Task<WeatherReportSnapshot?> GetReportAsync(
|
||||
WeatherReportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record WeatherReportRequest(
|
||||
string? LocationQuery,
|
||||
double? Latitude,
|
||||
double? Longitude,
|
||||
bool IsTomorrow,
|
||||
bool? UseCelsius);
|
||||
|
||||
public sealed record WeatherReportSnapshot(
|
||||
string LocationName,
|
||||
string Summary,
|
||||
int Temperature,
|
||||
int? HighTemperature,
|
||||
int? LowTemperature,
|
||||
string? Condition,
|
||||
bool UseCelsius);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@ public sealed class ProtocolToTurnContextMapper
|
||||
attributes["lastClockDomain"] = lastClockDomainText;
|
||||
}
|
||||
|
||||
if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) &&
|
||||
pendingProactivityOffer is string pendingProactivityOfferText &&
|
||||
!string.IsNullOrWhiteSpace(pendingProactivityOfferText))
|
||||
{
|
||||
attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
|
||||
}
|
||||
|
||||
attributes["listenHotphrase"] = turnState.ListenHotphrase;
|
||||
|
||||
if (turnState.ListenRules.Count > 0)
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var isPhotoCreateLaunch = string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase);
|
||||
var isClockSkillLaunch = string.Equals(skill?.SkillName, "@be/clock", StringComparison.OrdinalIgnoreCase);
|
||||
var isReportSkillLaunch = string.Equals(skill?.SkillName, "report-skill", StringComparison.OrdinalIgnoreCase);
|
||||
var localIntent = ReadSkillPayloadString(skill, "localIntent");
|
||||
var clockIntent = ReadSkillPayloadString(skill, "clockIntent");
|
||||
var clockDomain = ReadSkillPayloadString(skill, "domain");
|
||||
@@ -50,6 +51,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var globalIntent = ReadSkillPayloadString(skill, "globalIntent");
|
||||
var nluDomain = ReadSkillPayloadString(skill, "nluDomain");
|
||||
var volumeLevel = ReadSkillPayloadString(skill, "volumeLevel");
|
||||
var reportDate = ReadSkillPayloadString(skill, "date");
|
||||
var reportWeatherCondition = ReadSkillPayloadString(skill, "weatherCondition");
|
||||
var nluGuess = ReadClientEntity(turn, "guess");
|
||||
var wordOfDayGuess = ResolveWordOfDayGuess(turn, transcript, nluGuess);
|
||||
var outboundIntent = isGlobalCommand && !string.IsNullOrWhiteSpace(globalIntent)
|
||||
@@ -64,6 +67,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
? localIntent
|
||||
: isClockSkillLaunch && !string.IsNullOrWhiteSpace(clockIntent)
|
||||
? clockIntent
|
||||
: isReportSkillLaunch && !string.IsNullOrWhiteSpace(localIntent)
|
||||
? localIntent
|
||||
: isWordOfDayGuess
|
||||
? "guess"
|
||||
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -112,6 +117,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
|
||||
? rules
|
||||
: []
|
||||
: isReportSkillLaunch
|
||||
? []
|
||||
: isWordOfDayGuess
|
||||
? ["word-of-the-day/puzzle"]
|
||||
: isYesNoTurn && isYesNoIntent
|
||||
@@ -136,7 +143,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
timerMinutes,
|
||||
timerSeconds,
|
||||
alarmTime,
|
||||
alarmAmPm);
|
||||
alarmAmPm,
|
||||
isReportSkillLaunch,
|
||||
reportDate,
|
||||
reportWeatherCondition);
|
||||
var listenMessage = new
|
||||
{
|
||||
type = "LISTEN",
|
||||
@@ -159,6 +169,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
isPhotoGalleryLaunch ? "@be/gallery" :
|
||||
isPhotoCreateLaunch ? "@be/create" :
|
||||
isClockSkillLaunch ? "@be/clock" :
|
||||
isReportSkillLaunch ? "report-skill" :
|
||||
null,
|
||||
isGlobalCommand ? nluDomain ?? "global_commands" : null),
|
||||
match = new
|
||||
@@ -286,6 +297,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
DelayMs: 125));
|
||||
}
|
||||
|
||||
if (isReportSkillLaunch)
|
||||
{
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildSkillRedirectPayload(
|
||||
transId,
|
||||
"report-skill",
|
||||
outboundIntent,
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "report-skill")),
|
||||
DelayMs: 125));
|
||||
}
|
||||
|
||||
if (emitSkillActions && speak is not null)
|
||||
{
|
||||
messages.Add(new SocketReplyPlan(
|
||||
@@ -444,7 +471,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
string? timerMinutes,
|
||||
string? timerSeconds,
|
||||
string? alarmTime,
|
||||
string? alarmAmPm)
|
||||
string? alarmAmPm,
|
||||
bool reportSkillLaunch,
|
||||
string? reportDate,
|
||||
string? reportWeatherCondition)
|
||||
{
|
||||
if (yesNoTurn)
|
||||
{
|
||||
@@ -514,6 +544,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
return entities;
|
||||
}
|
||||
|
||||
if (reportSkillLaunch)
|
||||
{
|
||||
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(reportDate))
|
||||
{
|
||||
entities["date"] = reportDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reportWeatherCondition))
|
||||
{
|
||||
entities["Weather"] = reportWeatherCondition;
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
if (wordOfDayGuess)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
|
||||
@@ -538,6 +538,9 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
{
|
||||
session.Metadata["lastClockDomain"] = lastClockDomainValue.ToString();
|
||||
}
|
||||
|
||||
UpdatePendingProactivityOffer(session, plan.IntentName);
|
||||
|
||||
session.FollowUpExpiresUtc = plan.FollowUp.KeepMicOpen
|
||||
? DateTimeOffset.UtcNow.Add(plan.FollowUp.Timeout)
|
||||
: null;
|
||||
@@ -567,13 +570,13 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
!string.Equals(plan.IntentName, "alarm_cancel", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "timer_clarify", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "alarm_clarify", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) &&
|
||||
(messageType != "CLIENT_NLU" ||
|
||||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
|
||||
!string.Equals(plan.IntentName, "timer_value", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "alarm_value", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase) &&
|
||||
(messageType != "CLIENT_NLU" ||
|
||||
string.Equals(plan.IntentName, "word_of_the_day_guess", StringComparison.OrdinalIgnoreCase));
|
||||
var replies = ResponsePlanToSocketMessagesMapper.Map(plan, finalizedTurn, session, emitSkillActions).Select(map => new WebSocketReply
|
||||
{
|
||||
Text = map.Text,
|
||||
@@ -812,6 +815,7 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
{
|
||||
var messageType = ReadMessageType(turn);
|
||||
var clientIntent = ReadAttribute(turn, "clientIntent");
|
||||
var pendingProactivityOffer = ReadAttribute(turn, "pendingProactivityOffer");
|
||||
var transcript = NormalizeTranscript(turn.NormalizedTranscript ?? turn.RawTranscript);
|
||||
var listenRules = ReadRules(turn, "listenRules").Concat(ReadRules(turn, "clientRules")).ToArray();
|
||||
|
||||
@@ -846,6 +850,12 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) &&
|
||||
transcript is "yes" or "no" or "sure" or "nope" or "yup" or "uh huh" or "yeah" or "nah")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (listenRules.Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
@@ -960,6 +970,17 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
string.Equals(rule, "surprises-ota/want_to_download_now", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void UpdatePendingProactivityOffer(CloudSession session, string? intentName)
|
||||
{
|
||||
if (string.Equals(intentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
session.Metadata["pendingProactivityOffer"] = "pizza_fact";
|
||||
return;
|
||||
}
|
||||
|
||||
session.Metadata.Remove("pendingProactivityOffer");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadRules(TurnContext turn, string key)
|
||||
{
|
||||
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Jibo.Cloud.Infrastructure.Audio;
|
||||
using Jibo.Cloud.Infrastructure.Content;
|
||||
using Jibo.Cloud.Infrastructure.Persistence;
|
||||
using Jibo.Cloud.Infrastructure.Telemetry;
|
||||
using Jibo.Cloud.Infrastructure.Weather;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -23,7 +24,20 @@ public static class ServiceCollectionExtensions
|
||||
configuration.GetSection("OpenJibo:Stt").Bind(sttOptions);
|
||||
}
|
||||
|
||||
var openWeatherOptions = new OpenWeatherOptions();
|
||||
if (configuration is not null)
|
||||
{
|
||||
configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey))
|
||||
{
|
||||
openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
|
||||
}
|
||||
|
||||
services.AddSingleton(sttOptions);
|
||||
services.AddSingleton(openWeatherOptions);
|
||||
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
|
||||
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
||||
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
|
||||
|
||||
@@ -36,6 +36,62 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
||||
: null;
|
||||
}
|
||||
|
||||
public void SetName(PersonalMemoryTenantScope tenantScope, string name)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
||||
record.Name = name;
|
||||
}
|
||||
|
||||
public string? GetName(PersonalMemoryTenantScope tenantScope)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
return _tenantMemory.TryGetValue(key, out var record) ? record.Name : null;
|
||||
}
|
||||
|
||||
public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
||||
record.ImportantDates[NormalizeCategory(label)] = value;
|
||||
}
|
||||
|
||||
public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
return _tenantMemory.TryGetValue(key, out var record) &&
|
||||
record.ImportantDates.TryGetValue(NormalizeCategory(label), out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
||||
record.Affinities[NormalizeCategory(item)] = affinity;
|
||||
}
|
||||
|
||||
public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
return _tenantMemory.TryGetValue(key, out var record) &&
|
||||
record.Affinities.TryGetValue(NormalizeCategory(item), out var affinity)
|
||||
? affinity
|
||||
: null;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
if (!_tenantMemory.TryGetValue(key, out var record))
|
||||
{
|
||||
return new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return new Dictionary<string, PersonalAffinity>(record.Affinities, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
|
||||
{
|
||||
return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}";
|
||||
@@ -49,6 +105,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
||||
private sealed class TenantMemoryRecord
|
||||
{
|
||||
public string? Birthday { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Jibo.Cloud.Infrastructure.Weather;
|
||||
|
||||
public sealed class OpenWeatherOptions
|
||||
{
|
||||
public string BaseUrl { get; set; } = "https://api.openweathermap.org";
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
public string DefaultLocation { get; set; } = "Boston,US";
|
||||
|
||||
public bool UseCelsius { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Infrastructure.Weather;
|
||||
|
||||
public sealed class OpenWeatherReportProvider(
|
||||
HttpClient httpClient,
|
||||
OpenWeatherOptions options,
|
||||
ILogger<OpenWeatherReportProvider> logger)
|
||||
: IWeatherReportProvider
|
||||
{
|
||||
public async Task<WeatherReportSnapshot?> GetReportAsync(
|
||||
WeatherReportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ApiKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var location = await ResolveLocationAsync(request, cancellationToken);
|
||||
if (location is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var useCelsius = request.UseCelsius ?? options.UseCelsius;
|
||||
return request.IsTomorrow
|
||||
? await GetTomorrowForecastAsync(location.Value, useCelsius, cancellationToken)
|
||||
: await GetCurrentWeatherAsync(location.Value, useCelsius, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "OpenWeather lookup failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LocationPoint?> ResolveLocationAsync(
|
||||
WeatherReportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is { Latitude: not null, Longitude: not null })
|
||||
{
|
||||
return new LocationPoint(request.Latitude.Value, request.Longitude.Value, null);
|
||||
}
|
||||
|
||||
var query = string.IsNullOrWhiteSpace(request.LocationQuery)
|
||||
? options.DefaultLocation
|
||||
: request.LocationQuery.Trim();
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var geocodeUri = BuildRequestUri(
|
||||
"/geo/1.0/direct",
|
||||
("q", query),
|
||||
("limit", "1"),
|
||||
("appid", options.ApiKey!));
|
||||
using var response = await httpClient.GetAsync(geocodeUri, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array ||
|
||||
document.RootElement.GetArrayLength() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var location = document.RootElement[0];
|
||||
if (!TryReadDouble(location, "lat", out var latitude) ||
|
||||
!TryReadDouble(location, "lon", out var longitude))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var displayName = BuildLocationDisplayName(location);
|
||||
return new LocationPoint(latitude, longitude, displayName);
|
||||
}
|
||||
|
||||
private async Task<WeatherReportSnapshot?> GetCurrentWeatherAsync(
|
||||
LocationPoint location,
|
||||
bool useCelsius,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var weatherUri = BuildRequestUri(
|
||||
"/data/2.5/weather",
|
||||
("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)),
|
||||
("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)),
|
||||
("units", useCelsius ? "metric" : "imperial"),
|
||||
("appid", options.ApiKey!));
|
||||
using var response = await httpClient.GetAsync(weatherUri, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("main", out var main))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var locationName = ReadNonEmptyString(root, "name") ?? location.DisplayName ?? options.DefaultLocation;
|
||||
var summary = TryReadWeatherSummary(root);
|
||||
var condition = TryReadWeatherCondition(root);
|
||||
var temperature = TryReadInt(main, "temp");
|
||||
var high = TryReadInt(main, "temp_max");
|
||||
var low = TryReadInt(main, "temp_min");
|
||||
if (temperature is null && high is null && low is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedTemperature = temperature ?? high ?? low ?? 0;
|
||||
return new WeatherReportSnapshot(
|
||||
locationName,
|
||||
summary ?? "partly cloudy",
|
||||
resolvedTemperature,
|
||||
high,
|
||||
low,
|
||||
condition,
|
||||
useCelsius);
|
||||
}
|
||||
|
||||
private async Task<WeatherReportSnapshot?> GetTomorrowForecastAsync(
|
||||
LocationPoint location,
|
||||
bool useCelsius,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var forecastUri = BuildRequestUri(
|
||||
"/data/2.5/forecast",
|
||||
("lat", location.Latitude.ToString(CultureInfo.InvariantCulture)),
|
||||
("lon", location.Longitude.ToString(CultureInfo.InvariantCulture)),
|
||||
("units", useCelsius ? "metric" : "imperial"),
|
||||
("appid", options.ApiKey!));
|
||||
using var response = await httpClient.GetAsync(forecastUri, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var offset = TryReadForecastOffset(root);
|
||||
var tomorrow = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(1));
|
||||
var entries = new List<ForecastEntry>();
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
if (!TryReadLong(item, "dt", out var unixSeconds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset);
|
||||
if (DateOnly.FromDateTime(localTimestamp.DateTime) != tomorrow)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!item.TryGetProperty("main", out var main))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new ForecastEntry(
|
||||
localTimestamp,
|
||||
TryReadInt(main, "temp"),
|
||||
TryReadInt(main, "temp_max"),
|
||||
TryReadInt(main, "temp_min"),
|
||||
TryReadWeatherSummary(item),
|
||||
TryReadWeatherCondition(item)));
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var selectedEntry = entries
|
||||
.OrderBy(entry => Math.Abs((entry.LocalTime.TimeOfDay - TimeSpan.FromHours(12)).TotalMinutes))
|
||||
.First();
|
||||
var highs = entries
|
||||
.Where(entry => entry.HighTemperature is not null)
|
||||
.Select(entry => entry.HighTemperature!.Value)
|
||||
.ToArray();
|
||||
var lows = entries
|
||||
.Where(entry => entry.LowTemperature is not null)
|
||||
.Select(entry => entry.LowTemperature!.Value)
|
||||
.ToArray();
|
||||
|
||||
var locationName = ReadForecastLocationName(root) ?? location.DisplayName ?? options.DefaultLocation;
|
||||
var high = highs.Length > 0 ? highs.Max() : selectedEntry.HighTemperature;
|
||||
var low = lows.Length > 0 ? lows.Min() : selectedEntry.LowTemperature;
|
||||
var temperature = selectedEntry.Temperature ?? high ?? low ?? 0;
|
||||
|
||||
return new WeatherReportSnapshot(
|
||||
locationName,
|
||||
selectedEntry.Summary ?? "partly cloudy",
|
||||
temperature,
|
||||
high,
|
||||
low,
|
||||
selectedEntry.Condition,
|
||||
useCelsius);
|
||||
}
|
||||
|
||||
private Uri BuildRequestUri(string path, params (string Key, string Value)[] queryParts)
|
||||
{
|
||||
var baseUrl = options.BaseUrl.TrimEnd('/');
|
||||
var query = string.Join(
|
||||
"&",
|
||||
queryParts.Select(part =>
|
||||
$"{Uri.EscapeDataString(part.Key)}={Uri.EscapeDataString(part.Value)}"));
|
||||
return new Uri($"{baseUrl}{path}?{query}");
|
||||
}
|
||||
|
||||
private static TimeSpan TryReadForecastOffset(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("city", out var city))
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var timezoneSeconds = TryReadInt(city, "timezone");
|
||||
if (timezoneSeconds is null)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var seconds = Math.Clamp(timezoneSeconds.Value, -50400, 50400);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
private static string? ReadForecastLocationName(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("city", out var city))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = ReadNonEmptyString(city, "name");
|
||||
var country = ReadNonEmptyString(city, "country");
|
||||
return string.IsNullOrWhiteSpace(country) ? name : $"{name}, {country}";
|
||||
}
|
||||
|
||||
private static string? BuildLocationDisplayName(JsonElement location)
|
||||
{
|
||||
var name = ReadNonEmptyString(location, "name");
|
||||
var state = ReadNonEmptyString(location, "state");
|
||||
var country = ReadNonEmptyString(location, "country");
|
||||
if (!string.IsNullOrWhiteSpace(name) &&
|
||||
!string.IsNullOrWhiteSpace(state) &&
|
||||
!string.IsNullOrWhiteSpace(country))
|
||||
{
|
||||
return $"{name}, {state}, {country}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country))
|
||||
{
|
||||
return $"{name}, {country}";
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string? TryReadWeatherSummary(JsonElement root)
|
||||
{
|
||||
return TryReadWeatherProperty(root, "description");
|
||||
}
|
||||
|
||||
private static string? TryReadWeatherCondition(JsonElement root)
|
||||
{
|
||||
var main = TryReadWeatherProperty(root, "main");
|
||||
if (string.IsNullOrWhiteSpace(main))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = main.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"rain" or "drizzle" or "thunderstorm" => "rain",
|
||||
"snow" => "snow",
|
||||
"clear" => "sunny",
|
||||
"clouds" => "cloudy",
|
||||
"mist" or "smoke" or "haze" or "fog" => "fog",
|
||||
_ => normalized
|
||||
};
|
||||
}
|
||||
|
||||
private static string? TryReadWeatherProperty(JsonElement root, string key)
|
||||
{
|
||||
if (!root.TryGetProperty("weather", out var weather) ||
|
||||
weather.ValueKind != JsonValueKind.Array ||
|
||||
weather.GetArrayLength() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var first = weather[0];
|
||||
return ReadNonEmptyString(first, key);
|
||||
}
|
||||
|
||||
private static string? ReadNonEmptyString(JsonElement source, string key)
|
||||
{
|
||||
return source.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryReadDouble(JsonElement source, string key, out double value)
|
||||
{
|
||||
value = 0;
|
||||
return source.TryGetProperty(key, out var element) && element.TryGetDouble(out value);
|
||||
}
|
||||
|
||||
private static bool TryReadLong(JsonElement source, string key, out long value)
|
||||
{
|
||||
value = 0;
|
||||
return source.TryGetProperty(key, out var element) && element.TryGetInt64(out value);
|
||||
}
|
||||
|
||||
private static int? TryReadInt(JsonElement source, string key)
|
||||
{
|
||||
if (!source.TryGetProperty(key, out var element))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var numeric))
|
||||
{
|
||||
return (int)Math.Round(numeric, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readonly record struct LocationPoint(double Latitude, double Longitude, string? DisplayName);
|
||||
|
||||
private sealed record ForecastEntry(
|
||||
DateTimeOffset LocalTime,
|
||||
int? Temperature,
|
||||
int? HighTemperature,
|
||||
int? LowTemperature,
|
||||
string? Summary,
|
||||
string? Condition);
|
||||
}
|
||||
Reference in New Issue
Block a user