Add weekly weather cards and improve news API fallback

This commit is contained in:
Jacob Dubin
2026-05-11 22:44:56 -05:00
parent 67c738fae3
commit df3b34c8ad
15 changed files with 66158 additions and 37 deletions

View File

@@ -657,11 +657,17 @@ public sealed class JiboInteractionService(
"I couldn't fetch the weather right now. Please try again.");
}
var weeklySegments = BuildWeeklyForecastCardSegments(weeklySnapshots, referenceLocalTime);
var weeklySpokenReply = BuildWeeklyForecastSpokenReply(
weeklySnapshots,
referenceLocalTime,
weeklySegments,
weeklySnapshots[0].Snapshot.LocationName,
weeklySnapshots[0].Snapshot.UseCelsius,
isThisWeekForecast);
var weeklyWeatherPayload = BuildWeatherSkillPayload(weeklySpokenReply, weeklySnapshots[0].Snapshot, referenceLocalTime);
var weeklyWeatherPayload = BuildWeeklyWeatherSkillPayload(
weeklySpokenReply,
weeklySnapshots[0].Snapshot,
weeklySegments,
referenceLocalTime);
return new JiboInteractionDecision(
"weather",
weeklySpokenReply,
@@ -744,28 +750,38 @@ public sealed class JiboInteractionService(
}
private static string BuildWeeklyForecastSpokenReply(
IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots,
DateTimeOffset? referenceLocalTime,
IReadOnlyList<WeatherForecastCardSegment> segments,
string? locationName,
bool useCelsius,
bool isThisWeekForecast)
{
if (snapshots.Count == 0)
if (segments.Count == 0)
{
return "I couldn't build a forecast right now.";
}
var location = snapshots[0].Snapshot.LocationName;
if (string.IsNullOrWhiteSpace(location))
var location = string.IsNullOrWhiteSpace(locationName)
? "your area"
: NormalizeLocationForSpeech(locationName);
var unit = useCelsius ? "Celsius" : "Fahrenheit";
var leadIn = isThisWeekForecast
? $"Here's the rest of this week's forecast in {location}."
: $"I can share the next five-day forecast in {location}.";
return $"{leadIn} {string.Join(" ", segments.Select(static segment => segment.SpokenLine))} Temperatures are in {unit}.";
}
private static IReadOnlyList<WeatherForecastCardSegment> BuildWeeklyForecastCardSegments(
IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots,
DateTimeOffset? referenceLocalTime)
{
if (snapshots.Count == 0)
{
location = "your area";
}
else
{
location = NormalizeLocationForSpeech(location);
return [];
}
var unit = snapshots[0].Snapshot.UseCelsius ? "Celsius" : "Fahrenheit";
var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date;
var segments = snapshots
var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow;
var referenceDate = resolvedReference.Date;
return snapshots
.OrderBy(static item => item.DayOffset)
.Take(MaxWeatherForecastDayOffset)
.Select(item =>
@@ -776,13 +792,47 @@ public sealed class JiboInteractionService(
: item.Snapshot.Summary.Trim().TrimEnd('.');
var high = item.Snapshot.HighTemperature ?? item.Snapshot.Temperature;
var low = item.Snapshot.LowTemperature ?? item.Snapshot.Temperature;
return $"{dayName}: {summary}, high {high}, low {low}.";
});
var iconReference = new DateTimeOffset(
resolvedReference.Date.AddDays(item.DayOffset).AddHours(12),
resolvedReference.Offset);
var icon = ResolveWeatherAnimationIcon(item.Snapshot, iconReference);
var unit = item.Snapshot.UseCelsius ? "C" : "F";
var temperatureBand = ResolveWeatherTemperatureBand(high, item.Snapshot.UseCelsius);
var spokenLine = $"{dayName}: {summary}, high {high}, low {low}.";
return new WeatherForecastCardSegment(
dayName,
summary,
high,
low,
icon,
unit,
temperatureBand,
spokenLine);
})
.ToArray();
}
var leadIn = isThisWeekForecast
? $"Here's the rest of this week's forecast in {location}."
: $"I can share the next five-day forecast in {location}.";
return $"{leadIn} {string.Join(" ", segments)} Temperatures are in {unit}.";
private static IDictionary<string, object?> BuildWeeklyWeatherSkillPayload(
string spokenReply,
WeatherReportSnapshot snapshot,
IReadOnlyList<WeatherForecastCardSegment> segments,
DateTimeOffset? referenceLocalTime)
{
var payload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime);
payload["weather_weekly_cards"] = segments
.Select(static segment => new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["weather_day"] = segment.DayName,
["weather_summary"] = segment.Summary,
["weather_icon"] = segment.Icon,
["weather_high"] = segment.High,
["weather_low"] = segment.Low,
["weather_unit"] = segment.Unit,
["weather_theme"] = segment.Theme,
["weather_spoken_line"] = segment.SpokenLine
})
.ToArray();
return payload;
}
private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
@@ -4620,6 +4670,16 @@ public sealed class JiboInteractionService(
"WY"
};
private sealed record WeatherForecastCardSegment(
string DayName,
string Summary,
int High,
int Low,
string Icon,
string Unit,
string Theme,
string SpokenLine);
private const string GreetingRouteMetadataKey = "greetingsRoute";
private const string GreetingSpeakerMetadataKey = "greetingsSpeaker";
private const string LastProactiveGreetingUtcMetadataKey = "greetingsLastProactiveUtc";

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
@@ -805,7 +806,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
};
}
var weatherHiLoView = BuildWeatherHiLoView(skillPayload);
object? weatherHiLoView = BuildWeatherHiLoView(skillPayload);
var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload);
if (weatherHiLoView is null && weeklyWeatherCards.Count > 0)
{
weatherHiLoView = weeklyWeatherCards[0].View;
}
var useWeatherSequence = false;
if (weatherHiLoView is not null)
{
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
@@ -853,6 +861,30 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
["views"] = weatherViews
};
if (weeklyWeatherCards.Count > 1)
{
useWeatherSequence = true;
jcpConfig["children"] = BuildWeatherHiLoSequenceChildren(
weeklyWeatherCards,
promptSubCategory,
mimId,
mimType);
}
}
var jcp = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "SLIM",
["config"] = jcpConfig
};
if (useWeatherSequence &&
jcpConfig.TryGetValue("children", out var sequenceChildren) &&
sequenceChildren is not null)
{
jcp["type"] = "SEQUENCE";
jcp.Remove("config");
jcp["children"] = sequenceChildren;
}
return new
@@ -871,11 +903,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
config = new
{
jcp = new
{
type = "SLIM",
config = jcpConfig
}
jcp
}
},
analytics = new Dictionary<string, object?>(),
@@ -1105,6 +1133,184 @@ public sealed class ResponsePlanToSocketMessagesMapper
};
}
private static IReadOnlyList<WeatherHiLoSequenceCard> BuildWeatherHiLoSequenceCards(IDictionary<string, object?>? payload)
{
if (payload is null ||
!payload.TryGetValue("weather_weekly_cards", out var rawCards) ||
rawCards is null)
{
return [];
}
var cards = ReadPayloadObjectArray(rawCards);
if (cards.Count == 0)
{
return [];
}
var sequenceCards = new List<WeatherHiLoSequenceCard>(cards.Count);
foreach (var card in cards)
{
var weatherCardPayload = new Dictionary<string, object?>(card, StringComparer.OrdinalIgnoreCase)
{
["weather_view_enabled"] = true,
["weather_view_kind"] = "weatherHiLo"
};
var view = BuildWeatherHiLoView(weatherCardPayload);
if (view is null)
{
continue;
}
sequenceCards.Add(new WeatherHiLoSequenceCard(
view,
ReadPayloadString(weatherCardPayload, "weather_day"),
ReadPayloadString(weatherCardPayload, "weather_icon"),
ReadPayloadString(weatherCardPayload, "weather_spoken_line")));
}
return sequenceCards;
}
private static IReadOnlyList<object> BuildWeatherHiLoSequenceChildren(
IReadOnlyList<WeatherHiLoSequenceCard> cards,
string promptSubCategory,
string mimId,
string mimType)
{
var children = new List<object>(cards.Count);
for (var index = 0; index < cards.Count; index += 1)
{
var card = cards[index];
var promptLabel = string.IsNullOrWhiteSpace(card.DayName)
? $"Day{index + 1}"
: Regex.Replace(card.DayName, "[^A-Za-z0-9]", string.Empty, RegexOptions.CultureInvariant);
var promptId = $"WeatherForecast{promptLabel}_AN_13";
var spokenLine = string.IsNullOrWhiteSpace(card.SpokenLine)
? "Here is another day's forecast."
: card.SpokenLine!;
var icon = string.IsNullOrWhiteSpace(card.Icon)
? "cloudy"
: card.Icon!;
var esml =
$"<speak><anim cat='weather' meta='{icon}' nonBlocking='true' /><break size='0.2'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeXml(spokenLine)}</es></speak>";
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "Javascript",
["data"] = card.View,
["pause"] = true
};
children.Add(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "SLIM",
["config"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["play"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["esml"] = esml,
["meta"] = new
{
prompt_id = promptId,
prompt_sub_category = promptSubCategory,
mim_id = mimId,
mim_type = mimType
}
},
["gui"] = new
{
type = "Javascript",
data = "views.weatherHiLo",
pause = true
},
["display"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["view"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "Javascript",
["data"] = card.View,
["pause"] = true,
["context"] = resolvedGuiContext
}
},
["timeout"] = 6,
["barge_in"] = true,
["no_matches_for_gui"] = 0,
["no_inputs_for_gui"] = 0
}
});
}
return children;
}
private static IReadOnlyList<IDictionary<string, object?>> ReadPayloadObjectArray(object rawValue)
{
if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
return jsonArray
.EnumerateArray()
.Select(ConvertJsonObjectToDictionary)
.Where(static item => item is not null)
.Cast<IDictionary<string, object?>>()
.ToArray();
}
if (rawValue is IEnumerable<object?> rawObjects)
{
return rawObjects
.Select(ConvertObjectToDictionary)
.Where(static item => item is not null)
.Cast<IDictionary<string, object?>>()
.ToArray();
}
return [];
}
private static IDictionary<string, object?>? ConvertObjectToDictionary(object? value)
{
if (value is null)
{
return null;
}
if (value is IDictionary<string, object?> dictionary)
{
return new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase);
}
return value is JsonElement jsonValue
? ConvertJsonObjectToDictionary(jsonValue)
: null;
}
private static IDictionary<string, object?>? ConvertJsonObjectToDictionary(JsonElement value)
{
if (value.ValueKind != JsonValueKind.Object)
{
return null;
}
var dictionary = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var property in value.EnumerateObject())
{
dictionary[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number when property.Value.TryGetInt32(out var intValue) => intValue,
JsonValueKind.Number when property.Value.TryGetDouble(out var doubleValue) => doubleValue,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Object => ConvertJsonObjectToDictionary(property.Value),
JsonValueKind.Array => property.Value,
_ => null
};
}
return dictionary;
}
private static object? BuildWeatherHiLoView(IDictionary<string, object?>? payload)
{
if (!TryReadPayloadBool(payload, "weather_view_enabled"))
@@ -1347,6 +1553,12 @@ public sealed class ResponsePlanToSocketMessagesMapper
return Guid.NewGuid().ToString("N");
}
private sealed record WeatherHiLoSequenceCard(
object View,
string? DayName,
string? Icon,
string? SpokenLine);
public sealed record SocketReplyPlan(string Text, int DelayMs = 0);
}

