Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions Core/Resgrid.Services/CommunicationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ public class CommunicationService : ICommunicationService
private readonly IDepartmentSettingsService _departmentSettingsService;
private readonly ISubscriptionsService _subscriptionsService;
private readonly IUserStateService _userStateService;
private readonly IDepartmentsService _departmentsService;

public CommunicationService(ISmsService smsService, IEmailService emailService, IPushService pushService, IGeoLocationProvider geoLocationProvider,
IOutboundVoiceProvider outboundVoiceProvider, IUserProfileService userProfileService, IDepartmentSettingsService departmentSettingsService,
ISubscriptionsService subscriptionsService, IUserStateService userStateService)
ISubscriptionsService subscriptionsService, IUserStateService userStateService, IDepartmentsService departmentsService)
{
_smsService = smsService;
_emailService = emailService;
Expand All @@ -37,6 +38,7 @@ public CommunicationService(ISmsService smsService, IEmailService emailService,
_departmentSettingsService = departmentSettingsService;
_subscriptionsService = subscriptionsService;
_userStateService = userStateService;
_departmentsService = departmentsService;
}

public async Task<bool> SendMessageAsync(Message message, string sendersName, string departmentNumber, int departmentId, UserProfile profile = null, Department department = null)
Expand All @@ -50,7 +52,7 @@ public async Task<bool> SendMessageAsync(Message message, string sendersName, st
if (profile == null && !String.IsNullOrWhiteSpace(message.ReceivingUserId))
profile = await _userProfileService.GetProfileByUserIdAsync(message.ReceivingUserId);

if (profile == null || profile.SendMessageSms)
if (profile == null || (message.SystemGenerated ? profile.SendNotificationSms : profile.SendMessageSms))
{
if (profile == null || profile.MobileNumberVerified.IsContactMethodAllowedForSending())
{
Expand All @@ -66,7 +68,7 @@ public async Task<bool> SendMessageAsync(Message message, string sendersName, st
}
}

if (profile == null || profile.SendMessageEmail)
if (profile == null || (message.SystemGenerated ? profile.SendNotificationEmail : profile.SendMessageEmail))
{
if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending())
{
Expand All @@ -81,7 +83,7 @@ public async Task<bool> SendMessageAsync(Message message, string sendersName, st
}
}

if (profile == null || profile.SendMessagePush)
if (profile == null || (message.SystemGenerated ? profile.SendNotificationPush : profile.SendMessagePush))
{
var spm = new StandardPushMessage();
spm.MessageId = message.MessageId;
Expand Down Expand Up @@ -684,6 +686,9 @@ public async Task<bool> SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven

foreach (var recipient in recipients)
{
if (!await CanSendToUser(recipient.UserId, departmentId))
continue;

// Send a Push Notification
if (recipient.SendPush)
{
Expand Down Expand Up @@ -766,6 +771,14 @@ public async Task<bool> SendTextMessageAsync(string userId, string title, string

private async Task<bool> CanSendToUser(string userId, int departmentId)
{
// Filter out disabled or deleted users
if (!string.IsNullOrWhiteSpace(userId))
{
var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false);
if (member == null || member.IsDisabled.GetValueOrDefault() || member.IsDeleted)
return false;
}

var supressStaffingInfo = await _departmentSettingsService.GetDepartmentStaffingSuppressInfoAsync(departmentId);
var lastUserStaffing = await _userStateService.GetLastUserStateByUserIdAsync(userId);

Expand Down
17 changes: 13 additions & 4 deletions Core/Resgrid.Services/WeatherAlertService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default)

foreach (var member in members)
{
if (member.UserId != senderId)
if (member.UserId != senderId && !member.IsDisabled.GetValueOrDefault() && !member.IsDeleted)
message.AddRecipient(member.UserId);
}

Expand Down Expand Up @@ -560,6 +560,7 @@ private static string FormatAlertMessageBody(WeatherAlert alert, Department depa
{
var sb = new System.Text.StringBuilder();

// Header
sb.AppendLine($"WEATHER ALERT: {alert.Event?.ToUpper()}");
sb.AppendLine($"Severity: {SeverityNames[Math.Min(alert.Severity, 4)]}");

Expand All @@ -573,18 +574,26 @@ private static string FormatAlertMessageBody(WeatherAlert alert, Department depa

sb.AppendLine();

// Headline as summary
if (!string.IsNullOrEmpty(alert.Headline))
{
sb.AppendLine(alert.Headline);
sb.AppendLine();
}

// Description — the core alert details
if (!string.IsNullOrEmpty(alert.Description))
{
sb.AppendLine(alert.Description);
}

// Safety instructions, if provided
if (!string.IsNullOrEmpty(alert.Instruction))
{
sb.AppendLine();
sb.AppendLine(alert.Instruction);
}

sb.AppendLine();
sb.AppendLine("View active weather alerts for full details.");

var body = sb.ToString();
if (body.Length > 3950)
body = body.Substring(0, 3947) + "...";
Expand Down
9 changes: 8 additions & 1 deletion Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class with_the_communication_service : TestBase
protected Mock<IDepartmentSettingsService> _departmentSettingsServiceMock;
protected Mock<ISubscriptionsService> _subscriptionsServiceMock;
protected Mock<IUserStateService> _userStateServiceMock;
protected Mock<IDepartmentsService> _departmentsServiceMock;

protected ICommunicationService _communicationService;

Expand All @@ -40,10 +41,16 @@ protected with_the_communication_service()
_departmentSettingsServiceMock = new Mock<IDepartmentSettingsService>();
_subscriptionsServiceMock = new Mock<ISubscriptionsService>();
_userStateServiceMock = new Mock<IUserStateService>();
_departmentsServiceMock = new Mock<IDepartmentsService>();

// CanSendToUser requires a valid DepartmentMember for the user to proceed.
_departmentsServiceMock
.Setup(x => x.GetDepartmentMemberAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<bool>()))
.ReturnsAsync(new DepartmentMember());

_communicationService = new CommunicationService(_smsServiceMock.Object, _emailServiceMock.Object, _pushServiceMock.Object,
_geoLocationProviderMock.Object, _outboundVoiceProviderMock.Object, _userProfileServiceMock.Object, _departmentSettingsServiceMock.Object,
_subscriptionsServiceMock.Object, _userStateServiceMock.Object);
_subscriptionsServiceMock.Object, _userStateServiceMock.Object, _departmentsServiceMock.Object);
}
}

Expand Down
34 changes: 34 additions & 0 deletions Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,40 @@ public async Task exists_async_should_return_false_when_presigned_head_reports_m
s3Client.Verify(x => x.GetPreSignedURL(It.Is<GetPreSignedUrlRequest>(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once);
}

[Test]
public async Task exists_async_should_verify_with_presigned_head_when_metadata_throws_raw_format_exception()
{
var s3Client = new Mock<IAmazonS3>(MockBehavior.Strict);
s3Client
.Setup(x => x.GetObjectMetadataAsync(It.IsAny<GetObjectMetadataRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new FormatException("bad metadata expiration header"));
s3Client
.Setup(x => x.GetPreSignedURL(It.IsAny<GetPreSignedUrlRequest>()))
.Returns<GetPreSignedUrlRequest>(request =>
{
request.BucketName.Should().Be("tts-bucket");
request.Key.Should().Be("tts/audio.wav");
request.Verb.Should().Be(HttpVerb.HEAD);
request.Protocol.Should().Be(Protocol.HTTP);
return "http://download.example.com/tts/audio.wav?signature=head-raw-format";
});

var handler = new RecordingHttpMessageHandler((request, _) =>
{
request.Method.Should().Be(HttpMethod.Head);
request.RequestUri.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=head-raw-format"));
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
});
var service = CreateService(s3Client.Object, handler, useSsl: false);

var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None);

exists.Should().BeTrue();
handler.Requests.Should().HaveCount(1);
s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is<GetObjectMetadataRequest>(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny<CancellationToken>()), Times.Once);
s3Client.Verify(x => x.GetPreSignedURL(It.Is<GetPreSignedUrlRequest>(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once);
}

[Test]
public async Task upload_async_should_treat_malformed_put_response_as_success_when_the_object_is_verified()
{
Expand Down
109 changes: 105 additions & 4 deletions Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
Expand Down Expand Up @@ -46,8 +48,11 @@ public async Task generate_async_should_return_cached_response_without_generatin
{
var cachedUri = new Uri("https://cdn.example.com/tts/abc123.wav");

_audioProcessingService
.Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165))
.Returns(("mb-us1", 130));
_cacheService
.Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165))
.Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130))
.Returns(CacheKey);
_cacheService
.Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny<CancellationToken>()))
Expand Down Expand Up @@ -76,8 +81,11 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss
var audioBytes = new byte[] { 1, 2, 3, 4 };
var objectUri = new Uri("https://cdn.example.com/tts/abc123.wav");

_audioProcessingService
.Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165))
.Returns(("mb-us1", 130));
_cacheService
.Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165))
.Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130))
.Returns(CacheKey);
_cacheService
.SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny<CancellationToken>()))
Expand Down Expand Up @@ -111,6 +119,9 @@ public async Task generate_async_should_apply_configured_klatt_variant_to_reques
var cachedUri = new Uri("https://cdn.example.com/tts/xyz789.wav");
var cacheKey = new TtsCacheKey("xyz789", "tts/xyz789.wav");

