From 071f4021327a8c84ccdddb8d4de107238381989b Mon Sep 17 00:00:00 2001 From: Sergey Egorov Date: Thu, 17 Oct 2024 22:24:47 +0530 Subject: [PATCH 1/5] OTP via Telegram --- .../Configuration/PluginSettings.cs | 18 ++- .../Controllers/MessagesController.cs | 52 ++------- .../Controllers/OTPController.cs | 58 ++++++++++ .../Controllers/SmsControllerHelper.cs | 74 ++++++++++++ .../DTOs/OtpDetailsDto.cs | 19 ++++ .../DTOs/TelegramServiceDto.cs | 45 ++++++++ .../Exceptions/TelegramOtpException.cs | 28 +++++ .../Interfaces/ITelegramService.cs | 15 +++ .../Services/OtpService.cs | 31 ++++++ .../Services/TelegramService.cs | 105 ++++++++++++++++++ plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs | 2 + .../pluginsettings.json | 7 ++ src/OnlineSales/Interfaces/IOtpService.cs | 11 ++ 13 files changed, 421 insertions(+), 44 deletions(-) create mode 100644 plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Exceptions/TelegramOtpException.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs create mode 100644 src/OnlineSales/Interfaces/IOtpService.cs diff --git a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs index fc82ab32..a2c47420 100644 --- a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs +++ b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs @@ -10,6 +10,8 @@ public class PluginConfig public GatewaysConfig SmsGateways { get; set; } = new GatewaysConfig(); + public OtpGatewaysConfig OtpGateways { get; set; } = new OtpGatewaysConfig(); + public List SmsCountryGateways { get; set; } = new List(); } @@ -99,4 +101,18 @@ public class TwilioConfig public string AuthToken { get; set; } = string.Empty; public string SenderId { get; set; } = string.Empty; -} \ No newline at end of file +} + +public class OtpGatewaysConfig +{ + public TelegramConfig Telegram { get; set; } = new TelegramConfig(); +} + +public class TelegramConfig +{ + public string ApiUrl { get; set; } = string.Empty; + + public string AuthToken { get; set; } = string.Empty; + + public string? SenderUserName { get; set; } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs index a40b2380..3c1ce892 100644 --- a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs @@ -5,11 +5,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using OnlineSales.Exceptions; using OnlineSales.Plugin.Sms.Data; using OnlineSales.Plugin.Sms.DTOs; using OnlineSales.Plugin.Sms.Entities; -using PhoneNumbers; using Serilog; namespace OnlineSales.Plugin.Sms.Controllers; @@ -18,19 +16,20 @@ namespace OnlineSales.Plugin.Sms.Controllers; public class MessagesController : Controller { private readonly ISmsService smsService; - private readonly PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.GetInstance(); - private readonly SmsDbContext dbContext; + private readonly SmsControllerHelper controllerHelper; public MessagesController(SmsDbContext dbContext, ISmsService smsService) { - this.dbContext = dbContext; this.smsService = smsService; + + controllerHelper = new SmsControllerHelper(dbContext); } [HttpPost] [Route("sms")] - [AllowAnonymous] + [AllowAnonymous] // @@ why? [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] public async Task SendSms( @@ -39,43 +38,14 @@ public async Task SendSms( { try { - if (accessToken == null || accessToken.Replace("Bearer ", string.Empty) != SmsPlugin.Configuration.SmsAccessKey) - { - return new UnauthorizedResult(); - } - - var recipient = string.Empty; - - try - { - var phoneNumber = phoneNumberUtil.Parse(smsDetails.Recipient, string.Empty); - - recipient = phoneNumberUtil.Format(phoneNumber, PhoneNumberFormat.E164); - } - catch (NumberParseException npex) - { - ModelState.AddModelError(npex.ErrorType.ToString(), npex.Message); - } + SmsControllerHelper.CheckAuthentication(accessToken); + var recipient = controllerHelper.GetRecipient(smsDetails.Recipient, ModelState); - if (!ModelState.IsValid) - { - throw new InvalidModelStateException(ModelState); - } - - var smsLog = new SmsLog - { - Sender = smsService.GetSender(recipient), - Recipient = smsDetails.Recipient, - Message = smsDetails.Message, - Status = SmsLog.SmsStatus.NotSent, - }; - - dbContext.SmsLogs!.Add(smsLog); - await dbContext.SaveChangesAsync(); + var smsLog = await controllerHelper.AddLog(recipient, smsService.GetSender(recipient), smsDetails.Message); await smsService.SendAsync(recipient, smsDetails.Message); - smsLog.Status = SmsLog.SmsStatus.Sent; + await controllerHelper.UpdateLogStatus(smsLog, SmsLog.SmsStatus.Sent); return Ok(); } @@ -85,9 +55,5 @@ public async Task SendSms( throw; } - finally - { - await dbContext.SaveChangesAsync(); - } } } \ No newline at end of file diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs new file mode 100644 index 00000000..c6f3008f --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs @@ -0,0 +1,58 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using OnlineSales.Plugin.Sms.Data; +using OnlineSales.Plugin.Sms.DTOs; +using Serilog; + +namespace OnlineSales.Plugin.Sms.Controllers; + +[Route("api/otp")] +public class OtpController : Controller +{ + private readonly IOtpService otpService; + private readonly SmsControllerHelper controllerHelper; + + public OtpController(SmsDbContext dbContext, IOtpService otpService) + { + this.otpService = otpService; + + controllerHelper = new SmsControllerHelper(dbContext); + } + + [HttpPost] + [Route("otp")] + [AllowAnonymous] // @@ why? + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task SendSms( + [FromBody] OtpDetailsDto otpDetails, + [FromHeader(Name = "Authentication")] string accessToken) + { + try + { + SmsControllerHelper.CheckAuthentication(accessToken); + var recipient = controllerHelper.GetRecipient(otpDetails.Recipient, ModelState); + + // @@var smsLog = await controllerHelper.AddLog(recipient, "OTP", otpDetails.OtpCode); + + await otpService.SendOtpAsync(recipient, otpDetails.OtpCode); + + // @@await controllerHelper.UpdateLogStatus(smsLog, SmsLog.SmsStatus.Sent); + + return Ok(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to send OTP code to {0}: {1}", otpDetails.Recipient, otpDetails.OtpCode); + + throw; + } + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs new file mode 100644 index 00000000..a1cb301e --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs @@ -0,0 +1,74 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OnlineSales.Exceptions; +using OnlineSales.Plugin.Sms.Data; +using OnlineSales.Plugin.Sms.Entities; +using PhoneNumbers; + +namespace OnlineSales.Plugin.Sms.Controllers; + +internal class SmsControllerHelper +{ + private readonly PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.GetInstance(); + private readonly SmsDbContext dbContext; + + public SmsControllerHelper(SmsDbContext dbContext) + { + this.dbContext = dbContext; + } + + public static void CheckAuthentication(string accessToken) + { + if (accessToken == null || accessToken.Replace("Bearer ", string.Empty) != SmsPlugin.Configuration.SmsAccessKey) + { + throw new UnauthorizedAccessException(); + } + } + + public string GetRecipient(string phone, ModelStateDictionary modelState) + { + var recipient = string.Empty; + try + { + var phoneNumber = phoneNumberUtil.Parse(phone, string.Empty); + + recipient = phoneNumberUtil.Format(phoneNumber, PhoneNumberFormat.E164); + } + catch (NumberParseException npex) + { + modelState.AddModelError(npex.ErrorType.ToString(), npex.Message); + } + + if (!modelState.IsValid) + { + throw new InvalidModelStateException(modelState); + } + + return recipient; + } + + public async Task AddLog(string recipient, string sender, string message) + { + var smsLog = new SmsLog + { + Sender = sender, + Recipient = recipient, + Message = message, + Status = SmsLog.SmsStatus.NotSent, + }; + + dbContext.SmsLogs!.Add(smsLog); + await dbContext.SaveChangesAsync(); + + return smsLog; + } + + public async Task UpdateLogStatus(SmsLog smsLog, SmsLog.SmsStatus status) + { + smsLog.Status = status; + await dbContext.SaveChangesAsync(); + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs new file mode 100644 index 00000000..25c4b0e1 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs @@ -0,0 +1,19 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using System.ComponentModel.DataAnnotations; + +namespace OnlineSales.Plugin.Sms.DTOs; + +public class OtpDetailsDto +{ + [Required] + public string Recipient { get; set; } = string.Empty; + + [Required] + [MinLength(4)] + [MaxLength(8)] + [RegularExpression(@"^\d+$", ErrorMessage = "OTP code can contain digits only")] + public string OtpCode { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs new file mode 100644 index 00000000..65800073 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs @@ -0,0 +1,45 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +namespace OnlineSales.Plugin.Sms.DTOs; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the Telegram name convinion")] +public class TelegramCheckSendAbilityDto +{ + required public string phone_number { get; set; } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the Telegram name convinion")] +public class TelegramSendVerificationMessageDto +{ + required public string phone_number { get; set; } + + required public string request_id { get; set; } + + required public string code { get; set; } + + public string? sender { get; set; } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the Telegram name convinion")] +public class TelegramResponseDto +{ + public bool ok { get; set; } + + public string? error { get; set; } + + public TelegramRequestStatusDto? result { get; set; } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the Telegram name convinion")] +public class TelegramRequestStatusDto +{ + public string request_id { get; set; } = string.Empty; // Unique identifier of the verification request. + + public string phone_number { get; set; } = string.Empty; // The phone number to which the verification code was sent, in the E.164 format. + + public float request_cost { get; set; } // Total request cost incurred by either checkSendAbility or sendVerificationMessage. + + public float? remaining_balance { get; set; } // Optional. Remaining balance in credits. Returned only in response to a request that incurs a charge. +} diff --git a/plugins/OnlineSales.Plugin.Sms/Exceptions/TelegramOtpException.cs b/plugins/OnlineSales.Plugin.Sms/Exceptions/TelegramOtpException.cs new file mode 100644 index 00000000..cbc6901b --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Exceptions/TelegramOtpException.cs @@ -0,0 +1,28 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using System.Net; +using System.Runtime.Serialization; + +namespace OnlineSales.Plugin.Sms.Exceptions; + +[Serializable] +public class TelegramServiceCallException : Exception +{ + public TelegramServiceCallException(string message, HttpStatusCode statusCode) + : base(message) + { + StatusCode = statusCode; + } + + public HttpStatusCode StatusCode { get; private set; } +} + +public class TelegramFailedResultException : Exception +{ + public TelegramFailedResultException(string? message) + : base(message) + { + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs b/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs new file mode 100644 index 00000000..d4b77b5e --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs @@ -0,0 +1,15 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using OnlineSales.Plugin.Sms.DTOs; + +namespace OnlineSales.Interfaces +{ + public interface ITelegramService + { + Task CanDeliverAsync(string recipient); + + Task SendAsync(string recipient, string requestId, string otpCode); + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs new file mode 100644 index 00000000..eddbcb05 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs @@ -0,0 +1,31 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +namespace OnlineSales.Plugin.Sms.Services +{ + public class OtpService : IOtpService + { + private readonly ITelegramService telegramService; + private readonly ISmsService smsService; + + public OtpService(ITelegramService telegramService, ISmsService smsService) + { + this.telegramService = telegramService; + this.smsService = smsService; + } + + public async Task SendOtpAsync(string recepient, string code) + { + var checkResult = await telegramService.CanDeliverAsync(recepient); + if (checkResult == null) + { + await smsService.SendAsync(recepient, code); + } + else + { + await telegramService.SendAsync(recepient, checkResult!.request_id, code); + } + } + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs new file mode 100644 index 00000000..15ca9c0a --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs @@ -0,0 +1,105 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using OnlineSales.Plugin.Sms.DTOs; +using OnlineSales.Plugin.Sms.Exceptions; + +namespace OnlineSales.Plugin.Sms.Services; + +internal class TelegramService : ITelegramService, IDisposable +{ + private readonly HttpClient? client; + + public TelegramService() + { + var telegramConfig = SmsPlugin.Configuration.OtpGateways.Telegram; + if (telegramConfig.AuthToken == "$SMSGATEWAYS__TELEGRAM__AUTHTOKEN") + { + client = null; + } + else + { + client = new HttpClient() + { + BaseAddress = new(telegramConfig.ApiUrl), + }; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", telegramConfig.AuthToken); + } + } + + public async Task CanDeliverAsync(string recipient) + { + if(client == null) + { + return null; + } + + var dto = new TelegramCheckSendAbilityDto + { + phone_number = recipient, + }; + var jsonDto = JsonSerializer.Serialize(dto); + var content = new StringContent(jsonDto, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var response = await client.PostAsync("checkSendAbility", content); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var respResult = JsonSerializer.Deserialize(responseContent); + if (!(respResult?.ok ?? false)) + { + return null; + } + + return respResult.result; + } + + public async Task SendAsync(string recipient, string requestId, string otpCode) + { + if (client == null) + { + throw new InvalidOperationException("TelegramService.SendAsync() cannot be called if the Telegram service isn't configured."); + } + + var dto = new TelegramSendVerificationMessageDto + { + phone_number = recipient, + request_id = requestId, + code = otpCode, + }; + + var sender = SmsPlugin.Configuration.OtpGateways.Telegram.SenderUserName; + if (!string.IsNullOrEmpty(sender) && sender != "$OTPGATEWAYS__TELEGRAM__SENDERUSERNAME") + { + dto.sender = sender; + } + + var jsonDto = JsonSerializer.Serialize(dto); + var content = new StringContent(jsonDto, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var response = await client.PostAsync("sendVerificationMessage", content); + if (!response.IsSuccessStatusCode) + { + throw new TelegramServiceCallException("sendVerificationMessage endpoint failed", response.StatusCode); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var checkResult = JsonSerializer.Deserialize(responseContent); + if (!(checkResult?.ok ?? false)) + { + throw new TelegramFailedResultException(checkResult?.error); + } + } + + public void Dispose() + { + client?.Dispose(); + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs b/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs index 3fcb7801..7f690ca3 100644 --- a/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs +++ b/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs @@ -29,6 +29,8 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/plugins/OnlineSales.Plugin.Sms/pluginsettings.json b/plugins/OnlineSales.Plugin.Sms/pluginsettings.json index 54fb47dd..623ff19b 100644 --- a/plugins/OnlineSales.Plugin.Sms/pluginsettings.json +++ b/plugins/OnlineSales.Plugin.Sms/pluginsettings.json @@ -57,6 +57,13 @@ "Gateway": "Smsc" } ], + "OtpGateways": { + "Telegram": { + "ApiUrl": "https://gatewayapi.telegram.org/", + "AuthToken": "$SMSGATEWAYS__TELEGRAM__AUTHTOKEN", + "SenderUserName": "$OTPGATEWAYS__TELEGRAM__SENDERUSERNAME" // Optional + } + }, "Tasks": [ { "SyncSmsLogTask": { diff --git a/src/OnlineSales/Interfaces/IOtpService.cs b/src/OnlineSales/Interfaces/IOtpService.cs new file mode 100644 index 00000000..4d4ae36a --- /dev/null +++ b/src/OnlineSales/Interfaces/IOtpService.cs @@ -0,0 +1,11 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +namespace OnlineSales.Interfaces +{ + public interface IOtpService + { + Task SendOtpAsync(string recepient, string code); + } +} From c5fec6aa468debafbea43ceba3051d21cf355bee Mon Sep 17 00:00:00 2001 From: Sergey Egorov Date: Thu, 17 Oct 2024 23:27:20 +0530 Subject: [PATCH 2/5] Refactoring: MessagesControllerBase --- .../Controllers/MessagesController.cs | 34 +++---- .../Controllers/MessagesControllerBase.cs | 96 +++++++++++++++++++ .../Controllers/OTPController.cs | 37 +++---- .../Controllers/SmsControllerHelper.cs | 74 -------------- 4 files changed, 122 insertions(+), 119 deletions(-) create mode 100644 plugins/OnlineSales.Plugin.Sms/Controllers/MessagesControllerBase.cs delete mode 100644 plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs index 3c1ce892..3a4d3be5 100644 --- a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs @@ -13,21 +13,19 @@ namespace OnlineSales.Plugin.Sms.Controllers; [Route("api/messages")] -public class MessagesController : Controller +public class MessagesController : MessagesControllerBase { private readonly ISmsService smsService; - private readonly SmsControllerHelper controllerHelper; public MessagesController(SmsDbContext dbContext, ISmsService smsService) + : base(dbContext) { this.smsService = smsService; - - controllerHelper = new SmsControllerHelper(dbContext); } [HttpPost] [Route("sms")] - [AllowAnonymous] // @@ why? + [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] @@ -36,24 +34,16 @@ public async Task SendSms( [FromBody] SmsDetailsDto smsDetails, [FromHeader(Name = "Authentication")] string accessToken) { - try - { - SmsControllerHelper.CheckAuthentication(accessToken); - var recipient = controllerHelper.GetRecipient(smsDetails.Recipient, ModelState); - - var smsLog = await controllerHelper.AddLog(recipient, smsService.GetSender(recipient), smsDetails.Message); - - await smsService.SendAsync(recipient, smsDetails.Message); - - await controllerHelper.UpdateLogStatus(smsLog, SmsLog.SmsStatus.Sent); + return await SendMessage(accessToken, smsDetails.Recipient, smsDetails.Message); + } - return Ok(); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to send SMS message to {0}: {1}", smsDetails.Recipient, smsDetails.Message); + protected override string GetSender(string recipient) + { + return smsService.GetSender(recipient); + } - throw; - } + protected override async Task SendMessage(string recipient, string message) + { + await smsService.SendAsync(recipient, message); } } \ No newline at end of file diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesControllerBase.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesControllerBase.cs new file mode 100644 index 00000000..b8173c91 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesControllerBase.cs @@ -0,0 +1,96 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OnlineSales.Entities; +using OnlineSales.Exceptions; +using OnlineSales.Plugin.Sms.Data; +using OnlineSales.Plugin.Sms.Entities; +using PhoneNumbers; +using Serilog; + +namespace OnlineSales.Plugin.Sms.Controllers; + +public abstract class MessagesControllerBase : Controller +{ + private readonly PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.GetInstance(); + private readonly SmsDbContext dbContext; + + protected MessagesControllerBase(SmsDbContext dbContext) + { + this.dbContext = dbContext; + } + + protected async Task SendMessage(string accessToken, string phone, string message) + { + try + { + if (accessToken == null || accessToken.Replace("Bearer ", string.Empty) != SmsPlugin.Configuration.SmsAccessKey) + { + return new UnauthorizedResult(); + } + + var recipient = GetRecipient(phone); + var sender = GetSender(recipient); + var smsLog = await AddLog(recipient, sender, message); + + await SendMessage(recipient, message); + smsLog.Status = SmsLog.SmsStatus.Sent; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to send message to {0}: {1}", phone, message); + throw; + } + finally + { + await dbContext.SaveChangesAsync(); + } + + return Ok(); + } + + protected abstract string GetSender(string recipient); + + protected abstract Task SendMessage(string recipient, string message); + + private string GetRecipient(string phone) + { + var recipient = string.Empty; + try + { + var phoneNumber = phoneNumberUtil.Parse(phone, string.Empty); + + recipient = phoneNumberUtil.Format(phoneNumber, PhoneNumberFormat.E164); + } + catch (NumberParseException npex) + { + ModelState.AddModelError(npex.ErrorType.ToString(), npex.Message); + } + + if (!ModelState.IsValid) + { + throw new InvalidModelStateException(ModelState); + } + + return recipient; + } + + private async Task AddLog(string recipient, string sender, string message) + { + var smsLog = new SmsLog + { + Sender = sender, + Recipient = recipient, + Message = message, + Status = SmsLog.SmsStatus.NotSent, + }; + + dbContext.SmsLogs!.Add(smsLog); + await dbContext.SaveChangesAsync(); + + return smsLog; + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs index c6f3008f..c5b28ec0 100644 --- a/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs @@ -12,47 +12,38 @@ namespace OnlineSales.Plugin.Sms.Controllers; [Route("api/otp")] -public class OtpController : Controller +public class OtpController : MessagesControllerBase { private readonly IOtpService otpService; - private readonly SmsControllerHelper controllerHelper; public OtpController(SmsDbContext dbContext, IOtpService otpService) + : base(dbContext) { this.otpService = otpService; - - controllerHelper = new SmsControllerHelper(dbContext); } [HttpPost] [Route("otp")] - [AllowAnonymous] // @@ why? + [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] - public async Task SendSms( + public async Task SendOtp( [FromBody] OtpDetailsDto otpDetails, [FromHeader(Name = "Authentication")] string accessToken) { - try - { - SmsControllerHelper.CheckAuthentication(accessToken); - var recipient = controllerHelper.GetRecipient(otpDetails.Recipient, ModelState); - - // @@var smsLog = await controllerHelper.AddLog(recipient, "OTP", otpDetails.OtpCode); - - await otpService.SendOtpAsync(recipient, otpDetails.OtpCode); - - // @@await controllerHelper.UpdateLogStatus(smsLog, SmsLog.SmsStatus.Sent); + return await SendMessage(accessToken, otpDetails.Recipient, otpDetails.OtpCode); + } - return Ok(); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to send OTP code to {0}: {1}", otpDetails.Recipient, otpDetails.OtpCode); + protected override string GetSender(string recipient) + { + var sender = SmsPlugin.Configuration.OtpGateways.Telegram.SenderUserName; + return string.IsNullOrEmpty(sender) || sender == "$OTPGATEWAYS__TELEGRAM__SENDERUSERNAME" ? "Telegram" : sender; + } - throw; - } + protected override async Task SendMessage(string recipient, string message) + { + await otpService.SendOtpAsync(recipient, message); } } diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs deleted file mode 100644 index a1cb301e..00000000 --- a/plugins/OnlineSales.Plugin.Sms/Controllers/SmsControllerHelper.cs +++ /dev/null @@ -1,74 +0,0 @@ -// -// Licensed under the MIT license. See LICENSE file in the samples root for full license information. -// - -using Microsoft.AspNetCore.Mvc.ModelBinding; -using OnlineSales.Exceptions; -using OnlineSales.Plugin.Sms.Data; -using OnlineSales.Plugin.Sms.Entities; -using PhoneNumbers; - -namespace OnlineSales.Plugin.Sms.Controllers; - -internal class SmsControllerHelper -{ - private readonly PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.GetInstance(); - private readonly SmsDbContext dbContext; - - public SmsControllerHelper(SmsDbContext dbContext) - { - this.dbContext = dbContext; - } - - public static void CheckAuthentication(string accessToken) - { - if (accessToken == null || accessToken.Replace("Bearer ", string.Empty) != SmsPlugin.Configuration.SmsAccessKey) - { - throw new UnauthorizedAccessException(); - } - } - - public string GetRecipient(string phone, ModelStateDictionary modelState) - { - var recipient = string.Empty; - try - { - var phoneNumber = phoneNumberUtil.Parse(phone, string.Empty); - - recipient = phoneNumberUtil.Format(phoneNumber, PhoneNumberFormat.E164); - } - catch (NumberParseException npex) - { - modelState.AddModelError(npex.ErrorType.ToString(), npex.Message); - } - - if (!modelState.IsValid) - { - throw new InvalidModelStateException(modelState); - } - - return recipient; - } - - public async Task AddLog(string recipient, string sender, string message) - { - var smsLog = new SmsLog - { - Sender = sender, - Recipient = recipient, - Message = message, - Status = SmsLog.SmsStatus.NotSent, - }; - - dbContext.SmsLogs!.Add(smsLog); - await dbContext.SaveChangesAsync(); - - return smsLog; - } - - public async Task UpdateLogStatus(SmsLog smsLog, SmsLog.SmsStatus status) - { - smsLog.Status = status; - await dbContext.SaveChangesAsync(); - } -} From 1a1c84f502afc538343f56be951b8d077a6aa477 Mon Sep 17 00:00:00 2001 From: Sergey Egorov Date: Fri, 18 Oct 2024 11:30:32 +0530 Subject: [PATCH 3/5] Code analyzer errors fix. --- .../Configuration/PluginSettings.cs | 2 +- .../Interfaces/ITelegramService.cs | 11 +++--- .../Services/OtpService.cs | 35 +++++++++---------- .../Services/TelegramService.cs | 10 +++--- src/OnlineSales/Interfaces/IOtpService.cs | 9 +++-- 5 files changed, 32 insertions(+), 35 deletions(-) diff --git a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs index a2c47420..1273fbcb 100644 --- a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs +++ b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs @@ -110,7 +110,7 @@ public class OtpGatewaysConfig public class TelegramConfig { - public string ApiUrl { get; set; } = string.Empty; + public Uri ApiUrl { get; set; } = new Uri("https://gatewayapi.telegram.org/"); public string AuthToken { get; set; } = string.Empty; diff --git a/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs b/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs index d4b77b5e..49a4a698 100644 --- a/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs @@ -4,12 +4,11 @@ using OnlineSales.Plugin.Sms.DTOs; -namespace OnlineSales.Interfaces +namespace OnlineSales.Interfaces; + +public interface ITelegramService { - public interface ITelegramService - { - Task CanDeliverAsync(string recipient); + Task CanDeliverAsync(string recipient); - Task SendAsync(string recipient, string requestId, string otpCode); - } + Task SendAsync(string recipient, string requestId, string otpCode); } diff --git a/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs index eddbcb05..3a89da3b 100644 --- a/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs @@ -2,30 +2,29 @@ // Licensed under the MIT license. See LICENSE file in the samples root for full license information. // -namespace OnlineSales.Plugin.Sms.Services +namespace OnlineSales.Plugin.Sms.Services; + +public class OtpService : IOtpService { - public class OtpService : IOtpService + private readonly ITelegramService telegramService; + private readonly ISmsService smsService; + + public OtpService(ITelegramService telegramService, ISmsService smsService) { - private readonly ITelegramService telegramService; - private readonly ISmsService smsService; + this.telegramService = telegramService; + this.smsService = smsService; + } - public OtpService(ITelegramService telegramService, ISmsService smsService) + public async Task SendOtpAsync(string recepient, string code) + { + var checkResult = await telegramService.CanDeliverAsync(recepient); + if (checkResult == null) { - this.telegramService = telegramService; - this.smsService = smsService; + await smsService.SendAsync(recepient, code); } - - public async Task SendOtpAsync(string recepient, string code) + else { - var checkResult = await telegramService.CanDeliverAsync(recepient); - if (checkResult == null) - { - await smsService.SendAsync(recepient, code); - } - else - { - await telegramService.SendAsync(recepient, checkResult!.request_id, code); - } + await telegramService.SendAsync(recepient, checkResult!.request_id, code); } } } diff --git a/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs index 15ca9c0a..a4d53f7e 100644 --- a/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs @@ -25,7 +25,7 @@ public TelegramService() { client = new HttpClient() { - BaseAddress = new(telegramConfig.ApiUrl), + BaseAddress = telegramConfig.ApiUrl, }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", telegramConfig.AuthToken); } @@ -53,12 +53,12 @@ public TelegramService() var responseContent = await response.Content.ReadAsStringAsync(); var respResult = JsonSerializer.Deserialize(responseContent); - if (!(respResult?.ok ?? false)) + if (respResult?.ok == false) { return null; } - return respResult.result; + return respResult?.result; } public async Task SendAsync(string recipient, string requestId, string otpCode) @@ -92,9 +92,9 @@ public async Task SendAsync(string recipient, string requestId, string otpCode) var responseContent = await response.Content.ReadAsStringAsync(); var checkResult = JsonSerializer.Deserialize(responseContent); - if (!(checkResult?.ok ?? false)) + if (checkResult?.ok == false) { - throw new TelegramFailedResultException(checkResult?.error); + throw new TelegramFailedResultException(checkResult.error); } } diff --git a/src/OnlineSales/Interfaces/IOtpService.cs b/src/OnlineSales/Interfaces/IOtpService.cs index 4d4ae36a..491f1c29 100644 --- a/src/OnlineSales/Interfaces/IOtpService.cs +++ b/src/OnlineSales/Interfaces/IOtpService.cs @@ -2,10 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the samples root for full license information. // -namespace OnlineSales.Interfaces +namespace OnlineSales.Interfaces; + +public interface IOtpService { - public interface IOtpService - { - Task SendOtpAsync(string recepient, string code); - } + Task SendOtpAsync(string recepient, string code); } From 3cb9ab79030ceee1424715c3d34be11a0a2b54ab Mon Sep 17 00:00:00 2001 From: Sergey Egorov Date: Wed, 23 Oct 2024 19:49:00 +0530 Subject: [PATCH 4/5] OTP via WhatsApp --- .../Configuration/PluginSettings.cs | 38 +++++++- .../Controllers/OTPController.cs | 8 +- .../DTOs/OtpDetailsDto.cs | 7 +- .../DTOs/TelegramServiceDto.cs | 2 - .../DTOs/WhatsAppDto.cs | 81 +++++++++++++++++ .../Interfaces/IWhatsAppService.cs | 28 ++++++ .../Services/OtpService.cs | 18 +++- .../Services/SmsService.cs | 3 + .../Services/TelegramService.cs | 6 -- .../Services/WhatsAppService.cs | 89 +++++++++++++++++++ plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs | 2 + .../pluginsettings.json | 22 ++++- src/OnlineSales/Interfaces/IOtpService.cs | 4 +- 13 files changed, 288 insertions(+), 20 deletions(-) create mode 100644 plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Interfaces/IWhatsAppService.cs create mode 100644 plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs diff --git a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs index 1273fbcb..c87e1cdc 100644 --- a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs +++ b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs @@ -28,6 +28,8 @@ public class GatewaysConfig public NotifyLkConfig NotifyLk { get; set; } = new NotifyLkConfig(); public TwilioConfig Twilio { get; set; } = new TwilioConfig(); + + public WhatsAppConfig WhatsApp { get; set; } = new WhatsAppConfig(); } public class CountryGatewayConfig @@ -103,9 +105,24 @@ public class TwilioConfig public string SenderId { get; set; } = string.Empty; } +public class WhatsAppConfig +{ + public Uri ApiUrl { get; set; } = new Uri("https://graph.facebook.com/"); + + public string ApiVersion { get; set; } = string.Empty; + + public string AuthToken { get; set; } = string.Empty; + + public string BusinessPhoneId { get; set; } = string.Empty; + + public bool EnableLinkPreviewByDefault { get; set; } = true; +} + public class OtpGatewaysConfig { public TelegramConfig Telegram { get; set; } = new TelegramConfig(); + + public WhatsAppOtpConfig WhatsApp { get; set; } = new WhatsAppOtpConfig(); } public class TelegramConfig @@ -113,6 +130,25 @@ public class TelegramConfig public Uri ApiUrl { get; set; } = new Uri("https://gatewayapi.telegram.org/"); public string AuthToken { get; set; } = string.Empty; +} + +public class WhatsAppOtpConfig +{ + public WhatsAppTemplateMessageConfig Default { get; set; } = new WhatsAppTemplateMessageConfig(); + + public Dictionary Language { get; set; } = new Dictionary(); + + public WhatsAppTemplateMessageConfig GetByLanguage(string? language) + { + return language != null && Language.TryGetValue(language, out var config) + ? config + : Default; + } +} + +public class WhatsAppTemplateMessageConfig +{ + public string TemplateName { get; set; } = string.Empty; - public string? SenderUserName { get; set; } + public string LanguageCode { get; set; } = string.Empty; } diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs index c5b28ec0..bf394e1e 100644 --- a/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs @@ -16,6 +16,8 @@ public class OtpController : MessagesControllerBase { private readonly IOtpService otpService; + private string language = string.Empty; + public OtpController(SmsDbContext dbContext, IOtpService otpService) : base(dbContext) { @@ -33,17 +35,17 @@ public async Task SendOtp( [FromBody] OtpDetailsDto otpDetails, [FromHeader(Name = "Authentication")] string accessToken) { + language = otpDetails.Language; return await SendMessage(accessToken, otpDetails.Recipient, otpDetails.OtpCode); } protected override string GetSender(string recipient) { - var sender = SmsPlugin.Configuration.OtpGateways.Telegram.SenderUserName; - return string.IsNullOrEmpty(sender) || sender == "$OTPGATEWAYS__TELEGRAM__SENDERUSERNAME" ? "Telegram" : sender; + return otpService.GetSender(); } protected override async Task SendMessage(string recipient, string message) { - await otpService.SendOtpAsync(recipient, message); + await otpService.SendOtpAsync(recipient, language, message); } } diff --git a/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs index 25c4b0e1..a5fb2db5 100644 --- a/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs @@ -9,11 +9,14 @@ namespace OnlineSales.Plugin.Sms.DTOs; public class OtpDetailsDto { [Required] - public string Recipient { get; set; } = string.Empty; + required public string Recipient { get; set; } + + [Required] + required public string Language { get; set; } [Required] [MinLength(4)] [MaxLength(8)] [RegularExpression(@"^\d+$", ErrorMessage = "OTP code can contain digits only")] - public string OtpCode { get; set; } = string.Empty; + required public string OtpCode { get; set; } } \ No newline at end of file diff --git a/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs index 65800073..fb5bec4c 100644 --- a/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs @@ -18,8 +18,6 @@ public class TelegramSendVerificationMessageDto required public string request_id { get; set; } required public string code { get; set; } - - public string? sender { get; set; } } [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the Telegram name convinion")] diff --git a/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs new file mode 100644 index 00000000..050bd131 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs @@ -0,0 +1,81 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +namespace OnlineSales.Plugin.Sms.DTOs; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] +public class WhatsAppSendAuthTemplateMessageDto +{ + public string messaging_product { get; set; } = "whatsapp"; + + public string recipient_type { get; set; } = "individual"; + + public string to { get; set; } = string.Empty; + + public string type { get; set; } = "template"; + + public AuthTemplateDto template { get; set; } = new AuthTemplateDto(); +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] +public class AuthTemplateDto +{ + public string name { get; set; } = string.Empty; + + public TemplateLanguageDto language { get; set; } = new TemplateLanguageDto(); + + public List components { get; set; } = new List + { + new TemplateComponentDto + { + type = "body", + parameters = new List + { + new TemplateParameterDto() + { + type = "text", + }, + }, + }, + new TemplateComponentDto + { + type = "button", + sub_type = "url", + index = 0, + parameters = new List + { + new TemplateParameterDto() + { + type = "text", + }, + }, + }, + }; +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] +public class TemplateLanguageDto +{ + public string code { get; set; } = string.Empty; +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] +public class TemplateComponentDto +{ + public string type { get; set; } = string.Empty; + + public string? sub_type { get; set; } + + public int? index { get; set; } + + public List? parameters { get; set; } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] +public class TemplateParameterDto +{ + public string type { get; set; } = string.Empty; + + public string? text { get; set; } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Interfaces/IWhatsAppService.cs b/plugins/OnlineSales.Plugin.Sms/Interfaces/IWhatsAppService.cs new file mode 100644 index 00000000..5767c419 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Interfaces/IWhatsAppService.cs @@ -0,0 +1,28 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +namespace OnlineSales.Plugin.Sms.Interfaces; + +public interface IWhatsAppService +{ + /// + /// Send an OTP code immediately, using the AuthTemlate. + /// We can add as many SendXXXTemplateMessage methods as templates exist (when it's required). + /// All these methods will have different set of parameters related to template. + /// + /// Recipient's phone number. + /// Language, like "en" or "dk" etc. + /// The OTP Code of any symbold up to 15 ones. + /// true - the code has been sent, false - an error is occured. + Task SendAuthTemplateMessage(string phone, string language, string otpCode); + + /// + /// Send a text message to user, if a 24-hour timer called a customer service window has started by his message. + /// + /// Recipient's phone number. + /// Message text. + /// If the message contains a link (that begins with http:// or https://), WhapsApp can show a preview. + /// true - the message has been sent, false - an error is occured. + Task SendTextMessage(string phone, string text, bool enableLinkPreview); +} diff --git a/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs index 3a89da3b..aad4ac8b 100644 --- a/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs @@ -2,29 +2,41 @@ // Licensed under the MIT license. See LICENSE file in the samples root for full license information. // +using OnlineSales.Plugin.Sms.Interfaces; + namespace OnlineSales.Plugin.Sms.Services; public class OtpService : IOtpService { private readonly ITelegramService telegramService; + private readonly IWhatsAppService whatsAppService; private readonly ISmsService smsService; - public OtpService(ITelegramService telegramService, ISmsService smsService) + public OtpService(ITelegramService telegramService, IWhatsAppService whatsAppService, ISmsService smsService) { this.telegramService = telegramService; + this.whatsAppService = whatsAppService; this.smsService = smsService; } - public async Task SendOtpAsync(string recepient, string code) + public async Task SendOtpAsync(string recepient, string language, string code) { var checkResult = await telegramService.CanDeliverAsync(recepient); if (checkResult == null) { - await smsService.SendAsync(recepient, code); + if (!await whatsAppService.SendAuthTemplateMessage(recepient, language, code)) + { + await smsService.SendAsync(recepient, code); + } } else { await telegramService.SendAsync(recepient, checkResult!.request_id, code); } } + + public string GetSender() + { + return "OtpService"; + } } diff --git a/plugins/OnlineSales.Plugin.Sms/Services/SmsService.cs b/plugins/OnlineSales.Plugin.Sms/Services/SmsService.cs index d9dedbb3..a5b5d552 100644 --- a/plugins/OnlineSales.Plugin.Sms/Services/SmsService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Services/SmsService.cs @@ -88,6 +88,9 @@ private void InitGateways() case "Twilio": gatewayService = new TwilioService(pluginSettings.SmsGateways.Twilio); break; + case "WhatsApp": + gatewayService = new WhatsAppService(pluginSettings.SmsGateways.WhatsApp); + break; } if (gatewayService != null) diff --git a/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs index a4d53f7e..339add2f 100644 --- a/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs @@ -75,12 +75,6 @@ public async Task SendAsync(string recipient, string requestId, string otpCode) code = otpCode, }; - var sender = SmsPlugin.Configuration.OtpGateways.Telegram.SenderUserName; - if (!string.IsNullOrEmpty(sender) && sender != "$OTPGATEWAYS__TELEGRAM__SENDERUSERNAME") - { - dto.sender = sender; - } - var jsonDto = JsonSerializer.Serialize(dto); var content = new StringContent(jsonDto, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); diff --git a/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs b/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs new file mode 100644 index 00000000..eb408fc9 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs @@ -0,0 +1,89 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using OnlineSales.Plugin.Sms.Configuration; +using OnlineSales.Plugin.Sms.DTOs; +using OnlineSales.Plugin.Sms.Interfaces; + +namespace OnlineSales.Plugin.Sms.Services +{ + public class WhatsAppService : IWhatsAppService, ISmsService, IDisposable + { + private readonly bool enableLinkPreviewByDefault; + private readonly HttpClient? client; + + public WhatsAppService(WhatsAppConfig config) + { + if (config.AuthToken == "$SMSGATEWAYS__WHATSAPP__AUTHTOKEN" || config.BusinessPhoneId == "$SMSGATEWAYS__WHATSAPP__BUSINESSPHONEID") + { + client = null; + } + else + { + var relative = $"{config.ApiVersion}/{config.BusinessPhoneId}"; + client = new HttpClient() + { + BaseAddress = new Uri(config.ApiUrl, relative), + }; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken); + } + + enableLinkPreviewByDefault = config.EnableLinkPreviewByDefault; + } + + public WhatsAppService() + : this(SmsPlugin.Configuration.SmsGateways.WhatsApp) + { + } + + public string GetSender(string recipient) + { + return "WhatsApp"; + } + + public async Task SendAsync(string recipient, string message) + { + await SendTextMessage(recipient, message, enableLinkPreviewByDefault); + } + + public async Task SendAuthTemplateMessage(string phone, string language, string otpCode) + { + if (client == null) + { + return false; + } + + var templateConfig = SmsPlugin.Configuration.OtpGateways.WhatsApp.GetByLanguage(language); + + var dto = new WhatsAppSendAuthTemplateMessageDto() + { + to = phone, + }; + dto.template.name = templateConfig.TemplateName; + dto.template.language.code = templateConfig.LanguageCode; + dto.template.components[0].parameters![0].text = + dto.template.components[1].parameters![0].text = otpCode; + + var jsonDto = JsonSerializer.Serialize(dto); + var content = new StringContent(jsonDto, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var response = await client.PostAsync("messages", content); + + return response.IsSuccessStatusCode; + } + + public Task SendTextMessage(string phone, string text, bool enableLinkPreview) + { + throw new NotImplementedException(); + } + + public void Dispose() + { + client?.Dispose(); + } + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs b/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs index 7f690ca3..1beccb7c 100644 --- a/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs +++ b/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs @@ -7,6 +7,7 @@ using OnlineSales.Data; using OnlineSales.Plugin.Sms.Configuration; using OnlineSales.Plugin.Sms.Data; +using OnlineSales.Plugin.Sms.Interfaces; using OnlineSales.Plugin.Sms.Services; using OnlineSales.Plugin.Sms.Tasks; @@ -31,6 +32,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/plugins/OnlineSales.Plugin.Sms/pluginsettings.json b/plugins/OnlineSales.Plugin.Sms/pluginsettings.json index 623ff19b..b3edb114 100644 --- a/plugins/OnlineSales.Plugin.Sms/pluginsettings.json +++ b/plugins/OnlineSales.Plugin.Sms/pluginsettings.json @@ -33,6 +33,13 @@ "Twilio": { "AccountSid": "$SMSGATEWAYS__TWILIO__ACCOUNTSID", "AuthToken": "$SMSGATEWAYS__TWILIO__AUTHTOKEN" + }, + "WhatsApp": { + "ApiUrl": "https://graph.facebook.com", + "ApiVersion": "21.0", + "AuthToken": "$SMSGATEWAYS__WHATSAPP__AUTHTOKEN", + "BusinessPhoneId": "$SMSGATEWAYS__WHATSAPP__BUSINESSPHONEID", + "EnableLinkPreviewByDefault": true } }, "SmsCountryGateways": [ @@ -60,8 +67,19 @@ "OtpGateways": { "Telegram": { "ApiUrl": "https://gatewayapi.telegram.org/", - "AuthToken": "$SMSGATEWAYS__TELEGRAM__AUTHTOKEN", - "SenderUserName": "$OTPGATEWAYS__TELEGRAM__SENDERUSERNAME" // Optional + "AuthToken": "$SMSGATEWAYS__TELEGRAM__AUTHTOKEN" + }, + "WhatsApp": { + "Default": { + "TemplateName": "$WHATSAPP__DEFAULT__TEMPLATENAME", + "LanguageCode": "en-US" + }, + "Language": { + "en": { + "TemplateName": "$WHATSAPP__LANGUAGE__EN__TEMPLATENAME", + "LanguageCode": "en-US" + } + } } }, "Tasks": [ diff --git a/src/OnlineSales/Interfaces/IOtpService.cs b/src/OnlineSales/Interfaces/IOtpService.cs index 491f1c29..aa85eeb9 100644 --- a/src/OnlineSales/Interfaces/IOtpService.cs +++ b/src/OnlineSales/Interfaces/IOtpService.cs @@ -6,5 +6,7 @@ namespace OnlineSales.Interfaces; public interface IOtpService { - Task SendOtpAsync(string recepient, string code); + Task SendOtpAsync(string recepient, string language, string code); + + string GetSender(); } From afe854c60751b41bbdaafd9fb464dd7d6e8d18b9 Mon Sep 17 00:00:00 2001 From: Sergey Egorov Date: Wed, 23 Oct 2024 21:34:34 +0530 Subject: [PATCH 5/5] WhatsAppService can send text messages. --- .../DTOs/WhatsAppDto.cs | 14 +- .../Exceptions/WhatsAppException.cs | 13 ++ .../Services/WhatsAppService.cs | 133 +++++++++++------- 3 files changed, 105 insertions(+), 55 deletions(-) create mode 100644 plugins/OnlineSales.Plugin.Sms/Exceptions/WhatsAppException.cs diff --git a/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs index 050bd131..20ed96f7 100644 --- a/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs @@ -15,11 +15,21 @@ public class WhatsAppSendAuthTemplateMessageDto public string type { get; set; } = "template"; - public AuthTemplateDto template { get; set; } = new AuthTemplateDto(); + public AuthTemplateMessageDto? template { get; set; } + + public TextMessageDto? text { get; set; } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] +public class TextMessageDto +{ + public bool preview_url { get; set; } + + public string body { get; set; } = string.Empty; } [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the WhatsApp naming convention")] -public class AuthTemplateDto +public class AuthTemplateMessageDto { public string name { get; set; } = string.Empty; diff --git a/plugins/OnlineSales.Plugin.Sms/Exceptions/WhatsAppException.cs b/plugins/OnlineSales.Plugin.Sms/Exceptions/WhatsAppException.cs new file mode 100644 index 00000000..aba9a4ba --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Exceptions/WhatsAppException.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT license. See LICENSE file in the samples root for full license information. +// + +namespace OnlineSales.Plugin.Sms.Exceptions; + +public class WhatsAppException : Exception +{ + public WhatsAppException(string? message) + : base(message) + { + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs b/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs index eb408fc9..7ad5ebd8 100644 --- a/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs +++ b/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs @@ -5,85 +5,112 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using OnlineSales.Plugin.Sms.Configuration; using OnlineSales.Plugin.Sms.DTOs; +using OnlineSales.Plugin.Sms.Exceptions; using OnlineSales.Plugin.Sms.Interfaces; -namespace OnlineSales.Plugin.Sms.Services +namespace OnlineSales.Plugin.Sms.Services; + +public class WhatsAppService : IWhatsAppService, ISmsService, IDisposable { - public class WhatsAppService : IWhatsAppService, ISmsService, IDisposable - { - private readonly bool enableLinkPreviewByDefault; - private readonly HttpClient? client; + private readonly bool enableLinkPreviewByDefault; + private readonly HttpClient? client; + private readonly JsonSerializerOptions? serializeOptions; - public WhatsAppService(WhatsAppConfig config) + public WhatsAppService(WhatsAppConfig config) + { + if (config.AuthToken == "$SMSGATEWAYS__WHATSAPP__AUTHTOKEN" || config.BusinessPhoneId == "$SMSGATEWAYS__WHATSAPP__BUSINESSPHONEID") + { + client = null; + } + else { - if (config.AuthToken == "$SMSGATEWAYS__WHATSAPP__AUTHTOKEN" || config.BusinessPhoneId == "$SMSGATEWAYS__WHATSAPP__BUSINESSPHONEID") + var relative = $"{config.ApiVersion}/{config.BusinessPhoneId}"; + client = new HttpClient() { - client = null; - } - else + BaseAddress = new Uri(config.ApiUrl, relative), + }; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken); + + serializeOptions = new JsonSerializerOptions { - var relative = $"{config.ApiVersion}/{config.BusinessPhoneId}"; - client = new HttpClient() - { - BaseAddress = new Uri(config.ApiUrl, relative), - }; - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken); - } - - enableLinkPreviewByDefault = config.EnableLinkPreviewByDefault; + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; } - public WhatsAppService() - : this(SmsPlugin.Configuration.SmsGateways.WhatsApp) - { - } + enableLinkPreviewByDefault = config.EnableLinkPreviewByDefault; + } + + public WhatsAppService() + : this(SmsPlugin.Configuration.SmsGateways.WhatsApp) + { + } - public string GetSender(string recipient) + public string GetSender(string recipient) + { + return "WhatsApp"; + } + + public async Task SendAsync(string recipient, string message) + { + if (!await SendTextMessage(recipient, message, enableLinkPreviewByDefault)) { - return "WhatsApp"; + throw new WhatsAppException("Failed to send a text message via WhatsApp"); } + } - public async Task SendAsync(string recipient, string message) + public async Task SendAuthTemplateMessage(string phone, string language, string otpCode) + { + if (client == null) { - await SendTextMessage(recipient, message, enableLinkPreviewByDefault); + return false; } - public async Task SendAuthTemplateMessage(string phone, string language, string otpCode) + var templateConfig = SmsPlugin.Configuration.OtpGateways.WhatsApp.GetByLanguage(language); + + var dto = new WhatsAppSendAuthTemplateMessageDto() { - if (client == null) - { - return false; - } + to = phone, + template = new AuthTemplateMessageDto(), + }; + dto.template.name = templateConfig.TemplateName; + dto.template.language.code = templateConfig.LanguageCode; + dto.template.components[0].parameters![0].text = + dto.template.components[1].parameters![0].text = otpCode; - var templateConfig = SmsPlugin.Configuration.OtpGateways.WhatsApp.GetByLanguage(language); + var jsonDto = JsonSerializer.Serialize(dto, serializeOptions); + return await PostMessageAsync(jsonDto); + } - var dto = new WhatsAppSendAuthTemplateMessageDto() + public async Task SendTextMessage(string phone, string text, bool enableLinkPreview) + { + var dto = new WhatsAppSendAuthTemplateMessageDto() + { + to = phone, + text = new TextMessageDto() { - to = phone, - }; - dto.template.name = templateConfig.TemplateName; - dto.template.language.code = templateConfig.LanguageCode; - dto.template.components[0].parameters![0].text = - dto.template.components[1].parameters![0].text = otpCode; + preview_url = enableLinkPreview, + body = text, + }, + }; - var jsonDto = JsonSerializer.Serialize(dto); - var content = new StringContent(jsonDto, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + var jsonDto = JsonSerializer.Serialize(dto, serializeOptions); + return await PostMessageAsync(jsonDto); + } - var response = await client.PostAsync("messages", content); + public void Dispose() + { + client?.Dispose(); + } - return response.IsSuccessStatusCode; - } + private async Task PostMessageAsync(string json) + { + var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); - public Task SendTextMessage(string phone, string text, bool enableLinkPreview) - { - throw new NotImplementedException(); - } + var response = await client!.PostAsync("messages", content); - public void Dispose() - { - client?.Dispose(); - } + return response.IsSuccessStatusCode; } }