From d28acd616bc014340ceab31baf6318debacc55d0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 17:51:02 -0700 Subject: [PATCH 1/4] RE1-T115 TTS fixes --- Web/Resgrid.Web.Tts/Program.cs | 39 +- Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj | 1 - .../Services/AudioProcessingService.cs | 12 +- .../Services/ITextPreprocessor.cs | 15 + .../Services/S3StorageService.cs | 774 ++++++++---------- .../Services/TextPreprocessor.cs | 387 +++++++++ Web/Resgrid.Web.Tts/Services/TtsService.cs | 7 +- 7 files changed, 751 insertions(+), 484 deletions(-) create mode 100644 Web/Resgrid.Web.Tts/Services/ITextPreprocessor.cs create mode 100644 Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs diff --git a/Web/Resgrid.Web.Tts/Program.cs b/Web/Resgrid.Web.Tts/Program.cs index 2dadf2d7..9a609dcb 100644 --- a/Web/Resgrid.Web.Tts/Program.cs +++ b/Web/Resgrid.Web.Tts/Program.cs @@ -1,6 +1,3 @@ -using Amazon; -using Amazon.Runtime; -using Amazon.S3; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; @@ -106,12 +103,8 @@ await context.HttpContext.Response.WriteAsJsonAsync( }; }); -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - return CreateS3Client(options); -}); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -156,34 +149,4 @@ await context.HttpContext.Response.WriteAsJsonAsync( app.Run(); -static AmazonS3Client CreateS3Client(S3StorageOptions options) -{ - var credentials = new BasicAWSCredentials(options.AccessKey, options.SecretKey); - var config = new AmazonS3Config - { - ForcePathStyle = options.ForcePathStyle, - AuthenticationRegion = options.Region - }; - - if (!string.IsNullOrWhiteSpace(options.Endpoint)) - { - if (Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri)) - { - config.ServiceURL = endpointUri.GetLeftPart(UriPartial.Authority); - config.UseHttp = endpointUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); - } - else - { - config.ServiceURL = $"{(options.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp)}://{options.Endpoint}"; - config.UseHttp = !options.UseSsl; - } - } - else - { - config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region); - } - - return new AmazonS3Client(credentials, config); -} - public partial class Program; diff --git a/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj b/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj index a8611a39..49fb2ad8 100644 --- a/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj +++ b/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj @@ -10,7 +10,6 @@ - diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index f3a35a87..6536649e 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -9,20 +9,23 @@ namespace Resgrid.Web.Tts.Services public sealed class AudioProcessingService : IAudioProcessingService { private const string MbrolaEnglishVoice = "mb-us1"; - private const int MbrolaEnglishSpeed = 130; + private const int MbrolaEnglishSpeed = 140; private const int MbrolaEnglishPitch = 50; - private const int MbrolaEnglishWordGap = 3; + private const int MbrolaEnglishWordGap = 2; private const string TelephoneAudioFilter = "highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1"; private readonly TtsOptions _options; private readonly ILogger _logger; + private readonly ITextPreprocessor _textPreprocessor; public AudioProcessingService( IOptions options, - ILogger logger) + ILogger logger, + ITextPreprocessor textPreprocessor) { _options = options.Value; _logger = logger; + _textPreprocessor = textPreprocessor; } public async Task GenerateNormalizedWavAsync(string text, string voice, int speed, CancellationToken cancellationToken) @@ -36,7 +39,8 @@ public async Task GenerateNormalizedWavAsync(string text, string voice, try { - await RunEspeakAsync(text, voice, speed, rawFilePath, cancellationToken); + var preprocessedText = _textPreprocessor.Preprocess(text, voice); + await RunEspeakAsync(preprocessedText, voice, speed, rawFilePath, cancellationToken); await RunFfmpegAsync(rawFilePath, normalizedFilePath, cancellationToken); return await File.ReadAllBytesAsync(normalizedFilePath, cancellationToken); diff --git a/Web/Resgrid.Web.Tts/Services/ITextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/ITextPreprocessor.cs new file mode 100644 index 00000000..3f4885a7 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/ITextPreprocessor.cs @@ -0,0 +1,15 @@ +namespace Resgrid.Web.Tts.Services +{ + /// + /// Preprocesses dispatch text before it is sent to the TTS engine so that + /// common abbreviations, codes, and jargon are spoken intelligibly. + /// + public interface ITextPreprocessor + { + /// + /// Normalises the input text for the given voice / language so that eSpeak + /// (or any downstream TTS engine) produces the most natural speech. + /// + string Preprocess(string text, string voice); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index 1845e54e..bbae8e17 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -1,502 +1,434 @@ -using Amazon.Runtime; -using Amazon.S3; -using Amazon.S3.Model; -using Microsoft.Extensions.Logging; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; using Microsoft.Extensions.Options; using Resgrid.Web.Tts.Configuration; -using System.Net; -using System.Net.Http; namespace Resgrid.Web.Tts.Services { public sealed class S3StorageService : IStorageService { private const int MaxRetryAttempts = 3; - private const int PresignedPutUrlExpiryMinutes = 5; + private const int PresignedUrlExpiryMinutes = 5; + + // The AWS SDK could not parse the Expiration header value "2026-05-04T00:00:00Z" + // returned by RustFS / MinIO. We handle all header parsing ourselves to avoid + // this class of failure entirely. + private static readonly string[] Iso8601Formats = + { + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fZ", + "yyyy-MM-ddTHH:mm:ss.ffZ", + "yyyy-MM-ddTHH:mm:ss.fffZ", + "yyyy-MM-ddTHH:mm:ss.ffffZ", + "yyyy-MM-ddTHH:mm:ss.fffffZ", + "yyyy-MM-ddTHH:mm:ss.ffffffZ", + "yyyy-MM-ddTHH:mm:ss.fffffffZ", + "yyyy-MM-ddTHH:mm:sszzz", + "yyyy-MM-ddTHH:mm:ss.fzzz", + "yyyy-MM-ddTHH:mm:ss.ffzzz", + "yyyy-MM-ddTHH:mm:ss.fffzzz", + "yyyy-MM-ddTHH:mm:ss.ffffzzz", + "yyyy-MM-ddTHH:mm:ss.fffffzzz", + "yyyy-MM-ddTHH:mm:ss.ffffffzzz", + "yyyy-MM-ddTHH:mm:ss.fffffffzzz", + }; - private readonly IAmazonS3 _s3Client; + private readonly IHttpClientFactory _httpClientFactory; private readonly S3StorageOptions _options; private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; public S3StorageService( - IAmazonS3 s3Client, + IHttpClientFactory httpClientFactory, IOptions options, - ILogger logger, - IHttpClientFactory httpClientFactory) + ILogger logger) { - _s3Client = s3Client; + _httpClientFactory = httpClientFactory; _options = options.Value; _logger = logger; - _httpClientFactory = httpClientFactory; } + // ----------------------------------------------------------------- + // IStorageService implementation + // ----------------------------------------------------------------- + public async Task ExistsAsync(string objectKey, CancellationToken cancellationToken) { try { - await ExecuteWithRetryAsync( - () => _s3Client.GetObjectMetadataAsync( - new GetObjectMetadataRequest - { - BucketName = _options.Bucket, - Key = objectKey - }, - cancellationToken), - $"checking metadata for {objectKey}", + using var response = await SendSignedRequestAsync( + HttpMethod.Head, + objectKey, + content: null, + contentType: null, cancellationToken); - return true; + return response.StatusCode switch + { + HttpStatusCode.OK => true, + HttpStatusCode.NotFound => false, + _ => throw new HttpRequestException( + $"HEAD {objectKey} returned unexpected status {(int)response.StatusCode}.", + null, + response.StatusCode) + }; } - catch (AmazonS3Exception ex) when (IsNotFound(ex)) + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return false; } - catch (AmazonUnmarshallingException ex) when (ex.InnerException is FormatException formatException) - { - return await HandleMalformedMetadataResponseAsync(objectKey, ex, formatException, cancellationToken); - } - catch (FormatException ex) - { - return await HandleMalformedMetadataResponseAsync(objectKey, ex, cancellationToken); - } } - private Task HandleMalformedMetadataResponseAsync( - string objectKey, - AmazonUnmarshallingException exception, - FormatException formatException, - CancellationToken cancellationToken) + public async Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) { - return HandleMalformedMetadataResponseAsync( - objectKey, - exception, - formatException, - exception.LastKnownLocation ?? "unknown", - cancellationToken, - wrappedByAmazonUnmarshallingException: true); - } + using var memoryStream = new MemoryStream(); + await content.CopyToAsync(memoryStream, cancellationToken); + var payload = memoryStream.ToArray(); - private Task HandleMalformedMetadataResponseAsync( - string objectKey, - FormatException exception, - CancellationToken cancellationToken) - { - return HandleMalformedMetadataResponseAsync( - objectKey, - exception, - exception, - "unknown", - cancellationToken, - wrappedByAmazonUnmarshallingException: false); + await ExecuteWithRetryAsync( + async () => + { + using var response = await SendSignedRequestAsync( + HttpMethod.Put, + objectKey, + payload, + contentType, + cancellationToken); + + response.EnsureSuccessStatusCode(); + }, + $"uploading {objectKey}", + cancellationToken); } - private async Task HandleMalformedMetadataResponseAsync( - string objectKey, - Exception exception, - FormatException formatException, - string lastKnownLocation, - CancellationToken cancellationToken, - bool wrappedByAmazonUnmarshallingException) + public async Task GetObjectAsync(string objectKey, CancellationToken cancellationToken) { - if (wrappedByAmazonUnmarshallingException) + try { - _logger.LogWarning( - exception, - "The S3 client could not parse the metadata response for {ObjectKey}. Verifying existence with a presigned HEAD request. Inner format error: {InnerFormatErrorMessage}", + using var response = await SendSignedRequestAsync( + HttpMethod.Get, objectKey, - formatException.Message); - } - else - { - _logger.LogWarning( - exception, - "The S3 client surfaced a raw FormatException while parsing the metadata response for {ObjectKey}. Verifying existence with a presigned HEAD request because the AWS SDK did not wrap the parsing failure in an AmazonUnmarshallingException.", - objectKey); - } + content: null, + contentType: null, + cancellationToken); - _logger.LogDebug( - formatException, - "FormatException while parsing the metadata response for {ObjectKey}. Last known location: {LastKnownLocation}.", - objectKey, - lastKnownLocation); + response.EnsureSuccessStatusCode(); - try - { - var exists = await ExistsWithPresignedHeadAsync(objectKey, cancellationToken); + var responseContentType = response.Content.Headers.ContentType?.ToString(); + var contentType = string.IsNullOrWhiteSpace(responseContentType) + ? "audio/wav" + : responseContentType; - _logger.LogDebug( - "Presigned HEAD verification after the metadata parsing failure reported that {ObjectKey} {ExistenceState}.", - objectKey, - exists ? "exists" : "does not exist"); + var entityTag = response.Headers.ETag?.Tag; + if (string.IsNullOrWhiteSpace(entityTag)) + { + entityTag = response.Content.Headers.TryGetValues("ETag", out var etagValues) + ? string.Join(",", etagValues) + : null; + } - return exists; - } - catch (AmazonServiceException verificationException) - { - _logger.LogWarning( - verificationException, - "Unable to verify whether {ObjectKey} exists after the metadata parsing failure. Assuming the object exists because S3 returned a response before the unmarshalling error.", - objectKey); - } - catch (HttpRequestException verificationException) - { - _logger.LogWarning( - verificationException, - "Unable to verify whether {ObjectKey} exists after the metadata parsing failure due to connectivity. Assuming the object exists because S3 returned a response before the unmarshalling error.", - objectKey); - } - catch (TaskCanceledException verificationException) when (!cancellationToken.IsCancellationRequested) - { - _logger.LogWarning( - verificationException, - "Unable to verify whether {ObjectKey} exists after the metadata parsing failure due to timeout. Assuming the object exists because S3 returned a response before the unmarshalling error.", - objectKey); - } - catch (IOException verificationException) - { - _logger.LogWarning( - verificationException, - "Unable to verify whether {ObjectKey} exists after the metadata parsing failure due to IO. Assuming the object exists because S3 returned a response before the unmarshalling error.", - objectKey); - } + var lastModified = response.Content.Headers.LastModified + ?? DateTimeOffset.UtcNow; - // The metadata request reached S3 and only failed while the SDK parsed the response. - // If the explicit presigned HEAD verification also fails, preserve the best-effort - // behavior and assume the object exists so callers such as CacheService understand - // this path can still return an optimistic result. - return true; - } + var audioBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); - public async Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) - { - var payload = await ReadContentBytesAsync(content, cancellationToken); - - try - { - await ExecuteWithRetryAsync( - () => UploadWithSdkAsync(objectKey, payload, contentType, cancellationToken), - $"uploading {objectKey}", - cancellationToken); + return new TtsAudioContent( + audioBytes, + contentType, + NormalizeEntityTag(entityTag), + lastModified); } - catch (FormatException ex) + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { - await HandleMalformedPutResponseAsync(objectKey, payload, contentType, ex, cancellationToken); + return null; } } - private async Task HandleMalformedPutResponseAsync( - string objectKey, - byte[] payload, - string contentType, - FormatException exception, - CancellationToken cancellationToken) + public Task GetObjectUrlAsync(string objectKey, CancellationToken cancellationToken) { - _logger.LogWarning( - exception, - "The S3 client could not parse the PUT response for {ObjectKey}. Verifying whether the upload persisted before falling back to a presigned PUT upload.", - objectKey); - - if (await WasUploadPersistedAsync(objectKey, cancellationToken)) + // If a public base URL is configured, use it directly. + if (!string.IsNullOrWhiteSpace(_options.PublicBaseUrl)) { - _logger.LogInformation( - "The upload for {ObjectKey} was verified after the PUT response parsing failure. Treating the upload as successful.", - objectKey); - - return; + return Task.FromResult(new Uri($"{_options.PublicBaseUrl.TrimEnd('/')}/{objectKey}")); } - _logger.LogWarning( - "The upload for {ObjectKey} could not be verified after the PUT response parsing failure. Retrying with a presigned PUT upload.", - objectKey); - - await UploadWithPresignedUrlAsync(objectKey, payload, contentType, cancellationToken); - } - - private async Task WasUploadPersistedAsync(string objectKey, CancellationToken cancellationToken) - { - try - { - return await ExistsAsync(objectKey, cancellationToken); - } - catch (AmazonServiceException ex) - { - _logger.LogWarning( - ex, - "Unable to verify whether {ObjectKey} exists after the PUT response parsing failure. Falling back to a presigned PUT upload.", - objectKey); - } - catch (HttpRequestException ex) - { - _logger.LogWarning( - ex, - "Unable to verify whether {ObjectKey} exists after the PUT response parsing failure due to connectivity. Falling back to a presigned PUT upload.", - objectKey); - } - catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) - { - _logger.LogWarning( - ex, - "Unable to verify whether {ObjectKey} exists after the PUT response parsing failure due to timeout. Falling back to a presigned PUT upload.", - objectKey); - } - catch (IOException ex) + if (_options.UsePresignedUrls) { - _logger.LogWarning( - ex, - "Unable to verify whether {ObjectKey} exists after the PUT response parsing failure due to IO. Falling back to a presigned PUT upload.", - objectKey); + var url = CreatePresignedGetUrl(objectKey); + return Task.FromResult(new Uri(url)); } - return false; + return Task.FromResult(BuildDirectObjectUrl(objectKey)); } - private Task UploadWithSdkAsync(string objectKey, byte[] payload, string contentType, CancellationToken cancellationToken) - { - return _s3Client.PutObjectAsync( - new PutObjectRequest - { - BucketName = _options.Bucket, - Key = objectKey, - InputStream = new MemoryStream(payload, writable: false), - ContentType = contentType - }, - cancellationToken); - } + // ----------------------------------------------------------------- + // HTTP request helpers + // ----------------------------------------------------------------- - private async Task UploadWithPresignedUrlAsync(string objectKey, byte[] payload, string contentType, CancellationToken cancellationToken) + private async Task SendSignedRequestAsync( + HttpMethod method, + string objectKey, + byte[]? content, + string? contentType, + CancellationToken cancellationToken) { - var client = _httpClientFactory.CreateClient(nameof(S3StorageService)); + var url = BuildObjectUrl(objectKey); + using var request = new HttpRequestMessage(method, url); - for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) - { - using var request = new HttpRequestMessage(HttpMethod.Put, CreatePresignedPutUrl(objectKey, contentType)); - request.Content = new ByteArrayContent(payload); + // Add the Date header for SigV4 signing. + var now = DateTimeOffset.UtcNow; + request.Headers.Add("x-amz-date", now.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture)); + if (content is not null) + { + request.Content = new ByteArrayContent(content); if (!string.IsNullOrWhiteSpace(contentType)) { - request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); } - try - { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + // Compute and add the content SHA-256 header. + var payloadHash = HexSha256(content); + request.Headers.Add("x-amz-content-sha256", payloadHash); + request.Content.Headers.Add("x-amz-content-sha256", payloadHash); + } + else + { + request.Headers.Add("x-amz-content-sha256", "UNSIGNED-PAYLOAD"); + } - if (response.IsSuccessStatusCode) - { - return; - } + // Add the Host header required by SigV4. + request.Headers.Host = GetHost(); - var exception = new HttpRequestException( - $"Presigned PUT upload for {objectKey} failed with status code {(int)response.StatusCode}.", - null, - response.StatusCode); + // Compute and add the Authorization header. + var scope = BuildScope(now); + var signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + var signature = CalculateSignature(method, objectKey, content, contentType, now, scope, signedHeaders); + var credential = $"{_options.AccessKey}/{scope}"; - if (attempt < MaxRetryAttempts && IsTransientStatusCode(response.StatusCode)) - { - await DelayRetryAsync($"uploading {objectKey} via presigned PUT", attempt, exception, cancellationToken); - continue; - } + request.Headers.TryAddWithoutValidation( + "Authorization", + $"AWS4-HMAC-SHA256 Credential={credential},SignedHeaders={signedHeaders},Signature={signature}"); - response.EnsureSuccessStatusCode(); - } - catch (HttpRequestException ex) when (attempt < MaxRetryAttempts && (!ex.StatusCode.HasValue || IsTransientStatusCode(ex.StatusCode.Value))) - { - await DelayRetryAsync($"uploading {objectKey} via presigned PUT", attempt, ex, cancellationToken); - } - catch (IOException ex) when (attempt < MaxRetryAttempts) - { - await DelayRetryAsync($"uploading {objectKey} via presigned PUT", attempt, ex, cancellationToken); - } - catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < MaxRetryAttempts) - { - await DelayRetryAsync($"uploading {objectKey} via presigned PUT", attempt, ex, cancellationToken); - } - } + return await CreateClient().SendAsync(request, cancellationToken); + } - throw new InvalidOperationException($"Presigned PUT upload retry loop terminated unexpectedly for {objectKey}."); + private string CreatePresignedGetUrl(string objectKey) + { + var now = DateTimeOffset.UtcNow; + var expires = PresignedUrlExpiryMinutes * 60; + var scope = BuildScope(now); + var signedHeaders = "host"; + + var canonicalUri = BuildCanonicalUri(objectKey); + var host = GetHost(); + + // Query params for presigned URL (must be sorted). + var queryParams = new SortedDictionary(StringComparer.Ordinal) + { + ["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256", + ["X-Amz-Credential"] = $"{_options.AccessKey}/{scope}", + ["X-Amz-Date"] = now.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture), + ["X-Amz-Expires"] = expires.ToString(CultureInfo.InvariantCulture), + ["X-Amz-SignedHeaders"] = signedHeaders, + }; + + var canonicalQueryString = string.Join("&", + queryParams.Select(kvp => $"{UrlEncode(kvp.Key)}={UrlEncode(kvp.Value)}")); + + // Null content for presigned GET — payload hash is UNSIGNED-PAYLOAD. + var signature = CalculateSignature( + HttpMethod.Get, + objectKey, + content: null, + contentType: null, + now, + scope, + signedHeaders, + canonicalQueryStringOverride: canonicalQueryString); + + // Build final URL. + var baseUrl = BuildObjectUrl(objectKey); + return $"{baseUrl}?{canonicalQueryString}&X-Amz-Signature={signature}"; } - private string CreatePresignedHeadUrl(string objectKey) + // ----------------------------------------------------------------- + // Signature V4 helpers + // ----------------------------------------------------------------- + + private string CalculateSignature( + HttpMethod method, + string objectKey, + byte[]? content, + string? contentType, + DateTimeOffset now, + string scope, + string signedHeaders, + string? canonicalQueryStringOverride = null) { - return _s3Client.GetPreSignedURL(new GetPreSignedUrlRequest - { - BucketName = _options.Bucket, - Key = objectKey, - Verb = HttpVerb.HEAD, - Protocol = GetPresignedUrlProtocol(), - Expires = DateTime.UtcNow.AddMinutes(PresignedPutUrlExpiryMinutes) - }); + var canonicalUri = BuildCanonicalUri(objectKey); + var canonicalQueryString = canonicalQueryStringOverride ?? string.Empty; + + var canonicalHeaders = + $"host:{GetHost()}\n" + + $"x-amz-content-sha256:{(content is not null ? HexSha256(content) : "UNSIGNED-PAYLOAD")}\n" + + $"x-amz-date:{now:yyyyMMddTHHmmssZ}\n"; + + var payloadHash = content is not null ? HexSha256(content) : "UNSIGNED-PAYLOAD"; + + var canonicalRequest = string.Join('\n', + method.Method, + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + payloadHash); + + var stringToSign = string.Join('\n', + "AWS4-HMAC-SHA256", + now.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture), + scope, + HexSha256(Encoding.UTF8.GetBytes(canonicalRequest))); + + var signingKey = DeriveSigningKey(now); + var signature = HmacSha256(signingKey, Encoding.UTF8.GetBytes(stringToSign)); + + return Convert.ToHexString(signature).ToLowerInvariant(); } - private async Task ExistsWithPresignedHeadAsync(string objectKey, CancellationToken cancellationToken) + private byte[] DeriveSigningKey(DateTimeOffset now) { - var client = _httpClientFactory.CreateClient(nameof(S3StorageService)); + var date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture); - for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) - { - using var request = new HttpRequestMessage(HttpMethod.Head, CreatePresignedHeadUrl(objectKey)); + var kDate = HmacSha256( + Encoding.UTF8.GetBytes($"AWS4{_options.SecretKey}"), + Encoding.UTF8.GetBytes(date)); - try - { - using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var kRegion = HmacSha256(kDate, Encoding.UTF8.GetBytes(_options.Region)); - if (response.StatusCode == HttpStatusCode.NotFound) - { - return false; - } + var kService = HmacSha256(kRegion, Encoding.UTF8.GetBytes("s3")); - if (response.IsSuccessStatusCode) - { - return true; - } + return HmacSha256(kService, Encoding.UTF8.GetBytes("aws4_request")); + } - var exception = new HttpRequestException( - $"Presigned HEAD existence check for {objectKey} failed with status code {(int)response.StatusCode}.", - null, - response.StatusCode); + // ----------------------------------------------------------------- + // URL builders + // ----------------------------------------------------------------- - if (attempt < MaxRetryAttempts && IsTransientStatusCode(response.StatusCode)) - { - await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, exception, cancellationToken); - continue; - } + private string BuildObjectUrl(string objectKey) + { + var endpointUri = GetEndpointUri(); + var authority = endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; - response.EnsureSuccessStatusCode(); - } - catch (HttpRequestException ex) when (attempt < MaxRetryAttempts && (!ex.StatusCode.HasValue || IsTransientStatusCode(ex.StatusCode.Value))) - { - await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, ex, cancellationToken); - } - catch (IOException ex) when (attempt < MaxRetryAttempts) - { - await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, ex, cancellationToken); - } - catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < MaxRetryAttempts) - { - await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, ex, cancellationToken); - } + if (_options.ForcePathStyle) + { + return $"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{objectKey}"; } - throw new InvalidOperationException($"Presigned HEAD existence check retry loop terminated unexpectedly for {objectKey}."); + return $"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{objectKey}"; } - private string CreatePresignedPutUrl(string objectKey, string contentType) + private Uri BuildDirectObjectUrl(string objectKey) { - return _s3Client.GetPreSignedURL(new GetPreSignedUrlRequest + if (!string.IsNullOrWhiteSpace(_options.Endpoint)) { - BucketName = _options.Bucket, - Key = objectKey, - Verb = HttpVerb.PUT, - Protocol = GetPresignedUrlProtocol(), - ContentType = contentType, - Expires = DateTime.UtcNow.AddMinutes(PresignedPutUrlExpiryMinutes) - }); - } + var endpointUri = GetEndpointUri(); + var authority = endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; - private static async Task ReadContentBytesAsync(Stream content, CancellationToken cancellationToken) - { - if (content.CanSeek) - { - content.Position = 0; + if (_options.ForcePathStyle) + { + return new Uri($"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{objectKey}"); + } + + return new Uri($"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{objectKey}"); } - using var memoryStream = new MemoryStream(); - await content.CopyToAsync(memoryStream, cancellationToken); - return memoryStream.ToArray(); + return new Uri($"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{objectKey}"); } - public async Task GetObjectAsync(string objectKey, CancellationToken cancellationToken) - { - try - { - using var response = await ExecuteWithRetryAsync( - () => _s3Client.GetObjectAsync( - new GetObjectRequest - { - BucketName = _options.Bucket, - Key = objectKey - }, - cancellationToken), - $"downloading {objectKey}", - cancellationToken); + private string BuildCanonicalUri(string objectKey) => $"/{_options.Bucket}/{objectKey}"; - await using var responseStream = response.ResponseStream; - using var memoryStream = new MemoryStream(); - await responseStream.CopyToAsync(memoryStream, cancellationToken); + private string GetHost() + { + var endpointUri = GetEndpointUri(); - var audioBytes = memoryStream.ToArray(); - var contentType = string.IsNullOrWhiteSpace(response.Headers.ContentType) - ? "audio/wav" - : response.Headers.ContentType; - var entityTag = string.IsNullOrWhiteSpace(response.ETag) - ? CreateEntityTag(audioBytes) - : NormalizeEntityTag(response.ETag); - var lastModified = response.LastModified == default - ? DateTimeOffset.UtcNow - : new DateTimeOffset(DateTime.SpecifyKind(response.LastModified, DateTimeKind.Utc)); - - return new TtsAudioContent(audioBytes, contentType, entityTag, lastModified); - } - catch (AmazonS3Exception ex) when (IsNotFound(ex)) + if (_options.ForcePathStyle) { - return null; + return endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; } + + return endpointUri.IsDefaultPort + ? $"{_options.Bucket}.{endpointUri.Host}" + : $"{_options.Bucket}.{endpointUri.Host}:{endpointUri.Port}"; } - public Task GetObjectUrlAsync(string objectKey, CancellationToken cancellationToken) + private Uri GetEndpointUri() { - if (!string.IsNullOrWhiteSpace(_options.PublicBaseUrl)) + if (Uri.TryCreate(_options.Endpoint, UriKind.Absolute, out var uri)) { - return Task.FromResult(new Uri($"{_options.PublicBaseUrl.TrimEnd('/')}/{objectKey}")); + return uri; } - if (_options.UsePresignedUrls) - { - var url = _s3Client.GetPreSignedURL(new GetPreSignedUrlRequest - { - BucketName = _options.Bucket, - Key = objectKey, - Protocol = GetPresignedUrlProtocol(), - Expires = DateTime.UtcNow.AddMinutes(_options.PresignedUrlExpiryMinutes) - }); - - return Task.FromResult(new Uri(url)); - } + var scheme = _options.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + return new Uri($"{scheme}://{_options.Endpoint}"); + } - return Task.FromResult(BuildDirectObjectUrl(objectKey)); + private string BuildScope(DateTimeOffset now) + { + return string.Concat( + now.ToString("yyyyMMdd", CultureInfo.InvariantCulture), + "/", + _options.Region, + "/s3/aws4_request"); } - private async Task ExecuteWithRetryAsync(Func> operation, string operationName, CancellationToken cancellationToken) + // ----------------------------------------------------------------- + // Retry helpers + // ----------------------------------------------------------------- + + private async Task ExecuteWithRetryAsync( + Func operation, + string operationName, + CancellationToken cancellationToken) { for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) { try { - return await operation(); - } - catch (AmazonS3Exception ex) when (IsNotFound(ex)) - { - throw; + await operation(); + return; } - catch (AmazonServiceException ex) when (attempt < MaxRetryAttempts && IsTransient(ex)) + catch (HttpRequestException ex) when (attempt < MaxRetryAttempts && IsTransientStatusCode(ex.StatusCode)) { await DelayRetryAsync(operationName, attempt, ex, cancellationToken); } - catch (HttpRequestException ex) when (attempt < MaxRetryAttempts) + catch (IOException ex) when (attempt < MaxRetryAttempts) { await DelayRetryAsync(operationName, attempt, ex, cancellationToken); } - catch (IOException ex) when (attempt < MaxRetryAttempts) + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < MaxRetryAttempts) { await DelayRetryAsync(operationName, attempt, ex, cancellationToken); } } - throw new InvalidOperationException($"S3 operation retry loop terminated unexpectedly for {operationName}."); + throw new InvalidOperationException( + $"S3 operation retry loop terminated unexpectedly for {operationName}."); } - private async Task DelayRetryAsync(string operationName, int attempt, Exception exception, CancellationToken cancellationToken) + private async Task DelayRetryAsync( + string operationName, + int attempt, + Exception exception, + CancellationToken cancellationToken) { var delay = TimeSpan.FromMilliseconds(150 * Math.Pow(2, attempt - 1)); @@ -510,89 +442,61 @@ private async Task DelayRetryAsync(string operationName, int attempt, Exception await Task.Delay(delay, cancellationToken); } - private bool IsTransient(AmazonServiceException exception) + private static bool IsTransientStatusCode(HttpStatusCode? statusCode) { - return IsTransientStatusCode(exception.StatusCode) - || exception.InnerException is HttpRequestException - || exception.InnerException is IOException; + return statusCode.HasValue + && (statusCode == HttpStatusCode.RequestTimeout + || statusCode == HttpStatusCode.TooManyRequests + || (int)statusCode >= 500); } - private static bool IsTransientStatusCode(HttpStatusCode statusCode) - { - return statusCode == HttpStatusCode.RequestTimeout - || statusCode == HttpStatusCode.TooManyRequests - || (int)statusCode >= 500; - } + // ----------------------------------------------------------------- + // HttpClient management + // ----------------------------------------------------------------- - private static bool IsNotFound(AmazonS3Exception exception) - { - return exception.StatusCode == HttpStatusCode.NotFound - || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase) - || string.Equals(exception.ErrorCode, "NotFound", StringComparison.OrdinalIgnoreCase); - } + private HttpClient CreateClient() + { + var client = _httpClientFactory.CreateClient(nameof(S3StorageService)); - private Protocol GetPresignedUrlProtocol() - { - if (Uri.TryCreate(_options.Endpoint, UriKind.Absolute, out var endpointUri)) - { - if (string.Equals(endpointUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) - { - return Protocol.HTTP; - } + // Set a reasonable timeout. This should be generous enough for + // large audio file uploads / downloads while still failing fast + // on a genuinely hung connection. + client.Timeout = TimeSpan.FromMinutes(2); - if (string.Equals(endpointUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - return Protocol.HTTPS; - } - } + return client; + } - return _options.UseSsl ? Protocol.HTTPS : Protocol.HTTP; - } + // ----------------------------------------------------------------- + // Crypto / encoding helpers - private Uri BuildDirectObjectUrl(string objectKey) + private static byte[] HmacSha256(byte[] key, byte[] data) { - if (!string.IsNullOrWhiteSpace(_options.Endpoint)) - { - var endpointUri = GetEndpointUri(); - var authority = endpointUri.IsDefaultPort - ? endpointUri.Host - : $"{endpointUri.Host}:{endpointUri.Port}"; - - if (_options.ForcePathStyle) - { - return new Uri($"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{objectKey}"); - } + using var hmac = new HMACSHA256(key); + return hmac.ComputeHash(data); + } - return new Uri($"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{objectKey}"); - } + private static string HexSha256(byte[] data) + { + return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + } - return new Uri($"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{objectKey}"); + private static string UrlEncode(string value) + { + return Uri.EscapeDataString(value); } - private Uri GetEndpointUri() + private static string NormalizeEntityTag(string? entityTag) { - if (Uri.TryCreate(_options.Endpoint, UriKind.Absolute, out var uri)) + if (string.IsNullOrWhiteSpace(entityTag)) { - return uri; + return $"\"{Guid.NewGuid():N}\""; } - var scheme = _options.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; - return new Uri($"{scheme}://{_options.Endpoint}"); - } - - private static string NormalizeEntityTag(string entityTag) - { var trimmed = entityTag.Trim(); - return trimmed.StartsWith("\"", StringComparison.Ordinal) + return trimmed.StartsWith('"') ? trimmed - : $"\"{trimmed.Trim('\"')}\""; - } - - private static string CreateEntityTag(byte[] audioBytes) - { - using var sha256 = System.Security.Cryptography.SHA256.Create(); - return $"\"{Convert.ToHexString(sha256.ComputeHash(audioBytes)).ToLowerInvariant()}\""; + : $"\"{trimmed}\""; } } } diff --git a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs new file mode 100644 index 00000000..664f2d62 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs @@ -0,0 +1,387 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Resgrid.Web.Tts.Services +{ + /// + /// Transforms dispatch jargon, abbreviations, and codes into expanded, + /// pronounceable English that eSpeak NG renders clearly. + /// + /// The preprocessor runs before the cache key is computed so that + /// two requests that differ only by abbreviation style share the same + /// synthesised audio. + /// + /// Expansion rules are deliberately conservative — we only touch terms + /// that eSpeak routinely gets wrong. Everything else is passed through + /// unchanged. + /// + public sealed partial class TextPreprocessor : ITextPreprocessor + { + // --------------------------------------------------------------- + // Fire / EMS / Police dispatch abbreviations + // Ordered longest-first so "HAZMAT" matches before "MAT". + // --------------------------------------------------------------- + private static readonly Dictionary AbbreviationMap = new(StringComparer.Ordinal) + { + // Patient / incident descriptors + { "SFD", "Single Family Dwelling" }, + { "MFD", "Multi-Family Dwelling" }, + { "MCI", "Mass Casualty Incident" }, + { "MVC", "Motor Vehicle Collision" }, + { "MVA", "Motor Vehicle Accident" }, + { "PI", "Personal Injury" }, + { "GSW", "Gunshot Wound" }, + { "DOA", "Dead on Arrival" }, + { "CPR", "Cardio Pulmonary Resuscitation" }, + { "AED", "Automated External Defibrillator" }, + { "CO", "Carbon Monoxide" }, + { "UTL", "Unable to Locate" }, + { "ETA", "Estimated Time of Arrival" }, + + // Service types + { "ALS", "Advanced Life Support" }, + { "BLS", "Basic Life Support" }, + { "EMS", "Emergency Medical Services" }, + { "ALSEMS","Advanced Life Support Emergency Medical Services" }, + + // Agencies + { "HAZMAT","Hazardous Materials" }, + { "HazMat","Hazardous Materials" }, + { "WMD", "Weapons of Mass Destruction" }, + { "PD", "Police Department" }, + { "FD", "Fire Department" }, + { "SO", "Sheriff's Office" }, + { "SAR", "Search and Rescue" }, + + // Incident command + { "IC", "Incident Command" }, + { "PIO", "Public Information Officer" }, + { "POV", "Personally Owned Vehicle" }, + + // Firefighting equipment / tactics + { "SCBA", "Self-Contained Breathing Apparatus" }, + { "PASS", "Personal Alert Safety System" }, + { "RIT", "Rapid Intervention Team" }, + { "PPE", "Personal Protective Equipment" }, + { "PAR", "Personnel Accountability Report" }, + + // Medical + { "DNR", "Do Not Resuscitate" }, + { "CPAP", "Continuous Positive Airway Pressure" }, + { "BVM", "Bag Valve Mask" }, + + // Command / operations + { "SOP", "Standard Operating Procedure" }, + { "SME", "Subject Matter Expert" }, + + // Miscellaneous + { "FAQ", "Frequently Asked Questions" }, + }; + + // --------------------------------------------------------------- + // CAD / dispatch shorthand that eSpeak reads letter-by-letter + // or mispronounces as garbled words. These are the raw tokens + // that appear in CAD-to-email or CAD-to-API dispatch feeds. + // Ordered longest-first. + // --------------------------------------------------------------- + private static readonly Dictionary DispatchShorthandMap = new(StringComparer.Ordinal) + { + // Transport & entrapment + { "XPORT", "Transport" }, + { "ENTRP", "Entrapment" }, + + // Structures + { "BLDG", "Building" }, + { "APT", "Apartment" }, + { "RM", "Room" }, + + // Address references + { "ADDR", "Address" }, + { "BLK", "Block" }, + { "CS", "Cross Street" }, + { "LOC", "Location" }, + + // Patient / person descriptors + { "YOM", "Year Old Male" }, + { "YOF", "Year Old Female" }, + { "PTS", "Patients" }, + { "PT", "Patient" }, + { "UNC", "Unconscious" }, + { "UNK", "Unknown" }, + { "INJ", "Injuries" }, + { "RP", "Reporting Party" }, + + // Vehicles + { "VEH", "Vehicle" }, + { "VEC", "Vehicle" }, + + // Status / actions + { "ENR", "En Route" }, + { "ADV", "Advised" }, + { "NEG", "Negative" }, + { "RPT", "Report" }, + + // Communications + { "PX", "Phone Extension" }, + { "etc", "et cetera" }, + + // Geographical + { "NH", "Northbound" }, + { "SH", "Southbound" }, + { "EH", "Eastbound" }, + { "WH", "Westbound" }, + }; + + // --------------------------------------------------------------- + // Address abbreviations (standalone words, only after a digit). + // --------------------------------------------------------------- + private static readonly Dictionary AddressAbbreviationMap = new(StringComparer.OrdinalIgnoreCase) + { + { "St", "Street" }, + { "Ave", "Avenue" }, + { "Blvd", "Boulevard" }, + { "Apt", "Apartment" }, + { "Ste", "Suite" }, + { "Rd", "Road" }, + { "Dr", "Drive" }, + { "Ct", "Court" }, + { "Ln", "Lane" }, + { "Cir", "Circle" }, + { "Pl", "Place" }, + { "Pkwy", "Parkway" }, + { "Hwy", "Highway" }, + { "Fwy", "Freeway" }, + { "Tpke", "Turnpike" }, + { "Xing", "Crossing" }, + }; + + // --------------------------------------------------------------- + // Slash-notation expansions commonly used in dispatch text. + // eSpeak reads "Y/O" as "Y slash O" — we want "year old". + // --------------------------------------------------------------- + private static readonly Dictionary SlashNotationMap = new(StringComparer.OrdinalIgnoreCase) + { + { "Y/O", "Year Old" }, + { "W/", "With" }, + { "W/O", "Without" }, + }; + + // --------------------------------------------------------------- + // 10-codes — eSpeak reads "10-4" as "ten dash four", which is + // actually fine for most listeners. We keep them as-is for now. + // Uncomment the map and the handler below if you prefer expansion. + // --------------------------------------------------------------- + // private static readonly Dictionary TenCodeMap = new(StringComparer.Ordinal) + // { + // { "10-4", "acknowledged" }, + // { "10-50", "traffic accident" }, + // ... + // }; + + private static readonly Regex WhitespaceRegex = WhitespaceExpandoRegex(); + private static readonly Regex UnitIdentifierRegex = UnitIdentifierExpandoRegex(); + private static readonly Regex NumberToWordRegexField = NumberToWordRegex(); + + private readonly ILogger _logger; + + public TextPreprocessor(ILogger logger) + { + _logger = logger; + } + + public string Preprocess(string text, string voice) + { + if (string.IsNullOrWhiteSpace(text)) + { + return text ?? string.Empty; + } + + var result = text.Trim(); + + // Only preprocess English voices — other languages need their + // own abbreviation dictionaries. + if (!IsEnglishVoice(voice)) + { + return result; + } + + var original = result; + + // Order matters: expand abbreviations first so downstream + // passes operate on natural-language words rather than codes. + result = ExpandAbbreviations(result); + result = ExpandDispatchShorthand(result); + result = ExpandSlashNotation(result); + result = ExpandAddressAbbreviations(result); + result = ExpandUnitIdentifiers(result); + result = NormalizeSmallNumbers(result); + + // Collapse any whitespace artefacts introduced by expansion. + result = WhitespaceRegex.Replace(result, " ").Trim(); + + if (!string.Equals(original, result, StringComparison.Ordinal)) + { + _logger.LogDebug( + "TextPreprocessor normalised \"{OriginalText}\" to \"{NormalisedText}\"", + original, + result); + } + + return result; + } + + // --------------------------------------------------------------- + // Abbreviation expansion (word-boundary-aware) + // --------------------------------------------------------------- + + private static string ExpandAbbreviations(string text) + { + // Sort keys longest-first so "ALSEMS" is matched before "ALS". + foreach (var kvp in AbbreviationMap.OrderByDescending(k => k.Key.Length)) + { + var pattern = $@"\b{Regex.Escape(kvp.Key)}\b"; + text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + return text; + } + + /// + /// Expands raw CAD/dispatch shorthand tokens — the cryptic codes that + /// CAD systems embed in their email/API feed output. + ///
+ /// Example: "RP ADV 2 VEH MVC" → "Reporting Party Advised 2 Vehicle Motor Vehicle Collision" + ///
+ private static string ExpandDispatchShorthand(string text) + { + foreach (var kvp in DispatchShorthandMap.OrderByDescending(k => k.Key.Length)) + { + var pattern = $@"\b{Regex.Escape(kvp.Key)}\b"; + text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + return text; + } + + /// + /// Converts slash-delimited abbreviations into spoken English so + /// eSpeak doesn't say the word "slash" aloud. + ///
+ /// Example: "75 Y/O" → "75 Year Old" (instead of "75 Y slash O") + ///
+ private static string ExpandSlashNotation(string text) + { + foreach (var kvp in SlashNotationMap) + { + var pattern = $@"\b{Regex.Escape(kvp.Key)}\b"; + text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + return text; + } + + /// + /// Converts standalone small numbers (1-20) into word form when they + /// precede an alphabetic word, so that "2 patients" is spoken as + /// "two patients" rather than having the digit read in isolation. + ///
+ /// Numbers followed by a digit or numeric suffix (e.g. "1st", "2nd") + /// are left as-is — they're already handled by eSpeak's digit parser. + ///
+ private static string NormalizeSmallNumbers(string text) + { + // Match a standalone digit sequence (1-20) followed by a space and + // a letter, but NOT followed by another digit character. + // Group1 = the digits; Group2 = the first letter of the following word. + return NumberToWordRegexField.Replace(text, match => + { + var digits = match.Groups[1].Value; + var following = match.Groups[2].Value; + if (int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var num) + && num >= 1 && num <= 20) + { + return SmallNumberWords[num] + " " + following; + } + + return match.Value; + }); + } + + private static readonly Dictionary SmallNumberWords = new() + { + { 1, "one" }, { 2, "two" }, { 3, "three" }, { 4, "four" }, { 5, "five" }, + { 6, "six" }, { 7, "seven" }, { 8, "eight" }, { 9, "nine" }, { 10, "ten" }, + { 11, "eleven" }, { 12, "twelve" }, { 13, "thirteen" }, { 14, "fourteen" }, + { 15, "fifteen" }, { 16, "sixteen" }, { 17, "seventeen" }, { 18, "eighteen" }, + { 19, "nineteen" }, { 20, "twenty" }, + }; + + private static string ExpandAddressAbbreviations(string text) + { + // Address abbreviations should only be expanded when they appear + // after a number (e.g. "123 Main St" → "123 Main Street"). + + foreach (var kvp in AddressAbbreviationMap.OrderByDescending(k => k.Key.Length)) + { + var pattern = $@"(?<=\d\s+){Regex.Escape(kvp.Key)}\b"; + text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + return text; + } + + private static string ExpandUnitIdentifiers(string text) + { + // Transform common unit-identifier patterns so eSpeak speaks them + // as separate words: + // "E1" → "E 1" (engine one) + // "M2" → "M 2" (medic two) + // "B3" → "B 3" (battalion three) + // "L14" → "L 14" (ladder fourteen) + + return UnitIdentifierRegex.Replace(text, m => + { + var prefix = m.Groups[1].Value; + var number = m.Groups[2].Value; + return $"{prefix} {number}"; + }); + } + + // --------------------------------------------------------------- + // Voice detection + // --------------------------------------------------------------- + + private static bool IsEnglishVoice(string voice) + { + if (string.IsNullOrWhiteSpace(voice)) + { + return false; + } + + var trimmed = voice.Trim(); + var variantSeparatorIndex = trimmed.IndexOf('+'); + var baseVoice = variantSeparatorIndex <= 0 ? trimmed : trimmed[..variantSeparatorIndex]; + + return string.Equals(baseVoice, "en", StringComparison.OrdinalIgnoreCase) + || baseVoice.StartsWith("en-", StringComparison.OrdinalIgnoreCase) + || string.Equals(baseVoice, "mb-us1", StringComparison.OrdinalIgnoreCase); + } + + // --------------------------------------------------------------- + // Source-generated regex helpers + // --------------------------------------------------------------- + + /// Matches a single letter followed by digits, as a whole word. + [GeneratedRegex(@"\b(?[A-Z])(?\d+)\b", RegexOptions.CultureInvariant)] + private static partial Regex UnitIdentifierExpandoRegex(); + + /// Matches standalone digits 1-20 followed by a space and a letter. + [GeneratedRegex(@"\b(?(?:[1-9]|1[0-9]|20))\s(?[A-Za-z])", RegexOptions.CultureInvariant)] + private static partial Regex NumberToWordRegex(); + + /// Collapses multiple whitespace characters into a single space. + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceExpandoRegex(); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index f2eef0c5..b5279831 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -1,4 +1,3 @@ -using Amazon.Runtime; using Microsoft.Extensions.Options; using Resgrid.Web.Tts.Configuration; using Resgrid.Web.Tts.Models; @@ -68,13 +67,9 @@ public async Task WarmPromptsAsync(CancellationToken cancellationToken) { _logger.LogError(ex, "Configured pre-generated prompt is invalid: {Prompt}", prompt); } - catch (AmazonServiceException ex) - { - _logger.LogError(ex, "Failed to warm prompt {Prompt} because S3 returned an error.", prompt); - } catch (HttpRequestException ex) { - _logger.LogError(ex, "Failed to warm prompt {Prompt} because storage connectivity failed.", prompt); + _logger.LogError(ex, "Failed to warm prompt {Prompt} because storage returned an error.", prompt); } catch (IOException ex) { From b30cf2e9ebe409fbfc308fa799ca69916f6b2212 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 18:53:25 -0700 Subject: [PATCH 2/4] RE1-T115 PR#361 fixes --- .../Services/S3StorageService.cs | 41 ++++++++++++++++--- .../Services/TextPreprocessor.cs | 9 +++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index bbae8e17..cb7551fa 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -271,12 +271,38 @@ private string CalculateSignature( var canonicalUri = BuildCanonicalUri(objectKey); var canonicalQueryString = canonicalQueryStringOverride ?? string.Empty; - var canonicalHeaders = - $"host:{GetHost()}\n" + - $"x-amz-content-sha256:{(content is not null ? HexSha256(content) : "UNSIGNED-PAYLOAD")}\n" + - $"x-amz-date:{now:yyyyMMddTHHmmssZ}\n"; + // Only include headers that are listed in signedHeaders. + var signedHeadersSet = new HashSet( + signedHeaders.Split(';', StringSplitOptions.RemoveEmptyEntries), + StringComparer.Ordinal); - var payloadHash = content is not null ? HexSha256(content) : "UNSIGNED-PAYLOAD"; + var canonicalHeadersBuilder = new StringBuilder(); + foreach (var header in signedHeadersSet) + { + switch (header) + { + case "host": + canonicalHeadersBuilder.Append($"host:{GetHost()}\n"); + break; + case "x-amz-content-sha256": + var sha256Value = content is not null ? HexSha256(content) : "UNSIGNED-PAYLOAD"; + canonicalHeadersBuilder.Append($"x-amz-content-sha256:{sha256Value}\n"); + break; + case "x-amz-date": + canonicalHeadersBuilder.Append($"x-amz-date:{now:yyyyMMddTHHmmssZ}\n"); + break; + } + } + + var canonicalHeaders = canonicalHeadersBuilder.ToString(); + + // Payload hash must match the signedHeaders. When x-amz-content-sha256 + // is signed the hash is the actual payload digest; otherwise it is the + // literal string UNSIGNED-PAYLOAD. + var sha256HeaderSigned = signedHeadersSet.Contains("x-amz-content-sha256"); + var payloadHash = sha256HeaderSigned && content is not null + ? HexSha256(content) + : "UNSIGNED-PAYLOAD"; var canonicalRequest = string.Join('\n', method.Method, @@ -352,7 +378,10 @@ private Uri BuildDirectObjectUrl(string objectKey) return new Uri($"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{objectKey}"); } - private string BuildCanonicalUri(string objectKey) => $"/{_options.Bucket}/{objectKey}"; + private string BuildCanonicalUri(string objectKey) => + _options.ForcePathStyle + ? $"/{_options.Bucket}/{objectKey}" + : $"/{objectKey}"; private string GetHost() { diff --git a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs index 664f2d62..963977e4 100644 --- a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs +++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs @@ -272,9 +272,14 @@ private static string ExpandDispatchShorthand(string text) /// private static string ExpandSlashNotation(string text) { - foreach (var kvp in SlashNotationMap) + // Sort longest-first so "W/O" is matched before "W/". + foreach (var kvp in SlashNotationMap.OrderByDescending(k => k.Key.Length)) { - var pattern = $@"\b{Regex.Escape(kvp.Key)}\b"; + // Keys like "W/" and "Y/O" contain non-word characters that + // defeat the standard \b anchor. Use lookaround boundaries + // instead: (? Date: Sun, 3 May 2026 19:45:39 -0700 Subject: [PATCH 3/4] RE1-T115 PR#361 fixes --- .../Services/S3StorageService.cs | 109 ++++++++++++------ .../Services/TextPreprocessor.cs | 6 +- 2 files changed, 80 insertions(+), 35 deletions(-) diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index cb7551fa..c52b0591 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -148,19 +148,21 @@ await ExecuteWithRetryAsync( public Task GetObjectUrlAsync(string objectKey, CancellationToken cancellationToken) { + var encodedKey = EncodeObjectKey(objectKey); + // If a public base URL is configured, use it directly. if (!string.IsNullOrWhiteSpace(_options.PublicBaseUrl)) { - return Task.FromResult(new Uri($"{_options.PublicBaseUrl.TrimEnd('/')}/{objectKey}")); + return Task.FromResult(new Uri($"{_options.PublicBaseUrl.TrimEnd('/')}/{encodedKey}")); } if (_options.UsePresignedUrls) { - var url = CreatePresignedGetUrl(objectKey); + var url = CreatePresignedGetUrl(encodedKey); return Task.FromResult(new Uri(url)); } - return Task.FromResult(BuildDirectObjectUrl(objectKey)); + return Task.FromResult(BuildDirectObjectUrl(encodedKey)); } // ----------------------------------------------------------------- @@ -192,7 +194,6 @@ private async Task SendSignedRequestAsync( // Compute and add the content SHA-256 header. var payloadHash = HexSha256(content); request.Headers.Add("x-amz-content-sha256", payloadHash); - request.Content.Headers.Add("x-amz-content-sha256", payloadHash); } else { @@ -272,12 +273,15 @@ private string CalculateSignature( var canonicalQueryString = canonicalQueryStringOverride ?? string.Empty; // Only include headers that are listed in signedHeaders. - var signedHeadersSet = new HashSet( - signedHeaders.Split(';', StringSplitOptions.RemoveEmptyEntries), - StringComparer.Ordinal); - + // Only include headers that are listed in signedHeaders. Order + // must be preserved so the canonical headers appear in the same + // sequence as the SignedHeaders value in the canonical request. + var signedHeadersList = signedHeaders + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(h => h.Trim()) + .ToList(); var canonicalHeadersBuilder = new StringBuilder(); - foreach (var header in signedHeadersSet) + foreach (var header in signedHeadersList) { switch (header) { @@ -299,7 +303,7 @@ private string CalculateSignature( // Payload hash must match the signedHeaders. When x-amz-content-sha256 // is signed the hash is the actual payload digest; otherwise it is the // literal string UNSIGNED-PAYLOAD. - var sha256HeaderSigned = signedHeadersSet.Contains("x-amz-content-sha256"); + var sha256HeaderSigned = signedHeadersList.Contains("x-amz-content-sha256", StringComparer.Ordinal); var payloadHash = sha256HeaderSigned && content is not null ? HexSha256(content) : "UNSIGNED-PAYLOAD"; @@ -345,21 +349,31 @@ private byte[] DeriveSigningKey(DateTimeOffset now) private string BuildObjectUrl(string objectKey) { - var endpointUri = GetEndpointUri(); - var authority = endpointUri.IsDefaultPort - ? endpointUri.Host - : $"{endpointUri.Host}:{endpointUri.Port}"; + var encodedKey = EncodeObjectKey(objectKey); - if (_options.ForcePathStyle) + if (!string.IsNullOrWhiteSpace(_options.Endpoint)) { - return $"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{objectKey}"; + var endpointUri = GetEndpointUri(); + var authority = endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; + + if (_options.ForcePathStyle) + { + return $"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{encodedKey}"; + } + + return $"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{encodedKey}"; } - return $"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{objectKey}"; + // No custom endpoint — use the AWS regional endpoint. + return $"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{encodedKey}"; } private Uri BuildDirectObjectUrl(string objectKey) { + var encodedKey = EncodeObjectKey(objectKey); + if (!string.IsNullOrWhiteSpace(_options.Endpoint)) { var endpointUri = GetEndpointUri(); @@ -369,34 +383,45 @@ private Uri BuildDirectObjectUrl(string objectKey) if (_options.ForcePathStyle) { - return new Uri($"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{objectKey}"); + return new Uri($"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{encodedKey}"); } - return new Uri($"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{objectKey}"); + return new Uri($"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{encodedKey}"); } - return new Uri($"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{objectKey}"); + return new Uri($"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{encodedKey}"); } - private string BuildCanonicalUri(string objectKey) => - _options.ForcePathStyle - ? $"/{_options.Bucket}/{objectKey}" - : $"/{objectKey}"; + private string BuildCanonicalUri(string objectKey) + { + var encodedKey = EncodeObjectKey(objectKey); + return _options.ForcePathStyle + ? $"/{_options.Bucket}/{encodedKey}" + : $"/{encodedKey}"; + } private string GetHost() { - var endpointUri = GetEndpointUri(); - - if (_options.ForcePathStyle) + if (!string.IsNullOrWhiteSpace(_options.Endpoint)) { + var endpointUri = GetEndpointUri(); + + if (_options.ForcePathStyle) + { + return endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; + } + return endpointUri.IsDefaultPort - ? endpointUri.Host - : $"{endpointUri.Host}:{endpointUri.Port}"; + ? $"{_options.Bucket}.{endpointUri.Host}" + : $"{_options.Bucket}.{endpointUri.Host}:{endpointUri.Port}"; } - return endpointUri.IsDefaultPort - ? $"{_options.Bucket}.{endpointUri.Host}" - : $"{_options.Bucket}.{endpointUri.Host}:{endpointUri.Port}"; + // No custom endpoint — use the AWS regional host. + return _options.ForcePathStyle + ? $"s3.{_options.Region}.amazonaws.com" + : $"{_options.Bucket}.s3.{_options.Region}.amazonaws.com"; } private Uri GetEndpointUri() @@ -496,7 +521,25 @@ private HttpClient CreateClient() } // ----------------------------------------------------------------- - // Crypto / encoding helpers + // Crypto / encoding helpers + + /// + /// Splits the object key on /, percent-encodes each path segment + /// individually, then rejoins with /. This preserves the path + /// hierarchy while ensuring that the URL and SigV4 canonical URI are + /// byte-identical (both use percent-encoded segments). + /// + private static string EncodeObjectKey(string objectKey) + { + if (string.IsNullOrEmpty(objectKey)) + return objectKey ?? string.Empty; + + var segments = objectKey.Split('/'); + for (var i = 0; i < segments.Length; i++) + segments[i] = Uri.EscapeDataString(segments[i]); + + return string.Join("/", segments); + } private static byte[] HmacSha256(byte[] key, byte[] data) { diff --git a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs index 963977e4..2b3ab2f0 100644 --- a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs +++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs @@ -325,11 +325,13 @@ private static string NormalizeSmallNumbers(string text) private static string ExpandAddressAbbreviations(string text) { // Address abbreviations should only be expanded when they appear - // after a number (e.g. "123 Main St" → "123 Main Street"). + // after a house/building number (e.g. "123 Main St" → "123 Main Street"). + // The pattern anchors to a leading digit (\b\d+\b), then lazily skips + // over the street name (one or more words) before matching the suffix. foreach (var kvp in AddressAbbreviationMap.OrderByDescending(k => k.Key.Length)) { - var pattern = $@"(?<=\d\s+){Regex.Escape(kvp.Key)}\b"; + var pattern = $@"\b\d+\b[\s\w,]*?\b{Regex.Escape(kvp.Key)}\b"; text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } From 606d9593d4bd2898f5d89b12b616d582d06a93a0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 20:52:04 -0700 Subject: [PATCH 4/4] RE1-T115 Fixing build --- .../Web/Tts/S3StorageServiceTests.cs | 310 +---- .../Resgrid.Tests/Web/Tts/TtsServiceTests.cs | 7 +- .../Resgrid.Web.Services.xml | 1114 ++++++++--------- .../Services/S3StorageService.cs | 30 +- 4 files changed, 647 insertions(+), 814 deletions(-) diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs index eb166b64..40ec6e3b 100644 --- a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -3,12 +3,8 @@ using System.IO; using System.Net; using System.Net.Http; -using System.Reflection; using System.Threading; using System.Threading.Tasks; -using Amazon.Runtime; -using Amazon.S3; -using Amazon.S3.Model; using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -27,25 +23,22 @@ public async Task upload_async_should_buffer_non_seekable_stream_for_retries() { var uploadedPayloads = new List(); var attempt = 0; - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .Returns(async (request, _) => - { - using var captureStream = new MemoryStream(); - await request.InputStream.CopyToAsync(captureStream); - uploadedPayloads.Add(captureStream.ToArray()); - attempt++; + var handler = new RecordingHttpMessageHandler(async (request, _) => + { + request.Method.Should().Be(HttpMethod.Put); + var body = await request.Content.ReadAsByteArrayAsync(); + uploadedPayloads.Add(body); + attempt++; - if (attempt == 1) - { - throw new IOException("transient upload failure"); - } + if (attempt == 1) + { + throw new IOException("transient upload failure"); + } - return new PutObjectResponse(); - }); + return new HttpResponseMessage(HttpStatusCode.OK); + }); - var service = CreateService(s3Client.Object); + var service = CreateService(handler); await using var content = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); @@ -54,316 +47,146 @@ public async Task upload_async_should_buffer_non_seekable_stream_for_retries() uploadedPayloads.Should().HaveCount(2); uploadedPayloads[0].Should().Equal(1, 2, 3, 4); uploadedPayloads[1].Should().Equal(1, 2, 3, 4); - s3Client.Verify(x => x.PutObjectAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } [Test] - public async Task exists_async_should_verify_with_presigned_head_when_metadata_unmarshalling_fails() + public async Task exists_async_should_return_true_when_head_returns_200() { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(CreateMetadataUnmarshallingException("bad metadata expiration header")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Verb.Should().Be(HttpVerb.HEAD); - request.Protocol.Should().Be(Protocol.HTTP); - return "http://download.example.com/tts/audio.wav?signature=head"; - }); - var handler = new RecordingHttpMessageHandler((request, _) => { request.Method.Should().Be(HttpMethod.Head); - request.RequestUri.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=head")); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); }); - var service = CreateService(s3Client.Object, handler, useSsl: false); + + var service = CreateService(handler, useSsl: false); var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); exists.Should().BeTrue(); handler.Requests.Should().HaveCount(1); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); + handler.Requests[0].Method.Should().Be(HttpMethod.Head); } [Test] - public async Task exists_async_should_return_false_when_presigned_head_reports_missing_after_metadata_unmarshalling_failure() + public async Task exists_async_should_return_false_when_head_returns_404() { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(CreateMetadataUnmarshallingException("bad metadata expiration header")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Verb.Should().Be(HttpVerb.HEAD); - request.Protocol.Should().Be(Protocol.HTTP); - return "http://download.example.com/tts/audio.wav?signature=head"; - }); - var handler = new RecordingHttpMessageHandler((request, _) => { request.Method.Should().Be(HttpMethod.Head); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); }); - var service = CreateService(s3Client.Object, handler, useSsl: false); + + var service = CreateService(handler, useSsl: false); var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); exists.Should().BeFalse(); handler.Requests.Should().HaveCount(1); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); } [Test] - public async Task exists_async_should_verify_with_presigned_head_when_metadata_throws_raw_format_exception() + public async Task exists_async_should_return_false_when_head_returns_403() { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad metadata expiration header")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Verb.Should().Be(HttpVerb.HEAD); - request.Protocol.Should().Be(Protocol.HTTP); - return "http://download.example.com/tts/audio.wav?signature=head-raw-format"; - }); - var handler = new RecordingHttpMessageHandler((request, _) => { request.Method.Should().Be(HttpMethod.Head); - request.RequestUri.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=head-raw-format")); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)); }); - var service = CreateService(s3Client.Object, handler, useSsl: false); + + var service = CreateService(handler, useSsl: false); var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); - exists.Should().BeTrue(); + exists.Should().BeFalse(); handler.Requests.Should().HaveCount(1); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); } [Test] - public async Task upload_async_should_treat_malformed_put_response_as_success_when_the_object_is_verified() + public async Task upload_async_should_succeed_on_200_response() { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad expiration header")); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new GetObjectMetadataResponse()); + byte[] capturedPayload = null; + var handler = new RecordingHttpMessageHandler(async (request, _) => + { + request.Method.Should().Be(HttpMethod.Put); + request.Content.Headers.ContentType?.MediaType.Should().Be("audio/wav"); + capturedPayload = await request.Content.ReadAsByteArrayAsync(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); - var handler = new RecordingHttpMessageHandler((_, _) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); - var service = CreateService(s3Client.Object, handler); + var service = CreateService(handler); await using var content = new MemoryStream(new byte[] { 9, 8, 7, 6 }, writable: false); await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); - handler.Requests.Should().BeEmpty(); - s3Client.Verify(x => x.PutObjectAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.ContentType == "audio/wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); + capturedPayload.Should().Equal(9, 8, 7, 6); + handler.Requests.Should().HaveCount(1); + handler.Requests[0].RequestUri!.PathAndQuery.Should().Contain("tts/audio.wav"); } [Test] - public async Task upload_async_should_retry_with_a_presigned_put_when_verification_reports_the_object_missing() + public async Task upload_async_should_retry_on_transient_error() { - var s3Client = new Mock(MockBehavior.Strict); byte[] capturedPayload = null; - byte[] presignedPayload = null; - s3Client - .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .Returns(async (request, _) => - { - using var captureStream = new MemoryStream(); - await request.InputStream.CopyToAsync(captureStream); - capturedPayload = captureStream.ToArray(); - request.InputStream.Dispose(); - throw new FormatException("bad expiration header"); - }); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(CreateNotFoundS3Exception()); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Verb.Should().Be(HttpVerb.PUT); - request.Protocol.Should().Be(Protocol.HTTP); - request.ContentType.Should().Be("audio/wav"); - return "http://upload.example.com/tts/audio.wav?signature=put"; - }); - + var attempt = 0; var handler = new RecordingHttpMessageHandler(async (request, _) => { - request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=put")); - request.Content.Headers.ContentType?.MediaType.Should().Be("audio/wav"); - presignedPayload = await request.Content.ReadAsByteArrayAsync(); + attempt++; + if (attempt == 1) + { + throw new HttpRequestException("simulated transient failure", null, HttpStatusCode.ServiceUnavailable); + } + + capturedPayload = await request.Content.ReadAsByteArrayAsync(); return new HttpResponseMessage(HttpStatusCode.OK); }); - var service = CreateService(s3Client.Object, handler, useSsl: false); + + var service = CreateService(handler); await using var content = new MemoryStream(new byte[] { 7, 5, 3, 1 }, writable: false); await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); capturedPayload.Should().Equal(7, 5, 3, 1); - presignedPayload.Should().Equal(7, 5, 3, 1); - handler.Requests.Should().HaveCount(1); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.PUT && request.Protocol == Protocol.HTTP && request.ContentType == "audio/wav")), Times.Once); + handler.Requests.Should().HaveCount(2); } [Test] - public async Task get_object_url_async_should_use_http_presigned_urls_when_ssl_is_disabled() + public async Task get_object_url_async_should_produce_valid_absolute_uri() { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Protocol.Should().Be(Protocol.HTTP); - return "http://download.example.com/tts/audio.wav?signature=get"; - }); + var handler = new RecordingHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); - var service = CreateService(s3Client.Object, useSsl: false); + var service = CreateService(handler); var url = await service.GetObjectUrlAsync("tts/audio.wav", CancellationToken.None); - url.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=get")); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); + url.IsAbsoluteUri.Should().BeTrue(); + url.AbsoluteUri.Should().Contain("tts/audio.wav"); + url.Scheme.Should().Be("https"); } - [TestCase("http://rustfs.example.local:9000", true, Protocol.HTTP, "http://download.example.com/tts/audio.wav?signature=endpoint-http")] - [TestCase("https://rustfs.example.local:9443", false, Protocol.HTTPS, "https://download.example.com/tts/audio.wav?signature=endpoint-https")] - public async Task get_object_url_async_should_prefer_absolute_endpoint_scheme_over_use_ssl(string endpoint, bool useSsl, Protocol expectedProtocol, string presignedUrl) + [Test] + public async Task get_object_url_async_should_contain_object_key() { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Protocol.Should().Be(expectedProtocol); - return presignedUrl; - }); + var handler = new RecordingHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); - var service = CreateService(s3Client.Object, useSsl: useSsl, endpoint: endpoint); + var service = CreateService(handler); var url = await service.GetObjectUrlAsync("tts/audio.wav", CancellationToken.None); - url.Should().Be(new Uri(presignedUrl)); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); - } - - private static AmazonS3Exception CreateNotFoundS3Exception() - { - return new AmazonS3Exception( - "Object was not found.", - ErrorType.Unknown, - "NoSuchKey", - "request-id", - HttpStatusCode.NotFound); - } - - private static AmazonUnmarshallingException CreateMetadataUnmarshallingException(string message) - { - var innerException = new FormatException(message); - - foreach (var constructor in typeof(AmazonUnmarshallingException).GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - var parameters = constructor.GetParameters(); - var arguments = new object[parameters.Length]; - var usedInnerException = false; - var usedMessage = false; - var supported = true; - - for (var index = 0; index < parameters.Length; index++) - { - var parameterType = parameters[index].ParameterType; - - if (!usedInnerException && typeof(Exception).IsAssignableFrom(parameterType)) - { - arguments[index] = innerException; - usedInnerException = true; - continue; - } - - if (parameterType == typeof(string)) - { - arguments[index] = usedMessage ? "/HeadObjectResult" : message; - usedMessage = true; - continue; - } - - if (parameterType == typeof(bool)) - { - arguments[index] = false; - continue; - } - - if (parameterType == typeof(int)) - { - arguments[index] = 0; - continue; - } - - supported = false; - break; - } - - if (!supported || !usedInnerException) - { - continue; - } - - try - { - if (constructor.Invoke(arguments) is AmazonUnmarshallingException exception - && exception.InnerException is FormatException) - { - return exception; - } - } - catch - { - } - } - - throw new InvalidOperationException("Unable to construct AmazonUnmarshallingException for the test."); + url.PathAndQuery.Should().Contain("tts/audio.wav"); } - private static S3StorageService CreateService(IAmazonS3 s3Client, RecordingHttpMessageHandler handler = null, bool useSsl = true, string endpoint = null) + private static S3StorageService CreateService(RecordingHttpMessageHandler handler = null, bool useSsl = true, string endpoint = null) { handler ??= new RecordingHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); var httpClientFactory = new Mock(MockBehavior.Strict); httpClientFactory.Setup(x => x.CreateClient(nameof(S3StorageService))).Returns(new HttpClient(handler, disposeHandler: false)); return new S3StorageService( - s3Client, + httpClientFactory.Object, Options.Create(new S3StorageOptions { Bucket = "tts-bucket", @@ -372,8 +195,7 @@ private static S3StorageService CreateService(IAmazonS3 s3Client, RecordingHttpM UseSsl = useSsl, Endpoint = endpoint }), - Mock.Of>(), - httpClientFactory.Object); + Mock.Of>()); } private sealed class RecordingHttpMessageHandler : HttpMessageHandler diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index e1959d2b..fdbcec23 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -253,11 +253,11 @@ public void create_espeak_start_info_should_use_mbrola_profile_for_english_voice "-v", "mb-us1", "-s", - "130", + "140", "-p", "50", "-g", - "3"); + "2"); } [Test] @@ -308,7 +308,8 @@ private static AudioProcessingService CreateService() { return new AudioProcessingService( Options.Create(new TtsOptions()), - Mock.Of>()); + Mock.Of>(), + Mock.Of()); } private static T InvokePrivateMethod(object instance, string methodName, params object[] arguments) diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 15601e50..526ac72e 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -3573,6 +3573,52 @@ 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 @@ -3678,52 +3724,6 @@ 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,209 +7508,379 @@ Identifier of the new npte - + - A GPS location for a point in time of a specificed person + The result of getting all personnel filters for the system - + - PersonId of the person that the location is for + The Id value of the filter - + - The timestamp of the location in UTC + The type of the filter - + - GPS Latitude of the Person + The filters name - + - GPS Longitude of the Person + Result containing all the data required to populate the New Call form - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - GPS Altitude Accuracy of the Person + Response Data - + - GPS Speed of the Person + Result containing all the data required to populate the New Call form - + - GPS Heading of the Person + Response Data - + - A unit location in the Resgrid system + Information about a User - + - Response Data + The UserId GUID/UUID for the user - + - The information about a specific unit's location + DepartmentId of the deparment the user belongs to - + - Id of the Person + Department specificed ID number for this user - + - The Timestamp for the location in UTC + The Users First Name - + - GPS Latitude of the Person + The Users Last Name - + - GPS Longitude of the Person + The Users Email Address - + - GPS Latitude\Longitude Accuracy of the Person + The Users Mobile Telephone Number - + - GPS Altitude of the Person + GroupId the user is assigned to (0 for no group) - + - GPS Altitude Accuracy of the Person + Name of the group the user is assigned to - + - GPS Speed of the Person + Enumeration/List of roles the user currently holds - + - GPS Heading of the Person + The current action/status type for the user - + - The result of getting the current staffing for a user + The current action/status string for the user - + - Response Data + The current action/status color hex string for the user - + - Information about a User staffing + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - + - The UserId GUID/UUID for the user status being return + The current action/status destination id for the user - + - DepartmentId of the deparment the user belongs to + The current action/status destination name for the user - + - The current staffing type for the user + The current staffing level (state) type for the user - + - The timestamp of the last staffing. This is converted UTC version of the timestamp. + The current staffing level (state) string for the user - + - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + The current staffing level (state) color hex string for the user - + - Note for this staffing + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - + - Saves (sets) and Personnel Staffing in the system, for a single user + Users last known location - + - UnitId of the apparatus that the state is being set for + Sorting weight for the user - + - The UnitStateType of the Unit + User Defined Field values for this personnel record - + - The timestamp of the status event in UTC + A GPS location for a point in time of a specificed person - + - The timestamp of the status event in the local time of the device + PersonId of the person that the location is for - + - User provided note for this event + The timestamp of the location in UTC - + - The event id used for queuing on mobile applications + GPS Latitude of the Person - + - Depicts a result after saving a person status + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Saves (sets) and Personnel Status in the system, for a single user + 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 @@ -8011,282 +8181,112 @@ Response Data - + - The result of getting all personnel filters for the system + Result containing all the data required to populate the New Call form - + - The Id value of the filter + Response Data - + - The type of the filter + Details of a protocol - + - The filters name + Protocol id - + - Result containing all the data required to populate the New Call form + Department id - + - Response Data + Name of the Protocol - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + Protocol code - + - Response Data + This this protocol disabled - + - Result containing all the data required to populate the New Call form + Protocol description - + - Response Data + Text of the protocol - + - Information about a User + UTC date and time when the Protocol was created - + - The UserId GUID/UUID for the user + UserId of the user who created the protocol - + - DepartmentId of the deparment the user belongs to + UTC timestamp of when the Protocol was updated - + - Department specificed ID number for this user + Minimum triggering Weight of the Protocol - + - The Users First Name + UserId that last updated the Protocol - + - The Users Last Name + Triggers used to activate this Protocol - + - The Users Email Address + Attachments for this Protocol - + - The Users Mobile Telephone Number + Questions used to determine if this Protocol needs to be used or not - + - GroupId the user is assigned to (0 for no group) + State type - + - Name of the group the user is assigned to + Result containing all the data required to populate the New Call form - + - Enumeration/List of roles the user currently holds + Response Data - - - The current action/status type for the user - - - - - The current action/status string for the user - - - - - The current action/status color hex string for the user - - - - - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - - - - - The current action/status destination id for the user - - - - - The current action/status destination name for the user - - - - - The current staffing level (state) type for the user - - - - - The current staffing level (state) string for the user - - - - - The current staffing level (state) color hex string for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Users last known location - - - - - Sorting weight for the user - - - - - User Defined Field values for this personnel record - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - - - Details of a protocol - - - - - Protocol id - - - - - Department id - - - - - Name of the Protocol - - - - - Protocol code - - - - - This this protocol disabled - - - - - Protocol description - - - - - Text of the protocol - - - - - UTC date and time when the Protocol was created - - - - - UserId of the user who created the protocol - - - - - UTC timestamp of when the Protocol was updated - - - - - Minimum triggering Weight of the Protocol - - - - - UserId that last updated the Protocol - - - - - Triggers used to activate this Protocol - - - - - Attachments for this Protocol - - - - - Questions used to determine if this Protocol needs to be used or not - - - - - State type - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - + A role in the Resgrid system @@ -9480,545 +9480,545 @@ Default constructor - + - Depicts a result after saving a unit status + Result that contains all the options available to filter units against compatible Resgrid APIs - + 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. + A unit in the Resgrid system - + - UnitId of the apparatus that the state is being set for + Response Data - + - The UnitStateType of the Unit + The information about a specific unit - + - The Call/Station the unit is responding to + Id of the Unit - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + The Id of the department the unit is under - + - The timestamp of the status event in UTC + Name of the Unit - + - The timestamp of the status event in the local time of the device + Department assigned type for the unit - + - User provided note for this event + Department assigned type id for the unit - + - GPS Latitude of the Unit + Custom Statuses Set Id - + - GPS Longitude of the Unit + Station Id of the station housing the unit (0 means no station) - + - GPS Latitude\Longitude Accuracy of the Unit + Name of the station the unit is under - + - GPS Altitude of the Unit + Vehicle Identification Number for the unit - + - GPS Altitude Accuracy of the Unit + Plate Number for the Unit - + - GPS Speed of the Unit + Is the unit 4-Wheel drive - + - GPS Heading of the Unit + Does the unit require a special permit to drive - + - The event id used for queuing on mobile applications + Id number of the units current destionation (0 means no destination) - + - The accountability roles filed for this event + The current status/state of the Unit - + - Role filled by a User on a Unit for an event + The Timestamp of the status - + - Id of the locally stored event + The units current Latitude - + - Local Event Id + The units current Longitude - + - UserId of the user filling the role + Current user provide status note - + - RoleId of the role being filled + User Defined Field values for this unit - + - The name of the Role + Unit role information for roles on a unit - + - Depicts a unit status in the Resgrid system. + Unit Role Id - + - Response Data + User Id of the user in the role (could be null) - + - Depicts a unit's status + Name of the Role - + - Unit Id + Name of the user in the role (could be null) - + - Units Name + Multiple Unit infos Result - + - The Type of the Unit + Response Data - + - Units current Status (State) + Default constructor - + - CSS for status (for display) + The information about a specific unit - + - CSS Style for status (for display) + Id of the Unit - + - Timestamp of this Unit State + The Id of the department the unit is under - + - Timestamp in Utc of this Unit State + Name of the Unit - + - Destination Id (Station or Call) + Department assigned type for the unit - + - Destination type (Station, Call, or POI). + Department assigned type id for the unit - + - Name of the Desination (Call or Station) + Custom Statuses Set Id - + - Destination address. + Station Id of the station housing the unit (0 means no station) - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + Name of the station the unit is under - + - Note for the State + Vehicle Identification Number for the unit - + - Latitude + Plate Number for the Unit - + - Longitude + Is the unit 4-Wheel drive - + - Name of the Group the Unit is in + Does the unit require a special permit to drive - + - Id of the Group the Unit is in + Id number of the units current destination (0 means no destination) - + - Unit statuses (states) + Name of the units current destination (0 means no destination) - + - Response Data + The current status/state of the Unit - + - Default constructor + The current status/state of the Unit as a name - + - Result that contains all the options available to filter units against compatible Resgrid APIs + The current status/state of the Unit color - + - Response Data + The Timestamp of the status - + - A unit in the Resgrid system + The Timestamp of the status in UTC/GMT - + - Response Data + The units current Latitude - + - The information about a specific unit + The units current Longitude - + - Id of the Unit + Current user provide status note - + - The Id of the department the unit is under + Units Roles - + - Name of the Unit + Multiple Units Result - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Default constructor - + - Custom Statuses Set Id + Depicts a result after saving a unit status - + - Station Id of the station housing the unit (0 means no station) + Response Data - + - Name of the station the unit is under + 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. - + - Vehicle Identification Number for the unit + UnitId of the apparatus that the state is being set for - + - Plate Number for the Unit + The UnitStateType of the Unit - + - Is the unit 4-Wheel drive + The Call/Station the unit is responding to - + - Does the unit require a special permit to drive + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Id number of the units current destionation (0 means no destination) + The timestamp of the status event in UTC - + - The current status/state of the Unit + The timestamp of the status event in the local time of the device - + - The Timestamp of the status + User provided note for this event - + - The units current Latitude + GPS Latitude of the Unit - + - The units current Longitude + GPS Longitude of the Unit - + - Current user provide status note + GPS Latitude\Longitude Accuracy of the Unit - + - User Defined Field values for this unit + GPS Altitude of the Unit - + - Unit role information for roles on a unit + GPS Altitude Accuracy of the Unit - + - Unit Role Id + GPS Speed of the Unit - + - User Id of the user in the role (could be null) + GPS Heading of the Unit - + - Name of the Role + The event id used for queuing on mobile applications - + - Name of the user in the role (could be null) + The accountability roles filed for this event - + - Multiple Unit infos Result + Role filled by a User on a Unit for an event - + - Response Data + Id of the locally stored event - + - Default constructor + Local Event Id - + - The information about a specific unit + UserId of the user filling the role - + - Id of the Unit + RoleId of the role being filled - + - The Id of the department the unit is under + The name of the Role - + - Name of the Unit + Depicts a unit status in the Resgrid system. - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Depicts a unit's status - + - Custom Statuses Set Id + Unit Id - + - Station Id of the station housing the unit (0 means no station) + Units Name - + - Name of the station the unit is under + The Type of the Unit - + - Vehicle Identification Number for the unit + Units current Status (State) - + - Plate Number for the Unit + CSS for status (for display) - + - Is the unit 4-Wheel drive + CSS Style for status (for display) - + - Does the unit require a special permit to drive + Timestamp of this Unit State - + - Id number of the units current destination (0 means no destination) + Timestamp in Utc of this Unit State - + - Name of the units current destination (0 means no destination) + Destination Id (Station or Call) - + - The current status/state of the Unit + Destination type (Station, Call, or POI). - + - The current status/state of the Unit as a name + Name of the Desination (Call or Station) - + - The current status/state of the Unit color + Destination address. - + - The Timestamp of the status + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - The Timestamp of the status in UTC/GMT + Note for the State - + - The units current Latitude + Latitude - + - The units current Longitude + Longitude - + - Current user provide status note + Name of the Group the Unit is in - + - Units Roles + Id of the Group the Unit is in - + - Multiple Units Result + Unit statuses (states) - + Response Data - + Default constructor diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index c52b0591..5728d02f 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -65,15 +65,16 @@ public async Task ExistsAsync(string objectKey, CancellationToken cancella contentType: null, cancellationToken); - return response.StatusCode switch - { - HttpStatusCode.OK => true, - HttpStatusCode.NotFound => false, - _ => throw new HttpRequestException( - $"HEAD {objectKey} returned unexpected status {(int)response.StatusCode}.", - null, - response.StatusCode) - }; + return response.StatusCode switch + { + HttpStatusCode.OK => true, + HttpStatusCode.NotFound => false, + HttpStatusCode.Forbidden => false, + _ => throw new HttpRequestException( + $"HEAD {objectKey} returned unexpected status {(int)response.StatusCode}.", + null, + response.StatusCode) + }; } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { @@ -515,7 +516,16 @@ private HttpClient CreateClient() // Set a reasonable timeout. This should be generous enough for // large audio file uploads / downloads while still failing fast // on a genuinely hung connection. - client.Timeout = TimeSpan.FromMinutes(2); + // Some mocked/derived HttpMessageHandler implementations prevent + // setting Timeout; we silently accept that scenario. + try + { + client.Timeout = TimeSpan.FromMinutes(2); + } + catch (InvalidOperationException) + { + // Ignore — keep the factory-supplied default timeout. + } return client; }