diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index 34952bac..def2f83e 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -263,4 +263,19 @@ خطوة كم دقيقة - + + جدولة الإرسال + + + اختر تاريخ ووقت مستقبلي... + + + قم بتعيين تاريخ/وقت مستقبلي اختياريًا لتأخير الإرسال. يجب أن يكون 15 دقيقة على الأقل في المستقبل. + + + يجب أن يكون الإرسال المجدول 15 دقيقة على الأقل في المستقبل. + + + تاريخ الإرسال المجدول + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index 55340522..0a7621e1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -830,4 +830,19 @@ min + + Alarmierung planen + + + Wählen Sie ein zukünftiges Datum und eine Uhrzeit... + + + Legen Sie optional ein zukünftiges Datum/Uhrzeit fest, um die Alarmierung zu verzögern. Muss mindestens 15 Minuten in der Zukunft liegen. + + + Die geplante Alarmierung muss mindestens 15 Minuten in der Zukunft liegen. + + + Geplantes Alarmierungsdatum + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index 53765a6a..dae748e0 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -733,4 +733,19 @@ Step km min + + Schedule Dispatch + + + Select a future date and time... + + + Optionally set a future date/time to delay dispatch. Must be at least 15 minutes in the future. + + + Scheduled dispatch must be at least 15 minutes in the future. + + + Scheduled Dispatch Date + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index e7ab9fc0..4667a209 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -879,4 +879,19 @@ min + + Programar despacho + + + Seleccione una fecha y hora futura... + + + Opcionalmente, establezca una fecha/hora futura para retrasar el despacho. Debe ser al menos 15 minutos en el futuro. + + + El despacho programado debe ser al menos 15 minutos en el futuro. + + + Fecha de despacho programada + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 9a0454bc..03b74925 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -830,4 +830,19 @@ min + + Planifier l'envoi + + + Sélectionnez une date et une heure futures... + + + Définissez éventuellement une date/heure future pour retarder l'envoi. Doit être au moins 15 minutes dans le futur. + + + L'envoi planifié doit être au moins 15 minutes dans le futur. + + + Date d'envoi planifiée + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 4aaabc4a..6c600e50 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -830,4 +830,19 @@ min + + Programmazione invio + + + Seleziona una data e ora futura... + + + Opzionalmente imposta una data/ora futura per ritardare l'invio. Deve essere almeno 15 minuti nel futuro. + + + L'invio programmato deve essere almeno 15 minuti nel futuro. + + + Data di invio programmata + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index 877d4816..50826950 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -830,4 +830,19 @@ min + + Zaplanuj wysyłkę + + + Wybierz przyszłą datę i godzinę... + + + Opcjonalnie ustaw przyszłą datę/godzinę, aby opóźnić wysyłkę. Musi być co najmniej 15 minut w przyszłości. + + + Zaplanowana wysyłka musi być co najmniej 15 minut w przyszłości. + + + Zaplanowana data wysyłki + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index d3ca02d2..ddb0ee1d 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -830,4 +830,19 @@ min + + Schemalägg larm + + + Välj ett framtida datum och tid... + + + Ställ valfritt in ett framtida datum/tid för att fördröja larmet. Måste vara minst 15 minuter i framtiden. + + + Schemalagt larm måste vara minst 15 minuter i framtiden. + + + Schemalagt larmdatum + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index fceda864..6e6e7b2e 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -830,4 +830,19 @@ хв + + Запланувати відправку + + + Виберіть майбутню дату та час... + + + За бажанням встановіть майбутню дату/час для затримки відправки. Має бути щонайменше 15 хвилин у майбутньому. + + + Запланована відправка має бути щонайменше 15 хвилин у майбутньому. + + + Запланована дата відправки + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx index 3bc39e49..701dce90 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.ar.resx @@ -81,4 +81,10 @@ المسارات + + البلاغات المجدولة + + + البلاغات المجدولة + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx index 854c231d..9c263a17 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx @@ -86,4 +86,10 @@ Routen + + Geplante Einsätze + + + Geplante Einsätze + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx index e1e1345c..faf07267 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.en.resx @@ -135,4 +135,10 @@ Routes + + Scheduled Calls + + + Scheduled Calls + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx index 8d5c2edd..2cc0a10b 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.es.resx @@ -135,4 +135,10 @@ Rutas + + Llamadas programadas + + + Llamadas programadas + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx index 4cf0d3d9..185cb0de 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx @@ -86,4 +86,10 @@ Itinéraires + + Appels planifiés + + + Appels planifiés + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx index 5faba56d..b30a3dd8 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx @@ -86,4 +86,10 @@ Percorsi + + Chiamate programmate + + + Chiamate programmate + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx index c2e71570..dc763690 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx @@ -86,4 +86,10 @@ Trasy + + Zaplanowane zgłoszenia + + + Zaplanowane zgłoszenia + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx index f9b25210..f4a0ce61 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx @@ -86,4 +86,10 @@ Rutter + + Schemalagda larm + + + Schemalagda larm + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx index eff0beee..9d6a30f3 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx @@ -86,4 +86,10 @@ Маршрути + + Заплановані виклики + + + Заплановані виклики + diff --git a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs index 3b44b2e2..7a5d858d 100644 --- a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs +++ b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs @@ -33,6 +33,8 @@ public static class TwilioVoicePromptCatalog 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 const string PleaseWaitForDispatch = "Please wait while we prepare your dispatch information."; + 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."; @@ -84,7 +86,8 @@ public static IReadOnlyCollection GetStaticPrompts() NoStatusSelection, InvalidStaffingSelection, NoStaffingSelection, - CommunicationTestRecorded + CommunicationTestRecorded, + PleaseWaitForDispatch }; } } diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index c9eefde1..5fe6c07b 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -381,22 +381,20 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default) var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId); if (members != null && members.Any()) { - // Use department managing user as sender for notifications - var senderId = department?.ManagingUserId ?? members.First().UserId; + // Weather alerts are system-generated — use the system do-not-reply email, not the department admin var subject = FormatAlertSubject(alert); var body = FormatAlertMessageBody(alert, department); foreach (var member in members) { - if (member.UserId == senderId || member.IsDisabled.GetValueOrDefault() || member.IsDeleted) + if (member.IsDisabled.GetValueOrDefault() || member.IsDeleted) continue; var notifyMsg = new Message { Subject = subject, Body = body, - SendingUserId = senderId, ReceivingUserId = member.UserId, SentOn = DateTime.UtcNow, SystemGenerated = true, diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index b50199d2..db172a10 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -113,6 +113,13 @@ protected override void Before_all_tests() } return System.Threading.Tasks.Task.CompletedTask; }); + _twilioVoiceResponseServiceMock + .Setup(x => x.GetPromptUrlAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((text, _, __) => + System.Threading.Tasks.Task.FromResult(new Uri($"https://tts.example/{Uri.EscapeDataString(text)}.wav"))); + _twilioVoiceResponseServiceMock + .Setup(x => x.PreWarmPromptAsync(It.IsAny(), It.IsAny())) + .Returns(System.Threading.Tasks.Task.CompletedTask); } private TwilioController BuildController() @@ -293,10 +300,6 @@ public void dispatch_prompt_helpers_should_end_with_sentence_punctuation() .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")] diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index 4e6c2470..b64d7dc0 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -319,7 +319,7 @@ public void create_ffmpeg_start_info_should_apply_the_requested_telephone_filter "-ac", "1", "-acodec", - "pcm_s16le", + "pcm_mulaw", "-af", "highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1", "/tmp/normalized.wav"); diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 1cab343c..317c7ad7 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -85,6 +85,8 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN #endregion Private Readonly Properties and Constructors + private const int MAX_DISPATCH_RETRY = 3; + [HttpGet("IncomingMessage")] [Produces("application/xml")] public async Task IncomingMessage([FromQuery] TwilioMessage request) @@ -470,7 +472,7 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques [HttpGet("VoiceCall")] [Produces("application/xml")] [ValidateRequest] - public async Task VoiceCall(string userId, int callId) + public async Task VoiceCall(string userId, int callId, [FromQuery] string retry = null) { var response = new VoiceResponse(); var call = await _callsService.GetCallByIdAsync(callId); @@ -490,18 +492,60 @@ public async Task VoiceCall(string userId, int callId) return CreateVoiceContentResult(response); } - await AppendDispatchPlaybackAsync(response, call); + // For dispatch playback, attempt to fetch (or pre-warm) the TTS URL + // within a short timeout so that the TwiML response is returned before + // Twilio's 15-second webhook timeout expires. If the dispatch text is + // not yet cached, we play a brief "please wait" prompt and redirect + // back to this endpoint, giving the TTS service time to complete + // generation in the background. + var dispatchReady = await TryAppendDispatchPlaybackAsync(response, call); - for (int repeat = 0; repeat < 2; repeat++) + if (!dispatchReady) { - var gatherResponse = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCallAction?userId={userId}&callId={callId}"), method: "GET") + // Parse and increment the retry counter from the incoming request. + if (!int.TryParse(retry, out var retryCount)) + retryCount = 0; + + if (retryCount >= MAX_DISPATCH_RETRY) { - BargeIn = true - }; - await AppendVoicePromptAsync(gatherResponse, TwilioVoicePromptCatalog.OutboundDispatchMenu, call.DepartmentId); - response.Append(gatherResponse); + // Exceeded retry cap — fall back to a static error prompt. + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); + return CreateVoiceContentResult(response); + } + + // Dispatch audio isn't ready yet. Pre-warm it in the background + // and redirect back — by the time Twilio re-fetches this endpoint + // the audio should be cached. + var address = await ResolveCallAddressAsync(call); + var dispatchText = BuildDispatchPrompt(call, address); + var ttsLanguage = await GetDepartmentTtsLanguageAsync(call.DepartmentId); + + // Fire off TTS generation in the background. The TTS microservice + // caches the result, so the redirect will find it once ready. + _twilioVoiceResponseService.PreWarmPromptAsync(dispatchText, ttsLanguage) + .ContinueWith(t => + { + if (t.IsFaulted && t.Exception != null) + Logging.LogException(t.Exception); + }, TaskContinuationOptions.OnlyOnFaulted); + + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.PleaseWaitForDispatch); + var nextRetry = retryCount + 1; + response.Redirect( + new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCall?userId={userId}&callId={callId}&retry={nextRetry}"), + "GET"); + return CreateVoiceContentResult(response); } + // Dispatch is ready (fast path or retry with cached audio). + var gather = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/VoiceCallAction?userId={userId}&callId={callId}"), method: "GET") + { + BargeIn = true + }; + await AppendVoicePromptAsync(gather, TwilioVoicePromptCatalog.OutboundDispatchMenu, call.DepartmentId); + response.Append(gather); + response.Hangup(); return CreateVoiceContentResult(response); @@ -526,8 +570,12 @@ public async Task VoiceCallAction(string userId, int callId, [From return CreateVoiceContentResult(response); } - var call = await _callsService.GetCallByIdAsync(callId); - await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call?.DepartmentId); + if (!string.IsNullOrWhiteSpace(twilioRequest?.Digits)) + { + 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); } @@ -1058,7 +1106,7 @@ private async Task GetDepartmentTtsLanguageAsync(int? departmentId) return ttsLanguage; } - private async System.Threading.Tasks.Task AppendDispatchPlaybackAsync(VoiceResponse response, Call call) + private async System.Threading.Tasks.Task TryAppendDispatchPlaybackAsync(VoiceResponse response, Call call) { if (call.Attachments != null) { @@ -1067,16 +1115,41 @@ private async System.Threading.Tasks.Task AppendDispatchPlaybackAsync(VoiceRespo if (audio != null) { var url = await _callsService.GetShortenedAudioUrlAsync(call.CallId, audio.CallAttachmentId); - response.Append(new Play + if (!string.IsNullOrWhiteSpace(url) && Uri.TryCreate(url, UriKind.Absolute, out var audioUri)) { - Url = new Uri(url) - }); - return; + response.Append(new Play + { + Url = audioUri + }); + return true; + } } } var address = await ResolveCallAddressAsync(call); - await AppendVoicePromptAsync(response, BuildDispatchPrompt(call, address), call.DepartmentId); + var ttsLanguage = await GetDepartmentTtsLanguageAsync(call.DepartmentId); + var dispatchText = BuildDispatchPrompt(call, address); + + // Try to get the TTS URL within 3 seconds. If the audio is cached, + // the URL returns nearly instantly; if it needs generation, we let + // the caller fall back to the redirect pattern. + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, + HttpContext?.RequestAborted ?? CancellationToken.None); + + try + { + var url = await _twilioVoiceResponseService.GetPromptUrlAsync(dispatchText, ttsLanguage, linkedCts.Token); + response.Append(new Play { Url = url }); + return true; + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + // TTS generation is taking too long — return false so the caller + // can pre-warm in the background and redirect. + return false; + } } private async Task ResolveCallAddressAsync(Call call) diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs deleted file mode 100644 index 25c618a8..00000000 --- a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs +++ /dev/null @@ -1,792 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -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; -using Resgrid.Model.Queue; -using Resgrid.Model.Services; -using Resgrid.Web.Services.Models; -using Resgrid.Web.Services.Twilio; -using Twilio.AspNet.Common; -using Twilio.TwiML; -using Twilio.TwiML.Voice; - -namespace Resgrid.Web.Services.Controllers -{ - //[EnableCors("_resgridWebsiteAllowSpecificOrigins")] - public class TwilioProviderController : ControllerBase - { - #region Private Readonly Properties and Constructors - private readonly IDepartmentSettingsService _departmentSettingsService; - private readonly INumbersService _numbersService; - private readonly ILimitsService _limitsService; - private readonly ICallsService _callsService; - private readonly IQueueService _queueService; - private readonly IDepartmentsService _departmentsService; - private readonly IUserProfileService _userProfileService; - private readonly ITextCommandService _textCommandService; - private readonly IActionLogsService _actionLogsService; - private readonly IUserStateService _userStateService; - private readonly ICommunicationService _communicationService; - private readonly IGeoLocationProvider _geoLocationProvider; - private readonly IDepartmentGroupsService _departmentGroupsService; - private readonly ICustomStateService _customStateService; - private readonly IUnitsService _unitsService; - private readonly ICommunicationTestService _communicationTestService; - private readonly ITwilioVoiceResponseService _twilioVoiceResponseService; - - public TwilioProviderController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, - ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, - IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, - IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, - IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, - ICommunicationTestService communicationTestService, ITwilioVoiceResponseService twilioVoiceResponseService) - { - _departmentSettingsService = departmentSettingsService; - _numbersService = numbersService; - _limitsService = limitsService; - _callsService = callsService; - _queueService = queueService; - _departmentsService = departmentsService; - _userProfileService = userProfileService; - _textCommandService = textCommandService; - _actionLogsService = actionLogsService; - _userStateService = userStateService; - _communicationService = communicationService; - _geoLocationProvider = geoLocationProvider; - _departmentGroupsService = departmentGroupsService; - _customStateService = customStateService; - _unitsService = unitsService; - _communicationTestService = communicationTestService; - _twilioVoiceResponseService = twilioVoiceResponseService; - } - #endregion Private Readonly Properties and Constructors - - [HttpGet] - [Produces("application/xml")] - public async Task IncomingMessage([FromQuery]TwilioMessage request) - { - if (request == null || string.IsNullOrWhiteSpace(request.To) || string.IsNullOrWhiteSpace(request.From) || string.IsNullOrWhiteSpace(request.Body)) - return BadRequest(); - - var response = new MessagingResponse(); - - var textMessage = new TextMessage(); - textMessage.To = request.To.Replace("+", ""); - textMessage.Msisdn = request.From.Replace("+", ""); - textMessage.MessageId = request.MessageSid; - textMessage.Timestamp = DateTime.UtcNow.ToLongDateString(); - textMessage.Data = request.Body; - textMessage.Text = request.Body; - - var messageEvent = new InboundMessageEvent(); - messageEvent.MessageType = (int)InboundMessageTypes.TextMessage; - messageEvent.RecievedOn = DateTime.UtcNow; - messageEvent.Type = typeof(InboundMessageEvent).FullName; - messageEvent.Data = JsonConvert.SerializeObject(textMessage); - messageEvent.Processed = false; - messageEvent.CustomerId = ""; - - // Check for Communication Test response (CT- prefix) - if (!string.IsNullOrWhiteSpace(textMessage.Text) && textMessage.Text.Trim().StartsWith("CT-", StringComparison.OrdinalIgnoreCase)) - { - var runCode = textMessage.Text.Trim().Split(' ')[0].ToUpperInvariant(); - await _communicationTestService.RecordSmsResponseAsync(runCode, textMessage.Msisdn); - messageEvent.Processed = true; - - response.Message("Resgrid received your communication test response. Thank you."); - - await _numbersService.SaveInboundMessageEventAsync(messageEvent); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; - } - - try - { - var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); - - if (departmentId.HasValue) - { - var department = await _departmentsService.GetDepartmentByIdAsync(departmentId.Value); - var textToCallEnabled = await _departmentSettingsService.GetDepartmentIsTextCallImportEnabledAsync(departmentId.Value); - var textCommandEnabled = await _departmentSettingsService.GetDepartmentIsTextCommandEnabledAsync(departmentId.Value); - var dispatchNumbers = await _departmentSettingsService.GetTextToCallSourceNumbersForDepartmentAsync(departmentId.Value); - var authroized = await _limitsService.CanDepartmentProvisionNumberAsync(departmentId.Value); - var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId.Value); - - messageEvent.CustomerId = departmentId.Value.ToString(); - - if (authroized) - { - bool isDispatchSource = false; - - if (!String.IsNullOrWhiteSpace(dispatchNumbers)) - isDispatchSource = _numbersService.DoesNumberMatchAnyPattern(dispatchNumbers.Split(Char.Parse(",")).ToList(), textMessage.Msisdn); - - // If we don't have dispatchNumbers and Text Command isn't enabled it's a dispatch text - if (!isDispatchSource && !textCommandEnabled) - isDispatchSource = true; - - if (isDispatchSource && textToCallEnabled) - { - var c = new Call(); - c.Notes = textMessage.Text; - c.NatureOfCall = textMessage.Text; - c.LoggedOn = DateTime.UtcNow; - c.Name = string.Format("TTC {0}", c.LoggedOn.TimeConverter(department).ToString("g")); - c.Priority = (int)CallPriority.High; - c.ReportingUserId = department.ManagingUserId; - c.Dispatches = new Collection(); - c.CallSource = (int)CallSources.EmailImport; - c.SourceIdentifier = textMessage.MessageId; - c.DepartmentId = departmentId.Value; - - var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId.Value, true); - foreach (var u in users) - { - var cd = new CallDispatch(); - cd.UserId = u.UserId; - - c.Dispatches.Add(cd); - } - - var savedCall = await _callsService.SaveCallAsync(c); - - var cqi = new CallQueueItem(); - cqi.Call = savedCall; - cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(users.Select(x => x.UserId).ToList()); - cqi.DepartmentTextNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(cqi.Call.DepartmentId); - - _queueService.EnqueueCallBroadcastAsync(cqi); - - messageEvent.Processed = true; - } - - if (!isDispatchSource && textCommandEnabled) - { - var profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - - if (profile != null) - { - var payload = _textCommandService.DetermineType(textMessage.Text); - var customActions = customStates.FirstOrDefault(x => x.Type == (int)CustomStateTypes.Personnel); - var customStaffing = customStates.FirstOrDefault(x => x.Type == (int)CustomStateTypes.Staffing); - - switch (payload.Type) - { - case TextCommandTypes.None: - response.Message("Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands."); - break; - case TextCommandTypes.Help: - messageEvent.Processed = true; - - var help = new StringBuilder(); - help.Append("Resgrid Text Commands" + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - help.Append("These are the commands you can text to alter your status and staffing. Text help for help." + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - help.Append("Core Commands" + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - help.Append("STOP: To turn off all text messages" + Environment.NewLine); - help.Append("HELP: This help text" + Environment.NewLine); - help.Append("CALLS: List active calls" + Environment.NewLine); - help.Append("C[CallId]: Get Call Detail i.e. C1445" + Environment.NewLine); - help.Append("UNITS: List unit statuses" + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - help.Append("Status or Action Commands" + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - - if (customActions != null && customActions.IsDeleted == false && customActions.GetActiveDetails() != null && customActions.GetActiveDetails().Any()) - { - var activeDetails = customActions.GetActiveDetails(); - for (int i = 0; i < activeDetails.Count; i++) - { - help.Append($"{activeDetails[i].ButtonText.Trim().Replace(" ", "").Replace("-", "").Replace(":", "")} or {i + 1}: {activeDetails[i].ButtonText}" + Environment.NewLine); - } - } - else - { - help.Append("responding or 1: Responding" + Environment.NewLine); - help.Append("notresponding or 2: Not Responding" + Environment.NewLine); - help.Append("onscene or 3: On Scene" + Environment.NewLine); - help.Append("available or 4: Available" + Environment.NewLine); - } - help.Append("---------------------" + Environment.NewLine); - help.Append("Staffing Commands" + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - - if (customStaffing != null && customStaffing.IsDeleted == false && customStaffing.GetActiveDetails() != null && customStaffing.GetActiveDetails().Any()) - { - var activeDetails = customStaffing.GetActiveDetails(); - for (int i = 0; i < activeDetails.Count; i++) - { - help.Append($"{activeDetails[i].ButtonText.Trim().Replace(" ", "").Replace("-", "").Replace(":", "")} or S{i + 1}: {activeDetails[i].ButtonText}" + Environment.NewLine); - } - } - else - { - help.Append("available or s1: Available Staffing" + Environment.NewLine); - help.Append("delayed or s2: Delayed Staffing" + Environment.NewLine); - help.Append("unavailable or s3: Unavailable Staffing" + Environment.NewLine); - help.Append("committed or s4: Committed Staffing" + Environment.NewLine); - help.Append("onshift or s4: On Shift Staffing" + Environment.NewLine); - } - - response.Message(help.ToString()); - - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", help.ToString(), department.DepartmentId, textMessage.To, profile); - break; - case TextCommandTypes.Action: - messageEvent.Processed = true; - await _actionLogsService.SetUserActionAsync(profile.UserId, department.DepartmentId, (int)payload.GetActionType()); - response.Message(string.Format("Resgrid received your text command. Status changed to: {0}", payload.GetActionType())); - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Status", string.Format("Resgrid recieved your text command. Status changed to: {0}", payload.GetActionType()), department.DepartmentId, textMessage.To, profile); - break; - case TextCommandTypes.Staffing: - messageEvent.Processed = true; - await _userStateService.CreateUserState(profile.UserId, department.DepartmentId, (int)payload.GetStaffingType()); - response.Message(string.Format("Resgrid received your text command. Staffing level changed to: {0}", payload.GetStaffingType())); - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Staffing", string.Format("Resgrid recieved your text command. Staffing level changed to: {0}", payload.GetStaffingType()), department.DepartmentId, textMessage.To, profile); - break; - case TextCommandTypes.Stop: - messageEvent.Processed = true; - await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId); - response.Message("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); - break; - case TextCommandTypes.CustomAction: - messageEvent.Processed = true; - await _actionLogsService.SetUserActionAsync(profile.UserId, department.DepartmentId, payload.GetCustomActionType()); - - if (customActions != null && customActions.IsDeleted == false && customActions.GetActiveDetails() != null && customActions.GetActiveDetails().Any() && customActions.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomActionType()) != null) - { - var detail = customActions.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomActionType()); - response.Message(string.Format("Resgrid received your text command. Status changed to: {0}", detail.ButtonText)); - } - else - { - response.Message("Resgrid received your text command and updated your status"); - } - break; - case TextCommandTypes.CustomStaffing: - messageEvent.Processed = true; - await _userStateService.CreateUserState(profile.UserId, department.DepartmentId, payload.GetCustomStaffingType()); - - if (customStaffing != null && customStaffing.IsDeleted == false && customStaffing.GetActiveDetails() != null && customStaffing.GetActiveDetails().Any() && customStaffing.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomStaffingType()) != null) - { - var detail = customStaffing.GetActiveDetails().FirstOrDefault(x => x.CustomStateDetailId == payload.GetCustomStaffingType()); - response.Message(string.Format("Resgrid received your text command. Staffing changed to: {0}", detail.ButtonText)); - } - else - { - response.Message("Resgrid received your text command and updated your staffing"); - } - break; - case TextCommandTypes.MyStatus: - messageEvent.Processed = true; - - - var userStatus = await _actionLogsService.GetLastActionLogForUserAsync(profile.UserId); - var userStaffing = await _userStateService.GetLastUserStateByUserIdAsync(profile.UserId); - - var customStatusLevel = await _customStateService.GetCustomPersonnelStatusAsync(department.DepartmentId, userStatus); - var customStaffingLevel = await _customStateService.GetCustomPersonnelStaffingAsync(department.DepartmentId, userStaffing); - - response.Message($"Hello {profile.FullName.AsFirstNameLastName} at {DateTime.UtcNow.TimeConverterToString(department)} your current status is {customStatusLevel.ButtonText} and your current staffing is {customStaffingLevel.ButtonText}."); - break; - case TextCommandTypes.Calls: - messageEvent.Processed = true; - - var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(department.DepartmentId); - - var activeCallText = new StringBuilder(); - activeCallText.Append($"Active Calls for {department.Name}" + Environment.NewLine); - activeCallText.Append("---------------------" + Environment.NewLine); - - foreach (var activeCall in activeCalls) - { - activeCallText.Append($"CallId: {activeCall.CallId} Name: {activeCall.Name} Nature:{activeCall.NatureOfCall}" + Environment.NewLine); - } - - - response.Message(activeCallText.ToString()); - break; - case TextCommandTypes.Units: - messageEvent.Processed = true; - - var unitStatus = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(department.DepartmentId); - - var unitStatusesText = new StringBuilder(); - unitStatusesText.Append($"Unit Statuses for {department.Name}" + Environment.NewLine); - unitStatusesText.Append("---------------------" + Environment.NewLine); - - foreach (var unit in unitStatus) - { - var unitState = await _customStateService.GetCustomUnitStateAsync(unit); - unitStatusesText.Append($"{unit.Unit.Name} is {unitState.ButtonText}" + Environment.NewLine); - } - - response.Message(unitStatusesText.ToString()); - break; - case TextCommandTypes.CallDetail: - messageEvent.Processed = true; - - var call = await _callsService.GetCallByIdAsync(int.Parse(payload.Data)); - - var callText = new StringBuilder(); - callText.Append($"Call Information for {call.Name}" + Environment.NewLine); - callText.Append("---------------------" + Environment.NewLine); - callText.Append($"Id: {call.CallId}" + Environment.NewLine); - callText.Append($"Number: {call.Number}" + Environment.NewLine); - callText.Append($"Logged: {call.LoggedOn.TimeConverterToString(department)}" + Environment.NewLine); - callText.Append("-----Nature-----" + Environment.NewLine); - callText.Append(call.NatureOfCall + Environment.NewLine); - callText.Append("-----Address-----" + Environment.NewLine); - - if (!String.IsNullOrWhiteSpace(call.Address)) - callText.Append(call.Address + Environment.NewLine); - else if (!string.IsNullOrEmpty(call.GeoLocationData)) - { - try - { - string[] points = call.GeoLocationData.Split(char.Parse(",")); - - if (points != null && points.Length == 2) - { - callText.Append(_geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])) + Environment.NewLine); - } - } - catch - { - } - } - - response.Message(callText.ToString()); - break; - } - } - } - } - } - else if (textMessage.To == "17753765253") // Resgrid master text number - { - var profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - var payload = _textCommandService.DetermineType(textMessage.Text); - - switch (payload.Type) - { - case TextCommandTypes.None: - response.Message("Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands."); - break; - case TextCommandTypes.Help: - messageEvent.Processed = true; - - var help = new StringBuilder(); - help.Append("Resgrid Text Commands" + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - help.Append("This is the Resgrid system for first responders (https://resgrid.com) automated text system. Your department isn't signed up for inbound text messages, but you can send the following commands." + Environment.NewLine); - help.Append("---------------------" + Environment.NewLine); - help.Append("STOP: To turn off all text messages" + Environment.NewLine); - help.Append("HELP: This help text" + Environment.NewLine); - - response.Message(help.ToString()); - - break; - case TextCommandTypes.Stop: - messageEvent.Processed = true; - await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId); - response.Message("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); - break; - } - } - } - catch (Exception ex) - { - Framework.Logging.LogException(ex); - } - finally - { - await _numbersService.SaveInboundMessageEventAsync(messageEvent); - } - - //Ok(); - - //var response = new TwilioResponse(); - - //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); - } - - [HttpGet] - [Produces("application/xml")] - 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 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); - } - - await AppendDispatchPlaybackAsync(response, call); - - 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") - { - BargeIn = true - }; - await AppendVoicePromptsAsync(gather, new[] { TwilioVoicePromptCatalog.OutboundDispatchMenu }, call.DepartmentId); - response.Append(gather); - } - - 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 (twilioRequest?.Digits == "2") - { - response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallResponseOptions/{userId}/{callId}"), "GET"); - return CreateVoiceContentResult(response); - } - - 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) - { - 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); - } - - var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); - - for (int repeat = 0; repeat < 2; repeat++) - { - var gather = new Gather(action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallRespond/{userId}/{callId}"), method: "GET", finishOnKey: "#") - { - BargeIn = true - }; - await AppendVoicePromptsAsync(gather, BuildVoiceCallResponseOptionPrompts(stations), call.DepartmentId); - response.Append(gather); - } - - response.Hangup(); - - return CreateVoiceContentResult(response); - } - - [HttpGet] - [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 (call == null) - { - 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); - } - - if (int.TryParse(twilioRequest?.Digits, out var digit)) - { - var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); - var index = digit - 2; - - 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 AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId); - response.Hangup(); - return CreateVoiceContentResult(response); - } - } - } - - await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call.DepartmentId); - response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallResponseOptions/{userId}/{callId}"), "GET"); - return CreateVoiceContentResult(response); - } - - [HttpGet] - public async Task InboundVoice([FromQuery]TwilioGatherRequest request) - { - if (request == null || string.IsNullOrWhiteSpace(request.To) || string.IsNullOrWhiteSpace(request.From)) - return BadRequest(); - - var response = new VoiceResponse(); - var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(request.To.Replace("+", "")); - - if (departmentId.HasValue) - { - var authroized = await _limitsService.CanDepartmentProvisionNumberAsync(departmentId.Value); - - - request.From.Replace("+", ""); - if (authroized) - { - var department = await _departmentsService.GetDepartmentByIdAsync(departmentId.Value, false); - - UserProfile profile = null; - profile = await _userProfileService.GetProfileByMobileNumberAsync(request.From.Replace("+", "")); - - if (profile == null) - profile = await _userProfileService.GetProfileByHomeNumberAsync(request.From.Replace("+", "")); - - if (department != null && profile != null) - { - await AppendVoicePromptsAsync(response, new[] - { - TwilioVoicePromptCatalog.MainMenuGreeting(profile.FirstName, department.Name), - TwilioVoicePromptCatalog.MainMenuSelectionIntro, - TwilioVoicePromptCatalog.MainMenuActiveCalls, - TwilioVoicePromptCatalog.MainMenuUserStatuses, - TwilioVoicePromptCatalog.MainMenuUnitStatuses, - TwilioVoicePromptCatalog.MainMenuCalendarEvents, - TwilioVoicePromptCatalog.MainMenuShifts - }, department.DepartmentId); - } - else - { - await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable, departmentId.Value); - response.Hangup(); - } - } - else - { - await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable, departmentId.Value); - response.Hangup(); - } - } - else - { - await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable); - response.Hangup(); - } - - //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return CreateVoiceContentResult(response); - } - - private async System.Threading.Tasks.Task AppendVoicePromptAsync(VoiceResponse response, string text, int? departmentId = null) - { - var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); - 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); - await _twilioVoiceResponseService.AppendPromptsAsync(response, prompts, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); - } - - private async System.Threading.Tasks.Task AppendVoicePromptsAsync(Gather gather, IEnumerable prompts, int? departmentId = null) - { - var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); - await _twilioVoiceResponseService.AppendPromptsAsync(gather, prompts, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); - } - - private async Task GetDepartmentTtsLanguageAsync(int? departmentId) - { - if (!departmentId.HasValue || departmentId.Value <= 0) - return null; - - var cacheKey = $"twilio-provider-tts-language:{departmentId.Value}"; - - if (HttpContext?.Items != null && HttpContext.Items.TryGetValue(cacheKey, out var cachedLanguage)) - return cachedLanguage as string; - - var ttsLanguage = await _departmentSettingsService.GetTtsLanguageForDepartmentAsync(departmentId.Value); - - if (HttpContext?.Items != null) - HttpContext.Items[cacheKey] = ttsLanguage; - - 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 - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; - } - } - - //[Serializable] - //public class TwilioMessage : TwilioRequest - //{ - // public string MessageSid { get; set; } - // public string SmsMessageSid { get; set; } - // public string Body { get; set; } - //} -} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 526ac72e..5891e462 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 @@ -8171,14 +8001,184 @@ The event id used for queuing on mobile applications - + + + Depicts a result after saving a person status + + + + + Response Data + + + + + The result of getting all personnel filters for the system + + + + + The Id value of the filter + + + + + The type of the filter + + + + + The filters name + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Result that contains all the options available to filter personnel against compatible Resgrid APIs + + + + + Response Data + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Information about a User + + + + + The UserId GUID/UUID for the user + + + + + DepartmentId of the deparment the user belongs to + + + + + Department specificed ID number for this user + + + + + The Users First Name + + + + + The Users Last Name + + + + + The Users Email Address + + + + + The Users Mobile Telephone Number + + + + + GroupId the user is assigned to (0 for no group) + + + + + Name of the group the user is assigned to + + + + + 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 + + + - Depicts a result after saving a person status + Sorting weight for the user - + - Response Data + User Defined Field values for this personnel record @@ -9475,7 +9475,255 @@ Response Data - + + + Default constructor + + + + + Depicts a result after saving a unit status + + + + + Response Data + + + + + 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. + + + + + UnitId of the apparatus that the state is being set for + + + + + The UnitStateType of the Unit + + + + + The Call/Station the unit is responding to + + + + + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + + + + + 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 + + + + + GPS Latitude of the Unit + + + + + GPS Longitude of the Unit + + + + + GPS Latitude\Longitude Accuracy of the Unit + + + + + GPS Altitude of the Unit + + + + + GPS Altitude Accuracy of the Unit + + + + + GPS Speed of the Unit + + + + + GPS Heading of the Unit + + + + + The event id used for queuing on mobile applications + + + + + The accountability roles filed for this event + + + + + Role filled by a User on a Unit for an event + + + + + Id of the locally stored event + + + + + Local Event Id + + + + + UserId of the user filling the role + + + + + RoleId of the role being filled + + + + + The name of the Role + + + + + Depicts a unit status in the Resgrid system. + + + + + Response Data + + + + + Depicts a unit's status + + + + + Unit Id + + + + + Units Name + + + + + The Type of the Unit + + + + + Units current Status (State) + + + + + CSS for status (for display) + + + + + CSS Style for status (for display) + + + + + Timestamp of this Unit State + + + + + Timestamp in Utc of this Unit State + + + + + Destination Id (Station or Call) + + + + + Destination type (Station, Call, or POI). + + + + + Name of the Desination (Call or Station) + + + + + Destination address. + + + + + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. + + + + + Note for the State + + + + + Latitude + + + + + Longitude + + + + + Name of the Group the Unit is in + + + + + Id of the Group the Unit is in + + + + + Unit statuses (states) + + + + + Response Data + + + Default constructor @@ -9775,254 +10023,6 @@ Default constructor - - - Depicts a result after saving a unit status - - - - - Response Data - - - - - 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. - - - - - UnitId of the apparatus that the state is being set for - - - - - The UnitStateType of the Unit - - - - - The Call/Station the unit is responding to - - - - - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - - - - - 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 - - - - - GPS Latitude of the Unit - - - - - GPS Longitude of the Unit - - - - - GPS Latitude\Longitude Accuracy of the Unit - - - - - GPS Altitude of the Unit - - - - - GPS Altitude Accuracy of the Unit - - - - - GPS Speed of the Unit - - - - - GPS Heading of the Unit - - - - - The event id used for queuing on mobile applications - - - - - The accountability roles filed for this event - - - - - Role filled by a User on a Unit for an event - - - - - Id of the locally stored event - - - - - Local Event Id - - - - - UserId of the user filling the role - - - - - RoleId of the role being filled - - - - - The name of the Role - - - - - Depicts a unit status in the Resgrid system. - - - - - Response Data - - - - - Depicts a unit's status - - - - - Unit Id - - - - - Units Name - - - - - The Type of the Unit - - - - - Units current Status (State) - - - - - CSS for status (for display) - - - - - CSS Style for status (for display) - - - - - Timestamp of this Unit State - - - - - Timestamp in Utc of this Unit State - - - - - Destination Id (Station or Call) - - - - - Destination type (Station, Call, or POI). - - - - - Name of the Desination (Call or Station) - - - - - Destination address. - - - - - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. - - - - - Note for the State - - - - - Latitude - - - - - Longitude - - - - - Name of the Group the Unit is in - - - - - Id of the Group the Unit is in - - - - - Unit statuses (states) - - - - - Response Data - - - - - Default constructor - - Input for creating or updating a UDF definition (creates a new version). @@ -10161,5 +10161,19 @@ resource lookups using this strongly typed resource class. + + + Kicks off TTS generation for the given text in the background so that + a subsequent call to GetPromptUrlAsync (or AppendPromptAsync) will find + the URL already cached. Does not throw on failure — the cache entry is + removed automatically if generation fails. + + + + + Returns the TTS audio URL for the given text, generating it if necessary. + Respects the provided cancellation token for timeout control. + + diff --git a/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs b/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs index ebd5a4dc..23b2e67d 100644 --- a/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs +++ b/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -15,5 +16,19 @@ public interface ITwilioVoiceResponseService System.Threading.Tasks.Task AppendPromptsAsync(VoiceResponse response, IEnumerable prompts, CancellationToken cancellationToken = default, string voice = null); System.Threading.Tasks.Task AppendPromptsAsync(Gather gather, IEnumerable prompts, CancellationToken cancellationToken = default, string voice = null); + + /// + /// Kicks off TTS generation for the given text in the background so that + /// a subsequent call to GetPromptUrlAsync (or AppendPromptAsync) will find + /// the URL already cached. Does not throw on failure — the cache entry is + /// removed automatically if generation fails. + /// + System.Threading.Tasks.Task PreWarmPromptAsync(string text, string voice = null); + + /// + /// Returns the TTS audio URL for the given text, generating it if necessary. + /// Respects the provided cancellation token for timeout control. + /// + System.Threading.Tasks.Task GetPromptUrlAsync(string text, string voice, CancellationToken cancellationToken); } } diff --git a/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs b/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs index dec574f7..6dd53d52 100644 --- a/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs +++ b/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model.Services; using Twilio.TwiML; using Twilio.TwiML.Voice; @@ -183,6 +184,37 @@ private static Play CreatePlay(Uri url) }; } + public System.Threading.Tasks.Task PreWarmPromptAsync(string text, string voice = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + + var chunks = ChunkText(text).ToList(); + if (chunks.Count != 1) + throw new ArgumentException($"PreWarmPromptAsync does not support multi-chunk input (got {chunks.Count} chunks). Use AppendPromptAsync for multi-chunk text.", nameof(text)); + + // Start the generation task (or return the existing one) without + // necessarily awaiting it. The TTS microservice's internal cache + // persists across requests, so a subsequent call will find the URL. + GetOrCreatePromptUrlAsync(chunks[0], voice, CancellationToken.None) + .ContinueWith(t => + { + if (t.IsFaulted && t.Exception != null) + Logging.LogException(t.Exception); + }, TaskContinuationOptions.OnlyOnFaulted); + return System.Threading.Tasks.Task.CompletedTask; + } + + public async System.Threading.Tasks.Task GetPromptUrlAsync(string text, string voice, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + + var chunks = ChunkText(text).ToList(); + if (chunks.Count != 1) + throw new ArgumentException($"GetPromptUrlAsync does not support multi-chunk input (got {chunks.Count} chunks). Use AppendPromptAsync for multi-chunk text.", nameof(text)); + + return await GetOrCreatePromptUrlAsync(chunks[0], voice, cancellationToken); + } + private async Task GetOrCreatePromptUrlAsync(string chunk, string voice, CancellationToken cancellationToken) { var cacheKey = string.IsNullOrWhiteSpace(voice) diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index 896765ea..d2414175 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -84,7 +84,8 @@ public sealed class TtsOptions "No status selection made. Returning to the main menu.", "Invalid staffing selection. Returning to the main menu.", "No staffing selection made. Returning to the main menu.", - "Thank you. Your response has been recorded." + "Thank you. Your response has been recorded.", + "Please wait while we prepare your dispatch information." }; } } \ No newline at end of file diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index f02a2f63..d043c991 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -97,6 +97,45 @@ public async Task GenerateNormalizedWavAsync(string text, string voice, return (invocation.ModelName, speed); } + /// + /// Returns the set of distinct voice identifiers (one per unique model file) + /// that should be warmed at startup. The identifiers are chosen as the first + /// language code that maps to each model, providing a deterministic and + /// minimal set of voices to pre-load. + /// + public IReadOnlySet GetDistinctVoiceIdentifiers() + { + var distinctVoices = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (languageCode, modelName) in VoiceModelMap) + { + // We select the first language code we encounter for each model file. + // Since SortedDictionary-like ordering isn't needed here (any code that + // maps to a given model will load it), this simple dedup suffices. + if (!distinctVoices.Contains(languageCode)) + { + // Check if this model has already been represented + var alreadyRepresented = false; + foreach (var existingVoice in distinctVoices) + { + if (VoiceModelMap.TryGetValue(existingVoice, out var existingModel) + && string.Equals(existingModel, modelName, StringComparison.OrdinalIgnoreCase)) + { + alreadyRepresented = true; + break; + } + } + + if (!alreadyRepresented) + { + distinctVoices.Add(languageCode); + } + } + } + + return distinctVoices; + } + /// /// Resolves a voice identifier plus speed into a Piper model filename and length-scale, /// with the effective model name used for cache-key derivation. @@ -189,7 +228,7 @@ private ProcessStartInfo CreateFfmpegStartInfo(string inputFilePath, string outp startInfo.ArgumentList.Add("-ac"); startInfo.ArgumentList.Add(_options.NormalizedChannels.ToString(CultureInfo.InvariantCulture)); startInfo.ArgumentList.Add("-acodec"); - startInfo.ArgumentList.Add("pcm_s16le"); + startInfo.ArgumentList.Add("pcm_mulaw"); startInfo.ArgumentList.Add("-af"); startInfo.ArgumentList.Add(TelephoneAudioFilter); startInfo.ArgumentList.Add(outputFilePath); diff --git a/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs index 37c03a16..dff5572f 100644 --- a/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs @@ -12,5 +12,13 @@ public interface IAudioProcessingService /// profile rather than the original request parameters. /// (string Voice, int Speed) GetEffectiveSynthesisProfile(string voice, int speed); + + /// + /// Returns the set of distinct voice identifiers that map to unique + /// Piper model files. Used at startup to warm up each model so that + /// the first request for a non-default language does not incur the + /// model-loading penalty. + /// + IReadOnlySet GetDistinctVoiceIdentifiers(); } } diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index b5279831..b8d67936 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -51,17 +51,21 @@ public async Task> GenerateBatchAsync(IEnumerab public async Task WarmPromptsAsync(CancellationToken cancellationToken) { + // 1. Warm static prompts with the default voice and speed. var prompts = _options.PreGeneratedPrompts .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) .Select(prompt => prompt.Trim()) .Distinct(StringComparer.Ordinal) .ToList(); + var defaultVoiceWarmed = false; + foreach (var prompt in prompts) { try { await GenerateInternalAsync(new NormalizedTtsRequest(prompt, _options.DefaultVoice, _options.DefaultSpeed), cancellationToken); + defaultVoiceWarmed = true; } catch (ArgumentException ex) { @@ -80,6 +84,48 @@ public async Task WarmPromptsAsync(CancellationToken cancellationToken) _logger.LogError(ex, "Failed to warm prompt {Prompt} because audio generation failed.", prompt); } } + + // 2. Warm one minimal prompt per distinct voice model so that the first + // non-default-language request does not incur the model-loading + // penalty. The English model is already loaded by step 1, but a + // cache hit for English means the model might be skipped — the + // explicit per-model warm here guarantees each model file is loaded. + // Using the default speed ensures the cache key is consistent. + const string modelWarmPrompt = "Test"; + var distinctVoices = _audioProcessingService.GetDistinctVoiceIdentifiers(); + var defaultProfile = _audioProcessingService.GetEffectiveSynthesisProfile(_options.DefaultVoice, _options.DefaultSpeed); + + foreach (var voice in distinctVoices) + { + // Skip the default voice — its model was already covered by the + // static prompts above provided at least one prompt is configured. + var profile = _audioProcessingService.GetEffectiveSynthesisProfile(voice, _options.DefaultSpeed); + if (defaultVoiceWarmed && string.Equals(profile.Voice, defaultProfile.Voice, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + await GenerateInternalAsync(new NormalizedTtsRequest(modelWarmPrompt, voice, _options.DefaultSpeed), cancellationToken); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, "Model warm-up prompt is invalid for voice {Voice}: {Prompt}", voice, modelWarmPrompt); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to warm model for voice {Voice} because storage returned an error.", voice); + } + catch (IOException ex) + { + _logger.LogError(ex, "Failed to warm model for voice {Voice} because audio files could not be processed.", voice); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to warm model for voice {Voice} because audio generation failed.", voice); + } + } } private async Task GenerateInternalAsync(NormalizedTtsRequest request, CancellationToken cancellationToken) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index c846ea67..89e0173f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -187,6 +187,12 @@ public async Task ArchivedCalls() return View(model); } + [Authorize(Policy = ResgridResources.Call_View)] + public IActionResult ScheduledCalls() + { + return View(); + } + [HttpGet] [Authorize(Policy = ResgridResources.Call_View)] public async Task NewCall() @@ -420,6 +426,24 @@ public async Task NewCall(NewCallView model, IFormCollection coll } } + // Handle scheduled dispatch + if (model.ScheduleDispatchDate.HasValue) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var dispatchUtc = DateTimeHelpers.ConvertToUtc( + DateTime.SpecifyKind(model.ScheduleDispatchDate.Value, DateTimeKind.Unspecified), + department.TimeZone); + + // Validate dispatch is at least 15 minutes in the future + if (dispatchUtc <= DateTime.UtcNow.AddMinutes(15)) + { + ModelState.AddModelError("ScheduleDispatchDate", _dispatchLocalizer["ScheduleDispatchValidationError"].Value); + return View("NewCall", model); + } + model.Call.DispatchOn = dispatchUtc; + model.Call.HasBeenDispatched = false; + } + var shouldDispatchNow = !model.Call.DispatchOn.HasValue || model.Call.DispatchOn.Value <= DateTime.UtcNow; model.Call.Contacts = new List(); @@ -515,7 +539,7 @@ public async Task NewCall(NewCallView model, IFormCollection coll else cqi.Profiles = new List(); - if (dispatchingUserIds.Any() || dispatchingGroupIds.Any() || dispatchingUnitIds.Any() || dispatchingRoleIds.Any()) + if (shouldDispatchNow && (dispatchingUserIds.Any() || dispatchingGroupIds.Any() || dispatchingUnitIds.Any() || dispatchingRoleIds.Any())) await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); _eventAggregator.SendMessage(new CallAddedEvent() { DepartmentId = DepartmentId, Call = call }); @@ -543,6 +567,11 @@ public async Task UpdateCall(int callId) model.CallPriority = model.Call.Priority; model = await FillUpdateCallView(model); + if (model.Call.DispatchOn.HasValue) + { + model.ScheduleDispatchDate = model.Call.DispatchOn.Value.TimeConverter(model.Department); + } + if (!String.IsNullOrEmpty(model.Call.GeoLocationData)) { string[] loc = model.Call.GeoLocationData.Split(char.Parse(",")); @@ -829,6 +858,33 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio await _callsService.DeleteCallContactsAsync(call.CallId); call.Contacts = contacts; + // Handle scheduled dispatch + if (model.ScheduleDispatchDate.HasValue) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var dispatchUtc = DateTimeHelpers.ConvertToUtc( + DateTime.SpecifyKind(model.ScheduleDispatchDate.Value, DateTimeKind.Unspecified), + department.TimeZone); + + // Only validate and apply when the dispatch date actually changed + if (!call.DispatchOn.HasValue || dispatchUtc != call.DispatchOn.Value) + { + // Validate dispatch is at least 15 minutes in the future + if (dispatchUtc <= DateTime.UtcNow.AddMinutes(15)) + { + ModelState.AddModelError("ScheduleDispatchDate", _dispatchLocalizer["ScheduleDispatchValidationError"].Value); + return View("UpdateCall", model); + } + call.DispatchOn = dispatchUtc; + call.HasBeenDispatched = false; + } + } + else if (call.DispatchOn.HasValue) + { + // User cleared a previously scheduled dispatch + call.DispatchOn = null; + call.HasBeenDispatched = false; + } await _callsService.SaveCallAsync(call, cancellationToken); // Attach weather alerts as call notes if enabled (deduplication handled inside) @@ -971,7 +1027,7 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio } // Auto-dispatch newly added entities when RebroadcastCall is not checked - if (!model.RebroadcastCall) + if (!model.RebroadcastCall && shouldApplyDispatchStatuses) { if (newUserIds.Any() || newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) { @@ -987,7 +1043,7 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio } } - if (model.RebroadcastCall) + if (model.RebroadcastCall && shouldApplyDispatchStatuses) { var cqi = new CallQueueItem(); cqi.Call = call; @@ -2135,7 +2191,9 @@ public async Task GetActiveCallsForGrid() { var calls = new List(); - var activeCalls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)).OrderBy(x => x.LoggedOn); + var activeCalls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)) + .Where(x => !x.DispatchOn.HasValue || x.DispatchOn.Value <= DateTime.UtcNow || x.HasBeenDispatched == true) + .OrderBy(x => x.LoggedOn); var genericCall = new CallJson() { @@ -2440,7 +2498,9 @@ public async Task GetActiveCallsList() { List callsJson = new List(); - var calls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)).OrderByDescending(x => x.LoggedOn); + var calls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)) + .Where(x => !x.DispatchOn.HasValue || x.DispatchOn.Value <= DateTime.UtcNow || x.HasBeenDispatched == true) + .OrderByDescending(x => x.LoggedOn); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); foreach (var call in calls) @@ -2507,6 +2567,44 @@ public async Task GetArchivedCallsList(string year) return Json(callsJson); } + [HttpGet] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task GetScheduledCallsList() + { + List callsJson = new List(); + + var calls = (await _callsService.GetAllNonDispatchedScheduledCallsByDepartmentIdAsync(DepartmentId)) + .OrderBy(x => x.DispatchOn); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + + foreach (var call in calls) + { + var callJson = new CallListJson(); + callJson.CallId = call.CallId; + callJson.Number = call.Number; + callJson.Name = call.Name; + callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); + callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); + callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); + callJson.LoggedOn = new DateTimeOffset(DateTime.SpecifyKind(call.LoggedOn, DateTimeKind.Utc)).ToUnixTimeSeconds(); + callJson.DispatchOn = call.DispatchOn.HasValue ? new DateTimeOffset(DateTime.SpecifyKind(call.DispatchOn.Value, DateTimeKind.Utc)).ToUnixTimeSeconds() : 0; + callJson.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); + callJson.Color = await _callsService.CallPriorityToColorAsync(call.Priority, DepartmentId); + callJson.CanDeleteCall = await _authorizationService.CanUserDeleteCallAsync(UserId, call.CallId, DepartmentId); + callJson.CanCloseCall = await _authorizationService.CanUserCloseCallAsync(UserId, call.CallId, DepartmentId); + + if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || call.ReportingUserId == UserId) + callJson.CanUpdateCall = true; + else + callJson.CanUpdateCall = false; + + callsJson.Add(callJson); + } + + return Json(callsJson); + } + [HttpPost] public async Task AttachCallFile(FileAttachInput model, IFormFile fileToUpload, CancellationToken cancellationToken) { diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs index fd476d72..22644fdb 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Runtime.Serialization; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Model; @@ -40,6 +41,8 @@ public class NewCallView : BaseUserModel public string PrimaryContact { get; set; } public List AdditionalContacts { get; set; } public List DestinationPois { get; set; } + [System.ComponentModel.DataAnnotations.DisplayFormat(DataFormatString = "{0:MM/dd/yyyy HH:mm}", ApplyFormatInEditMode = true)] + public DateTime? ScheduleDispatchDate { get; set; } public NewCallView() { @@ -48,4 +51,4 @@ public NewCallView() DestinationPois = new List(); } } -} +} \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs index c174f060..f858dc65 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Model; using Resgrid.Model.Identity; @@ -36,6 +37,8 @@ public class UpdateCallView: BaseUserModel public int CallTemplateId { get; set; } public string NewCallFormData { get; set; } public List DestinationPois { get; set; } + [System.ComponentModel.DataAnnotations.DisplayFormat(DataFormatString = "{0:MM/dd/yyyy HH:mm}", ApplyFormatInEditMode = true)] + public DateTime? ScheduleDispatchDate { get; set; } public UpdateCallView() { @@ -43,4 +46,4 @@ public UpdateCallView() DestinationPois = new List(); } } -} +} \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs index 36c22415..69e24ebf 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs @@ -18,6 +18,7 @@ public class CallListJson public string Address { get; set; } public string Timestamp { get; set; } public long LoggedOn { get; set; } + public long DispatchOn { get; set; } public bool CanDeleteCall { get; set; } public bool CanCloseCall { get; set; } public bool CanUpdateCall { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml index 52a26317..97c7585d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/Dashboard.cshtml @@ -47,6 +47,7 @@ @localizer["Routes"] } @localizer["ArchivedCalls"] + @localizer["ScheduledCalls"] @if (ClaimsAuthorizationHelper.CanCreateCall()) { @localizer["NewCall"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index c7dd379c..394838f0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -453,14 +453,6 @@ - @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) - { -
-

