diff --git a/src/Indice.Services/SmsServiceApifon.cs b/src/Indice.Services/SmsServiceApifon.cs index a7e32abde..531406b94 100644 --- a/src/Indice.Services/SmsServiceApifon.cs +++ b/src/Indice.Services/SmsServiceApifon.cs @@ -31,6 +31,9 @@ ILogger logger if (string.IsNullOrWhiteSpace(Settings.ApiKey)) { throw new ArgumentException($"SMS settings {nameof(SmsServiceApifonSettings.ApiKey)} is empty."); } + if (!string.IsNullOrWhiteSpace(Settings.WebhookUrl) && string.IsNullOrWhiteSpace(Settings.WebhookSecret)) { + throw new ArgumentException($"{nameof(SmsServiceApifonSettings.WebhookSecret)} cannot be empty if a {nameof(SmsServiceApifonSettings.WebhookUrl)} is provided."); + } } /// The Apifon base URL address. @@ -60,13 +63,16 @@ public async Task SendAsync(string destination, string subject, str throw new ArgumentException("Invalid recipients. Recipients should be valid phone numbers", nameof(recipient)); } return phone.ToString("D"); - }) - .ToArray(); + }).ToArray(); if (recipients.Any(phoneNumber => phoneNumber.Any(numberChar => !char.IsNumber(numberChar)))) { throw new ArgumentException("Invalid recipients. Recipients cannot contain letters.", nameof(destination)); } // https://docs.apifon.com/apireference.html#sms-request var payload = ApifonRequest.CreateSms(sender?.Id ?? Settings.Sender ?? Settings.SenderName!, recipients, body!, Settings.EnableUrlShortener); + if (!string.IsNullOrWhiteSpace(Settings.WebhookUrl)) { + payload.WithCallbackUrl(Settings.WebhookUrl, Settings.WebhookSecret); + } + var signature = payload.Sign(Settings.ApiKey!, HttpMethod.Post.ToString(), SERVICE_ENDPOINT); var request = new HttpRequestMessage { Content = new StringContent(payload.ToJson(), Encoding.UTF8, "application/json"), @@ -130,6 +136,10 @@ public class SmsServiceApifonSettings : SmsServiceSettings public string Token { get; set; } = null!; /// If enabled all urls in the message will be replaced with shortened urls public bool EnableUrlShortener { get; set; } = false; + /// The base URL to be used for callback events. + public string WebhookUrl { get; set; } = string.Empty; + /// The secret to be used for hashing the request. + public string WebhookSecret { get; set; } = string.Empty; } internal class ApifonResponse @@ -212,6 +222,8 @@ internal static Dictionary ExtractParametersAndRepl [JsonPropertyName("message")] public ApifonMessage Message { get; set; } = new(); + [JsonPropertyName("reference_id")] + public string? ReferenceId { get; set; } [JsonPropertyName("subscribers")] public List Subscribers { get; set; } = []; /// SMS validity period. Min 30 - Max 4320 (default). @@ -293,6 +305,7 @@ public static string ToJson(this TRequest request, JsonSerializerOptio DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); + /// Signs an Apifon request. public static string Sign(this TRequest request, string secretKey, string method, string uri) where TRequest : ApifonRequest { var toSign = method + "\n" + uri + "\n" @@ -302,4 +315,17 @@ public static string Sign(this TRequest request, string secretKey, str using var hmacSha256 = new HMACSHA256(encoding.GetBytes(secretKey)); return Convert.ToBase64String(hmacSha256.ComputeHash(encoding.GetBytes(toSign))); } + + /// Generates a callback URL with a reference identifier for an Apifon request. + public static TRequest WithCallbackUrl(this TRequest request, string baseUrl, string secretKey) where TRequest : ApifonRequest { + var timestamp = DateTimeOffset.UtcNow; + var referenceId = Guid.NewGuid().ToString("N"); + var payload = $"{referenceId}:{timestamp:O}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + var hex = Convert.ToHexString(hash); + request.ReferenceId = referenceId; + request.CallbackUrl = $"{baseUrl}?hmac={hex}&ref={referenceId}&ts={Uri.EscapeDataString(timestamp.ToString("O"))}"; + return request; + } } \ No newline at end of file diff --git a/test/Indice.Services.Tests/SmsTests.cs b/test/Indice.Services.Tests/SmsTests.cs index 0dc105030..4162768de 100644 --- a/test/Indice.Services.Tests/SmsTests.cs +++ b/test/Indice.Services.Tests/SmsTests.cs @@ -43,7 +43,9 @@ public async Task TestApifonSms(string apiKey, string token, string phoneNumber, ["Sms:Sender"] = sender, ["Sms:SenderName"] = senderName, ["Sms:TestMode"] = true.ToString(), - ["Sms:EnableUrlShortener"] = true.ToString() + ["Sms:EnableUrlShortener"] = true.ToString(), + ["Sms:WebhookUrl"] = string.Empty, + ["Sms:WebhookSecret"] = string.Empty }; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(inMemorySettings) @@ -56,15 +58,15 @@ public async Task TestApifonSms(string apiKey, string token, string phoneNumber, var serviceProvider = collection.BuildServiceProvider(); - var excepion = default(Exception); + var exception = default(Exception); try { var service = serviceProvider.GetRequiredService(); await service.SendAsync(phoneNumber, subject, body); } catch (Exception smsServiceException) { - excepion = smsServiceException; + exception = smsServiceException; } - Assert.Null(excepion); + Assert.Null(exception); } [Theory(Skip = "Sensitive Data")]