View File

@@ -83,16 +83,20 @@ public sealed class NewsApiBriefingProvider(
if (!response.IsSuccessStatusCode)
{
var responseBody = await TryReadResponseBodySnippetAsync(response, cancellationToken);
var apiError = TryParseApiError(responseBody);
CaptureFailure(
"http_error",
$"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.",
apiError?.Message ?? $"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.",
(int)response.StatusCode,
uri);
uri,
apiError?.Code);
logger.LogWarning(
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
"NewsAPI request failed for category {Category}. StatusCode={StatusCode} Reason={ReasonPhrase} ErrorCode={ErrorCode} ErrorMessage={ErrorMessage} Body={Body}",
category,
(int)response.StatusCode,
response.ReasonPhrase,
apiError?.Code ?? string.Empty,
apiError?.Message ?? string.Empty,
responseBody ?? string.Empty);
continue;
}
@@ -211,15 +215,19 @@ public sealed class NewsApiBriefingProvider(
else
{
var fallbackBody = await TryReadResponseBodySnippetAsync(broadResponse, cancellationToken);
var apiError = TryParseApiError(fallbackBody);
CaptureFailure(
"http_error",
$"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.",
apiError?.Message ?? $"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.",
(int)broadResponse.StatusCode,
broadUri);
broadUri,
apiError?.Code);
logger.LogWarning(
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
"NewsAPI uncategorized fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} ErrorCode={ErrorCode} ErrorMessage={ErrorMessage} Body={Body}",
(int)broadResponse.StatusCode,
broadResponse.ReasonPhrase,
apiError?.Code ?? string.Empty,
apiError?.Message ?? string.Empty,
fallbackBody ?? string.Empty);
}
}
@@ -274,15 +282,19 @@ public sealed class NewsApiBriefingProvider(
else
{
var everythingBody = await TryReadResponseBodySnippetAsync(everythingResponse, cancellationToken);
var apiError = TryParseApiError(everythingBody);
CaptureFailure(
"http_error",
$"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.",
apiError?.Message ?? $"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.",
(int)everythingResponse.StatusCode,
everythingUri);
everythingUri,
apiError?.Code);
logger.LogWarning(
"NewsAPI everything fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} Body={Body}",
"NewsAPI everything fallback failed. StatusCode={StatusCode} Reason={ReasonPhrase} ErrorCode={ErrorCode} ErrorMessage={ErrorMessage} Body={Body}",
(int)everythingResponse.StatusCode,
everythingResponse.ReasonPhrase,
apiError?.Code ?? string.Empty,
apiError?.Message ?? string.Empty,
everythingBody ?? string.Empty);
}
}
@@ -444,6 +456,36 @@ public sealed class NewsApiBriefingProvider(
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static ApiError? TryParseApiError(string? responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
{
return null;
}
try
{
using var document = JsonDocument.Parse(responseBody);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return null;
}
var code = ReadString(document.RootElement, "code");
var message = ReadString(document.RootElement, "message");
if (string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(message))
{
return null;
}
return new ApiError(code, message);
}
catch
{
return null;
}
}
private static string SanitizeEndpoint(Uri uri)
{
var path = uri.GetLeftPart(UriPartial.Path);
@@ -516,5 +558,6 @@ public sealed class NewsApiBriefingProvider(
private const int MaxHeadlines = 5;
private const int MaxCategories = 2;
private sealed record ApiError(string? Code, string? Message);
private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpiresUtc);
}