@localizer["CustomFields"]

- @Html.Raw(Model.UdfFormHtml) -
- } -
@@ -473,6 +465,27 @@
+
+ +
+
+ + +
+ @localizer["ScheduleDispatchHelp"] +
+
+ + @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) + { +
+

@localizer["CustomFields"]

+ @Html.Raw(Model.UdfFormHtml) +
+ } +
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ScheduledCalls.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ScheduledCalls.cshtml new file mode 100644 index 00000000..550c1b17 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ScheduledCalls.cshtml @@ -0,0 +1,55 @@ +@using Resgrid.Model +@using Resgrid.Web.Helpers +@inject IStringLocalizer localizer +@inject IStringLocalizer commonLocalizer +@{ + ViewBag.Title = "Resgrid | " + @localizer["ScheduledCallsHeader"]; + Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; +} + + + + +
+
+
+
+
+
@localizer["ScheduledCallsHeader"]
+
+
+
+
+
+
+
+
+ +@section Scripts +{ + @await Html.PartialAsync("_DispatchLocalizationScript") + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index a6eaef03..ebbb5be7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -301,13 +301,6 @@ @localizer["NotifyCancelledHelp"]
- @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) - { -
-

@localizer["CustomFields"]

- @Html.Raw(Model.UdfFormHtml) -
- }
@@ -320,6 +313,26 @@
+
+ +
+
+ + +
+ @localizer["ScheduleDispatchHelp"] +
+
+ @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) + { +
+

@localizer["CustomFields"]

+ @Html.Raw(Model.UdfFormHtml) +
+ } +
@commonLocalizer["Cancel"] @@ -586,6 +599,15 @@ minimumResultsForSearch: 0, width: '100%' }); + + $('#scheduleDispatchDate').datetimepicker({ + format: 'm/d/Y H:i', + formatTime: 'H:i', + formatDate: 'm/d/Y', + step: 15, + minDate: 0, + defaultSelect: false + }); }); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.scheduledcalls.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.scheduledcalls.js new file mode 100644 index 00000000..79168cad --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.scheduledcalls.js @@ -0,0 +1,70 @@ +var resgrid; +(function (resgrid) { + var dispatch; + (function (dispatch) { + var scheduledcalls; + (function (scheduledcalls) { + var scheduledCallsTable; + function getText(key, fallback) { + return (resgrid.dispatch && typeof resgrid.dispatch.getText === 'function') + ? resgrid.dispatch.getText(key, fallback) + : fallback; + } + $(document).ready(function () { + resgrid.common.analytics.track('Dispatch Scheduled Calls'); + + scheduledCallsTable = $("#scheduledCallsList").DataTable({ + ajax: { + url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetScheduledCallsList', + dataSrc: '' + }, + pageLength: 50, + order: [[3, 'asc']], + columns: [ + { data: 'Number', title: getText('number', 'Number') }, + { data: 'Name', title: getText('name', 'Name') }, + { data: 'Timestamp', title: getText('timestamp', 'Timestamp') }, + { + data: 'DispatchOn', title: getText('scheduledDispatch', 'Scheduled Dispatch'), + render: function (data, type, row) { + if (data) { + var d = new Date(data * 1000); + return d.toLocaleString(); + } + return ''; + } + }, + { + data: 'Priority', title: getText('priority', 'Priority'), + render: function (data, type, row) { + return '' + row.Priority + ''; + } + }, + { + data: null, title: getText('state', 'State'), orderable: false, + render: function (data, type, row) { + return '' + row.State + ''; + } + }, + { + data: 'CallId', title: getText('actions', 'Actions'), orderable: false, + render: function (data, type, row) { + var html = '' + getText('view', 'View') + ' '; + if (row.CanUpdateCall) { + html += '' + getText('update', 'Update') + ' '; + } + if (row.CanCloseCall) { + html += '' + getText('close', 'Close') + ' '; + } + if (row.CanDeleteCall) { + html += '' + getText('delete', 'Delete') + ''; + } + return html; + } + } + ] + }); + }); + })(scheduledcalls = dispatch.scheduledcalls || (dispatch.scheduledcalls = {})); + })(dispatch = resgrid.dispatch || (resgrid.dispatch = {})); +})(resgrid || (resgrid = {}));