diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs index 1e912c70..eb5c2c51 100644 --- a/Core/Resgrid.Config/TtsConfig.cs +++ b/Core/Resgrid.Config/TtsConfig.cs @@ -24,7 +24,7 @@ public static class TtsConfig public static int S3PresignedUrlExpiryMinutes = 60; public static string S3PublicBaseUrl = ""; - public static string DefaultVoice = "en-us+f3"; + public static string DefaultVoice = "en-us+klatt6"; public static int DefaultSpeed = 175; public static int MaxConcurrentGenerations = 4; public static int MaxTextLength = 1000; diff --git a/Core/Resgrid.Model/EspeakVoiceCatalog.cs b/Core/Resgrid.Model/EspeakVoiceCatalog.cs index e1b5369b..23cc61dc 100644 --- a/Core/Resgrid.Model/EspeakVoiceCatalog.cs +++ b/Core/Resgrid.Model/EspeakVoiceCatalog.cs @@ -37,7 +37,6 @@ public static class EspeakVoiceCatalog new TtsVoiceOption("da", "Danish"), new TtsVoiceOption("nl", "Dutch"), new TtsVoiceOption("en-us", "English", "American"), - new TtsVoiceOption("en-us+f3", "English", "American Female 3"), new TtsVoiceOption("en", "English", "British"), new TtsVoiceOption("en-029", "English", "Caribbean"), new TtsVoiceOption("en-gb-x-gbclan", "English", "Lancastrian"), @@ -141,7 +140,7 @@ public static class EspeakVoiceCatalog private static readonly Dictionary VoiceLookup = VoicesInternal.ToDictionary(x => x.Identifier, StringComparer.OrdinalIgnoreCase); - public const string DefaultIdentifier = "en-us+f3"; + public const string DefaultIdentifier = "en-us"; public static IReadOnlyList Voices => VoicesInternal; @@ -149,10 +148,7 @@ public static bool TryNormalizeIdentifier(string voice, out string normalizedVoi { normalizedVoice = null; - if (string.IsNullOrWhiteSpace(voice)) - return false; - - if (!VoiceLookup.TryGetValue(voice.Trim(), out var option)) + if (!TryGetOption(voice, out var option)) return false; normalizedVoice = option.Identifier; @@ -174,7 +170,15 @@ public static bool TryGetOption(string voice, out TtsVoiceOption option) if (string.IsNullOrWhiteSpace(voice)) return false; - return VoiceLookup.TryGetValue(voice.Trim(), out option); + var normalizedVoice = GetBaseIdentifier(voice); + return VoiceLookup.TryGetValue(normalizedVoice, out option); + } + + private static string GetBaseIdentifier(string voice) + { + var trimmedVoice = voice.Trim(); + var variantSeparatorIndex = trimmedVoice.IndexOf('+'); + return variantSeparatorIndex <= 0 ? trimmedVoice : trimmedVoice[..variantSeparatorIndex]; } } diff --git a/Core/Resgrid.Model/Resgrid.Model.csproj b/Core/Resgrid.Model/Resgrid.Model.csproj index 1c6041b3..152dd04a 100644 --- a/Core/Resgrid.Model/Resgrid.Model.csproj +++ b/Core/Resgrid.Model/Resgrid.Model.csproj @@ -82,7 +82,7 @@ - + diff --git a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs index 36c460eb..3b44b2e2 100644 --- a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs +++ b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs @@ -5,11 +5,11 @@ namespace Resgrid.Model public static class TwilioVoicePromptCatalog { public const string CallClosed = "This call has been closed. Goodbye."; - public const string RespondingToScene = "You have been marked responding to the scene, goodbye."; + public const string RespondingToScene = "You have been marked responding to the scene. Goodbye."; public const string InvalidSelection = "Sorry, that was not a valid selection."; public const string VerificationGreeting = "Hello, this is Resgrid calling with your verification code."; public const string VerificationClosing = "That was your Resgrid verification code. Goodbye."; - public const string InboundVoiceUnavailable = "Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye."; + public const string InboundVoiceUnavailable = "Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye."; public const string VoiceVerificationFailure = "We couldn't complete your verification call. Please request a new code and try again. Goodbye."; public const string MainMenuSelectionIntro = "Please select from the following options."; public const string MainMenuActiveCalls = "To list current active calls, press 1."; @@ -20,30 +20,36 @@ public static class TwilioVoicePromptCatalog public const string MainMenuSetStatus = "To set your current status, press 6."; public const string MainMenuSetStaffing = "To set your current staffing level, press 7."; public const string RepeatAndRespondToScene = "Press 0 to repeat. Press 1 to respond to the scene."; + public const string OutboundDispatchMenu = "To hear the dispatch again, press 1. To hear response options, press 2."; + public const string OutboundResponseSelectionIntro = "To choose a response option, enter the option number, then press pound."; + public const string RepeatDispatchWithPound = "To hear the dispatch again, enter 0 and press pound."; public const string GoBackToMainMenu = "Press 0 to go back to the main menu."; - public const string StatusSelectionIntro = "To set your current status, please select from the following options."; - public const string StaffingSelectionIntro = "To set your current staffing, please select from the following options."; - public const string InvalidStatusSelection = "Invalid status selection, goodbye."; - public const string NoStatusSelection = "No status selection made, goodbye."; + public const string GoBackToMainMenuWithPound = "To go back to the main menu, enter 0 and press pound."; + public const string StatusSelectionIntro = "To set your current status, enter the number of your selection, then press pound."; + public const string StaffingSelectionIntro = "To set your current staffing, enter the number of your selection, then press pound."; + public const string InvalidStatusSelection = "Invalid status selection. Returning to the main menu."; + public const string NoStatusSelection = "No status selection made. Returning to the main menu."; public const string InvalidStaffingSelection = "Invalid staffing selection. Returning to the main menu."; public const string NoStaffingSelection = "No staffing selection made. Returning to the main menu."; public const string CommunicationTestRecorded = "Thank you. Your response has been recorded."; - public static string CallClosedByNumber(string callNumber) => $"This call, Id {callNumber} has been closed. Goodbye."; + public static string CallClosedByNumber(string callNumber) => $"This call, ID {callNumber}, has been closed. Goodbye."; - public static string RespondingToStation(string stationName) => $"You have been marked responding to {stationName}, goodbye."; + public static string RespondingToStation(string stationName) => $"You have been marked responding to {stationName}. Goodbye."; - public static string RespondToStationOption(int digit, string stationName) => $"Press {digit} to respond to {stationName}."; + public static string RespondToStationOption(int digit, string stationName) => $"To respond to {stationName}, enter {digit} and press pound."; + + public static string RespondToSceneOption(int digit) => $"To respond to the scene, enter {digit} and press pound."; public static string VerificationCode(string spokenCode) => $"Your verification code is: {spokenCode}."; - public static string MainMenuGreeting(string firstName, string departmentName) => $"Hello {firstName}, this is the Resgrid automated voice system for {departmentName}."; + public static string MainMenuGreeting(string firstName, string departmentName) => $"Hello {firstName}. This is the Resgrid automated voice system for {departmentName}."; - public static string StatusOption(int digit, string buttonText) => $"Press {digit} for {buttonText}."; + public static string StatusOption(int digit, string buttonText) => $"For {buttonText}, enter {digit} and press pound."; - public static string StaffingOption(int digit, string buttonText) => $"Press {digit} for {buttonText}."; + public static string StaffingOption(int digit, string buttonText) => $"For {buttonText}, enter {digit} and press pound."; - public static string StatusMarked(string buttonText) => $"You have been marked as {buttonText}, goodbye."; + public static string StatusMarked(string buttonText) => $"You have been marked as {buttonText}. Goodbye."; public static string StaffingMarked(string buttonText) => $"You have been marked as {buttonText}. Goodbye."; @@ -67,7 +73,11 @@ public static IReadOnlyCollection GetStaticPrompts() MainMenuSetStatus, MainMenuSetStaffing, RepeatAndRespondToScene, + OutboundDispatchMenu, + OutboundResponseSelectionIntro, + RepeatDispatchWithPound, GoBackToMainMenu, + GoBackToMainMenuWithPound, StatusSelectionIntro, StaffingSelectionIntro, InvalidStatusSelection, diff --git a/Core/Resgrid.Services/Resgrid.Services.csproj b/Core/Resgrid.Services/Resgrid.Services.csproj index 1644cdac..b50c7cac 100644 --- a/Core/Resgrid.Services/Resgrid.Services.csproj +++ b/Core/Resgrid.Services/Resgrid.Services.csproj @@ -15,7 +15,7 @@ - + diff --git a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj index 34067f74..5c7c4080 100644 --- a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj +++ b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj @@ -33,7 +33,7 @@ - + diff --git a/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj b/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj index 6d050b02..7ab732dd 100644 --- a/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj +++ b/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj @@ -14,9 +14,9 @@ - - - + + + diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index bfcbbe6f..77e049a2 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -33,7 +33,7 @@ - + diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs index 14b0c6cd..d74d1135 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs @@ -41,7 +41,7 @@ public void SetUp() _cacheProvider.Object); global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false; - TtsConfig.DefaultVoice = "en-us+f3"; + TtsConfig.DefaultVoice = "en-us+klatt6"; } [TearDown] @@ -77,13 +77,13 @@ public async Task should_fall_back_to_default_tts_language_when_setting_missing( var result = await _service.GetTtsLanguageForDepartmentAsync(7); - result.Should().Be("en-us+f3"); + result.Should().Be("en-us"); } [Test] public async Task should_fall_back_to_default_tts_language_when_setting_is_invalid() { - TtsConfig.DefaultVoice = "fr"; + TtsConfig.DefaultVoice = "fr+klatt6"; _departmentSettingsRepository .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage)) diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 78978c51..8b992826 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -13,6 +14,7 @@ using Resgrid.Model.Services; using Resgrid.Web.Services.Controllers; using Resgrid.Web.Services.Twilio; +using Twilio.AspNet.Common; using Twilio.TwiML; using Twilio.TwiML.Voice; @@ -64,6 +66,7 @@ protected override void Before_all_tests() _communicationTestServiceMock = new Mock(); _encryptionServiceMock = new Mock(); _twilioVoiceResponseServiceMock = new Mock(); + _departmentSettingsServiceMock.Setup(x => x.GetTtsLanguageForDepartmentAsync(It.IsAny())).ReturnsAsync((string)null); _twilioVoiceResponseServiceMock .Setup(x => x.AppendPromptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((response, text, _, __) => @@ -74,6 +77,42 @@ protected override void Before_all_tests() }); return System.Threading.Tasks.Task.CompletedTask; }); + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((gather, text, _, __) => + { + gather.Append(new Play + { + Url = new Uri($"https://tts.example/{Uri.EscapeDataString(text)}.wav") + }); + return System.Threading.Tasks.Task.CompletedTask; + }); + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptsAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns, CancellationToken, string>((response, prompts, _, __) => + { + foreach (var prompt in prompts) + { + response.Append(new Play + { + Url = new Uri($"https://tts.example/{Uri.EscapeDataString(prompt)}.wav") + }); + } + return System.Threading.Tasks.Task.CompletedTask; + }); + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptsAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns, CancellationToken, string>((gather, prompts, _, __) => + { + foreach (var prompt in prompts) + { + gather.Append(new Play + { + Url = new Uri($"https://tts.example/{Uri.EscapeDataString(prompt)}.wav") + }); + } + return System.Threading.Tasks.Task.CompletedTask; + }); } private TwilioController BuildController() @@ -101,6 +140,15 @@ private TwilioController BuildController() _twilioVoiceResponseServiceMock.Object); } + private static string InvokeBuildDispatchPrompt(Type controllerType, Call call, string address) + { + var buildDispatchPrompt = controllerType.GetMethod("BuildDispatchPrompt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var prompt = buildDispatchPrompt?.Invoke(null, new object[] { call, address }) as string; + + prompt.Should().NotBeNull(); + return prompt!; + } + [Test] public async System.Threading.Tasks.Task should_mark_home_code_consumed_after_successful_voice_generation() { @@ -180,6 +228,176 @@ public async System.Threading.Tasks.Task should_return_generic_message_when_decr _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public void voice_call_callback_actions_should_require_validate_request() + { + var callbackActions = new[] + { + nameof(TwilioController.VoiceCallAction), + nameof(TwilioController.VoiceCallResponseOptions), + nameof(TwilioController.VoiceCallRespond) + }; + + foreach (var actionName in callbackActions) + { + typeof(TwilioController) + .GetMethod(actionName)! + .CustomAttributes + .Should() + .Contain(attribute => attribute.AttributeType.Name == "ValidateRequestAttribute"); + } + } + + [Test] + public async System.Threading.Tasks.Task should_play_dispatch_before_outbound_response_menu() + { + var call = new Call + { + CallId = 42, + DepartmentId = 7, + Number = "42", + Name = "Call 42", + Priority = (int)CallPriority.High, + Address = "123 Main St", + NatureOfCall = "Structure fire" + }; + + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, true)).ReturnsAsync(call); + _callsServiceMock + .Setup(x => x.PopulateCallData(It.Is(c => c.CallId == 42), true, true, false, false, false, false, false, false, false, false)) + .ReturnsAsync(call); + + var result = await BuildController().VoiceCall("user1", 42); + + var content = ((ContentResult)result).Content; + var dispatchPrompt = Uri.EscapeDataString("Call 42, Priority High Address 123 Main St Nature Structure fire."); + var menuPrompt = Uri.EscapeDataString(TwilioVoicePromptCatalog.OutboundDispatchMenu); + + content.Should().Contain(dispatchPrompt); + content.Should().Contain("https://resgridapi.local/api/Twilio/VoiceCallAction?userId=user1&callId=42"); + content.IndexOf(dispatchPrompt, StringComparison.Ordinal).Should().BeLessThan(content.IndexOf(menuPrompt, StringComparison.Ordinal)); + } + + [Test] + public void dispatch_prompt_helpers_should_end_with_sentence_punctuation() + { + var call = new Call + { + Name = "Call 42", + Priority = (int)CallPriority.High, + Address = "123 Main St", + NatureOfCall = "Structure fire" + }; + + InvokeBuildDispatchPrompt(typeof(TwilioController), call, "123 Main St") + .Should().Be("Call 42, Priority High Address 123 Main St Nature Structure fire."); + InvokeBuildDispatchPrompt(typeof(TwilioController), call, null) + .Should().Be("Call 42, Priority High Nature Structure fire."); + InvokeBuildDispatchPrompt(typeof(TwilioProviderController), call, "123 Main St") + .Should().Be("Call 42, Priority High Address 123 Main St Nature Structure fire."); + InvokeBuildDispatchPrompt(typeof(TwilioProviderController), call, null) + .Should().Be("Call 42, Priority High Nature Structure fire."); + } + + [TestCase("1", "https://resgridapi.local/api/Twilio/VoiceCall?userId=user1&callId=42")] + [TestCase("2", "https://resgridapi.local/api/Twilio/VoiceCallResponseOptions?userId=user1&callId=42")] + public async System.Threading.Tasks.Task should_route_outbound_menu_selection_to_expected_step(string digits, string expectedUrl) + { + var result = await BuildController().VoiceCallAction("user1", 42, new VoiceRequest { Digits = digits }); + + var content = ((ContentResult)result).Content; + content.Should().Contain(expectedUrl); + } + + [Test] + public async System.Threading.Tasks.Task should_present_multi_digit_station_response_options() + { + var call = new Call { CallId = 42, DepartmentId = 7, Number = "42" }; + var stations = Enumerable.Range(1, 12) + .Select(i => new DepartmentGroup { DepartmentGroupId = 200 + i, Name = $"Station {i}" }) + .ToList(); + + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, true)).ReturnsAsync(call); + _departmentGroupsServiceMock.Setup(x => x.GetAllStationGroupsForDepartmentAsync(7)).ReturnsAsync(stations); + + var result = await BuildController().VoiceCallResponseOptions("user1", 42); + + var content = ((ContentResult)result).Content; + content.Should().Contain("finishOnKey=\"#\""); + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.OutboundResponseSelectionIntro)); + content.Should().Contain(Uri.EscapeDataString("To respond to Station 12, enter 13 and press pound.")); + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.RepeatDispatchWithPound)); + } + + [Test] + public async System.Threading.Tasks.Task should_mark_station_response_from_multi_digit_selection() + { + var call = new Call { CallId = 42, DepartmentId = 7, Number = "42" }; + var stations = Enumerable.Range(1, 12) + .Select(i => new DepartmentGroup { DepartmentGroupId = 300 + i, Name = $"Station {i}" }) + .ToList(); + var selectedStation = stations.Last(); + + _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, true)).ReturnsAsync(call); + _departmentGroupsServiceMock.Setup(x => x.GetAllStationGroupsForDepartmentAsync(7)).ReturnsAsync(stations); + _actionLogsServiceMock + .Setup(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToStation, null, selectedStation.DepartmentGroupId, It.IsAny())) + .ReturnsAsync(new ActionLog()); + + var result = await BuildController().VoiceCallRespond("user1", 42, new VoiceRequest { Digits = "13" }); + + var content = ((ContentResult)result).Content; + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.RespondingToStation(selectedStation.Name))); + _actionLogsServiceMock.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToStation, null, selectedStation.DepartmentGroupId, It.IsAny()), Times.Once); + } + + [Test] + public async System.Threading.Tasks.Task should_present_multi_digit_status_options() + { + var department = new Department { DepartmentId = 7, Name = "Dept 1" }; + var profile = new UserProfile { UserId = "user1", FirstName = "Pat" }; + var options = Enumerable.Range(1, 12) + .Select(i => new CustomStateDetail { CustomStateDetailId = 400 + i, ButtonText = $"Status {i}" }) + .ToList(); + + _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _customStateServiceMock.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(7)).ReturnsAsync(options); + + var result = await BuildController().InboundVoiceAction("user1", new VoiceRequest { Digits = "6" }); + + var content = ((ContentResult)result).Content; + content.Should().Contain("finishOnKey=\"#\""); + content.Should().NotContain("numDigits=\"1\""); + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.StatusSelectionIntro)); + content.Should().Contain(Uri.EscapeDataString("For Status 12, enter 12 and press pound.")); + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.GoBackToMainMenuWithPound)); + } + + [Test] + public async System.Threading.Tasks.Task should_mark_multi_digit_status_selection_by_menu_position() + { + var department = new Department { DepartmentId = 7, Name = "Dept 1" }; + var profile = new UserProfile { UserId = "user1", FirstName = "Pat" }; + var options = Enumerable.Range(1, 12) + .Select(i => new CustomStateDetail { CustomStateDetailId = 500 + i, ButtonText = $"Status {i}" }) + .ToList(); + var selectedOption = options.Last(); + + _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _customStateServiceMock.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(7)).ReturnsAsync(options); + _actionLogsServiceMock + .Setup(x => x.SetUserActionAsync("user1", 7, selectedOption.CustomStateDetailId, It.IsAny())) + .ReturnsAsync(new ActionLog()); + + var result = await BuildController().InboundVoiceActionStatus("user1", new VoiceRequest { Digits = "12" }); + + var content = ((ContentResult)result).Content; + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.StatusMarked(selectedOption.ButtonText))); + _actionLogsServiceMock.Verify(x => x.SetUserActionAsync("user1", 7, selectedOption.CustomStateDetailId, It.IsAny()), Times.Once); + } + [Test] public async System.Threading.Tasks.Task should_redirect_invalid_status_selection_back_to_user_scoped_menu() { @@ -228,5 +446,27 @@ public async System.Threading.Tasks.Task should_redirect_missing_status_selectio content.Should().NotContain("https://resgridapi.local/api/Twilio/InboundVoice"); _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.NoStatusSelection, It.IsAny(), It.IsAny()), Times.Once); } + + [Test] + public void voice_prompt_catalog_should_use_sentence_punctuation_for_espeak_playback() + { + TwilioVoicePromptCatalog.GetStaticPrompts() + .Should() + .OnlyContain(prompt => prompt.EndsWith(".", StringComparison.Ordinal)); + + TwilioVoicePromptCatalog.GetStaticPrompts() + .Should() + .NotContain(prompt => prompt.Contains(", goodbye.", StringComparison.OrdinalIgnoreCase)); + + TwilioVoicePromptCatalog.RespondingToScene.Should().Be("You have been marked responding to the scene. Goodbye."); + TwilioVoicePromptCatalog.InboundVoiceUnavailable.Should().Be("Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye."); + TwilioVoicePromptCatalog.InvalidStatusSelection.Should().Be("Invalid status selection. Returning to the main menu."); + TwilioVoicePromptCatalog.NoStatusSelection.Should().Be("No status selection made. Returning to the main menu."); + TwilioVoicePromptCatalog.CallClosedByNumber("42").Should().Be("This call, ID 42, has been closed. Goodbye."); + TwilioVoicePromptCatalog.RespondingToStation("Station 12").Should().Be("You have been marked responding to Station 12. Goodbye."); + TwilioVoicePromptCatalog.MainMenuGreeting("Pat", "Dept 1").Should().Be("Hello Pat. This is the Resgrid automated voice system for Dept 1."); + TwilioVoicePromptCatalog.StatusMarked("Available").Should().Be("You have been marked as Available. Goodbye."); + } + } } diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs index 2b3d5685..2598f030 100644 --- a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -69,16 +69,17 @@ public async Task exists_async_should_fall_back_to_presigned_head_when_metadata_ request.BucketName.Should().Be("tts-bucket"); request.Key.Should().Be("tts/audio.wav"); request.Verb.Should().Be(HttpVerb.HEAD); - return "https://upload.example.com/tts/audio.wav?signature=head"; + request.Protocol.Should().Be(Protocol.HTTP); + return "http://upload.example.com/tts/audio.wav?signature=head"; }); var handler = new RecordingHttpMessageHandler((request, _) => { request.Method.Should().Be(HttpMethod.Head); - request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=head")); + request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=head")); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); }); - var service = CreateService(s3Client.Object, handler); + var service = CreateService(s3Client.Object, handler, useSsl: false); var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); @@ -128,11 +129,12 @@ public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_r { request.BucketName.Should().Be("tts-bucket"); request.Key.Should().Be("tts/audio.wav"); + request.Protocol.Should().Be(Protocol.HTTP); return request.Verb switch { - HttpVerb.HEAD => "https://upload.example.com/tts/audio.wav?signature=metadata-head", - HttpVerb.PUT => "https://upload.example.com/tts/audio.wav?signature=metadata-put", + HttpVerb.HEAD => "http://upload.example.com/tts/audio.wav?signature=metadata-head", + HttpVerb.PUT => "http://upload.example.com/tts/audio.wav?signature=metadata-put", _ => throw new AssertionException($"Unexpected presigned verb {request.Verb}") }; }); @@ -146,13 +148,13 @@ public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_r if (request.Method == HttpMethod.Head) { headRequests++; - request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=metadata-head")); + request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=metadata-head")); return new HttpResponseMessage(HttpStatusCode.NotFound); } putRequests++; request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=metadata-put")); + request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=metadata-put")); var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); body.Should().Equal(2, 4, 6, 8); @@ -160,7 +162,7 @@ public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_r return new HttpResponseMessage(HttpStatusCode.OK); }); - var service = CreateService(s3Client.Object, handler); + var service = CreateService(s3Client.Object, handler, useSsl: false); await using var content = new MemoryStream(new byte[] { 2, 4, 6, 8 }, writable: false); @@ -255,7 +257,116 @@ public async Task upload_async_should_fall_back_to_presigned_put_when_put_respon s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); } - private static S3StorageService CreateService(IAmazonS3 s3Client, RecordingHttpMessageHandler handler = null) + [Test] + public async Task upload_async_should_reuse_buffered_payload_when_falling_back_after_sdk_disposes_input_stream() + { + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) + .Returns((request, _) => + { + request.InputStream.Dispose(); + return Task.FromException(new FormatException("bad expiration header")); + }); + s3Client + .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new FormatException("bad metadata expiration header")); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.Protocol.Should().Be(Protocol.HTTP); + + return request.Verb switch + { + HttpVerb.HEAD => "http://upload.example.com/tts/audio.wav?signature=disposed-head", + HttpVerb.PUT => "http://upload.example.com/tts/audio.wav?signature=disposed-put", + _ => throw new AssertionException($"Unexpected presigned verb {request.Verb}") + }; + }); + + var headRequests = 0; + var putRequests = 0; + var handler = new RecordingHttpMessageHandler(async (request, cancellationToken) => + { + if (request.Method == HttpMethod.Head) + { + headRequests++; + request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=disposed-head")); + throw new HttpRequestException("connectivity failure"); + } + + putRequests++; + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=disposed-put")); + + var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); + body.Should().Equal(7, 5, 3, 1); + request.Content.Headers.ContentType!.MediaType.Should().Be("audio/wav"); + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var service = CreateService(s3Client.Object, handler, useSsl: false); + + await using var content = new MemoryStream(new byte[] { 7, 5, 3, 1 }, writable: false); + + await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); + + headRequests.Should().Be(3); + putRequests.Should().Be(1); + handler.Requests.Should().HaveCount(4); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.HEAD)), Times.Exactly(3)); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.PUT)), Times.Once); + } + + [Test] + public async Task get_object_url_async_should_use_http_presigned_urls_when_ssl_is_disabled() + { + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Protocol.Should().Be(Protocol.HTTP); + return "http://download.example.com/tts/audio.wav?signature=get"; + }); + + var service = CreateService(s3Client.Object, useSsl: false); + + var url = await service.GetObjectUrlAsync("tts/audio.wav", CancellationToken.None); + + url.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=get")); + s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); + } + + [TestCase("http://rustfs.example.local:9000", true, Protocol.HTTP, "http://download.example.com/tts/audio.wav?signature=endpoint-http")] + [TestCase("https://rustfs.example.local:9443", false, Protocol.HTTPS, "https://download.example.com/tts/audio.wav?signature=endpoint-https")] + public async Task get_object_url_async_should_prefer_absolute_endpoint_scheme_over_use_ssl(string endpoint, bool useSsl, Protocol expectedProtocol, string presignedUrl) + { + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Protocol.Should().Be(expectedProtocol); + return presignedUrl; + }); + + var service = CreateService(s3Client.Object, useSsl: useSsl, endpoint: endpoint); + + var url = await service.GetObjectUrlAsync("tts/audio.wav", CancellationToken.None); + + url.Should().Be(new Uri(presignedUrl)); + s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); + } + + private static S3StorageService CreateService(IAmazonS3 s3Client, RecordingHttpMessageHandler handler = null, bool useSsl = true, string endpoint = null) { handler ??= new RecordingHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); var httpClientFactory = new Mock(MockBehavior.Strict); @@ -267,7 +378,9 @@ private static S3StorageService CreateService(IAmazonS3 s3Client, RecordingHttpM { Bucket = "tts-bucket", AccessKey = "access-key", - SecretKey = "secret-key" + SecretKey = "secret-key", + UseSsl = useSsl, + Endpoint = endpoint }), Mock.Of>(), httpClientFactory.Object); diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs index b48b13dd..1d37a860 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs @@ -33,7 +33,7 @@ public void SetUp() .Returns((_, hash) => new Uri($"https://tts.example.com/tts/audio/{hash}.wav")); _options = new TtsOptions { - DefaultVoice = "en-us+f3", + DefaultVoice = "en-us+klatt6", DefaultSpeed = 175, StaticPromptAdminKey = "secret-key", PreGeneratedPrompts = new List { "Alpha", "Beta" } @@ -95,8 +95,8 @@ public async Task regenerate_static_prompts_should_fall_back_to_configured_promp .Callback, CancellationToken>((requests, _) => capturedPrompts = requests.ToList()) .ReturnsAsync(new[] { - new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us+f3", Speed = 175 }, - new TtsResponse { Hash = "b", ObjectKey = "tts/b.wav", Url = "https://cdn.example.com/tts/b.wav", Voice = "en-us+f3", Speed = 175 } + new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us+klatt6", Speed = 175 }, + new TtsResponse { Hash = "b", ObjectKey = "tts/b.wav", Url = "https://cdn.example.com/tts/b.wav", Voice = "en-us+klatt6", Speed = 175 } }); var controller = BuildController(); @@ -106,7 +106,7 @@ public async Task regenerate_static_prompts_should_fall_back_to_configured_promp result.Result.Should().BeOfType(); capturedPrompts.Should().HaveCount(2); capturedPrompts!.Select(x => x.Text).Should().Equal("Alpha", "Beta"); - capturedPrompts.Select(x => x.Voice).Should().OnlyContain(x => x == "en-us+f3"); + capturedPrompts.Select(x => x.Voice).Should().OnlyContain(x => x == "en-us+klatt6"); capturedPrompts.Select(x => x.Speed).Should().OnlyContain(x => x == 175); } diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index aa688778..75676d77 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -33,7 +33,7 @@ public void SetUp() _audioProcessingService.Object, Options.Create(new TtsOptions { - DefaultVoice = "en-us+f3", + DefaultVoice = "en-us+klatt6", DefaultSpeed = 175, MaxConcurrentGenerations = 2, MaxTextLength = 500 @@ -47,7 +47,7 @@ public async Task generate_async_should_return_cached_response_without_generatin var cachedUri = new Uri("https://cdn.example.com/tts/abc123.wav"); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+f3", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -59,7 +59,7 @@ public async Task generate_async_should_return_cached_response_without_generatin result.Hash.Should().Be(CacheKey.Hash); result.ObjectKey.Should().Be(CacheKey.ObjectKey); result.Url.Should().Be(cachedUri.ToString()); - result.Voice.Should().Be("en-us+f3"); + result.Voice.Should().Be("en-us+klatt6"); result.Speed.Should().Be(175); _audioProcessingService.Verify( @@ -77,14 +77,14 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss var objectUri = new Uri("https://cdn.example.com/tts/abc123.wav"); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+f3", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) .Returns(CacheKey); _cacheService .SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) .ReturnsAsync((Uri)null) .ReturnsAsync((Uri)null); _audioProcessingService - .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+f3", 175, It.IsAny())) + .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny())) .ReturnsAsync(audioBytes); _cacheService .Setup(x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny())) @@ -94,17 +94,65 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss result.Cached.Should().BeFalse(); result.Url.Should().Be(objectUri.ToString()); - result.Voice.Should().Be("en-us+f3"); + result.Voice.Should().Be("en-us+klatt6"); result.Speed.Should().Be(175); _audioProcessingService.Verify( - x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+f3", 175, It.IsAny()), + x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny()), Times.Once); _cacheService.Verify( x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny()), Times.Once); } + [Test] + public async Task generate_async_should_apply_configured_klatt_variant_to_requested_language() + { + var cachedUri = new Uri("https://cdn.example.com/tts/xyz789.wav"); + var cacheKey = new TtsCacheKey("xyz789", "tts/xyz789.wav"); + + _cacheService + .Setup(x => x.CreateCacheKey("Bonjour", "fr+klatt6", 175)) + .Returns(cacheKey); + _cacheService + .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) + .ReturnsAsync(cachedUri); + + var result = await _service.GenerateAsync(new TtsRequest { Text = "Bonjour", Voice = "fr" }, CancellationToken.None); + + result.Cached.Should().BeTrue(); + result.Voice.Should().Be("fr+klatt6"); + result.Url.Should().Be(cachedUri.ToString()); + + _audioProcessingService.Verify( + x => x.GenerateNormalizedWavAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task generate_async_should_replace_legacy_f3_voice_with_configured_klatt_variant() + { + var cachedUri = new Uri("https://cdn.example.com/tts/legacy.wav"); + var cacheKey = new TtsCacheKey("legacy", "tts/legacy.wav"); + + _cacheService + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) + .Returns(cacheKey); + _cacheService + .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) + .ReturnsAsync(cachedUri); + + var result = await _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes", Voice = "en-us+f3" }, CancellationToken.None); + + result.Cached.Should().BeTrue(); + result.Voice.Should().Be("en-us+klatt6"); + result.Url.Should().Be(cachedUri.ToString()); + + _audioProcessingService.Verify( + x => x.GenerateNormalizedWavAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Test] public async Task generate_async_should_reject_blank_text() { @@ -124,7 +172,7 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th var cacheLookupCount = 0; _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+f3", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -134,7 +182,7 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th return Task.FromResult(attempt < 4 ? null : objectUri); }); _audioProcessingService - .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+f3", 175, It.IsAny())) + .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny())) .Returns(async () => { generationStarted.TrySetResult(true); @@ -158,7 +206,7 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th responses.Count(response => response.Cached).Should().Be(1); responses.Count(response => !response.Cached).Should().Be(1); _audioProcessingService.Verify( - x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+f3", 175, It.IsAny()), + x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny()), Times.Once); _cacheService.Verify( x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny()), diff --git a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj index 93df6deb..8461bfa4 100644 --- a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj +++ b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj @@ -42,12 +42,6 @@ - - - - - - diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index b762e13f..1cab343c 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -490,86 +490,79 @@ public async Task VoiceCall(string userId, int callId) return CreateVoiceContentResult(response); } - var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); + await AppendDispatchPlaybackAsync(response, call); - if (call.Attachments != null && - call.Attachments.Count(x => x.CallAttachmentType == (int)CallAttachmentTypes.DispatchAudio) > 0) + for (int repeat = 0; repeat < 2; repeat++) { - var audio = call.Attachments.FirstOrDefault(x => - x.CallAttachmentType == (int)CallAttachmentTypes.DispatchAudio); - - if (audio != null) + var gatherResponse = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCallAction?userId={userId}&callId={callId}"), method: "GET") { - var url = await _callsService.GetShortenedAudioUrlAsync(call.CallId, audio.CallAttachmentId); + BargeIn = true + }; + await AppendVoicePromptAsync(gatherResponse, TwilioVoicePromptCatalog.OutboundDispatchMenu, call.DepartmentId); + response.Append(gatherResponse); + } - Play playResponse = new Play(); - playResponse.Url = new Uri(url); + response.Hangup(); - StringBuilder sb1 = new StringBuilder(); - sb1.Append("Press 0 to repeat, Press 1 to respond to the scene"); + return CreateVoiceContentResult(response); + } - for (int i = 0; i < stations.Count; i++) - { - if (i >= 8) - break; + [HttpGet("VoiceCallAction")] + [Produces("application/xml")] + [ValidateRequest] + public async Task VoiceCallAction(string userId, int callId, [FromQuery] VoiceRequest twilioRequest) + { + var response = new VoiceResponse(); - sb1.Append(string.Format(", press {0} to respond to {1}", i + 2, stations[i].Name)); - } + if (twilioRequest?.Digits == "1") + { + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCall?userId={userId}&callId={callId}"), "GET"); + return CreateVoiceContentResult(response); + } - for (int repeat = 0; repeat < 2; repeat++) - { - var gatherResponse1 = new Gather(numDigits: 1, action: new Uri(string.Format("{0}/api/Twilio/VoiceCallAction?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), method: "GET") - { - BargeIn = true - }; - await AppendVoicePromptAsync(gatherResponse1, TwilioVoicePromptCatalog.RepeatAndRespondToScene, call.DepartmentId); - await AppendVoicePromptsAsync(gatherResponse1, BuildStationOptionPrompts(stations), call.DepartmentId); - gatherResponse1.Append(playResponse); - response.Append(gatherResponse1); - } + if (twilioRequest?.Digits == "2") + { + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCallResponseOptions?userId={userId}&callId={callId}"), "GET"); + return CreateVoiceContentResult(response); + } - response.Hangup(); + var call = await _callsService.GetCallByIdAsync(callId); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call?.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCall?userId={userId}&callId={callId}"), "GET"); + return CreateVoiceContentResult(response); + } - return CreateVoiceContentResult(response); - } - } + [HttpGet("VoiceCallResponseOptions")] + [Produces("application/xml")] + [ValidateRequest] + public async Task VoiceCallResponseOptions(string userId, int callId) + { + var response = new VoiceResponse(); + var call = await _callsService.GetCallByIdAsync(callId); - string address = call.Address; - if (String.IsNullOrWhiteSpace(address) && !string.IsNullOrWhiteSpace(call.GeoLocationData) && call.GeoLocationData.Length > 1) + if (call == null) { - try - { - string[] points = call.GeoLocationData.Split(char.Parse(",")); - - if (points != null && points.Length == 2) - { - address = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); - } - } - catch - { - } + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); + return CreateVoiceContentResult(response); } - if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address)) - address = call.Address; - - StringBuilder sb = new StringBuilder(); + if (call.State == (int)CallStates.Cancelled || call.State == (int)CallStates.Closed || call.IsDeleted) + { + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); + response.Hangup(); + return CreateVoiceContentResult(response); + } - if (!String.IsNullOrWhiteSpace(address)) - sb.Append(string.Format("{0}, Priority {1} Address {2} Nature {3}", call.Name, call.GetPriorityText(), call.Address, StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall))); - else - sb.Append(string.Format("{0}, Priority {1} Nature {2}", call.Name, call.GetPriorityText(), call.NatureOfCall)); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); for (int repeat = 0; repeat < 2; repeat++) { - var gatherResponse = new Gather(numDigits: 1, action: new Uri(string.Format("{0}/api/Twilio/VoiceCallAction?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), method: "GET") + var gatherResponse = new Gather(action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCallRespond?userId={userId}&callId={callId}"), method: "GET", finishOnKey: "#") { BargeIn = true }; - await AppendVoicePromptAsync(gatherResponse, sb.ToString(), call.DepartmentId); - await AppendVoicePromptAsync(gatherResponse, TwilioVoicePromptCatalog.RepeatAndRespondToScene, call.DepartmentId); - await AppendVoicePromptsAsync(gatherResponse, BuildStationOptionPrompts(stations), call.DepartmentId); + await AppendVoicePromptsAsync(gatherResponse, BuildVoiceCallResponseOptionPrompts(stations), call.DepartmentId); response.Append(gatherResponse); } @@ -578,30 +571,46 @@ public async Task VoiceCall(string userId, int callId) return CreateVoiceContentResult(response); } - [HttpGet("VoiceCallAction")] + [HttpGet("VoiceCallRespond")] [Produces("application/xml")] - public async Task VoiceCallAction(string userId, int callId, [FromQuery] VoiceRequest twilioRequest) + [ValidateRequest] + public async Task VoiceCallRespond(string userId, int callId, [FromQuery] VoiceRequest twilioRequest) { var response = new VoiceResponse(); + var call = await _callsService.GetCallByIdAsync(callId); - if (twilioRequest.Digits == "0") + if (call == null) { - response.Redirect(new Uri(string.Format("{0}/api/Twilio/VoiceCall?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), "GET"); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); + return CreateVoiceContentResult(response); } - else if (twilioRequest.Digits == "1") + + if (call.State == (int)CallStates.Cancelled || call.State == (int)CallStates.Closed || call.IsDeleted) { - var call = await _callsService.GetCallByIdAsync(callId); - await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); + response.Hangup(); + return CreateVoiceContentResult(response); + } + if (twilioRequest?.Digits == "0") + { + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCall?userId={userId}&callId={callId}"), "GET"); + return CreateVoiceContentResult(response); + } + + if (twilioRequest?.Digits == "1") + { + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToScene, call.DepartmentId); response.Hangup(); + return CreateVoiceContentResult(response); } - else if (int.TryParse(twilioRequest.Digits, out var digit)) + + if (int.TryParse(twilioRequest?.Digits, out var digit)) { - var call = await _callsService.GetCallByIdAsync(callId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); - - int index = digit - 2; + var index = digit - 2; if (index >= 0 && index < stations.Count) { @@ -609,21 +618,16 @@ public async Task VoiceCallAction(string userId, int callId, [From if (station != null) { - await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, - station.DepartmentGroupId); - + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, station.DepartmentGroupId); await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId); response.Hangup(); + return CreateVoiceContentResult(response); } } } - else - { - var call = await _callsService.GetCallByIdAsync(callId); - await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call?.DepartmentId); - response.Redirect(new Uri(string.Format("{0}/api/Twilio/VoiceCall?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), "GET"); - } + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCallResponseOptions?userId={userId}&callId={callId}"), "GET"); return CreateVoiceContentResult(response); } @@ -787,7 +791,10 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo var profile = await _userProfileService.GetProfileByUserIdAsync(userId); var prompts = new List(); - Gather gatherResponse = null; + Uri gatherAction = new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"); + string gatherFinishOnKey = null; + int? gatherNumDigits = 1; + string goBackPrompt = TwilioVoicePromptCatalog.GoBackToMainMenu; if (twilioRequest.Digits == "0") { @@ -887,10 +894,10 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo index++; } - gatherResponse = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceActionStatus?userId={userId}"), method: "GET") - { - BargeIn = true - }; + gatherAction = new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceActionStatus?userId={userId}"); + gatherFinishOnKey = "#"; + gatherNumDigits = null; + goBackPrompt = TwilioVoicePromptCatalog.GoBackToMainMenuWithPound; } else if (twilioRequest.Digits == "7") // Set current staffing { @@ -908,28 +915,20 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo index++; } - gatherResponse = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceActionStaffing?userId={userId}"), method: "GET") - { - BargeIn = true - }; - } - - if (gatherResponse == null) - { - gatherResponse = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), method: "GET") - { - BargeIn = true - }; + gatherAction = new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceActionStaffing?userId={userId}"); + gatherFinishOnKey = "#"; + gatherNumDigits = null; + goBackPrompt = TwilioVoicePromptCatalog.GoBackToMainMenuWithPound; } for (int repeat = 0; repeat < 2; repeat++) { - var gather = new Gather(numDigits: 1, action: gatherResponse.Action, method: gatherResponse.Method) + var gather = new Gather(action: gatherAction, method: "GET", finishOnKey: gatherFinishOnKey, numDigits: gatherNumDigits) { BargeIn = true }; await AppendVoicePromptsAsync(gather, prompts, department.DepartmentId); - await AppendVoicePromptAsync(gather, TwilioVoicePromptCatalog.GoBackToMainMenu, department.DepartmentId); + await AppendVoicePromptAsync(gather, goBackPrompt, department.DepartmentId); response.Append(gather); } @@ -1059,6 +1058,60 @@ private async Task GetDepartmentTtsLanguageAsync(int? departmentId) return ttsLanguage; } + private async System.Threading.Tasks.Task AppendDispatchPlaybackAsync(VoiceResponse response, Call call) + { + if (call.Attachments != null) + { + var audio = call.Attachments.FirstOrDefault(x => x.CallAttachmentType == (int)CallAttachmentTypes.DispatchAudio); + + if (audio != null) + { + var url = await _callsService.GetShortenedAudioUrlAsync(call.CallId, audio.CallAttachmentId); + response.Append(new Play + { + Url = new Uri(url) + }); + return; + } + } + + var address = await ResolveCallAddressAsync(call); + await AppendVoicePromptAsync(response, BuildDispatchPrompt(call, address), call.DepartmentId); + } + + private async Task ResolveCallAddressAsync(Call call) + { + var address = call.Address; + + if (String.IsNullOrWhiteSpace(address) && !string.IsNullOrWhiteSpace(call.GeoLocationData) && call.GeoLocationData.Length > 1) + { + try + { + string[] points = call.GeoLocationData.Split(char.Parse(",")); + + if (points != null && points.Length == 2) + address = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); + } + catch + { + } + } + + return String.IsNullOrWhiteSpace(address) ? call.Address : address; + } + + private static string BuildDispatchPrompt(Call call, string address) + { + var nature = StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall); + var prompt = !String.IsNullOrWhiteSpace(address) + ? string.Format("{0}, Priority {1} Address {2} Nature {3}", call.Name, call.GetPriorityText(), address, nature) + : string.Format("{0}, Priority {1} Nature {2}", call.Name, call.GetPriorityText(), nature); + + return prompt.EndsWith(".", StringComparison.Ordinal) || prompt.EndsWith("!", StringComparison.Ordinal) || prompt.EndsWith("?", StringComparison.Ordinal) + ? prompt + : $"{prompt}."; + } + private static ContentResult CreateVoiceContentResult(VoiceResponse response) { return new ContentResult @@ -1069,12 +1122,26 @@ private static ContentResult CreateVoiceContentResult(VoiceResponse response) }; } + private static IReadOnlyCollection BuildVoiceCallResponseOptionPrompts(IEnumerable stations) + { + var prompts = new List + { + TwilioVoicePromptCatalog.OutboundResponseSelectionIntro, + TwilioVoicePromptCatalog.RespondToSceneOption(1) + }; + + prompts.AddRange(BuildStationOptionPrompts(stations)); + prompts.Add(TwilioVoicePromptCatalog.RepeatDispatchWithPound); + + return prompts; + } + private static IReadOnlyCollection BuildStationOptionPrompts(IEnumerable stations) { var prompts = new List(); var index = 2; - foreach (var station in stations.Take(8)) + foreach (var station in stations) { prompts.Add(TwilioVoicePromptCatalog.RespondToStationOption(index, station.Name)); index++; diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs index 6e3526e2..25c618a8 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Helpers; using Resgrid.Model.Providers; @@ -435,12 +436,12 @@ public async Task VoiceCall(string userId, int callId) { var response = new VoiceResponse(); var call = await _callsService.GetCallByIdAsync(callId); + call = await _callsService.PopulateCallData(call, true, true, false, false, false, false, false, false, false); if (call == null) { await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); response.Hangup(); - //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); return CreateVoiceContentResult(response); } @@ -448,50 +449,79 @@ public async Task VoiceCall(string userId, int callId) { await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); response.Hangup(); - //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); return CreateVoiceContentResult(response); } - var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); + await AppendDispatchPlaybackAsync(response, call); - string address = call.Address; - if (String.IsNullOrWhiteSpace(address) && !string.IsNullOrWhiteSpace(call.GeoLocationData)) + for (int repeat = 0; repeat < 2; repeat++) { - try + var gather = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallAction/{userId}/{callId}"), method: "GET") { - string[] points = call.GeoLocationData.Split(char.Parse(",")); + BargeIn = true + }; + await AppendVoicePromptsAsync(gather, new[] { TwilioVoicePromptCatalog.OutboundDispatchMenu }, call.DepartmentId); + response.Append(gather); + } - if (points != null && points.Length == 2) - { - address = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); - } - } - catch { } + response.Hangup(); + + return CreateVoiceContentResult(response); + } + + [HttpGet] + public async Task VoiceCallAction(string userId, int callId, [FromQuery]TwilioGatherRequest twilioRequest) + { + var response = new VoiceResponse(); + + if (twilioRequest?.Digits == "1") + { + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCall/{userId}/{callId}"), "GET"); + return CreateVoiceContentResult(response); } - if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address)) - address = call.Address; + if (twilioRequest?.Digits == "2") + { + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallResponseOptions/{userId}/{callId}"), "GET"); + return CreateVoiceContentResult(response); + } - var prompts = new List + var call = await _callsService.GetCallByIdAsync(callId); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call?.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCall/{userId}/{callId}"), "GET"); + return CreateVoiceContentResult(response); + } + + [HttpGet] + [Produces("application/xml")] + public async Task VoiceCallResponseOptions(string userId, int callId) + { + var response = new VoiceResponse(); + var call = await _callsService.GetCallByIdAsync(callId); + + if (call == null) { - !String.IsNullOrWhiteSpace(address) - ? $"{call.Name}, Priority {call.GetPriorityText()} Address {call.Address} Nature {call.NatureOfCall}" - : $"{call.Name}, Priority {call.GetPriorityText()} Nature {call.NatureOfCall}", - TwilioVoicePromptCatalog.RepeatAndRespondToScene - }; + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); + return CreateVoiceContentResult(response); + } - for (int i = 0; i < stations.Count && i < 8; i++) + if (call.State == (int)CallStates.Cancelled || call.State == (int)CallStates.Closed || call.IsDeleted) { - prompts.Add(TwilioVoicePromptCatalog.RespondToStationOption(i + 2, stations[i].Name)); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); + response.Hangup(); + return CreateVoiceContentResult(response); } + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); + for (int repeat = 0; repeat < 2; repeat++) { - var gather = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallAction/{userId}/{callId}"), method: "GET") + var gather = new Gather(action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallRespond/{userId}/{callId}"), method: "GET", finishOnKey: "#") { BargeIn = true }; - await AppendVoicePromptsAsync(gather, prompts, call.DepartmentId); + await AppendVoicePromptsAsync(gather, BuildVoiceCallResponseOptionPrompts(stations), call.DepartmentId); response.Append(gather); } @@ -501,44 +531,61 @@ public async Task VoiceCall(string userId, int callId) } [HttpGet] - public async Task VoiceCallAction(string userId, int callId, [FromQuery]TwilioGatherRequest twilioRequest) + [Produces("application/xml")] + public async Task VoiceCallRespond(string userId, int callId, [FromQuery]TwilioGatherRequest twilioRequest) { var response = new VoiceResponse(); + var call = await _callsService.GetCallByIdAsync(callId); - if (twilioRequest.Digits == "0") - response.Redirect(new Uri(string.Format("{0}/Twilio/VoiceCall?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), "GET"); - else if (twilioRequest.Digits == "1") + if (call == null) { - var call = await _callsService.GetCallByIdAsync(callId); - await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); + return CreateVoiceContentResult(response); + } + + if (call.State == (int)CallStates.Cancelled || call.State == (int)CallStates.Closed || call.IsDeleted) + { + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); + response.Hangup(); + return CreateVoiceContentResult(response); + } + + if (twilioRequest?.Digits == "0") + { + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCall/{userId}/{callId}"), "GET"); + return CreateVoiceContentResult(response); + } + if (twilioRequest?.Digits == "1") + { + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToScene, call.DepartmentId); response.Hangup(); + return CreateVoiceContentResult(response); } - else + + if (int.TryParse(twilioRequest?.Digits, out var digit)) { - var call = await _callsService.GetCallByIdAsync(callId); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); + var index = digit - 2; - int index = int.Parse(twilioRequest.Digits) - 2; - - if (index >= 0 && index < 8) + if (index >= 0 && index < stations.Count) { var station = stations[index]; if (station != null) { - await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, - station.DepartmentGroupId); - + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, station.DepartmentGroupId); await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId); response.Hangup(); + return CreateVoiceContentResult(response); } - } } - //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallResponseOptions/{userId}/{callId}"), "GET"); return CreateVoiceContentResult(response); } @@ -608,6 +655,12 @@ private async System.Threading.Tasks.Task AppendVoicePromptAsync(VoiceResponse r await _twilioVoiceResponseService.AppendPromptAsync(response, text, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); } + private async System.Threading.Tasks.Task AppendVoicePromptAsync(Gather gather, string text, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptAsync(gather, text, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + private async System.Threading.Tasks.Task AppendVoicePromptsAsync(VoiceResponse response, IEnumerable prompts, int? departmentId = null) { var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); @@ -638,6 +691,86 @@ private async Task GetDepartmentTtsLanguageAsync(int? departmentId) return ttsLanguage; } + private async System.Threading.Tasks.Task AppendDispatchPlaybackAsync(VoiceResponse response, Call call) + { + if (call.Attachments != null) + { + var audio = call.Attachments.FirstOrDefault(x => x.CallAttachmentType == (int)CallAttachmentTypes.DispatchAudio); + + if (audio != null) + { + var url = await _callsService.GetShortenedAudioUrlAsync(call.CallId, audio.CallAttachmentId); + response.Append(new Play + { + Url = new Uri(url) + }); + return; + } + } + + var address = await ResolveCallAddressAsync(call); + await AppendVoicePromptAsync(response, BuildDispatchPrompt(call, address), call.DepartmentId); + } + + private async Task ResolveCallAddressAsync(Call call) + { + var address = call.Address; + + if (String.IsNullOrWhiteSpace(address) && !string.IsNullOrWhiteSpace(call.GeoLocationData) && call.GeoLocationData.Length > 1) + { + try + { + string[] points = call.GeoLocationData.Split(char.Parse(",")); + + if (points != null && points.Length == 2) + address = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); + } + catch { } + } + + return String.IsNullOrWhiteSpace(address) ? call.Address : address; + } + + private static string BuildDispatchPrompt(Call call, string address) + { + var nature = StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall); + var prompt = !String.IsNullOrWhiteSpace(address) + ? $"{call.Name}, Priority {call.GetPriorityText()} Address {address} Nature {nature}" + : $"{call.Name}, Priority {call.GetPriorityText()} Nature {nature}"; + + return prompt.EndsWith(".", StringComparison.Ordinal) || prompt.EndsWith("!", StringComparison.Ordinal) || prompt.EndsWith("?", StringComparison.Ordinal) + ? prompt + : $"{prompt}."; + } + + private static IReadOnlyCollection BuildVoiceCallResponseOptionPrompts(IEnumerable stations) + { + var prompts = new List + { + TwilioVoicePromptCatalog.OutboundResponseSelectionIntro, + TwilioVoicePromptCatalog.RespondToSceneOption(1) + }; + + prompts.AddRange(BuildStationOptionPrompts(stations)); + prompts.Add(TwilioVoicePromptCatalog.RepeatDispatchWithPound); + + return prompts; + } + + private static IReadOnlyCollection BuildStationOptionPrompts(IEnumerable stations) + { + var prompts = new List(); + var index = 2; + + foreach (var station in stations) + { + prompts.Add(TwilioVoicePromptCatalog.RespondToStationOption(index, station.Name)); + index++; + } + + return prompts; + } + private static ContentResult CreateVoiceContentResult(VoiceResponse response) { return new ContentResult diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj index 74a43ed6..bba76fc0 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj @@ -57,14 +57,6 @@ - - - - - - - - diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index fa225f9f..7fb17dc1 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -3573,52 +3573,6 @@ Is the user a group admin - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - Input data to add a staffing schedule in the Resgrid system @@ -3724,6 +3678,52 @@ Note for this staffing schedule + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + A resrouce in the system this could be a user or unit @@ -7508,379 +7508,209 @@ Identifier of the new npte - + - The result of getting all personnel filters for the system + A GPS location for a point in time of a specificed person - + - The Id value of the filter + PersonId of the person that the location is for - + - The type of the filter + The timestamp of the location in UTC - + - The filters name + GPS Latitude of the Person - + - Result containing all the data required to populate the New Call form + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + GPS Altitude of the Person - + - Response Data + GPS Altitude Accuracy of the Person - + - Result containing all the data required to populate the New Call form + GPS Speed of the Person - + - Response Data + GPS Heading of the Person - + - Information about a User + A unit location in the Resgrid system - + - The UserId GUID/UUID for the user + Response Data - + - DepartmentId of the deparment the user belongs to + The information about a specific unit's location - + - Department specificed ID number for this user + Id of the Person - + - The Users First Name + The Timestamp for the location in UTC - + - The Users Last Name + GPS Latitude of the Person - + - The Users Email Address + GPS Longitude of the Person - + - The Users Mobile Telephone Number + GPS Latitude\Longitude Accuracy of the Person - + - GroupId the user is assigned to (0 for no group) + GPS Altitude of the Person - + - Name of the group the user is assigned to + GPS Altitude Accuracy of the Person - + - Enumeration/List of roles the user currently holds + GPS Speed of the Person - + - The current action/status type for the user + GPS Heading of the Person - + - The current action/status string for the user + The result of getting the current staffing for a user - + - The current action/status color hex string for the user + Response Data - + - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + Information about a User staffing - + - The current action/status destination id for the user + The UserId GUID/UUID for the user status being return - + - The current action/status destination name for the user + DepartmentId of the deparment the user belongs to - + - The current staffing level (state) type for the user + The current staffing type for the user - + - The current staffing level (state) string for the user + The timestamp of the last staffing. This is converted UTC version of the timestamp. - + - The current staffing level (state) color hex string for the user + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - + - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + Note for this staffing - + - Users last known location + Saves (sets) and Personnel Staffing in the system, for a single user - + - Sorting weight for the user + UnitId of the apparatus that the state is being set for - + - User Defined Field values for this personnel record + The UnitStateType of the Unit - + - A GPS location for a point in time of a specificed person + The timestamp of the status event in UTC - + - PersonId of the person that the location is for + The timestamp of the status event in the local time of the device - + - The timestamp of the location in UTC + User provided note for this event - + - GPS Latitude of the Person + The event id used for queuing on mobile applications - + - GPS Longitude of the Person + Depicts a result after saving a person status - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - A unit location in the Resgrid system - - - - - Response Data - - - - - The information about a specific unit's location - - - - - Id of the Person - - - - - The Timestamp for the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - The result of getting the current staffing for a user - - - - - Response Data - - - - - Information about a User staffing - - - - - The UserId GUID/UUID for the user status being return - - - - - DepartmentId of the deparment the user belongs to - - - - - The current staffing type for the user - - - - - The timestamp of the last staffing. This is converted UTC version of the timestamp. - - - - - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - - - - - Note for this staffing - - - - - Saves (sets) and Personnel Staffing in the system, for a single user - - - - - UnitId of the apparatus that the state is being set for - - - - - The UnitStateType of the Unit - - - - - The timestamp of the status event in UTC - - - - - The timestamp of the status event in the local time of the device - - - - - User provided note for this event - - - - - The event id used for queuing on mobile applications - - - - - Depicts a result after saving a person status - - - - - Response Data - - - - - Saves (sets) and Personnel Status in the system, for a single user + Saves (sets) and Personnel Status in the system, for a single user @@ -8181,112 +8011,282 @@ Response Data - + - Result containing all the data required to populate the New Call form + The result of getting all personnel filters for the system - + - Response Data + The Id value of the filter - + - Details of a protocol + The type of the filter - + - Protocol id + The filters name - + - Department id + Result containing all the data required to populate the New Call form - + - Name of the Protocol + Response Data - + - Protocol code + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - This this protocol disabled + Response Data - + - Protocol description + Result containing all the data required to populate the New Call form - + - Text of the protocol + Response Data - + - UTC date and time when the Protocol was created + Information about a User - + - UserId of the user who created the protocol + The UserId GUID/UUID for the user - + - UTC timestamp of when the Protocol was updated + DepartmentId of the deparment the user belongs to - + - Minimum triggering Weight of the Protocol + Department specificed ID number for this user - + - UserId that last updated the Protocol + The Users First Name - + - Triggers used to activate this Protocol + The Users Last Name - + - Attachments for this Protocol + The Users Email Address - + - Questions used to determine if this Protocol needs to be used or not + The Users Mobile Telephone Number - + - State type + GroupId the user is assigned to (0 for no group) - + - Result containing all the data required to populate the New Call form + Name of the group the user is assigned to - + - Response Data + Enumeration/List of roles the user currently holds - + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Details of a protocol + + + + + Protocol id + + + + + Department id + + + + + Name of the Protocol + + + + + Protocol code + + + + + This this protocol disabled + + + + + Protocol description + + + + + Text of the protocol + + + + + UTC date and time when the Protocol was created + + + + + UserId of the user who created the protocol + + + + + UTC timestamp of when the Protocol was updated + + + + + Minimum triggering Weight of the Protocol + + + + + UserId that last updated the Protocol + + + + + Triggers used to activate this Protocol + + + + + Attachments for this Protocol + + + + + Questions used to determine if this Protocol needs to be used or not + + + + + State type + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + A role in the Resgrid system @@ -9480,545 +9480,545 @@ Default constructor - + - Result that contains all the options available to filter units against compatible Resgrid APIs + Depicts a result after saving a unit status - + Response Data - + - A unit in the Resgrid system + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Response Data + UnitId of the apparatus that the state is being set for - + - The information about a specific unit + The UnitStateType of the Unit - + - Id of the Unit + The Call/Station the unit is responding to - + - The Id of the department the unit is under + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Name of the Unit + The timestamp of the status event in UTC - + - Department assigned type for the unit + The timestamp of the status event in the local time of the device - + - Department assigned type id for the unit + User provided note for this event - + - Custom Statuses Set Id + GPS Latitude of the Unit - + - Station Id of the station housing the unit (0 means no station) + GPS Longitude of the Unit - + - Name of the station the unit is under + GPS Latitude\Longitude Accuracy of the Unit - + - Vehicle Identification Number for the unit + GPS Altitude of the Unit - + - Plate Number for the Unit + GPS Altitude Accuracy of the Unit - + - Is the unit 4-Wheel drive + GPS Speed of the Unit - + - Does the unit require a special permit to drive + GPS Heading of the Unit - + - Id number of the units current destionation (0 means no destination) + The event id used for queuing on mobile applications - + - The current status/state of the Unit + The accountability roles filed for this event - + - The Timestamp of the status + Role filled by a User on a Unit for an event - + - The units current Latitude + Id of the locally stored event - + - The units current Longitude + Local Event Id - + - Current user provide status note + UserId of the user filling the role - + - User Defined Field values for this unit + RoleId of the role being filled - + - Unit role information for roles on a unit + The name of the Role - + - Unit Role Id + Depicts a unit status in the Resgrid system. - + - User Id of the user in the role (could be null) + Response Data - + - Name of the Role + Depicts a unit's status - + - Name of the user in the role (could be null) + Unit Id - + - Multiple Unit infos Result + Units Name - + - Response Data + The Type of the Unit - + - Default constructor + Units current Status (State) - + - The information about a specific unit + CSS for status (for display) - + - Id of the Unit + CSS Style for status (for display) - + - The Id of the department the unit is under + Timestamp of this Unit State - + - Name of the Unit + Timestamp in Utc of this Unit State - + - Department assigned type for the unit + Destination Id (Station or Call) - + - Department assigned type id for the unit + Destination type (Station, Call, or POI). - + - Custom Statuses Set Id + Name of the Desination (Call or Station) - + - Station Id of the station housing the unit (0 means no station) + Destination address. - + - Name of the station the unit is under + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - Vehicle Identification Number for the unit + Note for the State - + - Plate Number for the Unit + Latitude - + - Is the unit 4-Wheel drive + Longitude - + - Does the unit require a special permit to drive + Name of the Group the Unit is in - + - Id number of the units current destination (0 means no destination) + Id of the Group the Unit is in - + - Name of the units current destination (0 means no destination) + Unit statuses (states) - + - The current status/state of the Unit + Response Data - + - The current status/state of the Unit as a name + Default constructor - + - The current status/state of the Unit color + Result that contains all the options available to filter units against compatible Resgrid APIs - + - The Timestamp of the status + Response Data - + - The Timestamp of the status in UTC/GMT + A unit in the Resgrid system - + - The units current Latitude + Response Data - + - The units current Longitude + The information about a specific unit - + - Current user provide status note + Id of the Unit - + - Units Roles + The Id of the department the unit is under - + - Multiple Units Result + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Default constructor + Department assigned type id for the unit - + - Depicts a result after saving a unit status + Custom Statuses Set Id - + - Response Data + Station Id of the station housing the unit (0 means no station) - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + Name of the station the unit is under - + - UnitId of the apparatus that the state is being set for + Vehicle Identification Number for the unit - + - The UnitStateType of the Unit + Plate Number for the Unit - + - The Call/Station the unit is responding to + Is the unit 4-Wheel drive - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + Does the unit require a special permit to drive - + - The timestamp of the status event in UTC + Id number of the units current destionation (0 means no destination) - + - The timestamp of the status event in the local time of the device + The current status/state of the Unit - + - User provided note for this event + The Timestamp of the status - + - GPS Latitude of the Unit + The units current Latitude - + - GPS Longitude of the Unit + The units current Longitude - + - GPS Latitude\Longitude Accuracy of the Unit + Current user provide status note - + - GPS Altitude of the Unit + User Defined Field values for this unit - + - GPS Altitude Accuracy of the Unit + Unit role information for roles on a unit - + - GPS Speed of the Unit + Unit Role Id - + - GPS Heading of the Unit + User Id of the user in the role (could be null) - + - The event id used for queuing on mobile applications + Name of the Role - + - The accountability roles filed for this event + Name of the user in the role (could be null) - + - Role filled by a User on a Unit for an event + Multiple Unit infos Result - + - Id of the locally stored event + Response Data - + - Local Event Id + Default constructor - + - UserId of the user filling the role + The information about a specific unit - + - RoleId of the role being filled + Id of the Unit - + - The name of the Role + The Id of the department the unit is under - + - Depicts a unit status in the Resgrid system. + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Depicts a unit's status + Department assigned type id for the unit - + - Unit Id + Custom Statuses Set Id - + - Units Name + Station Id of the station housing the unit (0 means no station) - + - The Type of the Unit + Name of the station the unit is under - + - Units current Status (State) + Vehicle Identification Number for the unit - + - CSS for status (for display) + Plate Number for the Unit - + - CSS Style for status (for display) + Is the unit 4-Wheel drive - + - Timestamp of this Unit State + Does the unit require a special permit to drive - + - Timestamp in Utc of this Unit State + Id number of the units current destination (0 means no destination) - + - Destination Id (Station or Call) + Name of the units current destination (0 means no destination) - + - Destination type (Station, Call, or POI). + The current status/state of the Unit - + - Name of the Desination (Call or Station) + The current status/state of the Unit as a name - + - Destination address. + The current status/state of the Unit color - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + The Timestamp of the status - + - Note for the State + The Timestamp of the status in UTC/GMT - + - Latitude + The units current Latitude - + - Longitude + The units current Longitude - + - Name of the Group the Unit is in + Current user provide status note - + - Id of the Group the Unit is in + Units Roles - + - Unit statuses (states) + Multiple Units Result - + Response Data - + Default constructor diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index 8eecec6e..602d560b 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -5,7 +5,7 @@ namespace Resgrid.Web.Tts.Configuration public sealed class TtsOptions { [Required] - public string DefaultVoice { get; set; } = "en-us+f3"; + public string DefaultVoice { get; set; } = "en-us+klatt6"; [Range(80, 450)] public int DefaultSpeed { get; set; } = 175; diff --git a/Web/Resgrid.Web.Tts/Dockerfile b/Web/Resgrid.Web.Tts/Dockerfile index 0c22b22e..eef9f4e3 100644 --- a/Web/Resgrid.Web.Tts/Dockerfile +++ b/Web/Resgrid.Web.Tts/Dockerfile @@ -21,6 +21,7 @@ RUN dotnet publish "Resgrid.Web.Tts.csproj" -c Release -o /app/publish /p:UseApp FROM base AS final RUN apt-get update \ && apt-get install -y --no-install-recommends espeak-ng ffmpeg ca-certificates \ + && printf 'language variant\nname klatt6\nklatt 6\n' > /usr/lib/x86_64-linux-gnu/espeak-ng-data/voices/!v/klatt6 \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 appgroup \ && useradd --uid 10001 --gid appgroup --create-home --shell /usr/sbin/nologin appuser diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index bc425243..426a4eb6 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -65,43 +65,24 @@ await ExecuteWithRetryAsync( public async Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) { - MemoryStream? bufferedContent = null; - var uploadContent = content; - - if (!content.CanSeek) - { - bufferedContent = new MemoryStream(); - await content.CopyToAsync(bufferedContent, cancellationToken); - bufferedContent.Position = 0; - uploadContent = bufferedContent; - } + var payload = await ReadContentBytesAsync(content, cancellationToken); try { - try - { - await ExecuteWithRetryAsync( - () => UploadWithSdkAsync(objectKey, uploadContent, contentType, cancellationToken), - $"uploading {objectKey}", - cancellationToken); - } - catch (FormatException ex) - { - await HandleMalformedPutResponseAsync(objectKey, uploadContent, contentType, ex, cancellationToken); - } + await ExecuteWithRetryAsync( + () => UploadWithSdkAsync(objectKey, payload, contentType, cancellationToken), + $"uploading {objectKey}", + cancellationToken); } - finally + catch (FormatException ex) { - if (bufferedContent is not null) - { - await bufferedContent.DisposeAsync(); - } + await HandleMalformedPutResponseAsync(objectKey, payload, contentType, ex, cancellationToken); } } private async Task HandleMalformedPutResponseAsync( string objectKey, - Stream content, + byte[] payload, string contentType, FormatException exception, CancellationToken cancellationToken) @@ -119,7 +100,7 @@ private async Task HandleMalformedPutResponseAsync( return; } - await UploadWithPresignedUrlAsync(objectKey, content, contentType, cancellationToken); + await UploadWithPresignedUrlAsync(objectKey, payload, contentType, cancellationToken); } private async Task WasUploadPersistedAsync(string objectKey, CancellationToken cancellationToken) @@ -167,28 +148,22 @@ private async Task WasUploadPersistedAsync(string objectKey, CancellationT return false; } - private Task UploadWithSdkAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) + private Task UploadWithSdkAsync(string objectKey, byte[] payload, string contentType, CancellationToken cancellationToken) { - if (content.CanSeek) - { - content.Position = 0; - } - return _s3Client.PutObjectAsync( new PutObjectRequest { BucketName = _options.Bucket, Key = objectKey, - InputStream = content, + InputStream = new MemoryStream(payload, writable: false), ContentType = contentType }, cancellationToken); } - private async Task UploadWithPresignedUrlAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) + private async Task UploadWithPresignedUrlAsync(string objectKey, byte[] payload, string contentType, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(nameof(S3StorageService)); - var payload = await ReadContentBytesAsync(content, cancellationToken); for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) { @@ -246,6 +221,7 @@ private string CreatePresignedHeadUrl(string objectKey) BucketName = _options.Bucket, Key = objectKey, Verb = HttpVerb.HEAD, + Protocol = GetPresignedUrlProtocol(), Expires = DateTime.UtcNow.AddMinutes(PresignedPutUrlExpiryMinutes) }); } @@ -309,6 +285,7 @@ private string CreatePresignedPutUrl(string objectKey, string contentType) BucketName = _options.Bucket, Key = objectKey, Verb = HttpVerb.PUT, + Protocol = GetPresignedUrlProtocol(), ContentType = contentType, Expires = DateTime.UtcNow.AddMinutes(PresignedPutUrlExpiryMinutes) }); @@ -377,6 +354,7 @@ public Task GetObjectUrlAsync(string objectKey, CancellationToken cancellat { BucketName = _options.Bucket, Key = objectKey, + Protocol = GetPresignedUrlProtocol(), Expires = DateTime.UtcNow.AddMinutes(_options.PresignedUrlExpiryMinutes) }); @@ -450,6 +428,24 @@ private static bool IsNotFound(AmazonS3Exception exception) || string.Equals(exception.ErrorCode, "NotFound", StringComparison.OrdinalIgnoreCase); } + private Protocol GetPresignedUrlProtocol() + { + if (Uri.TryCreate(_options.Endpoint, UriKind.Absolute, out var endpointUri)) + { + if (string.Equals(endpointUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + { + return Protocol.HTTP; + } + + if (string.Equals(endpointUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return Protocol.HTTPS; + } + } + + return _options.UseSsl ? Protocol.HTTPS : Protocol.HTTP; + } + private Uri BuildDirectObjectUrl(string objectKey) { if (!string.IsNullOrWhiteSpace(_options.Endpoint)) diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index b3f353c9..293fa799 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -206,9 +206,7 @@ private NormalizedTtsRequest NormalizeRequest(TtsRequest? request) throw new ArgumentException($"Text exceeds the configured maximum length of {_options.MaxTextLength} characters.", nameof(request)); } - var voice = string.IsNullOrWhiteSpace(request.Voice) - ? _options.DefaultVoice - : request.Voice.Trim(); + var voice = NormalizeVoice(request.Voice); var speed = request.Speed ?? _options.DefaultSpeed; if (speed < 80 || speed > 450) @@ -219,6 +217,42 @@ private NormalizedTtsRequest NormalizeRequest(TtsRequest? request) return new NormalizedTtsRequest(text, voice, speed); } + private string NormalizeVoice(string? voice) + { + var configuredDefaultVoice = string.IsNullOrWhiteSpace(_options.DefaultVoice) + ? "en-us+klatt6" + : _options.DefaultVoice.Trim(); + var requestedVoice = string.IsNullOrWhiteSpace(voice) + ? configuredDefaultVoice + : voice.Trim(); + + if (string.Equals(requestedVoice, "en-us+f3", StringComparison.OrdinalIgnoreCase)) + { + return configuredDefaultVoice; + } + + if (HasExplicitVariant(requestedVoice)) + { + return requestedVoice; + } + + var configuredVariantSuffix = GetVariantSuffix(configuredDefaultVoice); + return string.IsNullOrWhiteSpace(configuredVariantSuffix) + ? requestedVoice + : $"{requestedVoice}{configuredVariantSuffix}"; + } + + private static bool HasExplicitVariant(string voice) + { + return voice.IndexOf('+') > 0; + } + + private static string? GetVariantSuffix(string voice) + { + var variantSeparatorIndex = voice.IndexOf('+'); + return variantSeparatorIndex <= 0 ? null : voice[variantSeparatorIndex..]; + } + private static TtsResponse CreateResponse(TtsCacheKey cacheKey, NormalizedTtsRequest request, Uri objectUrl, bool cached) { return new TtsResponse diff --git a/Web/Resgrid.Web.Tts/k8s/deployment.yaml b/Web/Resgrid.Web.Tts/k8s/deployment.yaml index d25f28f2..a9213255 100644 --- a/Web/Resgrid.Web.Tts/k8s/deployment.yaml +++ b/Web/Resgrid.Web.Tts/k8s/deployment.yaml @@ -17,7 +17,7 @@ data: RESGRID__TtsConfig__S3ForcePathStyle: "true" RESGRID__TtsConfig__S3UsePresignedUrls: "true" RESGRID__TtsConfig__S3PresignedUrlExpiryMinutes: "60" - RESGRID__TtsConfig__DefaultVoice: en-us+f3 + RESGRID__TtsConfig__DefaultVoice: en-us+klatt6 RESGRID__TtsConfig__DefaultSpeed: "175" RESGRID__TtsConfig__MaxConcurrentGenerations: "4" RESGRID__TtsConfig__MaxTextLength: "1000" diff --git a/Web/Resgrid.Web/Resgrid.Web.csproj b/Web/Resgrid.Web/Resgrid.Web.csproj index 82c0da62..42ced3cc 100644 --- a/Web/Resgrid.Web/Resgrid.Web.csproj +++ b/Web/Resgrid.Web/Resgrid.Web.csproj @@ -50,13 +50,6 @@ - - - - - - - diff --git a/Workers/Resgrid.Workers.Framework/BatchProcessingQueueHandler.cs b/Workers/Resgrid.Workers.Framework/BatchProcessingQueueHandler.cs index ec52d525..d83c5f7e 100644 --- a/Workers/Resgrid.Workers.Framework/BatchProcessingQueueHandler.cs +++ b/Workers/Resgrid.Workers.Framework/BatchProcessingQueueHandler.cs @@ -48,7 +48,7 @@ public virtual void Do(IBatchCommand batchCommand) { while (true) { - this.Cycle(batchCommand); + var result = this.Cycle(batchCommand).Result; } }, TaskCreationOptions.LongRunning); @@ -64,7 +64,7 @@ protected async Task Cycle(IBatchCommand batchCommand) do { var messages = await this.queue.GetItems(32); - ProcessMessages(this.queue, messages, batchCommand.Run); + await ProcessMessages(this.queue, messages, batchCommand.Run); continueProcessing = messages.Count() > 0; } diff --git a/Workers/Resgrid.Workers.Framework/QueueHandler.cs b/Workers/Resgrid.Workers.Framework/QueueHandler.cs index 78809397..931f2c8b 100644 --- a/Workers/Resgrid.Workers.Framework/QueueHandler.cs +++ b/Workers/Resgrid.Workers.Framework/QueueHandler.cs @@ -53,7 +53,7 @@ public virtual void Do(ICommand command, CancellationToken token) ///while (command.Continue) while (!token.IsCancellationRequested) { - this.Cycle(command); + var result = this.Cycle(command).Result; } queue.Clear();