View File

@@ -149,6 +149,103 @@ public sealed class ProviderCachingTests
Assert.Equal(1, handler.GetCallCount("/v2/everything"));
}
[Fact]
public async Task NewsApiBriefingProvider_ContinuesFallbackChain_WhenCategoryReturnsHttpError()
{
var handler = new CountingHttpMessageHandler(message =>
{
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
var query = message.RequestUri?.Query ?? string.Empty;
if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"status":"error","code":"parameterInvalid","message":"Category not supported for this key."}""",
Encoding.UTF8,
"application/json")
};
}
return JsonResponse(
"""{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
});
var provider = new NewsApiBriefingProvider(
new HttpClient(handler),
new NewsApiOptions
{
ApiKey = "test-key",
CacheTtlSeconds = 300,
FailureCacheTtlSeconds = 30
},
NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3));
Assert.NotNull(result);
Assert.Single(result!.Headlines);
Assert.Equal("General robotics update", result.Headlines[0].Title);
Assert.Equal("success", result.ProviderStatus);
Assert.Equal(2, handler.GetCallCount("/v2/top-headlines"));
}
[Fact]
public async Task NewsApiBriefingProvider_PropagatesApiErrorCodeAndMessage_WhenAllEndpointsFail()
{
var handler = new CountingHttpMessageHandler(message =>
{
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
if (string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"status":"error","code":"parameterInvalid","message":"Category 'general' is not available for this account."}""",
Encoding.UTF8,
"application/json")
};
}
if (string.Equals(path, "/v2/everything", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"status":"error","code":"parametersMissing","message":"Missing required search query."}""",
Encoding.UTF8,
"application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var provider = new NewsApiBriefingProvider(
new HttpClient(handler),
new NewsApiOptions
{
ApiKey = "test-key",
DefaultCategories = ["general"],
CacheTtlSeconds = 300,
FailureCacheTtlSeconds = 30
},
NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3));
Assert.NotNull(result);
Assert.Empty(result!.Headlines);
Assert.Equal("http_error", result.ProviderStatus);
Assert.Equal("parameterInvalid", result.ProviderErrorCode);
Assert.Equal("Category 'general' is not available for this account.", result.ProviderMessage);
Assert.Equal((int)HttpStatusCode.BadRequest, result.ProviderHttpStatusCode);
Assert.Contains("/v2/top-headlines", result.ProviderEndpoint, StringComparison.OrdinalIgnoreCase);
}
private static HttpResponseMessage JsonResponse(string body)
{
return new HttpResponseMessage(HttpStatusCode.OK)

View File

@@ -1801,6 +1801,14 @@ public sealed class JiboInteractionServiceTests
Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(decision.SkillPayload);
Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue));
var weeklyCards = Assert.IsAssignableFrom<IReadOnlyList<IDictionary<string, object?>>>(weeklyCardsValue);
Assert.Equal(5, weeklyCards.Count);
var firstCard = weeklyCards[0];
Assert.Equal("Tuesday", firstCard["weather_day"]);
Assert.Equal(61, firstCard["weather_high"]);
Assert.Equal(52, firstCard["weather_low"]);
}
[Fact]
@@ -1829,6 +1837,10 @@ public sealed class JiboInteractionServiceTests
Assert.NotNull(provider.LastRequest);
Assert.Equal("Seattle", provider.LastRequest!.LocationQuery);
Assert.Equal(5, provider.LastRequest.ForecastDayOffset);
Assert.NotNull(decision.SkillPayload);
Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue));
var weeklyCards = Assert.IsAssignableFrom<IReadOnlyList<IDictionary<string, object?>>>(weeklyCardsValue);
Assert.Equal(5, weeklyCards.Count);
}
[Fact]