_audioProcessingService
.Setup(x => x.GetEffectiveSynthesisProfile("fr+klatt4", 165))
.Returns(("fr+klatt4", 165));
_cacheService
.Setup(x => x.CreateCacheKey("Bonjour", "fr+klatt4", 165))
.Returns(cacheKey);
Expand All @@ -136,8 +147,11 @@ public async Task generate_async_should_replace_legacy_default_voices_with_confi
var cachedUri = new Uri("https://cdn.example.com/tts/legacy.wav");
var cacheKey = new TtsCacheKey("legacy", "tts/legacy.wav");

_audioProcessingService
.Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165))
.Returns(("mb-us1", 130));
_cacheService
.Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165))
.Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130))
.Returns(cacheKey);
_cacheService
.Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny<CancellationToken>()))
Expand Down Expand Up @@ -172,8 +186,11 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th
var allowGenerationCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var cacheLookupCount = 0;

_audioProcessingService
.Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165))
.Returns(("mb-us1", 130));
_cacheService
.Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165))
.Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130))
.Returns(CacheKey);
_cacheService
.Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny<CancellationToken>()))
Expand Down Expand Up @@ -217,4 +234,88 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th
Times.Exactly(4));
}
}

[TestFixture]
public class AudioProcessingServiceTests
{
[Test]
public void create_espeak_start_info_should_use_mbrola_profile_for_english_voices()
{
var service = CreateService();

var startInfo = InvokePrivateMethod<ProcessStartInfo>(service, "CreateEspeakStartInfo", "en-gb-x-rp+klatt4", 165, "/tmp/raw.wav");

startInfo.FileName.Should().Be("espeak-ng");
startInfo.ArgumentList.Should().Equal(
"--stdin",
"-w",
"/tmp/raw.wav",
"-v",
"mb-us1",
"-s",
"130",
"-p",
"50",
"-g",
"3");
}

[Test]
public void create_espeak_start_info_should_keep_requested_voice_and_speed_for_non_english_voices()
{
var service = CreateService();

var startInfo = InvokePrivateMethod<ProcessStartInfo>(service, "CreateEspeakStartInfo", "fr+klatt4", 165, "/tmp/raw.wav");

startInfo.FileName.Should().Be("espeak-ng");
startInfo.ArgumentList.Should().Equal(
"--stdin",
"-w",
"/tmp/raw.wav",
"-v",
"fr+klatt4",
"-s",
"165");
}

[Test]
public void create_ffmpeg_start_info_should_apply_the_requested_telephone_filter()
{
var service = CreateService();

var startInfo = InvokePrivateMethod<ProcessStartInfo>(service, "CreateFfmpegStartInfo", "/tmp/raw.wav", "/tmp/normalized.wav");

startInfo.FileName.Should().Be("ffmpeg");
startInfo.ArgumentList.Should().Equal(
"-nostdin",
"-loglevel",
"error",
"-y",
"-i",
"/tmp/raw.wav",
"-ar",
"8000",
"-ac",
"1",
"-acodec",
"pcm_s16le",
"-af",
"highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1",
"/tmp/normalized.wav");
}

private static AudioProcessingService CreateService()
{
return new AudioProcessingService(
Options.Create(new TtsOptions()),
Mock.Of<ILogger<AudioProcessingService>>());
}

private static T InvokePrivateMethod<T>(object instance, string methodName, params object[] arguments)
{
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
method.Should().NotBeNull($"{methodName} should exist on {instance.GetType().FullName}");
return (T)method!.Invoke(instance, arguments)!;
}
}
}
8 changes: 6 additions & 2 deletions Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ public async Task<ActionResult<SendMessageResult>> SendMessage([FromBody] NewMes
{
foreach (var departmentMember in departmentUsers)
{
if (departmentMember.IsDisabled.GetValueOrDefault() || departmentMember.IsDeleted)
continue;

message.AddRecipient(departmentMember.UserId);
}
}
Expand All @@ -319,8 +322,9 @@ public async Task<ActionResult<SendMessageResult>> SendMessage([FromBody] NewMes

if (usersToSendTo.All(x => x != userIdToSendTo) && userIdToSendTo != UserId)
{
// Ensure the user is in the same department
if (departmentUsers.Any(x => x.UserId == userIdToSendTo))
// Ensure the user is in the same department and not disabled/deleted
var dm = departmentUsers.FirstOrDefault(x => x.UserId == userIdToSendTo);
if (dm != null && !dm.IsDisabled.GetValueOrDefault() && !dm.IsDeleted)
{
usersToSendTo.Add(userIdToSendTo);
message.AddRecipient(userIdToSendTo);
Expand Down
Loading
Loading