From 2d265c1bf8e08fc1cfbd66452c133cfb2881c158 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 21 Apr 2026 12:28:03 -0700 Subject: [PATCH 1/6] Bug fix --- Resgrid.Audio.Core/AudioProcessor.cs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Resgrid.Audio.Core/AudioProcessor.cs b/Resgrid.Audio.Core/AudioProcessor.cs index ec25fa1..02a4f36 100644 --- a/Resgrid.Audio.Core/AudioProcessor.cs +++ b/Resgrid.Audio.Core/AudioProcessor.cs @@ -72,7 +72,7 @@ private void _timer_Elapsed(object sender, ElapsedEventArgs e) { startedWatcher.Value.LastCheckedTimestamp = DateTime.UtcNow; - if ((DateTime.UtcNow - startedWatcher.Value.TriggerFiredTimestamp).TotalSeconds >= _config.AudioLength) + if ((DateTime.UtcNow - startedWatcher.Value.TriggerFiredTimestamp).TotalSeconds >= _config.AudioLength + 5) { watchersToRemove.Add(startedWatcher.Value.Id); } @@ -82,23 +82,28 @@ private void _timer_Elapsed(object sender, ElapsedEventArgs e) { foreach (var id in watchersToRemove) { - var watcher = _startedWatchers[id]; + lock (_lock) + { + var watcher = _startedWatchers[id]; - var mp3Audio = _audioRecorder.SaveWatcherAudio(watcher); - TriggerProcessingFinished?.Invoke(this, new TriggerProcessedEventArgs(watcher, watcher.GetTrigger(), DateTime.UtcNow, mp3Audio)); + var mp3Audio = _audioRecorder.SaveWatcherAudio(watcher); + _watcherAudioStorage.FinishWatcher(watcher.Id); + TriggerProcessingFinished?.Invoke(this, new TriggerProcessedEventArgs(watcher, watcher.GetTrigger(), DateTime.UtcNow, mp3Audio)); - _startedWatchers.Remove(id); - _audioEvaluator.RemoveActiveWatcher(id); - _watcherAudioStorage.FinishWatcher(watcher.Id); + _startedWatchers.Remove(id); + _audioEvaluator.RemoveActiveWatcher(id); + } } //_audioEvaluator.ClearTones(); watchersToRemove.Clear(); - } } + else + { + CleanUpCheck(); + } - CleanUpCheck(); _timerProcessing = false; } } From f6c69013a3cd63ccb778b835b1b32b020b5ba3a6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 27 Apr 2026 15:52:51 -0700 Subject: [PATCH 2/6] RR1-T101 Updated to .net 10 and email import functionality --- .dockerignore | 4 + Dockerfile | 18 + .../Resgrid.Providers.ApiClient.csproj | 69 +- .../V4/CallsApi.cs | 33 + .../V4/DispatchListBuilder.cs | 59 + .../V4/HealthApi.cs | 13 + .../V4/Models/AuthModels.cs | 40 + .../V4/Models/CallModels.cs | 78 ++ .../V4/ResgridApiClientOptions.cs | 33 + .../V4/ResgridV4ApiClient.cs | 289 +++++ .../Resgrid.Providers.ApiClient/app.config | 11 - .../packages.config | 5 - README.md | 436 +++---- Resgrid.Audio.Core/AudioEvaluator.cs | 2 +- Resgrid.Audio.Core/AudioProcessor.cs | 4 +- Resgrid.Audio.Core/ComService.cs | 186 +-- .../Events/CallCreatedEventArgs.cs | 4 +- Resgrid.Audio.Core/Functions.cs | 2 +- Resgrid.Audio.Core/Inputs/AudioRecorder.cs | 55 +- Resgrid.Audio.Core/Model/Config.cs | 32 +- Resgrid.Audio.Core/Model/Trigger.cs | 2 +- Resgrid.Audio.Core/Model/Watcher.cs | 10 +- Resgrid.Audio.Core/Resgrid.Audio.Core.csproj | 132 +-- Resgrid.Audio.Core/SampleAggregator.cs | 1 - Resgrid.Audio.Core/app.config | 11 - Resgrid.Audio.Core/packages.config | 11 - Resgrid.Audio.Relay.Console/App.config | 22 - .../Configuration/RelayHostOptions.cs | 49 + Resgrid.Audio.Relay.Console/Program.cs | 416 ++++++- .../Properties/AssemblyInfo.cs | 1 + .../Resgrid.Audio.Relay.Console.csproj | 191 +-- .../Smtp/SmtpDispatchAddressParser.cs | 71 ++ .../Smtp/SmtpRelayInfrastructure.cs | 512 ++++++++ .../Smtp/SmtpTelemetry.cs | 1029 +++++++++++++++++ Resgrid.Audio.Relay.Console/appsettings.json | 39 + Resgrid.Audio.Relay.Console/packages.config | 14 - Resgrid.Audio.Relay.Console/settings.json | 16 +- Resgrid.Audio.Relay/App.config | 18 - Resgrid.Audio.Relay/App.xaml | 43 +- Resgrid.Audio.Relay/App.xaml.cs | 7 +- Resgrid.Audio.Relay/MainWindow.xaml | 154 +-- Resgrid.Audio.Relay/MainWindow.xaml.cs | 112 +- .../Resgrid.Audio.Relay.csproj | 196 +--- Resgrid.Audio.Relay/packages.config | 18 - .../AudioEvaluatorWithFileTests.cs | 3 +- .../DispatchListBuilderTests.cs | 34 + Resgrid.Audio.Tests/DtmfTests.cs | 4 +- .../Resgrid.Audio.Tests.csproj | 125 +- .../SmtpDispatchAddressParserTests.cs | 42 + .../SmtpRelayTelemetryTests.cs | 321 +++++ Resgrid.Audio.Tests/app.config | 19 - Resgrid.Audio.Tests/packages.config | 14 - global.json | 6 + 53 files changed, 3780 insertions(+), 1236 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/Models/AuthModels.cs create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs create mode 100644 Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs delete mode 100644 Providers/Resgrid.Providers.ApiClient/app.config delete mode 100644 Providers/Resgrid.Providers.ApiClient/packages.config delete mode 100644 Resgrid.Audio.Core/app.config delete mode 100644 Resgrid.Audio.Core/packages.config delete mode 100644 Resgrid.Audio.Relay.Console/App.config create mode 100644 Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs create mode 100644 Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs create mode 100644 Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs create mode 100644 Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs create mode 100644 Resgrid.Audio.Relay.Console/appsettings.json delete mode 100644 Resgrid.Audio.Relay.Console/packages.config delete mode 100644 Resgrid.Audio.Relay/App.config delete mode 100644 Resgrid.Audio.Relay/packages.config create mode 100644 Resgrid.Audio.Tests/DispatchListBuilderTests.cs create mode 100644 Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs create mode 100644 Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs delete mode 100644 Resgrid.Audio.Tests/app.config delete mode 100644 Resgrid.Audio.Tests/packages.config create mode 100644 global.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..09097ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f6d306 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY . . +RUN dotnet restore "Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj" -p:TargetFramework=net10.0 +RUN dotnet publish "Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj" -c Release -f net10.0 -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final +WORKDIR /app + +COPY --from=build /app/publish . + +ENV RELAY_Mode=smtp +ENV RELAY_Smtp__Port=2525 + +EXPOSE 2525 + +ENTRYPOINT ["dotnet", "Resgrid.Audio.Relay.Console.dll"] diff --git a/Providers/Resgrid.Providers.ApiClient/Resgrid.Providers.ApiClient.csproj b/Providers/Resgrid.Providers.ApiClient/Resgrid.Providers.ApiClient.csproj index 1687a22..22913a9 100644 --- a/Providers/Resgrid.Providers.ApiClient/Resgrid.Providers.ApiClient.csproj +++ b/Providers/Resgrid.Providers.ApiClient/Resgrid.Providers.ApiClient.csproj @@ -1,67 +1,18 @@ - - - + - Debug - AnyCPU - {7AA51788-4951-4EC0-8FEB-8381200600C4} - Library - Properties + net10.0 Resgrid.Providers.ApiClient Resgrid.Providers.ApiClient - v4.7.1 - 512 + false + disable + disable - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll - - - - - ..\..\packages\Microsoft.AspNet.WebApi.Client.5.2.6\lib\net45\System.Net.Http.Formatting.dll - - - - - - - - + - - - - - - - - - - - - + + - - + - - \ No newline at end of file + diff --git a/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs new file mode 100644 index 0000000..6292658 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs @@ -0,0 +1,33 @@ +using Resgrid.Providers.ApiClient.V4.Models; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Providers.ApiClient.V4 +{ + public static class CallsApi + { + public static async Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken = default) + { + var result = await ResgridV4ApiClient.PostAsync("Calls/SaveCall", call, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(result.Id)) + throw new InvalidOperationException("The Resgrid API did not return a call id for the saved call."); + + return result.Id; + } + + public static async Task GetCallAsync(string callId, CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(callId)) + throw new ArgumentException("A call id is required.", nameof(callId)); + + return await ResgridV4ApiClient.GetAsync($"Calls/GetCall?callId={Uri.EscapeDataString(callId)}", cancellationToken).ConfigureAwait(false); + } + + public static async Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken = default) + { + var result = await ResgridV4ApiClient.PostAsync("CallFiles/SaveCallFile", file, cancellationToken).ConfigureAwait(false); + return result?.Id; + } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs b/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs new file mode 100644 index 0000000..fa5f540 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Providers.ApiClient.V4 +{ + public enum DispatchCodeType + { + Department = 1, + Group = 2 + } + + public sealed class DispatchCode + { + public string Code { get; set; } + public DispatchCodeType Type { get; set; } + } + + public static class DispatchListBuilder + { + public static string Build(IEnumerable dispatchCodes, string departmentDispatchPrefix = "G") + { + if (dispatchCodes == null) + return null; + + var tokens = dispatchCodes + .Where(x => x != null && !String.IsNullOrWhiteSpace(x.Code)) + .Select(x => Format(x.Code, x.Type, departmentDispatchPrefix)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (tokens.Length == 0) + return null; + + return String.Join("|", tokens); + } + + public static string Format(string code, DispatchCodeType type, string departmentDispatchPrefix = "G") + { + if (String.IsNullOrWhiteSpace(code)) + throw new ArgumentException("A dispatch code is required.", nameof(code)); + + var normalizedCode = code.Trim(); + var prefix = type == DispatchCodeType.Department + ? NormalizePrefix(departmentDispatchPrefix) + : "G"; + + return $"{prefix}:{normalizedCode}"; + } + + private static string NormalizePrefix(string prefix) + { + if (String.IsNullOrWhiteSpace(prefix)) + return "G"; + + return prefix.Trim().TrimEnd(':').ToUpperInvariant(); + } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs new file mode 100644 index 0000000..a71c997 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/HealthApi.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Providers.ApiClient.V4 +{ + public static class HealthApi + { + public static Task IsHealthyAsync(CancellationToken cancellationToken = default) + { + return ResgridV4ApiClient.IsHealthyAsync(cancellationToken); + } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/Models/AuthModels.cs b/Providers/Resgrid.Providers.ApiClient/V4/Models/AuthModels.cs new file mode 100644 index 0000000..2a81156 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/Models/AuthModels.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.Json.Serialization; + +namespace Resgrid.Providers.ApiClient.V4.Models +{ + public sealed class OpenIdConfiguration + { + [JsonPropertyName("issuer")] + public string Issuer { get; set; } + + [JsonPropertyName("token_endpoint")] + public string TokenEndpoint { get; set; } + } + + public sealed class OAuthTokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + [JsonPropertyName("scope")] + public string Scope { get; set; } + } + + public sealed class ResgridApiTokenState + { + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public DateTimeOffset ExpiresAtUtc { get; set; } + public string UserId { get; set; } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs b/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs new file mode 100644 index 0000000..b3c7f5b --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs @@ -0,0 +1,78 @@ +using System; +using System.Text.Json.Serialization; + +namespace Resgrid.Providers.ApiClient.V4.Models +{ + public enum CallFileType + { + Audio = 1, + Image = 2, + File = 3, + Video = 4 + } + + public sealed class NewCallInput + { + public int Priority { get; set; } + public string Name { get; set; } + public string Nature { get; set; } + public string Note { get; set; } + public string Address { get; set; } + public string Geolocation { get; set; } + public string Type { get; set; } + public string What3Words { get; set; } + public string DispatchList { get; set; } + public string ContactName { get; set; } + public string ContactInfo { get; set; } + public string ExternalId { get; set; } + public string IncidentId { get; set; } + public string ReferenceId { get; set; } + public DateTimeOffset? DispatchOn { get; set; } + public string CallFormData { get; set; } + public bool? CheckInTimersEnabled { get; set; } + } + + public sealed class SaveCallFileInput + { + public string CallId { get; set; } + public string UserId { get; set; } + public int Type { get; set; } + public string Name { get; set; } + public string Data { get; set; } + public string Latitude { get; set; } + public string Longitude { get; set; } + public string Note { get; set; } + } + + public sealed class SaveOperationResult + { + public string Id { get; set; } + public string Status { get; set; } + } + + public sealed class GetCallResult + { + public CallResultData Data { get; set; } + public string Status { get; set; } + } + + public sealed class CallResultData + { + public string CallId { get; set; } + public string Number { get; set; } + public int Priority { get; set; } + public string Name { get; set; } + public string Nature { get; set; } + public string Note { get; set; } + public string Address { get; set; } + public string Geolocation { get; set; } + public DateTimeOffset LoggedOnUtc { get; set; } + public string ContactName { get; set; } + public string ContactInfo { get; set; } + public string ReferenceId { get; set; } + public string ExternalId { get; set; } + public string IncidentId { get; set; } + public string AudioFileId { get; set; } + public string Type { get; set; } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs b/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs new file mode 100644 index 0000000..346f2b8 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs @@ -0,0 +1,33 @@ +using System; + +namespace Resgrid.Providers.ApiClient.V4 +{ + public sealed class ResgridApiClientOptions + { + public string BaseUrl { get; set; } = "https://api.resgrid.com"; + public string ApiVersion { get; set; } = "4"; + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string RefreshToken { get; set; } + public string Scope { get; set; } = "openid profile email offline_access mobile"; + public string TokenCachePath { get; set; } + + public void Validate() + { + if (String.IsNullOrWhiteSpace(BaseUrl)) + throw new InvalidOperationException("A Resgrid API base URL is required."); + + if (String.IsNullOrWhiteSpace(ApiVersion)) + throw new InvalidOperationException("A Resgrid API version is required."); + + if (String.IsNullOrWhiteSpace(ClientId)) + throw new InvalidOperationException("A Resgrid API client id is required."); + + if (String.IsNullOrWhiteSpace(ClientSecret)) + throw new InvalidOperationException("A Resgrid API client secret is required."); + + if (String.IsNullOrWhiteSpace(RefreshToken) && String.IsNullOrWhiteSpace(TokenCachePath)) + throw new InvalidOperationException("A refresh token or token cache path is required."); + } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs b/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs new file mode 100644 index 0000000..dd96f49 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs @@ -0,0 +1,289 @@ +using Resgrid.Providers.ApiClient.V4.Models; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Providers.ApiClient.V4 +{ + public static class ResgridV4ApiClient + { + private static readonly SemaphoreSlim AuthLock = new SemaphoreSlim(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + private static HttpClient _client = CreateClient("https://api.resgrid.com"); + private static OpenIdConfiguration _openIdConfiguration; + private static ResgridApiClientOptions _options = new ResgridApiClientOptions(); + private static ResgridApiTokenState _tokenState = new ResgridApiTokenState(); + + public static string CurrentUserId => _tokenState?.UserId; + + public static void Init(ResgridApiClientOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + options.Validate(); + + _options = options; + _openIdConfiguration = null; + _tokenState = LoadTokenState(options); + + _client.Dispose(); + _client = CreateClient(options.BaseUrl); + } + + public static async Task IsHealthyAsync(CancellationToken cancellationToken = default) + { + using var response = await SendRawAsync(HttpMethod.Get, "Health/GetCurrent", null, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; + } + + public static async Task GetAsync(string url, CancellationToken cancellationToken = default) where T : class + { + using var response = await SendRawAsync(HttpMethod.Get, url, null, cancellationToken).ConfigureAwait(false); + return await ReadResponseAsync(response, cancellationToken).ConfigureAwait(false); + } + + public static async Task PostAsync(string url, object data, CancellationToken cancellationToken = default) where T : class + { + using var response = await SendRawAsync(HttpMethod.Post, url, data, cancellationToken).ConfigureAwait(false); + return await ReadResponseAsync(response, cancellationToken).ConfigureAwait(false); + } + + private static HttpClient CreateClient(string baseUrl) + { + var client = new HttpClient(); + client.BaseAddress = new Uri(NormalizeBaseUrl(baseUrl), UriKind.Absolute); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + return client; + } + + private static async Task SendRawAsync(HttpMethod method, string url, object data, CancellationToken cancellationToken) + { + await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false); + + var relativeUrl = BuildRelativeApiPath(url); + var response = await SendAuthorizedRequestAsync(method, relativeUrl, data, _tokenState.AccessToken, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response.Dispose(); + _tokenState.AccessToken = null; + _tokenState.ExpiresAtUtc = DateTimeOffset.MinValue; + + await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false); + return await SendAuthorizedRequestAsync(method, relativeUrl, data, _tokenState.AccessToken, cancellationToken).ConfigureAwait(false); + } + + return response; + } + + private static async Task SendAuthorizedRequestAsync(HttpMethod method, string url, object data, string accessToken, CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + if (data != null) + { + var payload = JsonSerializer.Serialize(data, JsonOptions); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + } + + return await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where T : class + { + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"The Resgrid API request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response: {payload}", + null, + response.StatusCode); + } + + var result = JsonSerializer.Deserialize(payload, JsonOptions); + if (result == null) + throw new InvalidOperationException($"The Resgrid API returned an empty {typeof(T).Name} payload."); + + return result; + } + + private static async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken) + { + if (HasValidAccessToken()) + return; + + await AuthLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (HasValidAccessToken()) + return; + + _openIdConfiguration ??= await GetOpenIdConfigurationAsync(cancellationToken).ConfigureAwait(false); + var refreshToken = ResolveRefreshToken(); + if (String.IsNullOrWhiteSpace(refreshToken)) + throw new InvalidOperationException("No Resgrid refresh token is available."); + + var formData = new Dictionary() + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = _options.ClientId, + ["client_secret"] = _options.ClientSecret + }; + + if (!String.IsNullOrWhiteSpace(_options.Scope)) + formData["scope"] = _options.Scope; + + using var response = await _client.PostAsync(_openIdConfiguration.TokenEndpoint, new FormUrlEncodedContent(formData), cancellationToken).ConfigureAwait(false); + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"The Resgrid token refresh request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response: {payload}", + null, + response.StatusCode); + } + + var tokenResponse = JsonSerializer.Deserialize(payload, JsonOptions); + if (tokenResponse == null || String.IsNullOrWhiteSpace(tokenResponse.AccessToken)) + throw new InvalidOperationException("The Resgrid token refresh response did not include an access token."); + + _tokenState.AccessToken = tokenResponse.AccessToken; + _tokenState.RefreshToken = String.IsNullOrWhiteSpace(tokenResponse.RefreshToken) + ? refreshToken + : tokenResponse.RefreshToken; + _tokenState.ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn > 0 ? tokenResponse.ExpiresIn : 300); + _tokenState.UserId = TryReadUserId(tokenResponse.AccessToken); + + await PersistTokenStateAsync(_tokenState, cancellationToken).ConfigureAwait(false); + } + finally + { + AuthLock.Release(); + } + } + + private static bool HasValidAccessToken() + { + return !String.IsNullOrWhiteSpace(_tokenState?.AccessToken) && + _tokenState.ExpiresAtUtc > DateTimeOffset.UtcNow.AddMinutes(1); + } + + private static async Task GetOpenIdConfigurationAsync(CancellationToken cancellationToken) + { + var discoveryUri = new Uri(_client.BaseAddress, ".well-known/openid-configuration"); + using var response = await _client.GetAsync(discoveryUri, cancellationToken).ConfigureAwait(false); + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"The Resgrid OIDC discovery request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response: {payload}", + null, + response.StatusCode); + } + + var configuration = JsonSerializer.Deserialize(payload, JsonOptions); + if (configuration == null || String.IsNullOrWhiteSpace(configuration.TokenEndpoint)) + throw new InvalidOperationException("The Resgrid OIDC discovery document did not contain a token endpoint."); + + return configuration; + } + + private static string BuildRelativeApiPath(string url) + { + var relativeUrl = url?.TrimStart('/') ?? String.Empty; + return $"api/v{_options.ApiVersion}/{relativeUrl}"; + } + + private static string NormalizeBaseUrl(string baseUrl) + { + return baseUrl.TrimEnd('/') + "/"; + } + + private static string ResolveRefreshToken() + { + if (!String.IsNullOrWhiteSpace(_tokenState?.RefreshToken)) + return _tokenState.RefreshToken; + + if (!String.IsNullOrWhiteSpace(_options.RefreshToken)) + return _options.RefreshToken; + + return null; + } + + private static ResgridApiTokenState LoadTokenState(ResgridApiClientOptions options) + { + var state = new ResgridApiTokenState + { + RefreshToken = options.RefreshToken + }; + + if (String.IsNullOrWhiteSpace(options.TokenCachePath)) + return state; + + var cachePath = Path.GetFullPath(options.TokenCachePath); + if (!File.Exists(cachePath)) + return state; + + var payload = File.ReadAllText(cachePath); + if (String.IsNullOrWhiteSpace(payload)) + return state; + + var cachedState = JsonSerializer.Deserialize(payload, JsonOptions); + if (cachedState == null) + return state; + + if (String.IsNullOrWhiteSpace(cachedState.RefreshToken)) + cachedState.RefreshToken = options.RefreshToken; + + return cachedState; + } + + private static async Task PersistTokenStateAsync(ResgridApiTokenState tokenState, CancellationToken cancellationToken) + { + if (String.IsNullOrWhiteSpace(_options.TokenCachePath)) + return; + + var cachePath = Path.GetFullPath(_options.TokenCachePath); + var cacheDirectory = Path.GetDirectoryName(cachePath); + if (!String.IsNullOrWhiteSpace(cacheDirectory)) + Directory.CreateDirectory(cacheDirectory); + + var payload = JsonSerializer.Serialize(tokenState, JsonOptions); + await File.WriteAllTextAsync(cachePath, payload, cancellationToken).ConfigureAwait(false); + } + + private static string TryReadUserId(string accessToken) + { + if (String.IsNullOrWhiteSpace(accessToken)) + return null; + + var handler = new JwtSecurityTokenHandler(); + if (!handler.CanReadToken(accessToken)) + return null; + + var token = handler.ReadJwtToken(accessToken); + return token.Claims.FirstOrDefault(x => String.Equals(x.Type, "sub", StringComparison.OrdinalIgnoreCase))?.Value; + } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/app.config b/Providers/Resgrid.Providers.ApiClient/app.config deleted file mode 100644 index 2bbe771..0000000 --- a/Providers/Resgrid.Providers.ApiClient/app.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/Providers/Resgrid.Providers.ApiClient/packages.config b/Providers/Resgrid.Providers.ApiClient/packages.config deleted file mode 100644 index b01d8a8..0000000 --- a/Providers/Resgrid.Providers.ApiClient/packages.config +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/README.md b/README.md index fea9f0f..3514135 100644 --- a/README.md +++ b/README.md @@ -1,226 +1,268 @@ -Resgrid Relay -=========================== +# Resgrid Relay -Resgrid Relay is a desktop and CLI application for creating calls in the Resgrid system based on watchers (audio, file, database, etc). +Resgrid Relay is a .NET 10 solution for creating Resgrid calls from either: -********* +1. Windows audio tone monitoring +2. Direct SMTP ingestion for emails sent to Resgrid dispatch addresses -[![Build status](https://ci.appveyor.com/api/projects/status/github/resgrid/relay?svg=true)](https://ci.appveyor.com/api/projects/status/github/resgrid/relay) +The project now uses the **Resgrid v4 API** and **OpenID Connect refresh-token authentication** instead of the legacy v3 username/password flow. -About Resgrid -------------- -Resgrid is a software as a service (SaaS) logistics, management and communications platform for first responders, volunteer fire departments, career fire, EMS, Search and Rescue (SAR), public safety, HAZMAT, CERT, disaster response, etc. +## Solution layout -[Sign up for your free Resgrid Account Today!](https://resgrid.com) +| Project | Target | Purpose | +| --- | --- | --- | +| `Providers\Resgrid.Providers.ApiClient` | `net10.0` | Resgrid v4 API + OIDC refresh-token client | +| `Resgrid.Audio.Core` | `net10.0-windows` | Windows audio detection, recording, and audio-call submission | +| `Resgrid.Audio.Relay.Console` | `net10.0`, `net10.0-windows` | Main worker/CLI entry point for SMTP and audio modes | +| `Resgrid.Audio.Relay` | `net10.0-windows` | Lightweight Windows monitoring UI | +| `Resgrid.Audio.Tests` | `net10.0-windows` | Audio, dispatch-list, and SMTP routing tests | -## System Requirements ## +## Modes -* Windows 7 or newer -* .Net Framework 4.6.2 -* 1.8Ghz Single Core Processor -* 8GB of RAM -* 2GB of Free Disk Space -* For Scanner audio a Scanner with an Audio Line Out i.e. [WS1065](https://amzn.to/2Kuck8k) is needed +### SMTP mode -## Configuration +- Cross-platform +- Intended for Linux/Docker deployments +- Runs an SMTP listener and creates Resgrid calls from inbound mail +- Replaces the old Postmark SMTP email API path + +### Audio mode + +- Windows only +- Uses the existing DTMF/audio watcher flow +- Creates a call in Resgrid and uploads captured dispatch audio as a separate v4 call file + +## Requirements + +### Development + +- .NET SDK **10.0.202** or newer compatible .NET 10 SDK +- Windows for the WPF app, tests, and audio mode + +### Runtime + +- **SMTP mode:** any platform with .NET 10 runtime +- **Audio mode:** Windows with an accessible audio input device + +## Authentication + +Relay authenticates to Resgrid through the v4 OIDC token endpoint: + +- Discovery: `https://api.resgrid.com/.well-known/openid-configuration` +- Token endpoint: `https://api.resgrid.com/api/v4/connect/token` +- Grant type: `refresh_token` + +You must provide: + +- `ClientId` +- `ClientSecret` +- `RefreshToken` + +Relay keeps the latest rotated refresh/access token state in a configurable token cache file. + +## Worker configuration (`appsettings.json` / environment variables) + +The console worker reads `appsettings.json` plus environment variables prefixed with `RELAY_`. + +```json +{ + "Mode": "smtp", + "AudioConfigPath": "settings.json", + "Resgrid": { + "BaseUrl": "https://api.resgrid.com", + "ApiVersion": "4", + "ClientId": "YOUR_CLIENT_ID", + "ClientSecret": "YOUR_CLIENT_SECRET", + "RefreshToken": "YOUR_REFRESH_TOKEN", + "Scope": "openid profile email offline_access mobile", + "TokenCachePath": ".\\data\\resgrid-token.json" + }, + "Telemetry": { + "Environment": "production", + "Sentry": { + "Dsn": "YOUR_SENTRY_DSN", + "Release": "resgrid-relay@1.0.0", + "SendDefaultPii": true + }, + "Countly": { + "Url": "https://countly.example.com", + "AppKey": "YOUR_COUNTLY_APP_KEY", + "DeviceId": "resgrid-relay-prod-01", + "RequestTimeoutSeconds": 5 + } + }, + "Smtp": { + "ServerName": "resgrid-relay", + "Port": 2525, + "DataDirectory": ".\\data", + "DuplicateWindowHours": 72, + "DefaultCallPriority": 1, + "MaxAttachmentBytes": 10485760, + "SaveRawMessages": true, + "DepartmentDispatchPrefix": "G", + "DepartmentAddressDomains": [ "dispatch.resgrid.com" ], + "GroupAddressDomains": [ "groups.resgrid.com" ] + } +} +``` + +### Environment variable example + +```powershell +$env:RELAY_Mode = "smtp" +$env:RELAY_Resgrid__ClientId = "YOUR_CLIENT_ID" +$env:RELAY_Resgrid__ClientSecret = "YOUR_CLIENT_SECRET" +$env:RELAY_Resgrid__RefreshToken = "YOUR_REFRESH_TOKEN" +$env:RELAY_Telemetry__Sentry__Dsn = "YOUR_SENTRY_DSN" +$env:RELAY_Telemetry__Countly__Url = "https://countly.example.com" +$env:RELAY_Telemetry__Countly__AppKey = "YOUR_COUNTLY_APP_KEY" +$env:RELAY_Smtp__Port = "2525" +``` + +### SMTP observability + +SMTP mode now emits structured logging for: + +- connection open / close / fault events +- sender and recipient acceptance or rejection +- message receipt, duplicate suppression, routing resolution, and processing lifecycle +- Resgrid call creation, attachment handling, and processing failures + +Optional integrations: + +- **Sentry** captures SMTP processing and connection exceptions with message/session context attached. +- **Countly** records custom events such as `smtp_connection_started`, `smtp_connection_completed`, `smtp_message_processed`, and `smtp_message_failed`. + +Notes: + +- Relay logs message metadata such as sender, recipients, subject, message ids, dispatch targets, and attachment names. +- Relay intentionally does **not** mirror full email bodies into logs; the imported call note and optional raw `.eml` retention remain the system-of-record for message content. +- If `Telemetry:Countly:DeviceId` is blank, Relay derives a stable device id from the machine name and SMTP server name. + +## Audio configuration (`settings.json`) + +Audio mode continues to use `settings.json`, but now stores Resgrid OIDC settings instead of username/password. ```json -// settings.json { "InputDevice": 0, - "AudioLength": 60, - "ApiUrl": "https://api.resgrid.com", - "Username": "TEST", - "Password": "TEST", + "AudioLength": 120, "Multiple": false, "Tolerance": 100, - "Threshold": -40, + "Threshold": -50, + "EnableSilenceDetection": false, + "Debug": false, + "DebugKey": "", + "Resgrid": { + "BaseUrl": "https://api.resgrid.com", + "ApiVersion": "4", + "ClientId": "YOUR_CLIENT_ID", + "ClientSecret": "YOUR_CLIENT_SECRET", + "RefreshToken": "YOUR_REFRESH_TOKEN", + "Scope": "openid profile email offline_access mobile", + "TokenCachePath": ".\\data\\resgrid-token.json" + }, + "DispatchMapping": { + "GroupDispatchPrefix": "G", + "DepartmentDispatchPrefix": "G" + }, "Watchers": [ { - "Id": "ee188c37-09f4-47c0-9ff1-fe34c9d6a5f1", + "Id": "ee188c37-09f4-47c0-9ff1-fe34c9d6a5f1", "Name": "Station 1", "Active": true, - "Code": null, - "Type": 0, - "Eval": 0, + "Code": "ABC123", + "AdditionalCodes": "", + "Type": 2, "Triggers": [ { "Frequency1": 524.0, "Frequency2": 794.0, - "Time": 500, + "Time1": 500, + "Time2": 500, "Count": 2 } ] - }, - ... + } ] } ``` -## Settings - -### Settings.json Values - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SettingDescription
InputDevice - The Audio Input device that system will listen to. It's recommend this is a hard Line In or Stereo Mix input and not a Mic input -
AudioLength - Time of time in SECONDS to record the dispatch audio for -
ApiUrl - The URL to talk to the Resgrid API (Services) for our hosted production system this is "https://api.resgrid.com" -
Username - Resgrid system login Username that can create calls -
Password - Resgrid system login Password for the Username above -
Multiple - Once a tone is Detected, do you want 1 call created and the Groups dispatched for it, or a call created in Resgrid for each watcher. If Multiple is false, one call will only be created and each Group (per watcher) will be dispatched as part of that call, if Multiple is true each watcher creates a call in Resgrid. -
Tolerance - The relative power of the tone frequency to trigger. This value should be between 50 and 250, ideally at or around 100 (the default). If your getting false triggers try increasing this value. -
Threshold - Decibel dB value for silence detection, default is -40. Depending on how loud the background audio or static is this value may need to be raised to cut out the static. -
Debug - Enable or Disable debug mode. Debug should only be enabled when trying to analyze an issue. -
Watchers - An Array of Watcher Objects -
- -### Watcher Settings Values - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SettingDescription
Id - Unique GUID\UUID for the watcher, this value is used to queue and dequeue watchers and verify if one is already running. This value must be unique for every Watcher in the array. -
Name - Name of the group that this watcher is for. Seeming Watchers are tied to Resgrid groups it's usually best to just put the group name in here. -
Active - Can be true or false. Determines if this watcher is active and it's triggers should be monitored. -
Code - Your department groups dispatch code. For a Type of 2 (Group) You get this value from the Stations & Groups section of the website, and it's the alphanumeric code in front of @groups.resgrid.com. For a Type of 1 (Department) you get this from the Calls Import Settings screen excluding @dispatch.resgrid.com. Do not include anything other then the 6 character code. -
Type - 1 = Department, 2 = Group -
Triggers - Array of triggers -
- -### Triggers Settings Values - - - - - - - - - - - - - - - - - - - - - -
SettingDescription
Frequency1 - The first (or only) tone frequency to monitor -
Frequency2 - The second tone frequency to monitor -
Time - Time in milliseconds the tone need to run for to be triggered. If your tones run for 1 second (1000 milliseconds) each you should set this value to 750 or 500. If your getting false positives increase this value a bit. But tone length detection can be difficult if there is competing traffic or noise. So a lower value is 'safer'. -
Count - 1 or 2, the number of distinct tones your trigger has. -
- -## Installation ## - -You should download the latest sable release from our Release page. It's recommend that you use the setup\installer based option. - -## Notes ## - - -## Author's ## -* Shawn Jackson (Twitter: @DesignLimbo Blog: http://designlimbo.com) -* Jason Jarrett (Twitter: @staxmanade Blog: http://staxmanade.com) - -## License ## -[Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) - -## Acknowledgments - -Resgrid Relay makes use of the following OSS projects: - -- Consolas released under the BSD 2-Clause license: https://github.com/rickardn/Consolas/blob/develop/LICENSE -- NAudio released under the Microsoft Public License: https://github.com/naudio/NAudio/blob/master/license.txt -- DtmfDetection released under the GNU Lesser GPL v3.0 License: https://github.com/Resgrid/DtmfDetection/blob/master/LICENSE \ No newline at end of file +### Audio watcher notes + +- `Type = 1` means **department** dispatch code +- `Type = 2` means **group** dispatch code +- `AdditionalCodes` is treated as extra **group** dispatch codes + +## SMTP routing behavior + +Relay accepts inbound SMTP messages for configured Resgrid-style dispatch domains and converts recipient addresses into v4 `DispatchList` entries. + +Examples: + +| Recipient | Routing | +| --- | --- | +| `abc123@dispatch.resgrid.com` | Department dispatch code | +| `station7@groups.resgrid.com` | Group dispatch code | + +Notes: + +- Duplicate messages are suppressed using a persisted message-id store +- Raw `.eml` files can be saved for traceability +- Attachments are uploaded to the created call through `CallFiles/SaveCallFile` +- Structured logs, Sentry exception capture, and Countly custom events are available for SMTP operations +- If your Resgrid department-dispatch code requires a prefix other than `G`, set `Smtp:DepartmentDispatchPrefix` + +## Commands + +From `Resgrid.Audio.Relay.Console`: + +```powershell +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- run +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- setup +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- devices +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- monitor 0 +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- version +``` + +## Docker + +Build the Linux SMTP worker image: + +```powershell +docker build -t resgrid-relay . +``` + +Run it: + +```powershell +docker run --rm -p 2525:2525 ` + -e RELAY_Mode=smtp ` + -e RELAY_Resgrid__ClientId=YOUR_CLIENT_ID ` + -e RELAY_Resgrid__ClientSecret=YOUR_CLIENT_SECRET ` + -e RELAY_Resgrid__RefreshToken=YOUR_REFRESH_TOKEN ` + resgrid-relay +``` + +## Build and test + +```powershell +dotnet build "Resgrid Audio.sln" +dotnet test "Resgrid Audio.sln" +``` + +## Notes + +- The worker now creates calls through `Calls/SaveCall` +- Audio and SMTP attachments are uploaded through `CallFiles/SaveCallFile` +- The worker derives the current Resgrid user id from the OIDC access token `sub` claim for file uploads +- The Windows audio path intentionally keeps the legacy DTMF assemblies isolated to the Windows-only project boundary + +## Authors + +- Shawn Jackson +- Jason Jarrett + +## License + +[Apache 2.0](LICENSE.txt) diff --git a/Resgrid.Audio.Core/AudioEvaluator.cs b/Resgrid.Audio.Core/AudioEvaluator.cs index 2caee32..0325a67 100644 --- a/Resgrid.Audio.Core/AudioEvaluator.cs +++ b/Resgrid.Audio.Core/AudioEvaluator.cs @@ -185,7 +185,7 @@ private DtmfTone AddTone(int frequency) var tone = _dtmfTone.FirstOrDefault(x => x.HighTone == frequency); - if (tone == null || tone == DtmfTone.None) + if (tone == DtmfTone.None) { tone = new DtmfTone(frequency, 0, (PhoneKey)(_dtmfTone.Count() + (int)PhoneKey.Custom1)); DtmfClassification.AddCustomTone(tone); diff --git a/Resgrid.Audio.Core/AudioProcessor.cs b/Resgrid.Audio.Core/AudioProcessor.cs index 02a4f36..96f7c35 100644 --- a/Resgrid.Audio.Core/AudioProcessor.cs +++ b/Resgrid.Audio.Core/AudioProcessor.cs @@ -25,8 +25,6 @@ public class AudioProcessor : IAudioProcessor private bool _initialized = false; private CircularBuffer _buffer; - private Settings _settings; - private Config _config; private Dictionary _startedWatchers; @@ -119,7 +117,7 @@ public void Init(Config config) _audioEvaluator.Init(_config); - _timer.Interval = config.AudioLength; + _timer.Interval = 1000; _timer.Enabled = true; _initialized = true; diff --git a/Resgrid.Audio.Core/ComService.cs b/Resgrid.Audio.Core/ComService.cs index 898ca76..59f2c08 100644 --- a/Resgrid.Audio.Core/ComService.cs +++ b/Resgrid.Audio.Core/ComService.cs @@ -1,11 +1,13 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; +using System.Net.Http; using System.Text; +using System.Threading.Tasks; using Resgrid.Audio.Core.Events; using Resgrid.Audio.Core.Model; -using Resgrid.Providers.ApiClient; -using Resgrid.Providers.ApiClient.V3; -using Resgrid.Providers.ApiClient.V3.Models; +using Resgrid.Providers.ApiClient.V4; +using Resgrid.Providers.ApiClient.V4.Models; using Serilog.Core; namespace Resgrid.Audio.Core @@ -36,103 +38,80 @@ public bool IsConnectionValid() { try { - var result = HealthApi.GetApiHealth().Result; - - if (result != null && result.DatabaseOnline) - return true; + return HealthApi.IsHealthyAsync().GetAwaiter().GetResult(); } - catch (Exception e) + catch (HttpRequestException ex) { + _logger.Error(ex.ToString()); + return false; + } + catch (InvalidOperationException ex) + { + _logger.Error(ex.ToString()); return false; } - - return false; } private void _audioProcessor_TriggerProcessingFinished(object sender, Events.TriggerProcessedEventArgs e) { if (e.Mp3Audio != null && e.Mp3Audio.Length > 0) { - Call newCall = new Call(); - newCall.Priority = (int)CallPriority.Medium; - StringBuilder watchersToned = new StringBuilder(); - - watchersToned.Append($"{e.Watcher.Name} "); - - newCall.GroupCodesToDispatch = new List(); - newCall.GroupCodesToDispatch.Add(e.Watcher.Code); - newCall.CallSource = 3; - newCall.SourceIdentifier = e.Watcher.Id.ToString(); - - var additionalCodes = e.Watcher.GetAdditionalWatchers(); - if (additionalCodes != null && additionalCodes.Count > 0) + try { - foreach (var code in additionalCodes) + var dispatchContext = BuildDispatchContext(e.Watcher); + var watchersToned = String.Join(", ", dispatchContext.WatcherNames); + var isDepartmentDispatch = dispatchContext.DispatchCodes.Any(x => x.Type == DispatchCodeType.Department); + var dispatchList = DispatchListBuilder.Build(dispatchContext.DispatchCodes, _config?.DispatchMapping?.DepartmentDispatchPrefix); + if (String.IsNullOrWhiteSpace(dispatchList)) + throw new InvalidOperationException($"No valid Resgrid dispatch targets were found for watcher '{e.Watcher.Name}'."); + + var callName = isDepartmentDispatch + ? $"ALLCALL Audio Import {DateTime.Now:g}" + : $"Audio Import {DateTime.Now:g}"; + + var callInput = new NewCallInput { - if (!newCall.GroupCodesToDispatch.Contains(code.Code)) - { - watchersToned.Append($"{code.Name} "); - newCall.GroupCodesToDispatch.Add(code.Code); - - if (code.Type == 1) - newCall.AllCall = true; - } - } - } - - // Run through the additional comma seperated group codes and add them. - if (!string.IsNullOrWhiteSpace(e.Watcher.AdditionalCodes)) - { - var newCodes = e.Watcher.AdditionalCodes.Split(char.Parse(",")); - - if (newCodes != null && newCodes.Length > 0) + Priority = 1, + Name = callName, + Nature = $"Relay import, listen to audio for call info. Toned: {watchersToned}", + Note = $"Audio imported call from a radio dispatch using the Resgrid Relay app. Listen to the attached audio for call information. Call was created on {DateTime.Now:F}. Department dispatch: {isDepartmentDispatch}. Watchers toned: {watchersToned}", + DispatchList = dispatchList, + ExternalId = e.Watcher.Id.ToString(), + ReferenceId = e.Watcher.TriggerFiredTimestamp.ToString("O"), + Type = "Relay Audio" + }; + + var savedCallId = CallsApi.SaveCallAsync(callInput).GetAwaiter().GetResult(); + var userId = ResgridV4ApiClient.CurrentUserId; + if (String.IsNullOrWhiteSpace(userId)) + throw new InvalidOperationException("The Resgrid access token did not contain a user id required to upload the dispatch audio."); + + CallsApi.SaveCallFileAsync(new SaveCallFileInput { - foreach (var code in newCodes) - { - if (!newCall.GroupCodesToDispatch.Contains(code)) - { - newCall.GroupCodesToDispatch.Add(code); - } - } - } + CallId = savedCallId, + UserId = userId, + Type = (int)CallFileType.Audio, + Name = $"Relay_{e.Watcher.TriggerFiredTimestamp:s}".Replace(":", "_") + ".mp3", + Data = Convert.ToBase64String(e.Mp3Audio), + Note = $"Captured dispatch audio for watcher {e.Watcher.Name}" + }).GetAwaiter().GetResult(); + + var savedCall = CallsApi.GetCallAsync(savedCallId).GetAwaiter().GetResult(); + CallCreatedEvent?.Invoke( + this, + new CallCreatedEventArgs(e.Watcher.Name, savedCallId, savedCall?.Data?.Number, DateTime.Now)); } - - newCall.NatureOfCall = - $"Relay import, listen to audio for call info. Toned: {watchersToned}"; - newCall.Notes = - $"Audio importted call from a radio dispatch using the Resgrid Relay app. Listen to attached audio for call information. Call was created on {DateTime.Now.ToString("F")}. Was an AllCall: {newCall.AllCall}. Watchers Toned: {watchersToned}"; - - if (e.Watcher.Type == 1) - newCall.AllCall = true; - - if (newCall.AllCall) - newCall.Name = $"ALLCALL Audio Import {DateTime.Now.ToString("g")}"; - else - newCall.Name = $"Audio Import {DateTime.Now.ToString("g")}"; - - newCall.Attachments = new List(); - newCall.Attachments.Add(new CallAttachment() + catch (HttpRequestException ex) { - CallAttachmentType = (int)CallAttachmentTypes.DispatchAudio, - FileName = $"Relay_{e.Watcher.TriggerFiredTimestamp.ToString("s").Replace(":", "_")}.mp3", - Timestamp = DateTime.UtcNow, - Data = e.Mp3Audio - }); - - try - { - var savedCall = AsyncHelpers.RunSync(() => CallsApi.AddNewCall(newCall)); - - if (savedCall != null) - CallCreatedEvent?.Invoke(this, new CallCreatedEventArgs(e.Watcher.Name, savedCall.CallId, savedCall.Number, DateTime.Now)); + _logger.Error(ex.ToString()); } - catch (Exception ex) + catch (InvalidOperationException ex) { _logger.Error(ex.ToString()); } - finally + catch (TaskCanceledException ex) { - newCall = null; + _logger.Error(ex.ToString()); } } else @@ -140,5 +119,50 @@ private void _audioProcessor_TriggerProcessingFinished(object sender, Events.Tri _logger.Warning($"No Dispatch Audio detected, unable to save call for {e.Watcher.Name}"); } } + + private (List DispatchCodes, List WatcherNames) BuildDispatchContext(Watcher watcher) + { + var dispatchCodes = new List(); + var watcherNames = new List(); + + AddDispatchCode(dispatchCodes, watcherNames, watcher.Name, watcher.Code, watcher.Type); + + var additionalWatchers = watcher.GetAdditionalWatchers(); + if (additionalWatchers != null && additionalWatchers.Count > 0) + { + foreach (var additionalWatcher in additionalWatchers) + { + AddDispatchCode(dispatchCodes, watcherNames, additionalWatcher.Name, additionalWatcher.Code, additionalWatcher.Type); + } + } + + if (!String.IsNullOrWhiteSpace(watcher.AdditionalCodes)) + { + foreach (var additionalCode in watcher.AdditionalCodes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + AddDispatchCode(dispatchCodes, watcherNames, watcher.Name, additionalCode, (int)DispatchCodeType.Group); + } + } + + return (dispatchCodes, watcherNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + + private static void AddDispatchCode(List dispatchCodes, List watcherNames, string watcherName, string code, int watcherType) + { + if (!String.IsNullOrWhiteSpace(watcherName)) + watcherNames.Add(watcherName.Trim()); + + if (String.IsNullOrWhiteSpace(code)) + return; + + if (dispatchCodes.Any(x => String.Equals(x.Code, code.Trim(), StringComparison.OrdinalIgnoreCase))) + return; + + dispatchCodes.Add(new DispatchCode + { + Code = code.Trim(), + Type = watcherType == (int)DispatchCodeType.Department ? DispatchCodeType.Department : DispatchCodeType.Group + }); + } } } diff --git a/Resgrid.Audio.Core/Events/CallCreatedEventArgs.cs b/Resgrid.Audio.Core/Events/CallCreatedEventArgs.cs index 1ce84fb..854f366 100644 --- a/Resgrid.Audio.Core/Events/CallCreatedEventArgs.cs +++ b/Resgrid.Audio.Core/Events/CallCreatedEventArgs.cs @@ -6,7 +6,7 @@ namespace Resgrid.Audio.Core.Events public class CallCreatedEventArgs { [DebuggerStepThrough] - public CallCreatedEventArgs(string watcherName, int callId, string callNumber, DateTime timestamp) + public CallCreatedEventArgs(string watcherName, string callId, string callNumber, DateTime timestamp) { WatcherName = watcherName; CallId = callId; @@ -15,7 +15,7 @@ public CallCreatedEventArgs(string watcherName, int callId, string callNumber, D } public string WatcherName { get; set; } - public int CallId { get; private set; } + public string CallId { get; private set; } public string CallNumber { get; private set; } public DateTime Timestamp { get; private set; } } diff --git a/Resgrid.Audio.Core/Functions.cs b/Resgrid.Audio.Core/Functions.cs index 7233596..be8961d 100644 --- a/Resgrid.Audio.Core/Functions.cs +++ b/Resgrid.Audio.Core/Functions.cs @@ -88,7 +88,7 @@ public static double[] WaveFileDataPrepare(String wavePath, out int SampleRate) System.IO.FileStream WaveFile = System.IO.File.OpenRead(wavePath); wave = new byte[WaveFile.Length]; data = new double[(wave.Length - 44) / 4];//shifting the headers out of the PCM data; - WaveFile.Read(wave, 0, Convert.ToInt32(WaveFile.Length));//read the wave file into the wave variable + WaveFile.ReadExactly(wave, 0, Convert.ToInt32(WaveFile.Length));//read the wave file into the wave variable /***********Converting and PCM accounting***************/ for (int i = 0; i < data.Length - i * 4; i++) { diff --git a/Resgrid.Audio.Core/Inputs/AudioRecorder.cs b/Resgrid.Audio.Core/Inputs/AudioRecorder.cs index 8408253..69e9283 100644 --- a/Resgrid.Audio.Core/Inputs/AudioRecorder.cs +++ b/Resgrid.Audio.Core/Inputs/AudioRecorder.cs @@ -1,11 +1,10 @@ // https://github.com/markheath/voicerecorder -using NAudio.Mixer; +using NAudio.MediaFoundation; using NAudio.Wave; using System; using System.IO; using System.Linq; -using NAudio.Lame; using Resgrid.Audio.Core.Model; namespace Resgrid.Audio.Core @@ -28,21 +27,20 @@ public class AudioRecorder : IAudioRecorder { WaveInEvent waveIn; private SampleAggregator _sampleAggregator; - double desiredVolume = 100; RecordingState recordingState; WaveFileWriter writer; WaveFormat recordingFormat; BufferedWaveProvider bwp; - private AudioEvaluator _audioEvaluator; - private IWatcherAudioStorage _audioStorage; + private readonly IAudioEvaluator _audioEvaluator; + private readonly IWatcherAudioStorage _audioStorage; public event EventHandler Stopped = delegate { }; private int RATE = 44100; // 44100 is a pretty standard rate, but for Speech-to-Text they almost always want 16000 private int BUFFERSIZE = (int)Math.Pow(2, 11); // must be a multiple of 2 - public AudioRecorder(AudioEvaluator audioEvaluator, IWatcherAudioStorage audioStorage) + public AudioRecorder(IAudioEvaluator audioEvaluator, IWatcherAudioStorage audioStorage) { _audioEvaluator = audioEvaluator; _audioStorage = audioStorage; @@ -114,10 +112,10 @@ public void BeginRecording(string waveFileName) public void Stop() { - if (recordingState == RecordingState.Recording) + if (recordingState == RecordingState.Monitoring || recordingState == RecordingState.Recording) { recordingState = RecordingState.RequestedStop; - waveIn.StopRecording(); + waveIn?.StopRecording(); } } @@ -182,22 +180,39 @@ private void WriteToFile(byte[] buffer, int bytesRecorded) public byte[] SaveWatcherAudio(Watcher watcher) { - var path = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).Replace("file:\\", ""); - var fileName = $"{path}\\DispatchAudio\\RelayAudio_{DateTime.Now.ToString("s").Replace(":", "_")}.wav"; + var buffer = _audioStorage.GetAudio(watcher.Id); + if (buffer == null || buffer.Length == 0) + return Array.Empty(); - var waveWriter = new WaveFileWriter(fileName, recordingFormat); + var directory = Path.Combine(AppContext.BaseDirectory, "DispatchAudio"); + Directory.CreateDirectory(directory); - var buffer = _audioStorage.GetAudio(watcher.Id); - waveWriter.Write(buffer, 0, buffer.Length); - waveWriter.Dispose(); + var fileRoot = Path.Combine(directory, $"RelayAudio_{DateTime.Now:s}".Replace(":", "_")); + var waveFilePath = $"{fileRoot}.wav"; + var mp3FilePath = $"{fileRoot}.mp3"; - using (var retMs = new MemoryStream()) - using (var ms = new MemoryStream()) - using (var rdr = new WaveFileReader(fileName)) - using (var wtr = new LameMP3FileWriter(retMs, rdr.WaveFormat, RATE / 10)) + try { - rdr.CopyTo(wtr); - return retMs.ToArray(); + using (var waveWriter = new WaveFileWriter(waveFilePath, recordingFormat)) + { + waveWriter.Write(buffer, 0, buffer.Length); + } + + MediaFoundationApi.Startup(); + using (var waveReader = new WaveFileReader(waveFilePath)) + { + MediaFoundationEncoder.EncodeToMp3(waveReader, mp3FilePath, 128000); + } + + return File.ReadAllBytes(mp3FilePath); + } + finally + { + if (File.Exists(waveFilePath)) + File.Delete(waveFilePath); + + if (File.Exists(mp3FilePath)) + File.Delete(mp3FilePath); } } } diff --git a/Resgrid.Audio.Core/Model/Config.cs b/Resgrid.Audio.Core/Model/Config.cs index b72abc1..42c0f99 100644 --- a/Resgrid.Audio.Core/Model/Config.cs +++ b/Resgrid.Audio.Core/Model/Config.cs @@ -1,21 +1,37 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; namespace Resgrid.Audio.Core.Model { public class Config { public int InputDevice { get; set; } - public int AudioLength { get; set; } + public int AudioLength { get; set; } = 60; public string ApiUrl { get; set; } - public string Username { get; set; } - public string Password { get; set; } public bool Multiple { get; set; } - public double Tolerance { get; set; } - public sbyte Threshold { get; set; } + public double Tolerance { get; set; } = 100; + public sbyte Threshold { get; set; } = -40; public bool EnableSilenceDetection { get; set; } public bool Debug { get; set; } public string DebugKey { get; set; } - public List Watchers { get; set; } + public ResgridConnectionSettings Resgrid { get; set; } = new ResgridConnectionSettings(); + public DispatchMappingSettings DispatchMapping { get; set; } = new DispatchMappingSettings(); + public List Watchers { get; set; } = new List(); + } + + public class ResgridConnectionSettings + { + public string BaseUrl { get; set; } = "https://api.resgrid.com"; + public string ApiVersion { get; set; } = "4"; + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string RefreshToken { get; set; } + public string Scope { get; set; } = "openid profile email offline_access mobile"; + public string TokenCachePath { get; set; } = ".\\data\\resgrid-token.json"; + } + + public class DispatchMappingSettings + { + public string GroupDispatchPrefix { get; set; } = "G"; + public string DepartmentDispatchPrefix { get; set; } = "G"; } } diff --git a/Resgrid.Audio.Core/Model/Trigger.cs b/Resgrid.Audio.Core/Model/Trigger.cs index d6b49d6..399f2fb 100644 --- a/Resgrid.Audio.Core/Model/Trigger.cs +++ b/Resgrid.Audio.Core/Model/Trigger.cs @@ -4,7 +4,7 @@ using System.Linq; using DtmfDetection; using DtmfDetection.NAudio; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Resgrid.Audio.Core.Model { diff --git a/Resgrid.Audio.Core/Model/Watcher.cs b/Resgrid.Audio.Core/Model/Watcher.cs index 5c9c625..f5c8420 100644 --- a/Resgrid.Audio.Core/Model/Watcher.cs +++ b/Resgrid.Audio.Core/Model/Watcher.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using DtmfDetection; using DtmfDetection.NAudio; +using System.Text.Json.Serialization; namespace Resgrid.Audio.Core.Model { @@ -10,7 +11,6 @@ public class Watcher { private Guid _id; private Trigger _trigger; - private int _audioCount; private List _additionalWatchers; public string Name { get; set; } @@ -20,17 +20,17 @@ public class Watcher public int Type { get; set; } // 1 Department, 2 Group public List Triggers { get; set; } - [Newtonsoft.Json.JsonIgnore] + [JsonIgnore] public DateTime TriggerFiredTimestamp { get; set; } - [Newtonsoft.Json.JsonIgnore] + [JsonIgnore] public DateTime LastCheckedTimestamp { get; set; } public Guid Id { get { - if (_id == null) + if (_id == Guid.Empty) _id = Guid.NewGuid(); return _id; diff --git a/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj b/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj index e1cee08..cf023b4 100644 --- a/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj +++ b/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj @@ -1,130 +1,34 @@ - - - + - Debug - AnyCPU - {64D892BF-42A5-40B6-93A4-E9ADE0774ABF} - Library - Properties + net10.0-windows Resgrid.Audio.Core Resgrid.Audio.Core - v4.7.1 - 512 - - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 + false + disable + disable + + + + + + - - ..\packages\Accord.3.8.0\lib\net462\Accord.dll - - - ..\packages\Accord.Math.3.8.0\lib\net462\Accord.Math.dll - - - ..\packages\Accord.Math.3.8.0\lib\net462\Accord.Math.Core.dll - ..\References\DtmfDetection.dll + true ..\References\DtmfDetection.NAudio.dll + true - + ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll + true - - ..\packages\NAudio.Lame.1.0.7\lib\net20\NAudio.Lame.dll - - - ..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll - - - ..\packages\Serilog.2.7.1\lib\net46\Serilog.dll - - - ..\packages\Serilog.Sinks.Console.3.1.1\lib\net45\Serilog.Sinks.Console.dll - - - - - ..\packages\Microsoft.AspNet.WebApi.Client.5.2.6\lib\net45\System.Net.Http.Formatting.dll - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {7aa51788-4951-4ec0-8feb-8381200600c4} - Resgrid.Providers.ApiClient - + - - Always - - - Always - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - \ No newline at end of file + diff --git a/Resgrid.Audio.Core/SampleAggregator.cs b/Resgrid.Audio.Core/SampleAggregator.cs index 237d111..fcbe33a 100644 --- a/Resgrid.Audio.Core/SampleAggregator.cs +++ b/Resgrid.Audio.Core/SampleAggregator.cs @@ -9,7 +9,6 @@ namespace Resgrid.Audio.Core { public class SampleAggregator { - private int RATE = 44100; // sample rate of the sound card private int BUFFERSIZE = (int)Math.Pow(2, 11); // must be a multiple of 2 public event EventHandler DataAvailable; diff --git a/Resgrid.Audio.Core/app.config b/Resgrid.Audio.Core/app.config deleted file mode 100644 index 2bbe771..0000000 --- a/Resgrid.Audio.Core/app.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/Resgrid.Audio.Core/packages.config b/Resgrid.Audio.Core/packages.config deleted file mode 100644 index 97da664..0000000 --- a/Resgrid.Audio.Core/packages.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/Resgrid.Audio.Relay.Console/App.config b/Resgrid.Audio.Relay.Console/App.config deleted file mode 100644 index 8f6585e..0000000 --- a/Resgrid.Audio.Relay.Console/App.config +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs b/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs new file mode 100644 index 0000000..26ede77 --- /dev/null +++ b/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs @@ -0,0 +1,49 @@ +using Resgrid.Providers.ApiClient.V4; + +namespace Resgrid.Audio.Relay.Console.Configuration +{ + public sealed class RelayHostOptions + { + public string Mode { get; set; } = "smtp"; + public string AudioConfigPath { get; set; } = "settings.json"; + public ResgridApiClientOptions Resgrid { get; set; } = new ResgridApiClientOptions(); + public RelayTelemetryOptions Telemetry { get; set; } = new RelayTelemetryOptions(); + public SmtpRelayOptions Smtp { get; set; } = new SmtpRelayOptions(); + } + + public sealed class RelayTelemetryOptions + { + public string Environment { get; set; } = ""; + public SentryTelemetryOptions Sentry { get; set; } = new SentryTelemetryOptions(); + public CountlyTelemetryOptions Countly { get; set; } = new CountlyTelemetryOptions(); + } + + public sealed class SentryTelemetryOptions + { + public string Dsn { get; set; } = ""; + public string Release { get; set; } = ""; + public bool SendDefaultPii { get; set; } = true; + } + + public sealed class CountlyTelemetryOptions + { + public string Url { get; set; } = ""; + public string AppKey { get; set; } = ""; + public string DeviceId { get; set; } = ""; + public int RequestTimeoutSeconds { get; set; } = 5; + } + + public sealed class SmtpRelayOptions + { + public string ServerName { get; set; } = "resgrid-relay"; + public int Port { get; set; } = 2525; + public string DataDirectory { get; set; } = ".\\data"; + public int DuplicateWindowHours { get; set; } = 72; + public int DefaultCallPriority { get; set; } = 1; + public int MaxAttachmentBytes { get; set; } = 10485760; + public bool SaveRawMessages { get; set; } = true; + public string DepartmentDispatchPrefix { get; set; } = "G"; + public string[] DepartmentAddressDomains { get; set; } = new[] { "dispatch.resgrid.com" }; + public string[] GroupAddressDomains { get; set; } = new[] { "groups.resgrid.com" }; + } +} diff --git a/Resgrid.Audio.Relay.Console/Program.cs b/Resgrid.Audio.Relay.Console/Program.cs index d853fa7..9f87181 100644 --- a/Resgrid.Audio.Relay.Console/Program.cs +++ b/Resgrid.Audio.Relay.Console/Program.cs @@ -1,24 +1,418 @@ -using Consolas.Core; -using Consolas.Mustache; -using Resgrid.Audio.Relay.Console.Models; -using SimpleInjector; +using Microsoft.Extensions.Configuration; +using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Audio.Relay.Console.Smtp; +using Resgrid.Providers.ApiClient.V4; +using Serilog; +using Serilog.Core; +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Cli = System.Console; +#if NET10_0_WINDOWS +using NAudio.Wave; +using Resgrid.Audio.Core; +using Resgrid.Audio.Core.Model; +#endif namespace Resgrid.Audio.Relay.Console { - public class Program : ConsoleApp + public static class Program { - static void Main(string[] args) + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { - Match(args); + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public static async Task Main(string[] args) + { + var command = args.Length == 0 ? "run" : args[0].Trim().ToLowerInvariant(); + + switch (command) + { + case "run": + return await RunAsync().ConfigureAwait(false); + case "setup": + return await SetupAsync().ConfigureAwait(false); + case "devices": + return ShowDevices(); + case "monitor": + return await MonitorAsync(args.Skip(1).ToArray()).ConfigureAwait(false); + case "version": + case "--version": + case "-v": + ShowVersion(); + return 0; + case "help": + case "--help": + case "-h": + ShowHelp(); + return 0; + default: + Cli.Error.WriteLine($"Unknown command '{command}'."); + ShowHelp(); + return 1; + } + } + + private static async Task RunAsync() + { + var hostOptions = LoadHostOptions(); + var mode = (hostOptions.Mode ?? "smtp").Trim().ToLowerInvariant(); + + using var cancellationTokenSource = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellationTokenSource.Cancel(); + }; + Cli.CancelKeyPress += cancelHandler; + + try + { + switch (mode) + { + case "smtp": + { + var smtpLogger = CreateLogger(debug: false); + await using var telemetry = SmtpTelemetry.Create(hostOptions, smtpLogger); + ResgridV4ApiClient.Init(hostOptions.Resgrid); + await SmtpRelayRunner.RunAsync(hostOptions.Smtp, telemetry, cancellationTokenSource.Token).ConfigureAwait(false); + return 0; + } + case "audio": + return await RunAudioModeAsync(hostOptions, cancellationTokenSource.Token).ConfigureAwait(false); + default: + Cli.Error.WriteLine($"Unsupported relay mode '{hostOptions.Mode}'. Supported modes are 'smtp' and 'audio'."); + return 1; + } + } + finally + { + Cli.CancelKeyPress -= cancelHandler; + } + } + + private static RelayHostOptions LoadHostOptions() + { + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables("RELAY_") + .Build(); + + var options = new RelayHostOptions(); + configuration.Bind(options); + + if (!Path.IsPathRooted(options.AudioConfigPath)) + options.AudioConfigPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, options.AudioConfigPath)); + + if (!String.IsNullOrWhiteSpace(options.Resgrid.TokenCachePath) && !Path.IsPathRooted(options.Resgrid.TokenCachePath)) + options.Resgrid.TokenCachePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, options.Resgrid.TokenCachePath)); + + return options; + } + + private static void ShowVersion() + { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + Cli.WriteLine($"Resgrid Relay {version}"); + } + + private static void ShowHelp() + { + Cli.WriteLine("Resgrid Relay"); + Cli.WriteLine("------------------------------"); + Cli.WriteLine("Commands:"); + Cli.WriteLine(" run Starts the relay in the configured mode (default)"); + Cli.WriteLine(" setup Creates or updates the Windows audio settings.json file"); + Cli.WriteLine(" devices Lists Windows audio input devices"); + Cli.WriteLine(" monitor Monitors a Windows audio device without dispatching calls"); + Cli.WriteLine(" version Prints the application version"); + Cli.WriteLine(); + Cli.WriteLine("Environment variables:"); + Cli.WriteLine(" RELAY_Mode=smtp|audio"); + Cli.WriteLine(" RELAY_Resgrid__ClientId=..."); + Cli.WriteLine(" RELAY_Resgrid__ClientSecret=..."); + Cli.WriteLine(" RELAY_Resgrid__RefreshToken=..."); + Cli.WriteLine(" RELAY_Telemetry__Sentry__Dsn=..."); + Cli.WriteLine(" RELAY_Telemetry__Countly__Url=https://countly.example.com"); + Cli.WriteLine(" RELAY_Telemetry__Countly__AppKey=..."); + Cli.WriteLine(" RELAY_Smtp__Port=2525"); + } + +#if NET10_0_WINDOWS + private static async Task RunAudioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) + { + var config = LoadAudioConfig(hostOptions.AudioConfigPath); + var apiOptions = ResolveResgridOptions(hostOptions, config); + ResgridV4ApiClient.Init(apiOptions); + + Logger logger = CreateLogger(config.Debug); + var audioStorage = new WatcherAudioStorage(logger); + var evaluator = new AudioEvaluator(logger); + var recorder = new AudioRecorder(evaluator, audioStorage); + var processor = new AudioProcessor(recorder, evaluator, audioStorage); + var comService = new ComService(logger, processor); + + evaluator.WatcherTriggered += (_, eventArgs) => + Cli.WriteLine($"{DateTime.Now:G}: WATCHER TRIGGERED: {eventArgs.Watcher.Name}"); + + comService.CallCreatedEvent += (_, eventArgs) => + Cli.WriteLine($"{eventArgs.Timestamp:G}: CALL CREATED: {eventArgs.CallId} ({eventArgs.CallNumber})"); + + comService.Init(config); + if (!comService.IsConnectionValid()) + { + Cli.Error.WriteLine("Unable to reach the Resgrid v4 API with the configured OpenID Connect settings."); + return 1; + } + + Cli.WriteLine($"Listening for dispatches on device {config.InputDevice}"); + processor.Init(config); + processor.Start(); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + ConsoleCancelEventHandler stopHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + recorder.Stop(); + linkedCts.Cancel(); + }; + Cli.CancelKeyPress += stopHandler; + + try + { + while (!linkedCts.IsCancellationRequested && + (recorder.RecordingState == RecordingState.Monitoring || + recorder.RecordingState == RecordingState.Recording || + recorder.RecordingState == RecordingState.RequestedStop)) + { + await Task.Delay(250, linkedCts.Token).ConfigureAwait(false); + } + } + catch (TaskCanceledException) + { + } + finally + { + Cli.CancelKeyPress -= stopHandler; + } + + return 0; + } + + private static async Task SetupAsync() + { + Cli.WriteLine("Resgrid Relay Audio Setup"); + Cli.WriteLine("------------------------------"); + + var config = File.Exists(DefaultAudioConfigPath()) + ? LoadAudioConfig(DefaultAudioConfigPath()) + : new Config(); + + config.Resgrid.BaseUrl = Prompt("Resgrid API Url", config.Resgrid.BaseUrl); + config.Resgrid.ClientId = Prompt("OIDC Client Id", config.Resgrid.ClientId); + config.Resgrid.ClientSecret = Prompt("OIDC Client Secret", config.Resgrid.ClientSecret); + config.Resgrid.RefreshToken = Prompt("OIDC Refresh Token", config.Resgrid.RefreshToken); + + ShowDevices(); + config.InputDevice = Int32.Parse(Prompt("Audio device number", config.InputDevice.ToString())); + config.AudioLength = Int32.Parse(Prompt("Recording length in seconds", config.AudioLength.ToString())); + config.Multiple = Prompt("Create separate calls per watcher? (y/n)", config.Multiple ? "y" : "n").StartsWith("y", StringComparison.OrdinalIgnoreCase); + + var watcherCount = Int32.Parse(Prompt("How many groups or department watchers?", config.Watchers.Count == 0 ? "1" : config.Watchers.Count.ToString())); + config.Watchers.Clear(); + for (var i = 0; i < watcherCount; i++) + { + config.Watchers.Add(CreateWatcher(i + 1)); + } + + SaveAudioConfig(DefaultAudioConfigPath(), config); + Cli.WriteLine($"Settings written to {DefaultAudioConfigPath()}"); + await Task.CompletedTask.ConfigureAwait(false); + return 0; } - public override void Configure(Container container) + private static Watcher CreateWatcher(int index) { - container.Register(); - container.Register(); + Cli.WriteLine(); + Cli.WriteLine($"Watcher #{index}"); + Cli.WriteLine("------------------------------"); + + var watcher = new Watcher + { + Id = Guid.NewGuid(), + Active = true, + Name = Prompt("Watcher Name", $"Watcher {index}"), + Code = Prompt("Watcher Group or Department Dispatch Code", String.Empty), + Type = Prompt("Is this a department dispatch code? (y/n)", "n").StartsWith("y", StringComparison.OrdinalIgnoreCase) + ? 1 + : 2, + AdditionalCodes = Prompt("Additional Group Dispatch Codes (comma separated)", String.Empty) + }; + + watcher.Triggers = new System.Collections.Generic.List + { + new Trigger + { + Count = Int32.Parse(Prompt("How many tones for this watcher (1 or 2)", "2")), + Frequency1 = Double.Parse(Prompt("Tone 1 frequency", "524")), + Time1 = Int32.Parse(Prompt("Tone 1 length in milliseconds", "500")) + } + }; + if (watcher.Triggers[0].Count >= 2) + { + watcher.Triggers[0].Frequency2 = Double.Parse(Prompt("Tone 2 frequency", "794")); + watcher.Triggers[0].Time2 = Int32.Parse(Prompt("Tone 2 length in milliseconds", "500")); + } + + return watcher; + } - ViewEngines.Add(); + private static int ShowDevices() + { + for (var waveInDevice = 0; waveInDevice < WaveIn.DeviceCount; waveInDevice++) + { + var deviceInfo = WaveIn.GetCapabilities(waveInDevice); + Cli.WriteLine($"Device {waveInDevice}: {deviceInfo.ProductName}, {deviceInfo.Channels} channels"); + } + + return 0; + } + + private static async Task MonitorAsync(string[] args) + { + var device = args.Length > 0 ? Int32.Parse(args[0]) : 0; + Logger logger = CreateLogger(debug: true); + var audioStorage = new WatcherAudioStorage(logger); + var evaluator = new AudioEvaluator(logger); + var recorder = new AudioRecorder(evaluator, audioStorage); + recorder.SetSampleAggregator(new SampleAggregator()); + recorder.SampleAggregator.MaximumCalculated += (_, eventArgs) => + Cli.WriteLine($"{DateTime.Now:G}: Min={eventArgs.MinSample} Max={eventArgs.MaxSample} dB={eventArgs.Db}"); + + recorder.BeginMonitoring(device); + Cli.WriteLine("Monitoring audio. Press Ctrl+C to stop."); + + using var cancellationTokenSource = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + recorder.Stop(); + cancellationTokenSource.Cancel(); + }; + Cli.CancelKeyPress += cancelHandler; + + try + { + while (!cancellationTokenSource.IsCancellationRequested && + (recorder.RecordingState == RecordingState.Monitoring || + recorder.RecordingState == RecordingState.Recording || + recorder.RecordingState == RecordingState.RequestedStop)) + { + await Task.Delay(250, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + catch (TaskCanceledException) + { + } + finally + { + Cli.CancelKeyPress -= cancelHandler; + } + + return 0; + } + + private static Config LoadAudioConfig(string path) + { + var payload = File.ReadAllText(path); + var config = JsonSerializer.Deserialize(payload, JsonOptions) ?? new Config(); + if (config.Resgrid == null) + config.Resgrid = new ResgridConnectionSettings(); + if (config.DispatchMapping == null) + config.DispatchMapping = new DispatchMappingSettings(); + + return config; + } + + private static void SaveAudioConfig(string path, Config config) + { + var directory = Path.GetDirectoryName(path); + if (!String.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, JsonSerializer.Serialize(config, JsonOptions)); + } + + private static string DefaultAudioConfigPath() + { + return Path.Combine(AppContext.BaseDirectory, "settings.json"); + } + + private static ResgridApiClientOptions ResolveResgridOptions(RelayHostOptions hostOptions, Config config) + { + return new ResgridApiClientOptions + { + BaseUrl = FirstNonEmpty(hostOptions.Resgrid.BaseUrl, config.Resgrid?.BaseUrl, config.ApiUrl, "https://api.resgrid.com"), + ApiVersion = FirstNonEmpty(hostOptions.Resgrid.ApiVersion, config.Resgrid?.ApiVersion, "4"), + ClientId = FirstNonEmpty(hostOptions.Resgrid.ClientId, config.Resgrid?.ClientId), + ClientSecret = FirstNonEmpty(hostOptions.Resgrid.ClientSecret, config.Resgrid?.ClientSecret), + RefreshToken = FirstNonEmpty(hostOptions.Resgrid.RefreshToken, config.Resgrid?.RefreshToken), + Scope = FirstNonEmpty(hostOptions.Resgrid.Scope, config.Resgrid?.Scope, "openid profile email offline_access mobile"), + TokenCachePath = FirstNonEmpty(hostOptions.Resgrid.TokenCachePath, config.Resgrid?.TokenCachePath, Path.Combine(AppContext.BaseDirectory, "data", "resgrid-token.json")) + }; + } + + private static string Prompt(string text, string defaultValue) + { + Cli.Write($"{text}{(String.IsNullOrWhiteSpace(defaultValue) ? String.Empty : $" [{defaultValue}]")}: "); + var input = Cli.ReadLine(); + return String.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim(); + } +#else + private static Task RunAudioModeAsync(RelayHostOptions hostOptions, CancellationToken cancellationToken) + { + Cli.Error.WriteLine("Audio mode is only available in the net10.0-windows build."); + return Task.FromResult(1); + } + + private static Task SetupAsync() + { + Cli.Error.WriteLine("Audio setup is only available in the net10.0-windows build."); + return Task.FromResult(1); + } + + private static int ShowDevices() + { + Cli.Error.WriteLine("Audio device enumeration is only available in the net10.0-windows build."); + return 1; + } + + private static Task MonitorAsync(string[] args) + { + Cli.Error.WriteLine("Audio monitoring is only available in the net10.0-windows build."); + return Task.FromResult(1); + } +#endif + + private static Logger CreateLogger(bool debug) + { + return new LoggerConfiguration() + .MinimumLevel.Is(debug ? Serilog.Events.LogEventLevel.Debug : Serilog.Events.LogEventLevel.Information) + .WriteTo.Console() + .CreateLogger(); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(x => !String.IsNullOrWhiteSpace(x)); } } } diff --git a/Resgrid.Audio.Relay.Console/Properties/AssemblyInfo.cs b/Resgrid.Audio.Relay.Console/Properties/AssemblyInfo.cs index 9d0a5a6..7209eb5 100644 --- a/Resgrid.Audio.Relay.Console/Properties/AssemblyInfo.cs +++ b/Resgrid.Audio.Relay.Console/Properties/AssemblyInfo.cs @@ -21,6 +21,7 @@ // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("17987428-a954-4a78-a286-bf91e76bbcf8")] +[assembly: InternalsVisibleTo("Resgrid.Audio.Tests")] // Version information for an assembly consists of the following four values: // diff --git a/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj b/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj index 71535f9..c832cda 100644 --- a/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj +++ b/Resgrid.Audio.Relay.Console/Resgrid.Audio.Relay.Console.csproj @@ -1,171 +1,48 @@ - - - + - Debug - AnyCPU - {17987428-A954-4A78-A286-BF91E76BBCF8} + net10.0;net10.0-windows Exe Resgrid.Audio.Relay.Console Resgrid.Audio.Relay.Console - v4.7.1 - 512 - true - - false - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - true - 3 - 1.0.0.%2a - false - true - true - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - 422A863762F08D2ECACFADFA91BB76C55730DC5C - - - Resgrid.Audio.Relay.Console_TemporaryKey.pfx - - - true - - - false + false + disable + disable + - - ..\packages\Consolas.0.7.1\lib\net40\Consolas.Core.dll - - - ..\packages\Microsoft.ApplicationInsights.Agent.Intercept.2.4.0\lib\net45\Microsoft.AI.Agent.Intercept.dll - - - ..\packages\Microsoft.ApplicationInsights.DependencyCollector.2.7.2\lib\net45\Microsoft.AI.DependencyCollector.dll - - - ..\packages\Microsoft.ApplicationInsights.2.7.2\lib\net46\Microsoft.ApplicationInsights.dll - - - ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll - - - ..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll - - - ..\packages\Nustache.1.16.0.8\lib\net20\Nustache.Core.dll - - - ..\packages\Serilog.2.7.1\lib\net46\Serilog.dll - - - ..\packages\Serilog.Sinks.Console.3.1.1\lib\net45\Serilog.Sinks.Console.dll - - - ..\packages\SimpleInjector.4.3.0\lib\net45\SimpleInjector.dll - - - - - ..\packages\System.Diagnostics.DiagnosticSource.4.5.0\lib\net46\System.Diagnostics.DiagnosticSource.dll - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - Always - - - Always - - - - - Always - - - - + - - - {7aa51788-4951-4ec0-8feb-8381200600c4} - Resgrid.Providers.ApiClient - - - {64d892bf-42a5-40b6-93a4-e9ade0774abf} - Resgrid.Audio.Core - + + + + - - False - Microsoft .NET Framework 4.7.1 %28x86 and x64%29 - true - - - False - .NET Framework 3.5 SP1 - false - + + PreserveNewest + + + PreserveNewest + - - \ No newline at end of file + diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs new file mode 100644 index 0000000..8c00b13 --- /dev/null +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs @@ -0,0 +1,71 @@ +using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Providers.ApiClient.V4; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Audio.Relay.Console.Smtp +{ + public sealed class SmtpDispatchAddressParser + { + private readonly SmtpRelayOptions _options; + + public SmtpDispatchAddressParser(SmtpRelayOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public List ParseRecipients(IEnumerable addresses) + { + var dispatchCodes = new List(); + if (addresses == null) + return dispatchCodes; + + foreach (var address in addresses) + { + if (TryParse(address, out var dispatchCode) && + dispatchCodes.All(x => !String.Equals(x.Code, dispatchCode.Code, StringComparison.OrdinalIgnoreCase))) + { + dispatchCodes.Add(dispatchCode); + } + } + + return dispatchCodes; + } + + public bool TryParse(string address, out DispatchCode dispatchCode) + { + dispatchCode = null; + if (String.IsNullOrWhiteSpace(address)) + return false; + + var parts = address.Trim().Split('@'); + if (parts.Length != 2 || String.IsNullOrWhiteSpace(parts[0]) || String.IsNullOrWhiteSpace(parts[1])) + return false; + + var code = parts[0].Trim(); + var domain = parts[1].Trim(); + if (_options.DepartmentAddressDomains.Any(x => String.Equals(x, domain, StringComparison.OrdinalIgnoreCase))) + { + dispatchCode = new DispatchCode + { + Code = code, + Type = DispatchCodeType.Department + }; + return true; + } + + if (_options.GroupAddressDomains.Any(x => String.Equals(x, domain, StringComparison.OrdinalIgnoreCase))) + { + dispatchCode = new DispatchCode + { + Code = code, + Type = DispatchCodeType.Group + }; + return true; + } + + return false; + } + } +} diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs new file mode 100644 index 0000000..a918f53 --- /dev/null +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs @@ -0,0 +1,512 @@ +using MimeKit; +using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Providers.ApiClient.V4; +using Resgrid.Providers.ApiClient.V4.Models; +using SmtpServer; +using SmtpServer.ComponentModel; +using SmtpServer.Mail; +using SmtpServer.Protocol; +using SmtpServer.Storage; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Relay.Console.Smtp +{ + internal static class SmtpRelayRunner + { + public static async Task RunAsync(SmtpRelayOptions options, ISmtpTelemetry telemetry, CancellationToken cancellationToken) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (telemetry == null) + throw new ArgumentNullException(nameof(telemetry)); + + var serviceProvider = new ServiceProvider(); + serviceProvider.Add((IMailboxFilterFactory)new RelayMailboxFilter(options, telemetry)); + serviceProvider.Add(new RelayMessageStore(options, telemetry)); + + var smtpServerOptions = new SmtpServerOptionsBuilder() + .ServerName(options.ServerName) + .Port(options.Port) + .Build(); + + var smtpServer = new SmtpServer.SmtpServer(smtpServerOptions, serviceProvider); + smtpServer.SessionCreated += (_, eventArgs) => telemetry.SessionCreated(eventArgs.Context); + smtpServer.SessionCompleted += (_, eventArgs) => telemetry.SessionCompleted(eventArgs.Context); + smtpServer.SessionCancelled += (_, eventArgs) => telemetry.SessionCancelled(eventArgs.Context); + smtpServer.SessionFaulted += (_, eventArgs) => telemetry.SessionFaulted(eventArgs.Context, eventArgs.Exception); + + telemetry.RelayStarting(options); + + try + { + await smtpServer.StartAsync(cancellationToken).ConfigureAwait(false); + telemetry.RelayStopped(options); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + telemetry.RelayStopped(options); + } + catch (Exception ex) + { + telemetry.RelayFaulted(options, ex); + throw; + } + } + } + + internal interface IResgridCallsClient + { + Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken); + Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken); + } + + internal sealed class ResgridCallsClient : IResgridCallsClient + { + public Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken) + { + return CallsApi.SaveCallAsync(call, cancellationToken); + } + + public Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken) + { + return CallsApi.SaveCallFileAsync(file, cancellationToken); + } + } + + internal sealed class AttachmentPayload + { + public string Name { get; set; } + public byte[] Data { get; set; } + public CallFileType Type { get; set; } + } + + internal sealed class RelayMessageStore : MessageStore + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + + private readonly SmtpRelayOptions _options; + private readonly SmtpDispatchAddressParser _dispatchAddressParser; + private readonly ProcessedMessageStore _processedMessageStore; + private readonly ISmtpTelemetry _telemetry; + private readonly IResgridCallsClient _callsClient; + private readonly string _dataDirectory; + private readonly string _messageDirectory; + + public RelayMessageStore(SmtpRelayOptions options, ISmtpTelemetry telemetry, IResgridCallsClient callsClient = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + _callsClient = callsClient ?? new ResgridCallsClient(); + _dispatchAddressParser = new SmtpDispatchAddressParser(options); + _dataDirectory = ResolvePath(options.DataDirectory); + _messageDirectory = Path.Combine(_dataDirectory, "messages"); + _processedMessageStore = new ProcessedMessageStore(Path.Combine(_dataDirectory, "processed-messages.json"), options.DuplicateWindowHours); + + Directory.CreateDirectory(_dataDirectory); + Directory.CreateDirectory(_messageDirectory); + } + + public override async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + await using var stream = new MemoryStream(); + var position = buffer.GetPosition(0); + while (buffer.TryGet(ref position, out var memory)) + { + await stream.WriteAsync(memory, cancellationToken).ConfigureAwait(false); + } + + stream.Position = 0; + var message = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false); + var subject = String.IsNullOrWhiteSpace(message.Subject) ? "SMTP Email Import" : message.Subject.Trim(); + var messageBody = GetMessageBody(message); + var nature = GetNature(messageBody, subject); + var stableMessageId = GetStableMessageId(message); + var messageSummary = SmtpMessageSummary.Create(message, stableMessageId, subject, nature, messageBody); + _telemetry.MessageReceived(context, messageSummary); + + var messageRegistered = false; + var processingStopwatch = Stopwatch.StartNew(); + + try + { + if (!await _processedMessageStore.TryRegisterAsync(stableMessageId, cancellationToken).ConfigureAwait(false)) + { + _telemetry.DuplicateMessage(context, messageSummary); + return SmtpResponse.Ok; + } + + messageRegistered = true; + + if (_options.SaveRawMessages) + { + stream.Position = 0; + var rawMessagePath = Path.Combine(_messageDirectory, $"{SanitizeFileName(stableMessageId)}.eml"); + await using var fileStream = File.Create(rawMessagePath); + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + messageSummary.RawMessagePath = rawMessagePath; + } + + var dispatchTargets = GetDispatchTargets(message); + messageSummary.SetDispatchTargets(dispatchTargets); + if (dispatchTargets.Count == 0) + { + _telemetry.UnroutableMessage(context, messageSummary); + return SmtpResponse.Ok; + } + + var fromMailbox = message.From.Mailboxes.FirstOrDefault(); + var attachments = ExtractAttachments(message).ToList(); + messageSummary.SetAttachments(attachments, _options.MaxAttachmentBytes); + _telemetry.MessageProcessingStarted(context, messageSummary); + + var skippedAttachments = new List(); + foreach (var attachment in attachments.Where(x => x.Data.Length > _options.MaxAttachmentBytes)) + { + skippedAttachments.Add($"{attachment.Name} skipped because it exceeded {_options.MaxAttachmentBytes} bytes."); + } + + var callId = await _callsClient.SaveCallAsync(new NewCallInput + { + Priority = _options.DefaultCallPriority, + Name = Trim(subject, 200), + Nature = Trim(nature, 500), + Note = BuildNote(message, messageBody, skippedAttachments), + DispatchList = DispatchListBuilder.Build(dispatchTargets, _options.DepartmentDispatchPrefix), + ContactName = fromMailbox == null ? null : Trim(String.IsNullOrWhiteSpace(fromMailbox.Name) ? fromMailbox.Address : fromMailbox.Name, 200), + ContactInfo = fromMailbox?.Address, + ExternalId = stableMessageId, + ReferenceId = message.MessageId + }, cancellationToken).ConfigureAwait(false); + + messageSummary.CallId = callId; + + var uploadableAttachments = attachments.Where(x => x.Data.Length <= _options.MaxAttachmentBytes).ToList(); + if (uploadableAttachments.Count > 0) + { + var userId = ResgridV4ApiClient.CurrentUserId; + if (String.IsNullOrWhiteSpace(userId)) + throw new InvalidOperationException("The Resgrid access token did not contain a user id required to upload SMTP message attachments."); + + foreach (var attachment in uploadableAttachments) + { + await _callsClient.SaveCallFileAsync(new SaveCallFileInput + { + CallId = callId, + UserId = userId, + Type = (int)attachment.Type, + Name = attachment.Name, + Data = Convert.ToBase64String(attachment.Data), + Note = $"SMTP attachment imported from message {stableMessageId}" + }, cancellationToken).ConfigureAwait(false); + } + } + + _telemetry.MessageProcessed(context, messageSummary, processingStopwatch.Elapsed); + return SmtpResponse.Ok; + } + catch (Exception ex) + { + _telemetry.MessageFailed(context, messageSummary, ex, processingStopwatch.Elapsed); + + if (messageRegistered) + { + try + { + await _processedMessageStore.RemoveAsync(stableMessageId, cancellationToken).ConfigureAwait(false); + } + catch (Exception cleanupException) + { + throw new AggregateException("SMTP processing failed and the duplicate-message registration could not be rolled back.", ex, cleanupException); + } + } + + throw; + } + } + + private List GetDispatchTargets(MimeMessage message) + { + return _dispatchAddressParser.ParseRecipients( + message.To.Mailboxes + .Concat(message.Cc.Mailboxes) + .Select(x => x.Address)); + } + + private string BuildNote(MimeMessage message, string messageBody, List skippedAttachments) + { + var builder = new StringBuilder(); + builder.AppendLine($"Imported from SMTP at {DateTimeOffset.UtcNow:O}"); + builder.AppendLine($"From: {String.Join(", ", message.From.Mailboxes.Select(x => x.ToString()))}"); + builder.AppendLine($"To: {String.Join(", ", message.To.Mailboxes.Select(x => x.ToString()))}"); + if (message.Cc.Count > 0) + builder.AppendLine($"Cc: {String.Join(", ", message.Cc.Mailboxes.Select(x => x.ToString()))}"); + builder.AppendLine($"Subject: {message.Subject}"); + builder.AppendLine($"Message-Id: {message.MessageId}"); + builder.AppendLine(); + builder.AppendLine(Trim(messageBody, 8000)); + + if (skippedAttachments.Count > 0) + { + builder.AppendLine(); + builder.AppendLine(String.Join(Environment.NewLine, skippedAttachments)); + } + + return builder.ToString(); + } + + private IEnumerable ExtractAttachments(MimeMessage message) + { + foreach (var attachment in message.Attachments) + { + switch (attachment) + { + case MimePart mimePart: + using (var attachmentStream = new MemoryStream()) + { + mimePart.Content.DecodeTo(attachmentStream); + yield return new AttachmentPayload + { + Name = String.IsNullOrWhiteSpace(mimePart.FileName) ? "attachment.bin" : mimePart.FileName, + Data = attachmentStream.ToArray(), + Type = ResolveAttachmentType(mimePart.ContentType?.MimeType) + }; + } + break; + case MessagePart messagePart: + using (var attachmentStream = new MemoryStream()) + { + messagePart.Message.WriteTo(attachmentStream); + yield return new AttachmentPayload + { + Name = "attached-message.eml", + Data = attachmentStream.ToArray(), + Type = CallFileType.File + }; + } + break; + } + } + } + + private static CallFileType ResolveAttachmentType(string mimeType) + { + if (String.IsNullOrWhiteSpace(mimeType)) + return CallFileType.File; + + if (mimeType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + return CallFileType.Audio; + + if (mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return CallFileType.Image; + + if (mimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) + return CallFileType.Video; + + return CallFileType.File; + } + + private static string GetMessageBody(MimeMessage message) + { + if (!String.IsNullOrWhiteSpace(message.TextBody)) + return message.TextBody.Trim(); + + if (!String.IsNullOrWhiteSpace(message.HtmlBody)) + return message.HtmlBody.Trim(); + + return "(No message body)"; + } + + private static string GetNature(string body, string fallback) + { + if (!String.IsNullOrWhiteSpace(body)) + { + var firstLine = body + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .FirstOrDefault(x => !String.IsNullOrWhiteSpace(x)); + + if (!String.IsNullOrWhiteSpace(firstLine)) + return firstLine; + } + + return fallback; + } + + private static string GetStableMessageId(MimeMessage message) + { + if (!String.IsNullOrWhiteSpace(message.MessageId)) + return message.MessageId.Trim('<', '>', ' '); + + var rawId = $"{message.Subject}|{String.Join(",", message.From.Mailboxes.Select(x => x.Address))}|{String.Join(",", message.To.Mailboxes.Select(x => x.Address))}|{GetMessageBody(message)}"; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawId))); + } + + private static string ResolvePath(string path) + { + if (Path.IsPathRooted(path)) + return path; + + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path)); + } + + private static string SanitizeFileName(string value) + { + var invalidCharacters = Path.GetInvalidFileNameChars(); + return new string(value.Select(x => invalidCharacters.Contains(x) ? '_' : x).ToArray()); + } + + private static string Trim(string value, int maxLength) + { + if (String.IsNullOrWhiteSpace(value)) + return value; + + return value.Length <= maxLength ? value : value.Substring(0, maxLength); + } + } + + internal sealed class RelayMailboxFilter : IMailboxFilter, IMailboxFilterFactory + { + private readonly SmtpRelayOptions _options; + private readonly ISmtpTelemetry _telemetry; + + public RelayMailboxFilter(SmtpRelayOptions options, ISmtpTelemetry telemetry) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + public Task CanAcceptFromAsync(ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken) + { + _telemetry.SenderAccepted(context, from, size); + return Task.FromResult(true); + } + + public Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox from, CancellationToken token) + { + var accepted = + (_options.DepartmentAddressDomains ?? Array.Empty()).Any(x => String.Equals(x, to.Host, StringComparison.OrdinalIgnoreCase)) || + (_options.GroupAddressDomains ?? Array.Empty()).Any(x => String.Equals(x, to.Host, StringComparison.OrdinalIgnoreCase)); + + _telemetry.RecipientEvaluated( + context, + to, + from, + accepted, + accepted ? null : "recipient domain is not configured for Resgrid dispatch routing"); + + return Task.FromResult(accepted); + } + + public IMailboxFilter CreateInstance(ISessionContext context) + { + return this; + } + } + + internal sealed class ProcessedMessageStore + { + private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + + private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); + private readonly string _path; + private readonly TimeSpan _retention; + + public ProcessedMessageStore(string path, int duplicateWindowHours) + { + _path = path; + _retention = TimeSpan.FromHours(duplicateWindowHours <= 0 ? 72 : duplicateWindowHours); + } + + public async Task TryRegisterAsync(string messageId, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var entries = await LoadAsync(cancellationToken).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow.Subtract(_retention); + var expiredKeys = entries + .Where(x => x.Value < cutoff) + .Select(x => x.Key) + .ToArray(); + + foreach (var expiredKey in expiredKeys) + { + entries.Remove(expiredKey); + } + + var changed = expiredKeys.Length > 0; + if (entries.ContainsKey(messageId)) + { + if (changed) + await SaveAsync(entries, cancellationToken).ConfigureAwait(false); + + return false; + } + + entries[messageId] = DateTimeOffset.UtcNow; + await SaveAsync(entries, cancellationToken).ConfigureAwait(false); + return true; + } + finally + { + _gate.Release(); + } + } + + public async Task RemoveAsync(string messageId, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var entries = await LoadAsync(cancellationToken).ConfigureAwait(false); + if (!entries.Remove(messageId)) + return; + + await SaveAsync(entries, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private async Task> LoadAsync(CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(_path); + if (!String.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + if (!File.Exists(_path)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var payload = await File.ReadAllTextAsync(_path, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(payload)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var items = JsonSerializer.Deserialize>(payload); + return items ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private async Task SaveAsync(Dictionary entries, CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize(entries, SerializerOptions); + await File.WriteAllTextAsync(_path, payload, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs new file mode 100644 index 0000000..0a87f58 --- /dev/null +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs @@ -0,0 +1,1029 @@ +using MimeKit; +using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Providers.ApiClient.V4; +using Sentry; +using Serilog; +using Serilog.Core; +using SmtpServer; +using SmtpServer.Mail; +using SmtpServer.Protocol; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Relay.Console.Smtp +{ + internal interface ISmtpTelemetry : IAsyncDisposable + { + void RelayStarting(SmtpRelayOptions options); + void RelayStopped(SmtpRelayOptions options); + void RelayFaulted(SmtpRelayOptions options, Exception exception); + void SessionCreated(ISessionContext context); + void SessionCompleted(ISessionContext context); + void SessionCancelled(ISessionContext context); + void SessionFaulted(ISessionContext context, Exception exception); + void SenderAccepted(ISessionContext context, IMailbox from, int size); + void RecipientEvaluated(ISessionContext context, IMailbox to, IMailbox from, bool accepted, string reason); + void MessageReceived(ISessionContext context, SmtpMessageSummary message); + void DuplicateMessage(ISessionContext context, SmtpMessageSummary message); + void UnroutableMessage(ISessionContext context, SmtpMessageSummary message); + void MessageProcessingStarted(ISessionContext context, SmtpMessageSummary message); + void MessageProcessed(ISessionContext context, SmtpMessageSummary message, TimeSpan duration); + void MessageFailed(ISessionContext context, SmtpMessageSummary message, Exception exception, TimeSpan duration); + } + + internal sealed class SmtpTelemetry : ISmtpTelemetry + { + private readonly ILogger _logger; + private readonly CountlyTelemetryClient _countlyClient; + private readonly IDisposable _sentrySdk; + private readonly string _environment; + private readonly string _release; + private readonly string _serverName; + + private SmtpTelemetry(ILogger logger, RelayTelemetryOptions options, string serverName) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _serverName = String.IsNullOrWhiteSpace(serverName) ? "resgrid-relay" : serverName.Trim(); + _environment = ResolveEnvironment(options); + _release = ResolveRelease(options); + _countlyClient = new CountlyTelemetryClient(_logger, options?.Countly, _serverName, _environment, _release); + + if (!String.IsNullOrWhiteSpace(options?.Sentry?.Dsn)) + { + _sentrySdk = SentrySdk.Init(sentryOptions => + { + sentryOptions.Dsn = options.Sentry.Dsn.Trim(); + sentryOptions.Environment = _environment; + sentryOptions.Release = _release; + sentryOptions.ServerName = _serverName; + sentryOptions.AttachStacktrace = true; + sentryOptions.SendDefaultPii = options.Sentry.SendDefaultPii; + sentryOptions.ShutdownTimeout = TimeSpan.FromSeconds(2); + }); + } + + _logger.Information( + "SMTP observability initialized. SentryEnabled={SentryEnabled} CountlyEnabled={CountlyEnabled} Environment={Environment} Release={Release}", + _sentrySdk != null, + _countlyClient.IsEnabled, + _environment, + _release); + } + + public static ISmtpTelemetry Create(RelayHostOptions hostOptions, Logger logger) + { + if (hostOptions == null) + throw new ArgumentNullException(nameof(hostOptions)); + if (logger == null) + throw new ArgumentNullException(nameof(logger)); + + return new SmtpTelemetry( + logger.ForContext("RelayMode", "smtp").ForContext("Component", "SmtpRelay"), + hostOptions.Telemetry ?? new RelayTelemetryOptions(), + hostOptions.Smtp?.ServerName); + } + + public void RelayStarting(SmtpRelayOptions options) + { + var domains = (options?.DepartmentAddressDomains ?? Array.Empty()) + .Concat(options?.GroupAddressDomains ?? Array.Empty()) + .Where(x => !String.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + _logger.Information( + "SMTP relay listening on port {Port} for {AcceptedDomains}", + options?.Port ?? 0, + domains); + + _countlyClient.TrackEvent("smtp_relay_started", new Dictionary + { + ["port"] = (options?.Port ?? 0).ToString(CultureInfo.InvariantCulture), + ["domain_count"] = domains.Length.ToString(CultureInfo.InvariantCulture) + }); + } + + public void RelayStopped(SmtpRelayOptions options) + { + _logger.Information("SMTP relay stopped listening on port {Port}", options?.Port ?? 0); + + _countlyClient.TrackEvent("smtp_relay_stopped", new Dictionary + { + ["port"] = (options?.Port ?? 0).ToString(CultureInfo.InvariantCulture) + }); + } + + public void RelayFaulted(SmtpRelayOptions options, Exception exception) + { + _logger.Error(exception, "SMTP relay faulted while listening on port {Port}", options?.Port ?? 0); + + _countlyClient.TrackEvent("smtp_relay_faulted", new Dictionary + { + ["port"] = (options?.Port ?? 0).ToString(CultureInfo.InvariantCulture), + ["failure_type"] = exception?.GetType().Name ?? "UnknownException" + }); + + CaptureException(exception, null, null, "relay_fault"); + } + + public void SessionCreated(ISessionContext context) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + AttachSessionHandlers(context, state); + + _logger.Information( + "SMTP connection opened. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} LocalEndPoint={LocalEndPoint} Secure={Secure}", + state.SessionId, + state.RemoteEndPoint, + state.LocalEndPoint, + state.IsSecure); + + _countlyClient.TrackEvent("smtp_connection_started", BuildConnectionSegmentation(state)); + } + + public void SessionCompleted(ISessionContext context) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + DetachSessionHandlers(context, state); + var duration = DateTimeOffset.UtcNow.Subtract(state.StartedAtUtc); + + _logger.Information( + "SMTP connection completed. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Sender={Sender} MessageCount={MessageCount} AcceptedRecipientCount={AcceptedRecipientCount} RejectedRecipientCount={RejectedRecipientCount} DurationMs={DurationMs}", + state.SessionId, + state.RemoteEndPoint, + state.Sender, + state.MessageCount, + state.AcceptedRecipientCount, + state.RejectedRecipientCount, + (long)duration.TotalMilliseconds); + + _countlyClient.TrackEvent("smtp_connection_completed", BuildConnectionSegmentation(state), duration); + } + + public void SessionCancelled(ISessionContext context) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + DetachSessionHandlers(context, state); + var duration = DateTimeOffset.UtcNow.Subtract(state.StartedAtUtc); + + _logger.Warning( + "SMTP connection cancelled. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} DurationMs={DurationMs}", + state.SessionId, + state.RemoteEndPoint, + (long)duration.TotalMilliseconds); + + _countlyClient.TrackEvent("smtp_connection_cancelled", BuildConnectionSegmentation(state), duration); + } + + public void SessionFaulted(ISessionContext context, Exception exception) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + DetachSessionHandlers(context, state); + var duration = DateTimeOffset.UtcNow.Subtract(state.StartedAtUtc); + + _logger.Error( + exception, + "SMTP connection faulted. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Sender={Sender} MessageCount={MessageCount} DurationMs={DurationMs}", + state.SessionId, + state.RemoteEndPoint, + state.Sender, + state.MessageCount, + (long)duration.TotalMilliseconds); + + _countlyClient.TrackEvent("smtp_connection_faulted", MergeSegmentations( + BuildConnectionSegmentation(state), + new Dictionary + { + ["failure_type"] = exception?.GetType().Name ?? "UnknownException" + }), duration); + + if (!state.MessageFailureReported) + CaptureException(exception, state, null, "session_fault"); + } + + public void SenderAccepted(ISessionContext context, IMailbox from, int size) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + state.Sender = FormatMailbox(from); + + _logger.Information( + "SMTP sender accepted. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Sender={Sender} MessageSize={MessageSize}", + state.SessionId, + state.RemoteEndPoint, + state.Sender, + size); + + _countlyClient.TrackEvent("smtp_sender_accepted", MergeSegmentations( + BuildConnectionSegmentation(state), + new Dictionary + { + ["sender_domain"] = GetDomain(state.Sender), + ["message_size"] = size.ToString(CultureInfo.InvariantCulture) + })); + } + + public void RecipientEvaluated(ISessionContext context, IMailbox to, IMailbox from, bool accepted, string reason) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + var recipient = FormatMailbox(to); + if (accepted) + state.RecordAcceptedRecipient(recipient); + else + state.RecordRejectedRecipient(recipient); + + if (accepted) + { + _logger.Information( + "SMTP recipient {Outcome}. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Sender={Sender} Recipient={Recipient} Reason={Reason}", + "accepted", + state.SessionId, + state.RemoteEndPoint, + FormatMailbox(from), + recipient, + reason); + } + else + { + _logger.Warning( + "SMTP recipient {Outcome}. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Sender={Sender} Recipient={Recipient} Reason={Reason}", + "rejected", + state.SessionId, + state.RemoteEndPoint, + FormatMailbox(from), + recipient, + reason); + } + + _countlyClient.TrackEvent( + accepted ? "smtp_recipient_accepted" : "smtp_recipient_rejected", + MergeSegmentations( + BuildConnectionSegmentation(state), + new Dictionary + { + ["recipient_domain"] = GetDomain(recipient), + ["outcome"] = accepted ? "accepted" : "rejected", + ["reason"] = NormalizeSegmentationValue(reason) + })); + } + + public void MessageReceived(ISessionContext context, SmtpMessageSummary message) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + state.IncrementMessageCount(); + + _logger.Information( + "SMTP message received. SessionId={SessionId} StableMessageId={StableMessageId} MessageId={MessageId} From={@FromAddresses} To={@ToAddresses} Cc={@CcAddresses} Subject={Subject}", + state.SessionId, + message.StableMessageId, + message.ReferenceMessageId, + message.FromAddresses, + message.ToAddresses, + message.CcAddresses, + message.Subject); + + _countlyClient.TrackEvent("smtp_message_received", BuildMessageSegmentation(state, message)); + } + + public void DuplicateMessage(ISessionContext context, SmtpMessageSummary message) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + _logger.Information( + "Skipping duplicate SMTP message. SessionId={SessionId} StableMessageId={StableMessageId} MessageId={MessageId} Subject={Subject}", + state.SessionId, + message.StableMessageId, + message.ReferenceMessageId, + message.Subject); + + _countlyClient.TrackEvent("smtp_message_duplicate", BuildMessageSegmentation(state, message)); + } + + public void UnroutableMessage(ISessionContext context, SmtpMessageSummary message) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + _logger.Warning( + "SMTP message did not contain a usable Resgrid dispatch recipient. SessionId={SessionId} StableMessageId={StableMessageId} MessageId={MessageId} From={@FromAddresses} To={@ToAddresses} Cc={@CcAddresses} Subject={Subject}", + state.SessionId, + message.StableMessageId, + message.ReferenceMessageId, + message.FromAddresses, + message.ToAddresses, + message.CcAddresses, + message.Subject); + + _countlyClient.TrackEvent("smtp_message_unroutable", MergeSegmentations( + BuildMessageSegmentation(state, message), + new Dictionary + { + ["route_kind"] = "none" + })); + } + + public void MessageProcessingStarted(ISessionContext context, SmtpMessageSummary message) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + _logger.Information( + "Processing SMTP message. SessionId={SessionId} StableMessageId={StableMessageId} Subject={Subject} RouteKind={RouteKind} DispatchTargets={@DispatchTargets} AttachmentNames={@AttachmentNames} OversizedAttachmentNames={@OversizedAttachmentNames} RawMessagePath={RawMessagePath}", + state.SessionId, + message.StableMessageId, + message.Subject, + message.RouteKind, + message.DispatchTargets, + message.AttachmentNames, + message.OversizedAttachmentNames, + message.RawMessagePath); + + _countlyClient.TrackEvent("smtp_message_processing_started", BuildMessageSegmentation(state, message)); + } + + public void MessageProcessed(ISessionContext context, SmtpMessageSummary message, TimeSpan duration) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + _logger.Information( + "Processed SMTP message into Resgrid call. SessionId={SessionId} StableMessageId={StableMessageId} MessageId={MessageId} CallId={CallId} RouteKind={RouteKind} AttachmentCount={AttachmentCount} OversizedAttachmentCount={OversizedAttachmentCount} DurationMs={DurationMs}", + state.SessionId, + message.StableMessageId, + message.ReferenceMessageId, + message.CallId, + message.RouteKind, + message.AttachmentCount, + message.OversizedAttachmentCount, + (long)duration.TotalMilliseconds); + + _countlyClient.TrackEvent("smtp_message_processed", MergeSegmentations( + BuildMessageSegmentation(state, message), + new Dictionary + { + ["outcome"] = "processed" + }), duration); + } + + public void MessageFailed(ISessionContext context, SmtpMessageSummary message, Exception exception, TimeSpan duration) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + state.MessageFailureReported = true; + + _logger.Error( + exception, + "Failed processing SMTP message. SessionId={SessionId} StableMessageId={StableMessageId} MessageId={MessageId} CallId={CallId} Subject={Subject} RouteKind={RouteKind} DispatchTargets={@DispatchTargets} AttachmentNames={@AttachmentNames} OversizedAttachmentNames={@OversizedAttachmentNames} DurationMs={DurationMs}", + state.SessionId, + message.StableMessageId, + message.ReferenceMessageId, + message.CallId, + message.Subject, + message.RouteKind, + message.DispatchTargets, + message.AttachmentNames, + message.OversizedAttachmentNames, + (long)duration.TotalMilliseconds); + + _countlyClient.TrackEvent("smtp_message_failed", MergeSegmentations( + BuildMessageSegmentation(state, message), + new Dictionary + { + ["outcome"] = "failed", + ["failure_type"] = exception?.GetType().Name ?? "UnknownException" + }), duration); + + CaptureException(exception, state, message, "message_failed"); + } + + public async ValueTask DisposeAsync() + { + await _countlyClient.DisposeAsync().ConfigureAwait(false); + if (_sentrySdk != null) + { + await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + _sentrySdk.Dispose(); + } + } + + private void AttachSessionHandlers(ISessionContext context, SmtpSessionState state) + { + if (state.CommandExecutingHandler == null) + { + state.CommandExecutingHandler = (_, eventArgs) => OnCommandExecuting(context, eventArgs.Command); + context.CommandExecuting += state.CommandExecutingHandler; + } + + if (state.ResponseExceptionHandler == null) + { + state.ResponseExceptionHandler = (_, eventArgs) => OnResponseException(context, eventArgs.Exception); + context.ResponseException += state.ResponseExceptionHandler; + } + } + + private void DetachSessionHandlers(ISessionContext context, SmtpSessionState state) + { + if (state.CommandExecutingHandler != null) + { + context.CommandExecuting -= state.CommandExecutingHandler; + state.CommandExecutingHandler = null; + } + + if (state.ResponseExceptionHandler != null) + { + context.ResponseException -= state.ResponseExceptionHandler; + state.ResponseExceptionHandler = null; + } + } + + private void OnCommandExecuting(ISessionContext context, SmtpCommand command) + { + if (command == null) + return; + + var state = SmtpSessionStateAccessor.GetOrCreate(context); + state.LastCommandName = command.GetType().Name; + + switch (command) + { + case EhloCommand ehloCommand: + LogClientIdentity(state, ehloCommand.DomainOrAddress, "EHLO"); + break; + case HeloCommand heloCommand: + LogClientIdentity(state, heloCommand.DomainOrAddress, "HELO"); + break; + case ProxyCommand proxyCommand: + state.ProxySourceEndPoint = proxyCommand.SourceEndpoint?.ToString(); + state.ProxyDestinationEndPoint = proxyCommand.DestinationEndpoint?.ToString(); + _logger.Information( + "SMTP proxy metadata received. SessionId={SessionId} ProxySourceEndPoint={ProxySourceEndPoint} ProxyDestinationEndPoint={ProxyDestinationEndPoint}", + state.SessionId, + state.ProxySourceEndPoint, + state.ProxyDestinationEndPoint); + break; + } + } + + private void OnResponseException(ISessionContext context, SmtpResponseException exception) + { + var state = SmtpSessionStateAccessor.GetOrCreate(context); + var response = exception?.Response; + + _logger.Warning( + "SMTP command rejected. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Command={Command} ReplyCode={ReplyCode} ResponseMessage={ResponseMessage}", + state.SessionId, + state.RemoteEndPoint, + state.LastCommandName, + response?.ReplyCode, + response?.Message); + + _countlyClient.TrackEvent("smtp_command_rejected", MergeSegmentations( + BuildConnectionSegmentation(state), + new Dictionary + { + ["command"] = NormalizeSegmentationValue(state.LastCommandName), + ["reply_code"] = response == null ? "unknown" : response.ReplyCode.ToString() + })); + } + + private void LogClientIdentity(SmtpSessionState state, string identity, string commandName) + { + if (String.IsNullOrWhiteSpace(identity)) + return; + + state.ClientIdentity = identity.Trim(); + _logger.Information( + "SMTP client identified. SessionId={SessionId} RemoteEndPoint={RemoteEndPoint} Command={Command} Identity={Identity}", + state.SessionId, + state.RemoteEndPoint, + commandName, + state.ClientIdentity); + + _countlyClient.TrackEvent("smtp_client_identified", MergeSegmentations( + BuildConnectionSegmentation(state), + new Dictionary + { + ["command"] = commandName.ToLowerInvariant(), + ["identity"] = NormalizeSegmentationValue(state.ClientIdentity) + })); + } + + private void CaptureException(Exception exception, SmtpSessionState state, SmtpMessageSummary message, string scopeName) + { + if (_sentrySdk == null || exception == null) + return; + + try + { + SentrySdk.CaptureException(exception, scope => + { + scope.SetTag("relay.mode", "smtp"); + scope.SetTag("smtp.scope", scopeName); + scope.SetTag("smtp.environment", _environment); + scope.SetTag("smtp.server_name", _serverName); + if (state != null) + { + scope.SetTag("smtp.session_id", state.SessionId.ToString()); + scope.SetExtra("smtp.remote_endpoint", state.RemoteEndPoint); + scope.SetExtra("smtp.local_endpoint", state.LocalEndPoint); + scope.SetExtra("smtp.proxy_source_endpoint", state.ProxySourceEndPoint); + scope.SetExtra("smtp.proxy_destination_endpoint", state.ProxyDestinationEndPoint); + scope.SetExtra("smtp.client_identity", state.ClientIdentity); + scope.SetExtra("smtp.sender", state.Sender); + scope.SetExtra("smtp.message_count", state.MessageCount); + scope.SetExtra("smtp.accepted_recipients", state.AcceptedRecipients.ToArray()); + scope.SetExtra("smtp.rejected_recipients", state.RejectedRecipients.ToArray()); + } + + if (message != null) + { + scope.SetTag("smtp.route_kind", message.RouteKind); + scope.SetExtra("smtp.stable_message_id", message.StableMessageId); + scope.SetExtra("smtp.message_id", message.ReferenceMessageId); + scope.SetExtra("smtp.subject", message.Subject); + scope.SetExtra("smtp.nature", message.Nature); + scope.SetExtra("smtp.from_addresses", message.FromAddresses); + scope.SetExtra("smtp.to_addresses", message.ToAddresses); + scope.SetExtra("smtp.cc_addresses", message.CcAddresses); + scope.SetExtra("smtp.dispatch_targets", message.DispatchTargets); + scope.SetExtra("smtp.attachment_names", message.AttachmentNames); + scope.SetExtra("smtp.oversized_attachment_names", message.OversizedAttachmentNames); + scope.SetExtra("smtp.raw_message_path", message.RawMessagePath); + scope.SetExtra("smtp.call_id", message.CallId); + } + }); + } + catch (Exception sentryException) + { + _logger.Warning(sentryException, "Failed sending SMTP exception to Sentry"); + } + } + + private Dictionary BuildConnectionSegmentation(SmtpSessionState state) + { + var segmentation = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["secure"] = state.IsSecure ? "true" : "false", + ["message_count"] = state.MessageCount.ToString(CultureInfo.InvariantCulture), + ["accepted_recipient_count"] = state.AcceptedRecipientCount.ToString(CultureInfo.InvariantCulture), + ["rejected_recipient_count"] = state.RejectedRecipientCount.ToString(CultureInfo.InvariantCulture) + }; + + AddSegmentationValue(segmentation, "sender_domain", GetDomain(state.Sender)); + AddSegmentationValue(segmentation, "client_identity", state.ClientIdentity); + return segmentation; + } + + private Dictionary BuildMessageSegmentation(SmtpSessionState state, SmtpMessageSummary message) + { + var segmentation = BuildConnectionSegmentation(state); + segmentation["dispatch_target_count"] = message.DispatchTargetCount.ToString(CultureInfo.InvariantCulture); + segmentation["attachment_count"] = message.AttachmentCount.ToString(CultureInfo.InvariantCulture); + segmentation["oversized_attachment_count"] = message.OversizedAttachmentCount.ToString(CultureInfo.InvariantCulture); + AddSegmentationValue(segmentation, "sender_domain", message.SenderDomain); + AddSegmentationValue(segmentation, "route_kind", message.RouteKind); + return segmentation; + } + + private static Dictionary MergeSegmentations( + Dictionary baseSegmentation, + Dictionary additionalSegmentation) + { + var segmentation = new Dictionary(baseSegmentation, StringComparer.OrdinalIgnoreCase); + if (additionalSegmentation == null) + return segmentation; + + foreach (var item in additionalSegmentation) + { + if (!String.IsNullOrWhiteSpace(item.Value)) + segmentation[item.Key] = item.Value; + } + + return segmentation; + } + + private static void AddSegmentationValue(Dictionary segmentation, string key, string value) + { + var normalizedValue = NormalizeSegmentationValue(value); + if (!String.IsNullOrWhiteSpace(normalizedValue)) + segmentation[key] = normalizedValue; + } + + private static string NormalizeSegmentationValue(string value) + { + if (String.IsNullOrWhiteSpace(value)) + return null; + + value = value.Trim().Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); + return value.Length <= 120 ? value : value.Substring(0, 120); + } + + private static string ResolveEnvironment(RelayTelemetryOptions options) + { + return FirstNonEmpty( + options?.Environment, + Environment.GetEnvironmentVariable("RELAY_ENVIRONMENT"), + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"), + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + "production") + .Trim(); + } + + private static string ResolveRelease(RelayTelemetryOptions options) + { + return FirstNonEmpty( + options?.Sentry?.Release, + $"resgrid-relay@{Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"}"); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(x => !String.IsNullOrWhiteSpace(x)) ?? String.Empty; + } + + private static string FormatMailbox(IMailbox mailbox) + { + if (mailbox == null) + return null; + if (String.IsNullOrWhiteSpace(mailbox.Host)) + return mailbox.User; + + return $"{mailbox.User}@{mailbox.Host}"; + } + + private static string GetDomain(string emailAddress) + { + if (String.IsNullOrWhiteSpace(emailAddress)) + return null; + + var delimiterIndex = emailAddress.LastIndexOf('@'); + if (delimiterIndex < 0 || delimiterIndex == emailAddress.Length - 1) + return null; + + return emailAddress.Substring(delimiterIndex + 1).Trim(); + } + } + + internal sealed class SmtpMessageSummary + { + private SmtpMessageSummary() + { + } + + public string StableMessageId { get; private set; } + public string ReferenceMessageId { get; private set; } + public string Subject { get; private set; } + public string Nature { get; private set; } + public string[] FromAddresses { get; private set; } = Array.Empty(); + public string[] ToAddresses { get; private set; } = Array.Empty(); + public string[] CcAddresses { get; private set; } = Array.Empty(); + public string[] DispatchTargets { get; private set; } = Array.Empty(); + public string[] AttachmentNames { get; private set; } = Array.Empty(); + public string[] OversizedAttachmentNames { get; private set; } = Array.Empty(); + public string RawMessagePath { get; set; } + public string CallId { get; set; } + public int BodyLength { get; private set; } + public int DepartmentDispatchCount { get; private set; } + public int GroupDispatchCount { get; private set; } + public int AttachmentCount => AttachmentNames.Length; + public int OversizedAttachmentCount => OversizedAttachmentNames.Length; + public int DispatchTargetCount => DepartmentDispatchCount + GroupDispatchCount; + public string SenderDomain => GetDomain(FromAddresses.FirstOrDefault()); + + public string RouteKind + { + get + { + if (DepartmentDispatchCount > 0 && GroupDispatchCount > 0) + return "mixed"; + if (DepartmentDispatchCount > 0) + return "department"; + if (GroupDispatchCount > 0) + return "group"; + + return "none"; + } + } + + public static SmtpMessageSummary Create(MimeMessage message, string stableMessageId, string subject, string nature, string messageBody) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + return new SmtpMessageSummary + { + StableMessageId = stableMessageId, + ReferenceMessageId = NormalizeMessageId(message.MessageId), + Subject = subject ?? String.Empty, + Nature = nature ?? String.Empty, + FromAddresses = message.From.Mailboxes.Select(x => x.Address).Where(x => !String.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + ToAddresses = message.To.Mailboxes.Select(x => x.Address).Where(x => !String.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + CcAddresses = message.Cc.Mailboxes.Select(x => x.Address).Where(x => !String.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + BodyLength = messageBody?.Length ?? 0 + }; + } + + public void SetDispatchTargets(IEnumerable dispatchTargets) + { + var items = dispatchTargets?.ToList() ?? new List(); + DepartmentDispatchCount = items.Count(x => x.Type == DispatchCodeType.Department); + GroupDispatchCount = items.Count(x => x.Type == DispatchCodeType.Group); + DispatchTargets = items.Select(x => $"{x.Type}:{x.Code}").ToArray(); + } + + public void SetAttachments(IEnumerable attachments, int maxAttachmentBytes) + { + var items = attachments?.ToList() ?? new List(); + AttachmentNames = items.Select(x => x.Name).ToArray(); + OversizedAttachmentNames = items.Where(x => x.Data.Length > maxAttachmentBytes).Select(x => x.Name).ToArray(); + } + + private static string NormalizeMessageId(string messageId) + { + if (String.IsNullOrWhiteSpace(messageId)) + return null; + + return messageId.Trim('<', '>', ' '); + } + + private static string GetDomain(string emailAddress) + { + if (String.IsNullOrWhiteSpace(emailAddress)) + return null; + + var delimiterIndex = emailAddress.LastIndexOf('@'); + if (delimiterIndex < 0 || delimiterIndex == emailAddress.Length - 1) + return null; + + return emailAddress.Substring(delimiterIndex + 1).Trim(); + } + } + + internal sealed class SmtpSessionState + { + private readonly HashSet _acceptedRecipients = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _rejectedRecipients = new HashSet(StringComparer.OrdinalIgnoreCase); + + public SmtpSessionState(Guid sessionId, string remoteEndPoint, string localEndPoint, bool isSecure) + { + SessionId = sessionId; + RemoteEndPoint = remoteEndPoint; + LocalEndPoint = localEndPoint; + IsSecure = isSecure; + StartedAtUtc = DateTimeOffset.UtcNow; + } + + public Guid SessionId { get; } + public DateTimeOffset StartedAtUtc { get; } + public string RemoteEndPoint { get; } + public string LocalEndPoint { get; } + public bool IsSecure { get; } + public string ProxySourceEndPoint { get; set; } + public string ProxyDestinationEndPoint { get; set; } + public string ClientIdentity { get; set; } + public string Sender { get; set; } + public string LastCommandName { get; set; } + public bool MessageFailureReported { get; set; } + public int MessageCount { get; private set; } + public int AcceptedRecipientCount => _acceptedRecipients.Count; + public int RejectedRecipientCount => _rejectedRecipients.Count; + public IEnumerable AcceptedRecipients => _acceptedRecipients; + public IEnumerable RejectedRecipients => _rejectedRecipients; + public EventHandler CommandExecutingHandler { get; set; } + public EventHandler ResponseExceptionHandler { get; set; } + + public void IncrementMessageCount() + { + MessageCount++; + } + + public void RecordAcceptedRecipient(string recipient) + { + if (!String.IsNullOrWhiteSpace(recipient)) + _acceptedRecipients.Add(recipient); + } + + public void RecordRejectedRecipient(string recipient) + { + if (!String.IsNullOrWhiteSpace(recipient)) + _rejectedRecipients.Add(recipient); + } + } + + internal static class SmtpSessionStateAccessor + { + private const string SessionStateKey = "Resgrid:SmtpSessionState"; + private const string LocalEndPointKey = "EndpointListener:LocalEndPoint"; + private const string RemoteEndPointKey = "EndpointListener:RemoteEndPoint"; + + public static SmtpSessionState GetOrCreate(ISessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (context.Properties.TryGetValue(SessionStateKey, out var value) && value is SmtpSessionState existing) + return existing; + + var state = new SmtpSessionState( + context.SessionId, + ResolveEndpoint(context.Properties, RemoteEndPointKey), + ResolveEndpoint(context.Properties, LocalEndPointKey), + context.Pipe?.IsSecure ?? false); + + context.Properties[SessionStateKey] = state; + return state; + } + + private static string ResolveEndpoint(IDictionary properties, string key) + { + if (properties == null || !properties.TryGetValue(key, out var value) || value == null) + return "unknown"; + + return value.ToString(); + } + } + + internal sealed class CountlyTelemetryClient : IAsyncDisposable + { + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly Channel _eventChannel; + private readonly Task _worker; + private readonly string _appKey; + private readonly string _deviceId; + private readonly string _environment; + private readonly string _release; + + public CountlyTelemetryClient(ILogger logger, CountlyTelemetryOptions options, string serverName, string environment, string release) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _environment = environment; + _release = release; + + if (options == null || + String.IsNullOrWhiteSpace(options.Url) || + String.IsNullOrWhiteSpace(options.AppKey)) + { + return; + } + + _appKey = options.AppKey.Trim(); + _deviceId = String.IsNullOrWhiteSpace(options.DeviceId) + ? $"{Environment.MachineName}:{serverName}" + : options.DeviceId.Trim(); + + _httpClient = new HttpClient + { + BaseAddress = new Uri(BuildCountlyBaseUri(options.Url)), + Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds <= 0 ? 5 : options.RequestTimeoutSeconds) + }; + + _eventChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + _worker = Task.Run(ProcessQueueAsync); + } + + public bool IsEnabled => _eventChannel != null; + + public void TrackEvent(string key, Dictionary segmentation = null, TimeSpan? duration = null) + { + if (!IsEnabled || String.IsNullOrWhiteSpace(key)) + return; + + var eventRequest = new CountlyEventRequest + { + Key = key.Trim(), + DurationSeconds = duration?.TotalSeconds, + Segmentation = MergeBaseSegmentation(segmentation) + }; + + if (!_eventChannel.Writer.TryWrite(eventRequest)) + { + _logger.Warning("Countly event queue rejected SMTP telemetry event {EventKey}", key); + } + } + + public async ValueTask DisposeAsync() + { + if (!IsEnabled) + return; + + _eventChannel.Writer.TryComplete(); + await _worker.ConfigureAwait(false); + _httpClient.Dispose(); + } + + private async Task ProcessQueueAsync() + { + while (await _eventChannel.Reader.WaitToReadAsync().ConfigureAwait(false)) + { + while (_eventChannel.Reader.TryRead(out var eventRequest)) + { + try + { + await SendEventAsync(eventRequest).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed sending SMTP telemetry event {EventKey} to Countly", eventRequest.Key); + } + } + } + } + + private async Task SendEventAsync(CountlyEventRequest eventRequest) + { + var payload = JsonSerializer.Serialize(new[] + { + new CountlyEventPayload + { + Key = eventRequest.Key, + Count = 1, + Duration = eventRequest.DurationSeconds, + Segmentation = eventRequest.Segmentation, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + } + }); + + using var content = new FormUrlEncodedContent(new Dictionary + { + ["app_key"] = _appKey, + ["device_id"] = _deviceId, + ["events"] = payload + }); + + using var response = await _httpClient.PostAsync("i", content).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + _logger.Warning( + "Countly rejected SMTP telemetry event {EventKey} with status {StatusCode}. Response={ResponseBody}", + eventRequest.Key, + (int)response.StatusCode, + TrimForLog(responseBody, 500)); + } + } + + private Dictionary MergeBaseSegmentation(Dictionary segmentation) + { + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (segmentation != null) + { + foreach (var entry in segmentation.Where(x => !String.IsNullOrWhiteSpace(x.Value))) + { + merged[entry.Key] = entry.Value; + } + } + + merged["environment"] = _environment; + merged["release"] = _release; + return merged; + } + + private static string BuildCountlyBaseUri(string url) + { + return $"{url.Trim().TrimEnd('/')}/"; + } + + private static string TrimForLog(string value, int maxLength) + { + if (String.IsNullOrWhiteSpace(value)) + return value; + + value = value.Trim().Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); + return value.Length <= maxLength ? value : value.Substring(0, maxLength); + } + + private sealed class CountlyEventRequest + { + public string Key { get; set; } + public Dictionary Segmentation { get; set; } + public double? DurationSeconds { get; set; } + } + + private sealed class CountlyEventPayload + { + [JsonPropertyName("key")] + public string Key { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("dur")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Duration { get; set; } + + [JsonPropertyName("segmentation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary Segmentation { get; set; } + + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + } + } +} diff --git a/Resgrid.Audio.Relay.Console/appsettings.json b/Resgrid.Audio.Relay.Console/appsettings.json new file mode 100644 index 0000000..21a2758 --- /dev/null +++ b/Resgrid.Audio.Relay.Console/appsettings.json @@ -0,0 +1,39 @@ +{ + "Mode": "smtp", + "AudioConfigPath": "settings.json", + "Resgrid": { + "BaseUrl": "https://api.resgrid.com", + "ApiVersion": "4", + "ClientId": "", + "ClientSecret": "", + "RefreshToken": "", + "Scope": "openid profile email offline_access mobile", + "TokenCachePath": ".\\data\\resgrid-token.json" + }, + "Telemetry": { + "Environment": "", + "Sentry": { + "Dsn": "", + "Release": "", + "SendDefaultPii": true + }, + "Countly": { + "Url": "", + "AppKey": "", + "DeviceId": "", + "RequestTimeoutSeconds": 5 + } + }, + "Smtp": { + "ServerName": "resgrid-relay", + "Port": 2525, + "DataDirectory": ".\\data", + "DuplicateWindowHours": 72, + "DefaultCallPriority": 1, + "MaxAttachmentBytes": 10485760, + "SaveRawMessages": true, + "DepartmentDispatchPrefix": "G", + "DepartmentAddressDomains": [ "dispatch.resgrid.com" ], + "GroupAddressDomains": [ "groups.resgrid.com" ] + } +} diff --git a/Resgrid.Audio.Relay.Console/packages.config b/Resgrid.Audio.Relay.Console/packages.config deleted file mode 100644 index 5e2394a..0000000 --- a/Resgrid.Audio.Relay.Console/packages.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resgrid.Audio.Relay.Console/settings.json b/Resgrid.Audio.Relay.Console/settings.json index 1878b1d..2f5e948 100644 --- a/Resgrid.Audio.Relay.Console/settings.json +++ b/Resgrid.Audio.Relay.Console/settings.json @@ -1,15 +1,25 @@ { "InputDevice": 0, "AudioLength": 120, - "ApiUrl": "https://api.resgrid.com", - "Username": "TEST", - "Password": "TEST", "Multiple": false, "Tolerance": 100, "Threshold": -50, "EnableSilenceDetection": false, "Debug": false, "DebugKey": "", + "Resgrid": { + "BaseUrl": "https://api.resgrid.com", + "ApiVersion": "4", + "ClientId": "YOUR_CLIENT_ID", + "ClientSecret": "YOUR_CLIENT_SECRET", + "RefreshToken": "YOUR_REFRESH_TOKEN", + "Scope": "openid profile email offline_access mobile", + "TokenCachePath": ".\\data\\resgrid-token.json" + }, + "DispatchMapping": { + "GroupDispatchPrefix": "G", + "DepartmentDispatchPrefix": "G" + }, "Watchers": [ { "Name": "Station 2", diff --git a/Resgrid.Audio.Relay/App.config b/Resgrid.Audio.Relay/App.config deleted file mode 100644 index c9e9d0c..0000000 --- a/Resgrid.Audio.Relay/App.config +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/Resgrid.Audio.Relay/App.xaml b/Resgrid.Audio.Relay/App.xaml index 6c9c018..1420646 100644 --- a/Resgrid.Audio.Relay/App.xaml +++ b/Resgrid.Audio.Relay/App.xaml @@ -1,37 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + diff --git a/Resgrid.Audio.Relay/App.xaml.cs b/Resgrid.Audio.Relay/App.xaml.cs index cbeac6a..31b1b2b 100644 --- a/Resgrid.Audio.Relay/App.xaml.cs +++ b/Resgrid.Audio.Relay/App.xaml.cs @@ -1,13 +1,8 @@ -using GalaSoft.MvvmLight.Threading; -using System.Windows; +using System.Windows; namespace Resgrid.Audio.Relay { public partial class App : Application { - static App() - { - DispatcherHelper.Initialize(); - } } } diff --git a/Resgrid.Audio.Relay/MainWindow.xaml b/Resgrid.Audio.Relay/MainWindow.xaml index ebada8c..16d6fa1 100644 --- a/Resgrid.Audio.Relay/MainWindow.xaml +++ b/Resgrid.Audio.Relay/MainWindow.xaml @@ -1,116 +1,52 @@ - + Title="Resgrid Relay Monitor" + Height="480" + Width="760" + MinWidth="640" + MinHeight="420" + WindowStartupLocation="CenterScreen"> + + + + + + + - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - + diff --git a/Resgrid.Audio.Relay/MainWindow.xaml.cs b/Resgrid.Audio.Relay/MainWindow.xaml.cs index de673cb..e4a5009 100644 --- a/Resgrid.Audio.Relay/MainWindow.xaml.cs +++ b/Resgrid.Audio.Relay/MainWindow.xaml.cs @@ -1,14 +1,116 @@ -using MahApps.Metro.Controls; -using Resgrid.Audio.Relay.ViewModel; +using NAudio.Wave; +using Resgrid.Audio.Core; +using Serilog; +using Serilog.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; namespace Resgrid.Audio.Relay { - public partial class MainWindow : MetroWindow + public partial class MainWindow : Window { + private readonly Logger _logger; + private readonly WatcherAudioStorage _audioStorage; + private readonly AudioEvaluator _audioEvaluator; + private readonly AudioRecorder _audioRecorder; + public MainWindow() { InitializeComponent(); - Closing += (s, e) => ViewModelLocator.Cleanup(); + + _logger = new LoggerConfiguration() + .MinimumLevel.Information() + .CreateLogger(); + + _audioStorage = new WatcherAudioStorage(_logger); + _audioEvaluator = new AudioEvaluator(_logger); + _audioRecorder = new AudioRecorder(_audioEvaluator, _audioStorage); + _audioRecorder.SetSampleAggregator(new SampleAggregator()); + _audioRecorder.SampleAggregator.MaximumCalculated += SampleAggregator_MaximumCalculated; + _audioRecorder.SampleAggregator.WaveformCalculated += SampleAggregator_WaveformCalculated; + + LoadDevices(); + Closed += OnClosed; + } + + private void LoadDevices() + { + var devices = new List(); + for (var deviceNumber = 0; deviceNumber < WaveIn.DeviceCount; deviceNumber++) + { + var capabilities = WaveIn.GetCapabilities(deviceNumber); + devices.Add(new DeviceOption + { + Id = deviceNumber, + Name = $"{deviceNumber}: {capabilities.ProductName} ({capabilities.Channels} channels)" + }); + } + + DeviceComboBox.ItemsSource = devices; + DeviceComboBox.SelectedIndex = devices.Count > 0 ? 0 : -1; + StatusTextBlock.Text = devices.Count > 0 ? "Ready" : "No input devices found"; + } + + private void ToggleMonitoring_Click(object sender, RoutedEventArgs e) + { + if (_audioRecorder.RecordingState == RecordingState.Stopped) + { + var selectedDevice = DeviceComboBox.SelectedItem as DeviceOption; + if (selectedDevice == null) + { + StatusTextBlock.Text = "Select an input device before starting."; + return; + } + + _audioRecorder.BeginMonitoring(selectedDevice.Id); + ToggleButton.Content = "Stop Monitoring"; + StatusTextBlock.Text = $"Monitoring {selectedDevice.Name}"; + AppendEvent($"Started monitoring device {selectedDevice.Name}"); + } + else + { + _audioRecorder.Stop(); + ToggleButton.Content = "Start Monitoring"; + StatusTextBlock.Text = "Stopped"; + AppendEvent("Stopped monitoring"); + } + } + + private void SampleAggregator_MaximumCalculated(object sender, MaxSampleEventArgs e) + { + Dispatcher.Invoke(() => + { + MinTextBlock.Text = e.MinSample.ToString("F4"); + MaxTextBlock.Text = e.MaxSample.ToString("F4"); + DbTextBlock.Text = e.Db.ToString("F2"); + }); + } + + private void SampleAggregator_WaveformCalculated(object sender, WaveformEventArgs e) + { + Dispatcher.Invoke(() => + { + FftTextBlock.Text = e.FastFourierTransform.Length.ToString(); + }); + } + + private void OnClosed(object sender, EventArgs e) + { + _audioRecorder.Stop(); + } + + private void AppendEvent(string text) + { + EventsTextBox.AppendText($"{DateTime.Now:G}: {text}{Environment.NewLine}"); + EventsTextBox.ScrollToEnd(); + } + + private sealed class DeviceOption + { + public int Id { get; set; } + public string Name { get; set; } } } -} \ No newline at end of file +} diff --git a/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj b/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj index d22d7d6..2893c6c 100644 --- a/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj +++ b/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj @@ -1,185 +1,37 @@ - - - + - Debug - AnyCPU - {B521F5CB-3C81-4E6F-8327-758CDA0C4D77} + net10.0-windows WinExe + true Resgrid.Audio.Relay ResgridRelay - v4.7.1 - 512 - {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 4 - true - - - + false + disable + disable - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\packages\Accord.3.8.0\lib\net462\Accord.dll - - - ..\packages\Accord.Audio.3.8.0\lib\net462\Accord.Audio.dll - - - ..\packages\Accord.Math.3.8.0\lib\net462\Accord.Math.dll - - - ..\packages\Accord.Math.3.8.0\lib\net462\Accord.Math.Core.dll - - - ..\packages\CommonServiceLocator.2.0.3\lib\net47\CommonServiceLocator.dll - - - ..\packages\ControlzEx.3.0.2.4\lib\net462\ControlzEx.dll - - - ..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.dll - - - ..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Extras.dll - - - ..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Platform.dll - - - ..\packages\LiveCharts.0.9.7\lib\net45\LiveCharts.dll - - - ..\packages\LiveCharts.Wpf.0.9.7\lib\net45\LiveCharts.Wpf.dll - - - ..\packages\MahApps.Metro.1.6.5\lib\net47\MahApps.Metro.dll - - - ..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll - - - ..\packages\MaterialDesignThemes.MahApps.0.0.11\lib\net45\MaterialDesignThemes.MahApps.dll - - - ..\packages\MaterialDesignThemes.2.4.0.1044\lib\net45\MaterialDesignThemes.Wpf.dll - - - ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll - - - ..\packages\ServiceLocation.1.3.1\lib\net45\ServiceLocation.dll - - - - - - - - ..\packages\MvvmLightLibs.5.4.1\lib\net45\System.Windows.Interactivity.dll - - - - - - - - - 4.0 - - - - - + - - MSBuild:Compile - Designer - - - - - - MSBuild:Compile - Designer - - - App.xaml - Code - - - MainWindow.xaml - Code - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - + + + + + + + + - - Code - - - True - True - Resources.resx - - - True - Settings.settings - True - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - SettingsSingleFileGenerator - Settings.Designer.cs - + + - - - - + + ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll + true + + - - {64d892bf-42a5-40b6-93a4-e9ade0774abf} - Resgrid.Audio.Core - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - \ No newline at end of file + diff --git a/Resgrid.Audio.Relay/packages.config b/Resgrid.Audio.Relay/packages.config deleted file mode 100644 index e46106b..0000000 --- a/Resgrid.Audio.Relay/packages.config +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resgrid.Audio.Tests/AudioEvaluatorWithFileTests.cs b/Resgrid.Audio.Tests/AudioEvaluatorWithFileTests.cs index 6846d90..81e36d0 100644 --- a/Resgrid.Audio.Tests/AudioEvaluatorWithFileTests.cs +++ b/Resgrid.Audio.Tests/AudioEvaluatorWithFileTests.cs @@ -15,11 +15,10 @@ public class AudioEvaluatorWithFileTests //[Test] public void EvaluateAudioTrigger_BasicShouldReturnTrue() { - var path = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).Replace("file:\\", ""); + var path = System.AppContext.BaseDirectory; //System.IO.FileStream WaveFile = System.IO.File.OpenRead(path + "\\Data\\TestAudio.wav"); //byte[] data = new byte[WaveFile.Length]; - int sampleRate; //double[] fileData = Functions.WaveFileDataPrepare(path + "\\Data\\TestAudio.wav", out sampleRate);'' List fftData = new List(); diff --git a/Resgrid.Audio.Tests/DispatchListBuilderTests.cs b/Resgrid.Audio.Tests/DispatchListBuilderTests.cs new file mode 100644 index 0000000..459bd30 --- /dev/null +++ b/Resgrid.Audio.Tests/DispatchListBuilderTests.cs @@ -0,0 +1,34 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.ApiClient.V4; + +namespace Resgrid.Audio.Tests +{ + [TestFixture] + public class DispatchListBuilderTests + { + [Test] + public void Build_Should_Format_Group_And_Department_Dispatch_Codes() + { + var result = DispatchListBuilder.Build(new[] + { + new DispatchCode { Code = "GRP001", Type = DispatchCodeType.Group }, + new DispatchCode { Code = "DEPT01", Type = DispatchCodeType.Department } + }, "G"); + + result.Should().Be("G:GRP001|G:DEPT01"); + } + + [Test] + public void Build_Should_Deduplicate_Dispatch_Codes_Case_Insensitively() + { + var result = DispatchListBuilder.Build(new[] + { + new DispatchCode { Code = "grp001", Type = DispatchCodeType.Group }, + new DispatchCode { Code = "GRP001", Type = DispatchCodeType.Group } + }, "G"); + + result.Should().Be("G:grp001"); + } + } +} diff --git a/Resgrid.Audio.Tests/DtmfTests.cs b/Resgrid.Audio.Tests/DtmfTests.cs index 9bd67b3..42976fb 100644 --- a/Resgrid.Audio.Tests/DtmfTests.cs +++ b/Resgrid.Audio.Tests/DtmfTests.cs @@ -12,8 +12,8 @@ public class DtmfTests [Test] public void DTMF_ShouldBeAbleToReadWavFile() { - var path = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().CodeBase).Replace("file:\\", ""); - using (var waveFile = new WaveFileReader(path + "\\Data\\TestAudio.wav")) + var path = System.IO.Path.Combine(System.AppContext.BaseDirectory, "Data", "TestAudio.wav"); + using (var waveFile = new WaveFileReader(path)) { var actualTones = waveFile.DtmfTones(false).ToArray(); diff --git a/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj b/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj index 16f9349..cc0c9cb 100644 --- a/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj +++ b/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj @@ -1,111 +1,44 @@ - - - - + - Debug - AnyCPU - {00380D66-9F9D-4C3F-83EF-CD46FAC4AFCF} - Library - Properties - Resgrid.Audio.Tests - Resgrid.Audio.Tests - v4.7.1 - 512 - - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 + net10.0-windows + false + false + disable + disable + - - ..\packages\Castle.Core.4.3.1\lib\net45\Castle.Core.dll - - - ..\packages\DtmfDetection.0.9.2\lib\net47\DtmfDetection.dll - - - ..\packages\DtmfDetection.NAudio.0.9.2\lib\net47\DtmfDetection.NAudio.dll - - - ..\packages\FluentAssertions.5.4.1\lib\net47\FluentAssertions.dll + + + + + + + + + + ..\References\DtmfDetection.dll + true - - ..\packages\Moq.4.9.0\lib\net45\Moq.dll + + ..\References\DtmfDetection.NAudio.dll + true - + ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll + true - - ..\packages\NUnit.3.10.1\lib\net45\nunit.framework.dll - - - ..\packages\Serilog.2.7.1\lib\net46\Serilog.dll - - - - - - ..\packages\System.Runtime.CompilerServices.Unsafe.4.5.1\lib\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll - - - ..\packages\System.Threading.Tasks.Extensions.4.5.1\lib\netstandard2.0\System.Threading.Tasks.Extensions.dll - - - ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll - - - - - - - - - - - - - - - - - - - - + - - {64d892bf-42a5-40b6-93a4-e9ade0774abf} - Resgrid.Audio.Core - + + + + Always - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - \ No newline at end of file + diff --git a/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs b/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs new file mode 100644 index 0000000..761ae60 --- /dev/null +++ b/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Audio.Relay.Console.Smtp; +using Resgrid.Providers.ApiClient.V4; + +namespace Resgrid.Audio.Tests +{ + [TestFixture] + public class SmtpDispatchAddressParserTests + { + [Test] + public void TryParse_Should_Map_Department_Address_To_Department_Dispatch_Code() + { + var parser = new SmtpDispatchAddressParser(new SmtpRelayOptions()); + + var result = parser.TryParse("abc123@dispatch.resgrid.com", out var dispatchCode); + + result.Should().BeTrue(); + dispatchCode.Code.Should().Be("abc123"); + dispatchCode.Type.Should().Be(DispatchCodeType.Department); + } + + [Test] + public void ParseRecipients_Should_Ignore_Unknown_Domains_And_Deduplicate_Valid_Ones() + { + var parser = new SmtpDispatchAddressParser(new SmtpRelayOptions()); + + var result = parser.ParseRecipients(new[] + { + "abc123@dispatch.resgrid.com", + "abc123@dispatch.resgrid.com", + "station7@groups.resgrid.com", + "invalid@example.com" + }); + + result.Should().HaveCount(2); + result[0].Type.Should().Be(DispatchCodeType.Department); + result[1].Type.Should().Be(DispatchCodeType.Group); + } + } +} diff --git a/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs b/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs new file mode 100644 index 0000000..a83973d --- /dev/null +++ b/Resgrid.Audio.Tests/SmtpRelayTelemetryTests.cs @@ -0,0 +1,321 @@ +using FluentAssertions; +using MimeKit; +using NUnit.Framework; +using Resgrid.Audio.Relay.Console.Configuration; +using Resgrid.Audio.Relay.Console.Smtp; +using Resgrid.Providers.ApiClient.V4.Models; +using SmtpServer; +using SmtpServer.IO; +using SmtpServer.Mail; +using SmtpServer.Protocol; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Audio.Tests +{ + [TestFixture] + public class SmtpRelayTelemetryTests + { + [Test] + public async Task SaveAsync_Should_Track_Unroutable_Message_And_Return_Ok() + { + var dataDirectory = CreateTempDirectory(); + try + { + var telemetry = new FakeSmtpTelemetry(); + var callsClient = new FakeResgridCallsClient(); + var store = new RelayMessageStore(CreateOptions(dataDirectory), telemetry, callsClient); + + var response = await store.SaveAsync( + new FakeSessionContext(), + null, + CreateMessageBuffer("msg-unroutable", "sender@example.com", new[] { "invalid@example.com" }, "Dispatch Test", "Alarm body"), + CancellationToken.None); + + response.ReplyCode.Should().Be(SmtpResponse.Ok.ReplyCode); + callsClient.SaveCallInputs.Should().BeEmpty(); + telemetry.ReceivedMessages.Should().ContainSingle(); + telemetry.UnroutableMessages.Should().ContainSingle(); + telemetry.UnroutableMessages[0].Subject.Should().Be("Dispatch Test"); + telemetry.UnroutableMessages[0].ToAddresses.Should().Contain("invalid@example.com"); + telemetry.UnroutableMessages[0].DispatchTargetCount.Should().Be(0); + } + finally + { + DeleteDirectory(dataDirectory); + } + } + + [Test] + public async Task SaveAsync_Should_Remove_Duplicate_Registration_When_Processing_Fails() + { + var dataDirectory = CreateTempDirectory(); + try + { + var buffer = CreateMessageBuffer("msg-retry", "sender@example.com", new[] { "abc123@dispatch.resgrid.com" }, "Structure Fire", "First line"); + + var failingTelemetry = new FakeSmtpTelemetry(); + var failingClient = new FakeResgridCallsClient + { + SaveCallException = new InvalidOperationException("Resgrid is unavailable") + }; + var failingStore = new RelayMessageStore(CreateOptions(dataDirectory), failingTelemetry, failingClient); + + await FluentActions + .Awaiting(() => failingStore.SaveAsync(new FakeSessionContext(), null, buffer, CancellationToken.None)) + .Should() + .ThrowAsync() + .WithMessage("Resgrid is unavailable"); + + failingTelemetry.FailedMessages.Should().ContainSingle(); + + var successTelemetry = new FakeSmtpTelemetry(); + var successClient = new FakeResgridCallsClient + { + NextCallId = "call-42" + }; + var successStore = new RelayMessageStore(CreateOptions(dataDirectory), successTelemetry, successClient); + + var response = await successStore.SaveAsync(new FakeSessionContext(), null, buffer, CancellationToken.None); + + response.ReplyCode.Should().Be(SmtpResponse.Ok.ReplyCode); + successClient.SaveCallInputs.Should().ContainSingle(); + successTelemetry.ProcessedMessages.Should().ContainSingle(); + successTelemetry.ProcessedMessages[0].CallId.Should().Be("call-42"); + } + finally + { + DeleteDirectory(dataDirectory); + } + } + + [Test] + public async Task MailboxFilter_Should_Track_Sender_And_Recipient_Outcomes() + { + var dataDirectory = CreateTempDirectory(); + try + { + var telemetry = new FakeSmtpTelemetry(); + var filter = new RelayMailboxFilter(CreateOptions(dataDirectory), telemetry); + var context = new FakeSessionContext(); + var sender = new Mailbox("sender", "example.com"); + + var senderAccepted = await filter.CanAcceptFromAsync(context, sender, 2048, CancellationToken.None); + var validRecipientAccepted = await filter.CanDeliverToAsync(context, new Mailbox("abc123", "dispatch.resgrid.com"), sender, CancellationToken.None); + var invalidRecipientAccepted = await filter.CanDeliverToAsync(context, new Mailbox("bad", "example.com"), sender, CancellationToken.None); + + senderAccepted.Should().BeTrue(); + validRecipientAccepted.Should().BeTrue(); + invalidRecipientAccepted.Should().BeFalse(); + telemetry.SenderAcceptCount.Should().Be(1); + telemetry.RecipientOutcomes.Should().HaveCount(2); + telemetry.RecipientOutcomes[0].Recipient.Should().Be("abc123@dispatch.resgrid.com"); + telemetry.RecipientOutcomes[0].Accepted.Should().BeTrue(); + telemetry.RecipientOutcomes[1].Recipient.Should().Be("bad@example.com"); + telemetry.RecipientOutcomes[1].Accepted.Should().BeFalse(); + telemetry.RecipientOutcomes[1].Reason.Should().Contain("recipient domain"); + } + finally + { + DeleteDirectory(dataDirectory); + } + } + + private static SmtpRelayOptions CreateOptions(string dataDirectory) + { + return new SmtpRelayOptions + { + ServerName = "relay-test", + DataDirectory = dataDirectory, + SaveRawMessages = false, + MaxAttachmentBytes = 1024, + DepartmentDispatchPrefix = "G", + DepartmentAddressDomains = new[] { "dispatch.resgrid.com" }, + GroupAddressDomains = new[] { "groups.resgrid.com" } + }; + } + + private static ReadOnlySequence CreateMessageBuffer(string messageId, string from, IEnumerable to, string subject, string body) + { + var message = new MimeMessage + { + MessageId = messageId, + Subject = subject, + Body = new TextPart("plain") + { + Text = body + } + }; + + message.From.Add(MailboxAddress.Parse(from)); + foreach (var recipient in to) + { + message.To.Add(MailboxAddress.Parse(recipient)); + } + + using var stream = new MemoryStream(); + message.WriteTo(stream); + return new ReadOnlySequence(stream.ToArray()); + } + + private static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), $"resgrid-relay-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteDirectory(string path) + { + if (!String.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + + private sealed class FakeResgridCallsClient : IResgridCallsClient + { + public List SaveCallInputs { get; } = new List(); + public List SaveCallFileInputs { get; } = new List(); + public Exception SaveCallException { get; set; } + public string NextCallId { get; set; } = "call-1"; + + public Task SaveCallAsync(NewCallInput call, CancellationToken cancellationToken) + { + if (SaveCallException != null) + throw SaveCallException; + + SaveCallInputs.Add(call); + return Task.FromResult(NextCallId); + } + + public Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken) + { + SaveCallFileInputs.Add(file); + return Task.FromResult($"file-{SaveCallFileInputs.Count}"); + } + } + + private sealed class FakeSmtpTelemetry : ISmtpTelemetry + { + public int SenderAcceptCount { get; private set; } + public List RecipientOutcomes { get; } = new List(); + public List ReceivedMessages { get; } = new List(); + public List UnroutableMessages { get; } = new List(); + public List ProcessedMessages { get; } = new List(); + public List<(SmtpMessageSummary Message, Exception Exception)> FailedMessages { get; } = new List<(SmtpMessageSummary, Exception)>(); + + public void RelayStarting(SmtpRelayOptions options) + { + } + + public void RelayStopped(SmtpRelayOptions options) + { + } + + public void RelayFaulted(SmtpRelayOptions options, Exception exception) + { + } + + public void SessionCreated(ISessionContext context) + { + } + + public void SessionCompleted(ISessionContext context) + { + } + + public void SessionCancelled(ISessionContext context) + { + } + + public void SessionFaulted(ISessionContext context, Exception exception) + { + } + + public void SenderAccepted(ISessionContext context, IMailbox from, int size) + { + SenderAcceptCount++; + } + + public void RecipientEvaluated(ISessionContext context, IMailbox to, IMailbox from, bool accepted, string reason) + { + RecipientOutcomes.Add(new RecipientOutcome + { + Recipient = to == null ? null : $"{to.User}@{to.Host}", + Accepted = accepted, + Reason = reason + }); + } + + public void MessageReceived(ISessionContext context, SmtpMessageSummary message) + { + ReceivedMessages.Add(message); + } + + public void DuplicateMessage(ISessionContext context, SmtpMessageSummary message) + { + } + + public void UnroutableMessage(ISessionContext context, SmtpMessageSummary message) + { + UnroutableMessages.Add(message); + } + + public void MessageProcessingStarted(ISessionContext context, SmtpMessageSummary message) + { + } + + public void MessageProcessed(ISessionContext context, SmtpMessageSummary message, TimeSpan duration) + { + ProcessedMessages.Add(message); + } + + public void MessageFailed(ISessionContext context, SmtpMessageSummary message, Exception exception, TimeSpan duration) + { + FailedMessages.Add((message, exception)); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + + private sealed class RecipientOutcome + { + public string Recipient { get; set; } + public bool Accepted { get; set; } + public string Reason { get; set; } + } + + private sealed class FakeSessionContext : ISessionContext + { +#pragma warning disable CS0067 + public FakeSessionContext() + { + Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["EndpointListener:RemoteEndPoint"] = "127.0.0.1:45000", + ["EndpointListener:LocalEndPoint"] = "127.0.0.1:2525" + }; + } + + public event EventHandler CommandExecuted; + public event EventHandler CommandExecuting; + public event EventHandler ResponseException; + public event EventHandler SessionAuthenticated; + + public AuthenticationContext Authentication => null; + public IEndpointDefinition EndpointDefinition => null; + public ISecurableDuplexPipe Pipe => null; + public IDictionary Properties { get; } + public ISmtpServerOptions ServerOptions => null; + public IServiceProvider ServiceProvider => null; + public Guid SessionId { get; } = Guid.NewGuid(); +#pragma warning restore CS0067 + } + } +} diff --git a/Resgrid.Audio.Tests/app.config b/Resgrid.Audio.Tests/app.config deleted file mode 100644 index acb008a..0000000 --- a/Resgrid.Audio.Tests/app.config +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resgrid.Audio.Tests/packages.config b/Resgrid.Audio.Tests/packages.config deleted file mode 100644 index f459e61..0000000 --- a/Resgrid.Audio.Tests/packages.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..99b1511 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.202", + "rollForward": "latestFeature" + } +} From 0420c02247c8d06cc4eb3d81bfa94b95d043d9e2 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 27 Apr 2026 22:20:49 -0700 Subject: [PATCH 3/6] RR1-T101 PR#7 fixes --- .github/copilot-instructions.md | 74 ++++++ .github/workflows/dotnet.yml | 251 ++++++++++++++++++ Resgrid.Audio.Core/Resgrid.Audio.Core.csproj | 6 +- .../Configuration/RelayHostOptions.cs | 1 + .../Smtp/SmtpRelayInfrastructure.cs | 4 + .../Resgrid.Audio.Relay.csproj | 8 +- .../Resgrid.Audio.Tests.csproj | 6 +- 7 files changed, 335 insertions(+), 15 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/dotnet.yml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..043ef0f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,74 @@ +# Copilot instructions for Resgrid Relay + +## Build, test, and run + +- Full solution build (Windows required for the audio projects and test project): + +```powershell +dotnet build "Resgrid Audio.sln" +``` + +- Full test suite: + +```powershell +dotnet test "Resgrid Audio.sln" +``` + +- Run a single NUnit test method: + +```powershell +dotnet test ".\Resgrid.Audio.Tests\Resgrid.Audio.Tests.csproj" --filter "FullyQualifiedName~Resgrid.Audio.Tests.SmtpDispatchAddressParserTests.TryParse_Should_Map_Department_Address_To_Department_Dispatch_Code" +``` + +- For SMTP-only work on a non-Windows agent, build the cross-platform console target directly: + +```powershell +dotnet build ".\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj" -f net10.0 +``` + +- Console worker entrypoints from the repo root: + +```powershell +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- run +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- setup +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- devices +dotnet run --project .\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj -- monitor 0 +``` + +- There is no dedicated lint, analyzer, or `dotnet format` command checked into this repository. + +## High-level architecture + +- `Resgrid.Audio.Relay.Console\Program.cs` is the live worker entrypoint. It selects `smtp` or `audio` mode, loads `appsettings.json` plus `RELAY_` environment variables into `RelayHostOptions`, and resolves relative paths from `AppContext.BaseDirectory`. + +- SMTP mode is the current cross-platform relay path. The main flow is: + + `Program.RunAsync` -> `SmtpRelayRunner` -> `RelayMailboxFilter` -> `RelayMessageStore` -> `SmtpDispatchAddressParser` / `DispatchListBuilder` -> `CallsApi` + + `RelayMessageStore` parses the MIME message, derives a stable message id, suppresses duplicates through `ProcessedMessageStore`, optionally saves the raw `.eml` under `data\messages\`, creates the call, then uploads attachments as separate call files. `SmtpTelemetry` wraps the path with Serilog logging and optional Sentry/Countly reporting. + +- Resgrid authentication and API access live in `Providers\Resgrid.Providers.ApiClient\V4`. `ResgridV4ApiClient` handles OIDC discovery, refresh-token exchange, token-cache persistence, and extraction of `CurrentUserId` from the access token `sub` claim for file uploads. `CallsApi` and `HealthApi` are the thin entrypoints used by the worker and audio pipeline. + +- Audio mode is Windows-only and lives in `Resgrid.Audio.Core`. `AudioEvaluator` detects watcher triggers from DTMF/pure tones, `AudioProcessor` manages active watcher lifecycles and captured audio, and `ComService` translates watcher metadata into v4 dispatch lists, creates the call, then uploads the generated MP3 as a separate call file. + +- `Resgrid.Audio.Relay` is a lightweight WPF monitor for selecting an input device and watching live audio metrics. It uses `AudioRecorder` directly; the actual relay/dispatch behavior stays in the console app plus `Resgrid.Audio.Core`. + +- `Resgrid.Audio.Tests` covers the current seams: SMTP routing and telemetry, v4 dispatch-list formatting, and parts of the audio path. It targets `net10.0-windows`. + +## Key conventions + +- Prefer compiled code over legacy files that still exist in the tree. `Resgrid.Audio.Relay.Console.csproj` removes `Args\`, `Commands\`, `Data\`, `Models\`, and `ConsoleTable.cs` from compilation. `Providers\Resgrid.Providers.ApiClient.csproj` removes `V3\**\*.cs`. `Resgrid.Audio.Relay.csproj` removes `ViewModel\**\*.cs` and older resource scaffolding. Do not treat those files as the active implementation path unless you are intentionally reviving them. + +- OIDC/v4 is the only live Resgrid integration. New API work should go through `ResgridV4ApiClient`, `CallsApi`, and the V4 models instead of the legacy username/password V3 code. + +- Configuration is split by mode. Host-level settings live in `Resgrid.Audio.Relay.Console\appsettings.json` plus `RELAY_` environment variables. Audio watcher definitions live in `Resgrid.Audio.Relay.Console\settings.json`. Keep new file-based settings consistent with the existing `AppContext.BaseDirectory` path resolution. + +- SMTP routing is domain-driven. Configured department domains become `DispatchCodeType.Department`; configured group domains become `DispatchCodeType.Group`. `DispatchListBuilder` emits pipe-delimited `PREFIX:CODE` tokens, with a configurable department prefix and `G` as the default group prefix. + +- SMTP duplicate suppression is persisted in `data\processed-messages.json`. Raw message retention uses `data\messages\`. Preserve the rollback behavior that removes a duplicate-registration entry if call creation or attachment upload fails after registration. + +- `Watcher.Type` is semantic: `1` means department dispatch and `2` means group dispatch. `Watcher.AdditionalCodes` is always treated as extra group dispatch codes. When `Config.Multiple` is `false`, simultaneous hits are merged into the first active watcher; when it is `true`, they create separate active watcher flows. + +- The Windows audio path still depends on checked-in binary references from `References\` and `packages\NAudio.1.8.4\...`. Avoid cleanup refactors that replace those references without revalidating the audio pipeline. + +- `.editorconfig` uses tabs for `.cs` and CRLF line endings. Tests use NUnit with FluentAssertions. diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..4a235a0 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,251 @@ +# This workflow runs build and tests on every pull request and every direct push +# to master. It also publishes artifacts and creates a GitHub Release only after +# a pull request is successfully merged to master. +# +# Required Docker Hub secrets: +# - DOCKERHUB_USERNAME +# - DOCKERHUB_TOKEN + +name: Build and publish + +on: + push: + branches: + - master + - main + pull_request: + types: [opened, synchronize, reopened, closed] + +env: + DOTNET_VERSION: "10.0.x" + RELEASE_VERSION: "v${{ github.run_number }}" + DOCKER_IMAGE_NAME: "resgridrelay" + +jobs: + build-and-test: + name: Build and test + if: | + github.event_name == 'push' || + github.event.action != 'closed' || + (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master') + runs-on: windows-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.action == 'closed' && github.event.pull_request.merge_commit_sha || github.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore "Resgrid Audio.sln" + + - name: Build + run: dotnet build "Resgrid Audio.sln" --configuration Release --no-restore + + - name: Test + run: dotnet test "Resgrid Audio.sln" --configuration Release --no-build --verbosity normal + + publish-apps: + name: Publish app assets + needs: build-and-test + if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' + runs-on: windows-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore "Resgrid Audio.sln" + + - name: Publish application outputs + shell: pwsh + run: | + $publishRoot = Join-Path $env:RUNNER_TEMP "publish" + $consoleLinux = Join-Path $publishRoot "relay-console-net10.0" + $consoleWindows = Join-Path $publishRoot "relay-console-net10.0-windows" + $monitorWindows = Join-Path $publishRoot "relay-monitor-net10.0-windows" + + dotnet publish ".\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj" --configuration Release --framework net10.0 --no-restore --output $consoleLinux /p:UseAppHost=false + dotnet publish ".\Resgrid.Audio.Relay.Console\Resgrid.Audio.Relay.Console.csproj" --configuration Release --framework net10.0-windows --no-restore --output $consoleWindows + dotnet publish ".\Resgrid.Audio.Relay\Resgrid.Audio.Relay.csproj" --configuration Release --framework net10.0-windows --no-restore --output $monitorWindows + + - name: Package release assets + shell: pwsh + run: | + $publishRoot = Join-Path $env:RUNNER_TEMP "publish" + $assetRoot = Join-Path $env:RUNNER_TEMP "release-assets" + + New-Item -ItemType Directory -Path $assetRoot -Force | Out-Null + + $assets = @( + @{ Source = Join-Path $publishRoot "relay-console-net10.0"; File = Join-Path $assetRoot "resgridrelay-console-net10.0-$env:RELEASE_VERSION.zip" }, + @{ Source = Join-Path $publishRoot "relay-console-net10.0-windows"; File = Join-Path $assetRoot "resgridrelay-console-net10.0-windows-$env:RELEASE_VERSION.zip" }, + @{ Source = Join-Path $publishRoot "relay-monitor-net10.0-windows"; File = Join-Path $assetRoot "resgridrelay-monitor-net10.0-windows-$env:RELEASE_VERSION.zip" } + ) + + foreach ($asset in $assets) + { + if (Test-Path $asset.File) + { + Remove-Item $asset.File -Force + } + + Compress-Archive -Path (Join-Path $asset.Source '*') -DestinationPath $asset.File + } + + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: release-assets + path: ${{ runner.temp }}\release-assets\*.zip + if-no-files-found: error + + docker-build-and-push: + name: Publish Docker image + needs: build-and-test + if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + + - name: Validate Docker Hub configuration + shell: bash + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + if [ -z "$DOCKERHUB_USERNAME" ]; then + echo "DOCKERHUB_USERNAME secret is required." + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }} + tags: | + type=raw,value=${{ env.RELEASE_VERSION }} + type=raw,value=latest + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create Docker release metadata + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/docker-release" + { + echo "Docker image:" + echo "${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}" + echo + echo "Tags:" + printf '%s\n' "${{ steps.meta.outputs.tags }}" + } > "$RUNNER_TEMP/docker-release/docker-image-tags.txt" + + - name: Upload Docker release metadata + uses: actions/upload-artifact@v4 + with: + name: docker-release-metadata + path: ${{ runner.temp }}/docker-release/docker-image-tags.txt + if-no-files-found: error + + github-release: + name: Create GitHub release + needs: [publish-apps, docker-build-and-push] + if: github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'master' + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Download app release assets + uses: actions/download-artifact@v4 + with: + name: release-assets + path: ${{ runner.temp }}/release-assets + + - name: Download Docker release metadata + uses: actions/download-artifact@v4 + with: + name: docker-release-metadata + path: ${{ runner.temp }}/docker-release + + - name: Prepare release notes + uses: actions/github-script@v7 + env: + DOCKER_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + with: + script: | + const fs = require('fs'); + const pr = context.payload.pull_request; + const cleanedBody = (pr.body || '') + .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') + .trim(); + + const notes = [ + cleanedBody || 'No release notes provided.', + '', + '## Docker image', + `- \`${process.env.DOCKER_IMAGE}:${process.env.RELEASE_VERSION}\``, + `- \`${process.env.DOCKER_IMAGE}:latest\`` + ].join('\n'); + + fs.writeFileSync('release_notes.md', notes); + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_VERSION }} + name: ${{ env.RELEASE_VERSION }} + body_path: release_notes.md + target_commitish: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + files: | + ${{ runner.temp }}/release-assets/*.zip + ${{ runner.temp }}/docker-release/docker-image-tags.txt + draft: false + prerelease: false + make_latest: true + fail_on_unmatched_files: true diff --git a/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj b/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj index cf023b4..ededdf9 100644 --- a/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj +++ b/Resgrid.Audio.Core/Resgrid.Audio.Core.csproj @@ -10,6 +10,7 @@ + @@ -22,10 +23,7 @@ ..\References\DtmfDetection.NAudio.dll true - - ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll - true - + diff --git a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs b/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs index 26ede77..8f78c69 100644 --- a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs +++ b/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs @@ -41,6 +41,7 @@ public sealed class SmtpRelayOptions public int DuplicateWindowHours { get; set; } = 72; public int DefaultCallPriority { get; set; } = 1; public int MaxAttachmentBytes { get; set; } = 10485760; + public int MaxMessageBytes { get; set; } = 26214400; // 25 MB public bool SaveRawMessages { get; set; } = true; public string DepartmentDispatchPrefix { get; set; } = "G"; public string[] DepartmentAddressDomains { get; set; } = new[] { "dispatch.resgrid.com" }; diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs index a918f53..e48c45c 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs @@ -37,6 +37,7 @@ public static async Task RunAsync(SmtpRelayOptions options, ISmtpTelemetry telem var smtpServerOptions = new SmtpServerOptionsBuilder() .ServerName(options.ServerName) .Port(options.Port) + .MaxMessageSize(options.MaxMessageBytes, MaxMessageSizeHandling.Strict) .Build(); var smtpServer = new SmtpServer.SmtpServer(smtpServerOptions, serviceProvider); @@ -121,6 +122,9 @@ public RelayMessageStore(SmtpRelayOptions options, ISmtpTelemetry telemetry, IRe public override async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) { + if (buffer.Length > _options.MaxMessageBytes) + return SmtpResponse.SizeLimitExceeded; + await using var stream = new MemoryStream(); var position = buffer.GetPosition(0); while (buffer.TryGet(ref position, out var memory)) diff --git a/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj b/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj index 2893c6c..fcf8cb0 100644 --- a/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj +++ b/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj @@ -21,16 +21,10 @@ + - - - ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll - true - - - diff --git a/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj b/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj index cc0c9cb..76af315 100644 --- a/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj +++ b/Resgrid.Audio.Tests/Resgrid.Audio.Tests.csproj @@ -10,6 +10,7 @@ + @@ -24,10 +25,7 @@ ..\References\DtmfDetection.NAudio.dll true - - ..\packages\NAudio.1.8.4\lib\net35\NAudio.dll - true - + From d86a59093348798bb6e420d2b7965d82f2a74eee Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Apr 2026 09:20:25 -0700 Subject: [PATCH 4/6] RR1-T101 PR#7 fixes --- .github/workflows/dotnet.yml | 49 ++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4a235a0..5df8be0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,8 +18,9 @@ on: env: DOTNET_VERSION: "10.0.x" - RELEASE_VERSION: "v${{ github.run_number }}" + RELEASE_VERSION: "2.0.${{ github.run_number }}" DOCKER_IMAGE_NAME: "resgridrelay" + DOCKER_IMAGE: "resgridllc/resgridrelay" jobs: build-and-test: @@ -154,7 +155,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }} + images: ${{ env.DOCKER_IMAGE }} tags: | type=raw,value=${{ env.RELEASE_VERSION }} type=raw,value=latest @@ -177,7 +178,7 @@ jobs: mkdir -p "$RUNNER_TEMP/docker-release" { echo "Docker image:" - echo "${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}" + echo "${{ env.DOCKER_IMAGE }}" echo echo "Tags:" printf '%s\n' "${{ steps.meta.outputs.tags }}" @@ -198,6 +199,7 @@ jobs: permissions: contents: write + pull-requests: read steps: - name: Download app release assets @@ -212,28 +214,33 @@ jobs: name: docker-release-metadata path: ${{ runner.temp }}/docker-release - - name: Prepare release notes + - name: Get merged PR info + id: pr-info uses: actions/github-script@v7 - env: - DOCKER_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }} - RELEASE_VERSION: ${{ env.RELEASE_VERSION }} with: script: | + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + }); + const pr = prs.data.find(p => p.merged_at); const fs = require('fs'); - const pr = context.payload.pull_request; - const cleanedBody = (pr.body || '') - .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') - .trim(); - - const notes = [ - cleanedBody || 'No release notes provided.', - '', - '## Docker image', - `- \`${process.env.DOCKER_IMAGE}:${process.env.RELEASE_VERSION}\``, - `- \`${process.env.DOCKER_IMAGE}:latest\`` - ].join('\n'); - - fs.writeFileSync('release_notes.md', notes); + if (pr) { + const body = (pr.body || '') + .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') + .trim() || 'No release notes provided.'; + const notes = [ + body, + '', + '## Docker image', + `- \`${{ env.DOCKER_IMAGE }}:${{ env.RELEASE_VERSION }}\``, + `- \`${{ env.DOCKER_IMAGE }}:latest\`` + ].join('\n'); + fs.writeFileSync('release_notes.md', notes); + } else { + fs.writeFileSync('release_notes.md', 'No release notes provided.'); + } - name: Create GitHub Release uses: softprops/action-gh-release@v2 From 37b8f05a7ce63e080ae7297271ef6d92d3ab90b2 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Apr 2026 09:21:09 -0700 Subject: [PATCH 5/6] RR1-T101 adding missing approve --- .github/workflows/auto-approve.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/auto-approve.yml diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml new file mode 100644 index 0000000..0427314 --- /dev/null +++ b/.github/workflows/auto-approve.yml @@ -0,0 +1,26 @@ +name: Auto approve + +on: + issue_comment: + types: + - created + +jobs: + auto-approve: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/github-script@v6 + name: Approve LGTM Review + if: github.actor == 'ucswift' && contains(github.event.comment.body, 'Approve') + with: + script: | + github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + review_id: 1, + event: 'APPROVE', + body: 'This PR is approved.' + }) \ No newline at end of file From 71e08c597f3440175e8d626aba0e003817c98027 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Apr 2026 09:51:12 -0700 Subject: [PATCH 6/6] RR1-T101 PR#7 fixes --- .github/workflows/auto-approve.yml | 5 ++- .github/workflows/dotnet.yml | 32 +++++++------------ .../Smtp/SmtpRelayInfrastructure.cs | 20 +++++++++--- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml index 0427314..5b3523c 100644 --- a/.github/workflows/auto-approve.yml +++ b/.github/workflows/auto-approve.yml @@ -11,16 +11,15 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9.0.0 name: Approve LGTM Review - if: github.actor == 'ucswift' && contains(github.event.comment.body, 'Approve') + if: github.event.issue.pull_request && github.event.comment.user.login == 'ucswift' && contains(github.event.comment.body, 'Approve') with: script: | github.rest.pulls.createReview({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, - review_id: 1, event: 'APPROVE', body: 'This PR is approved.' }) \ No newline at end of file diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 5df8be0..80c8090 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -219,28 +219,18 @@ jobs: uses: actions/github-script@v7 with: script: | - const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha, - }); - const pr = prs.data.find(p => p.merged_at); const fs = require('fs'); - if (pr) { - const body = (pr.body || '') - .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') - .trim() || 'No release notes provided.'; - const notes = [ - body, - '', - '## Docker image', - `- \`${{ env.DOCKER_IMAGE }}:${{ env.RELEASE_VERSION }}\``, - `- \`${{ env.DOCKER_IMAGE }}:latest\`` - ].join('\n'); - fs.writeFileSync('release_notes.md', notes); - } else { - fs.writeFileSync('release_notes.md', 'No release notes provided.'); - } + const body = (context.payload.pull_request.body || '') + .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') + .trim() || 'No release notes provided.'; + const notes = [ + body, + '', + '## Docker image', + `- \`${{ env.DOCKER_IMAGE }}:${{ env.RELEASE_VERSION }}\``, + `- \`${{ env.DOCKER_IMAGE }}:latest\`` + ].join('\n'); + fs.writeFileSync('release_notes.md', notes); - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs index e48c45c..97ebb2c 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs @@ -499,12 +499,22 @@ private async Task> LoadAsync(CancellationTok if (!File.Exists(_path)) return new Dictionary(StringComparer.OrdinalIgnoreCase); - var payload = await File.ReadAllTextAsync(_path, cancellationToken).ConfigureAwait(false); - if (String.IsNullOrWhiteSpace(payload)) - return new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + var payload = await File.ReadAllTextAsync(_path, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(payload)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var items = JsonSerializer.Deserialize>(payload); + if (items == null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); - var items = JsonSerializer.Deserialize>(payload); - return items ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + return new Dictionary(items, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is JsonException || ex is IOException) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } } private async Task SaveAsync(Dictionary entries, CancellationToken cancellationToken)