diff --git a/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs b/plugins/OnlineSales.Plugin.Sms/Configuration/PluginSettings.cs index fc82ab32..c87e1cdc 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(); } @@ -26,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 @@ -99,4 +103,52 @@ 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 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 +{ + 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 LanguageCode { get; set; } = string.Empty; +} diff --git a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs index a40b2380..3a4d3be5 100644 --- a/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/MessagesController.cs @@ -5,25 +5,21 @@ 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; [Route("api/messages")] -public class MessagesController : Controller +public class MessagesController : MessagesControllerBase { private readonly ISmsService smsService; - private readonly PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.GetInstance(); - private readonly SmsDbContext dbContext; public MessagesController(SmsDbContext dbContext, ISmsService smsService) + : base(dbContext) { - this.dbContext = dbContext; this.smsService = smsService; } @@ -31,63 +27,23 @@ public MessagesController(SmsDbContext dbContext, ISmsService smsService) [Route("sms")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] public async Task SendSms( [FromBody] SmsDetailsDto smsDetails, [FromHeader(Name = "Authentication")] string accessToken) { - 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); - } - - 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(); - - await smsService.SendAsync(recipient, smsDetails.Message); - - smsLog.Status = 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; - } - finally - { - await dbContext.SaveChangesAsync(); - } + 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 new file mode 100644 index 00000000..bf394e1e --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Controllers/OTPController.cs @@ -0,0 +1,51 @@ +// +// 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 : MessagesControllerBase +{ + private readonly IOtpService otpService; + + private string language = string.Empty; + + public OtpController(SmsDbContext dbContext, IOtpService otpService) + : base(dbContext) + { + this.otpService = otpService; + } + + [HttpPost] + [Route("otp")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + 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) + { + return otpService.GetSender(); + } + + protected override async Task SendMessage(string recipient, string 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 new file mode 100644 index 00000000..a5fb2db5 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/OtpDetailsDto.cs @@ -0,0 +1,22 @@ +// +// 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] + 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")] + 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 new file mode 100644 index 00000000..fb5bec4c --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/TelegramServiceDto.cs @@ -0,0 +1,43 @@ +// +// 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; } +} + +[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/DTOs/WhatsAppDto.cs b/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs new file mode 100644 index 00000000..20ed96f7 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/DTOs/WhatsAppDto.cs @@ -0,0 +1,91 @@ +// +// 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 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 AuthTemplateMessageDto +{ + 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/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/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/Interfaces/ITelegramService.cs b/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs new file mode 100644 index 00000000..49a4a698 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Interfaces/ITelegramService.cs @@ -0,0 +1,14 @@ +// +// 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/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 new file mode 100644 index 00000000..aad4ac8b --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Services/OtpService.cs @@ -0,0 +1,42 @@ +// +// 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, IWhatsAppService whatsAppService, ISmsService smsService) + { + this.telegramService = telegramService; + this.whatsAppService = whatsAppService; + this.smsService = smsService; + } + + public async Task SendOtpAsync(string recepient, string language, string code) + { + var checkResult = await telegramService.CanDeliverAsync(recepient); + if (checkResult == null) + { + 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 new file mode 100644 index 00000000..339add2f --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Services/TelegramService.cs @@ -0,0 +1,99 @@ +// +// 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 = 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 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/Services/WhatsAppService.cs b/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs new file mode 100644 index 00000000..7ad5ebd8 --- /dev/null +++ b/plugins/OnlineSales.Plugin.Sms/Services/WhatsAppService.cs @@ -0,0 +1,116 @@ +// +// 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 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; + +public class WhatsAppService : IWhatsAppService, ISmsService, IDisposable +{ + private readonly bool enableLinkPreviewByDefault; + private readonly HttpClient? client; + private readonly JsonSerializerOptions? serializeOptions; + + 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); + + serializeOptions = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + } + + 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) + { + if (!await SendTextMessage(recipient, message, enableLinkPreviewByDefault)) + { + throw new WhatsAppException("Failed to send a text message via WhatsApp"); + } + } + + 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, + 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 jsonDto = JsonSerializer.Serialize(dto, serializeOptions); + return await PostMessageAsync(jsonDto); + } + + public async Task SendTextMessage(string phone, string text, bool enableLinkPreview) + { + var dto = new WhatsAppSendAuthTemplateMessageDto() + { + to = phone, + text = new TextMessageDto() + { + preview_url = enableLinkPreview, + body = text, + }, + }; + + var jsonDto = JsonSerializer.Serialize(dto, serializeOptions); + return await PostMessageAsync(jsonDto); + } + + public void Dispose() + { + client?.Dispose(); + } + + private async Task PostMessageAsync(string json) + { + var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var response = await client!.PostAsync("messages", content); + + return response.IsSuccessStatusCode; + } +} diff --git a/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs b/plugins/OnlineSales.Plugin.Sms/SmsPlugin.cs index 3fcb7801..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; @@ -29,6 +30,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); 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 54fb47dd..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": [ @@ -57,6 +64,24 @@ "Gateway": "Smsc" } ], + "OtpGateways": { + "Telegram": { + "ApiUrl": "https://gatewayapi.telegram.org/", + "AuthToken": "$SMSGATEWAYS__TELEGRAM__AUTHTOKEN" + }, + "WhatsApp": { + "Default": { + "TemplateName": "$WHATSAPP__DEFAULT__TEMPLATENAME", + "LanguageCode": "en-US" + }, + "Language": { + "en": { + "TemplateName": "$WHATSAPP__LANGUAGE__EN__TEMPLATENAME", + "LanguageCode": "en-US" + } + } + } + }, "Tasks": [ { "SyncSmsLogTask": { diff --git a/src/OnlineSales/Interfaces/IOtpService.cs b/src/OnlineSales/Interfaces/IOtpService.cs new file mode 100644 index 00000000..aa85eeb9 --- /dev/null +++ b/src/OnlineSales/Interfaces/IOtpService.cs @@ -0,0 +1,12 @@ +// +// 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 language, string code); + + string GetSender(); +}