View File

@@ -2250,6 +2250,53 @@ public sealed class JiboWebSocketServiceTests
Assert.Contains("tempNormal_v01.crn", payloadText, StringComparison.Ordinal);
}
[Fact]
public async Task ClientAsr_WeatherNextWeek_WithProvider_EmitsWeatherHiLoSequenceCards()
{
var customStore = new InMemoryCloudStateStore();
var customService = CreateService(
customStore,
new StubWeatherReportProvider(
new WeatherReportSnapshot("Seattle, US", "light rain", 58, 61, 52, "rain", false)));
await customService.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-weather-next-week-token",
Text = """{"type":"LISTEN","transID":"trans-weather-next-week","data":{"hotphrase":true,"rules":["launch","globals/global_commands_launch"]}}"""
});
var replies = await customService.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = "hub-weather-next-week-token",
Text = """{"type":"CLIENT_ASR","transID":"trans-weather-next-week","data":{"text":"forecast for seattle next week"}}"""
});
Assert.True(replies.Count >= 3);
var skillReply = replies.Last(static reply => string.Equals(ReadReplyType(reply), "SKILL_ACTION", StringComparison.Ordinal));
using var skillPayload = JsonDocument.Parse(skillReply.Text!);
var jcp = skillPayload.RootElement
.GetProperty("data")
.GetProperty("action")
.GetProperty("config")
.GetProperty("jcp");
Assert.Equal("SEQUENCE", jcp.GetProperty("type").GetString());
var children = jcp.GetProperty("children");
Assert.Equal(5, children.GetArrayLength());
var firstChildConfig = children[0].GetProperty("config");
Assert.True(firstChildConfig.TryGetProperty("display", out var firstDisplay));
Assert.Equal(
"weatherTempView",
firstDisplay.GetProperty("view").GetProperty("data").GetProperty("viewConfig").GetProperty("id").GetString());
}
[Fact]
public async Task ClientAsr_OpenTheRadio_EmitsRadioRedirectAndSilentCompletion()
{