diff --git a/Directory.Packages.props b/Directory.Packages.props index 03890ac..261ee50 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/src/Meezi.API/Controllers/AuthController.cs b/src/Meezi.API/Controllers/AuthController.cs index d4f7fd4..529fb8f 100644 --- a/src/Meezi.API/Controllers/AuthController.cs +++ b/src/Meezi.API/Controllers/AuthController.cs @@ -6,7 +6,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Meezi.API.Models.Auth; using Meezi.API.Services; -using Meezi.API.Services; using Meezi.Core.Constants; using Meezi.Shared; diff --git a/src/Meezi.Infrastructure/DependencyInjection.cs b/src/Meezi.Infrastructure/DependencyInjection.cs index ea2b0e8..ea3554c 100644 --- a/src/Meezi.Infrastructure/DependencyInjection.cs +++ b/src/Meezi.Infrastructure/DependencyInjection.cs @@ -29,7 +29,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); - services.AddHttpClient(); + services.AddScoped(); services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); diff --git a/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs b/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs index cc16d29..d183a4e 100644 --- a/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs +++ b/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs @@ -1,5 +1,5 @@ -using System.Net.Http.Json; -using System.Text.Json.Serialization; +using Kavenegar; +using Kavenegar.Exceptions; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Services.Platform; using Microsoft.Extensions.Configuration; @@ -9,35 +9,31 @@ using Microsoft.Extensions.Logging; namespace Meezi.Infrastructure.ExternalServices; /// -/// Kavenegar SMS gateway implementation. +/// Kavenegar SMS gateway implementation using the official Kavenegar .NET SDK. /// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back /// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.). /// public class KavenegarSmsService : ISmsService { // ── DB config keys ──────────────────────────────────────────────────────── - private const string DbKeyApiKey = "integrations.kavenegar.apiKey"; - private const string DbKeyEnabled = "integrations.kavenegar.enabled"; - private const string DbKeySender = "integrations.kavenegar.senderNumber"; - private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate"; + private const string DbKeyApiKey = "integrations.kavenegar.apiKey"; + private const string DbKeyEnabled = "integrations.kavenegar.enabled"; + private const string DbKeySender = "integrations.kavenegar.senderNumber"; + private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate"; - private const string BaseUrl = "https://api.kavenegar.com/v1"; - private const int MaxBatchSize = 200; + private const int MaxBatchSize = 200; - private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly IPlatformRuntimeConfig _platform; private readonly IHostEnvironment _environment; private readonly ILogger _logger; public KavenegarSmsService( - HttpClient httpClient, IConfiguration configuration, IPlatformRuntimeConfig platform, IHostEnvironment environment, ILogger logger) { - _httpClient = httpClient; _configuration = configuration; _platform = platform; _environment = environment; @@ -61,16 +57,11 @@ public class KavenegarSmsService : ISmsService return; } - var url = $"{BaseUrl}/{apiKey}/verify/lookup.json"; - var content = new FormUrlEncodedContent(new Dictionary + var receptor = NormalizePhone(phone); + await RunSdkAsync(apiKey, api => { - ["receptor"] = NormalizePhone(phone), - ["token"] = otp, - ["template"] = template, - }); - - var response = await _httpClient.PostAsync(url, content, cancellationToken); - await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken); + api.VerifyLookup(receptor, otp, null, null, template); + }, "OTP"); } public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default) @@ -82,11 +73,11 @@ public class KavenegarSmsService : ISmsService return; } - var url = $"{BaseUrl}/{apiKey}/sms/send.json"; - var content = BuildSendForm(phone, message, sender); - - var response = await _httpClient.PostAsync(url, content, cancellationToken); - await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken); + var receptor = NormalizePhone(phone); + await RunSdkAsync(apiKey, api => + { + api.Send(sender, receptor, message); + }, "Send"); } public async Task SendBulkAsync( @@ -103,17 +94,18 @@ public class KavenegarSmsService : ISmsService return new BulkSendResult(0, phones.Count); } - var url = $"{BaseUrl}/{apiKey}/sms/send.json"; int sent = 0, failed = 0; foreach (var batch in phones.Chunk(MaxBatchSize)) { try { - // Kavenegar /sms/send.json accepts comma-separated receptors - var content = BuildSendForm(string.Join(",", batch), message, sender); - var response = await _httpClient.PostAsync(url, content, cancellationToken); - await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken); + var receptors = batch.Select(NormalizePhone).ToList(); + await RunSdkAsync(apiKey, api => + { + api.Send(sender, receptors, message); + }, "BulkSend"); + sent += batch.Length; _logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length); } @@ -134,20 +126,12 @@ public class KavenegarSmsService : ISmsService try { - var url = $"{BaseUrl}/{apiKey}/account/info.json"; - var response = await _httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) + return await Task.Run(() => { - _logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode); - return null; - } - - var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - if (body?.Return?.Status is not 200 || body.Entries is null) - return null; - - return new KavenegarAccountInfo(body.Entries.RemainCredit, body.Entries.Type ?? "master"); + var api = new KavenegarApi(apiKey); + var info = api.AccountInfo(); + return new KavenegarAccountInfo(info.RemainCredit, info.Type ?? "master"); + }, cancellationToken); } catch (Exception ex) { @@ -156,42 +140,42 @@ public class KavenegarSmsService : ISmsService } } + // ── SDK runner ──────────────────────────────────────────────────────────── + + /// + /// Runs a synchronous Kavenegar SDK call on the thread pool. + /// Translates SDK exceptions to logged InvalidOperationException. + /// + private async Task RunSdkAsync(string apiKey, Action action, string operation) + { + await Task.Run(() => + { + try + { + var api = new KavenegarApi(apiKey); + action(api); + } + catch (ApiException ex) + { + _logger.LogWarning( + "Kavenegar {Op} API error {Code}: {Message}", + operation, ex.Code, ex.Message); + throw new InvalidOperationException( + $"Kavenegar {operation} failed (code {ex.Code}): {ex.Message}", ex); + } + catch (HttpException ex) + { + _logger.LogWarning( + "Kavenegar {Op} HTTP error {Code}: {Message}", + operation, ex.Code, ex.Message); + throw new InvalidOperationException( + $"Kavenegar {operation} HTTP error (code {ex.Code}): {ex.Message}", ex); + } + }); + } + // ── Helpers ─────────────────────────────────────────────────────────────── - private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender) - { - var dict = new Dictionary - { - ["receptor"] = receptor, - ["message"] = message, - }; - if (!string.IsNullOrWhiteSpace(sender)) - dict["sender"] = sender; - return new FormUrlEncodedContent(dict); - } - - private async Task EnsureKavenegarSuccessAsync( - HttpResponseMessage response, - string operation, - CancellationToken cancellationToken) - { - if (!response.IsSuccessStatusCode) - { - var errorCode = (int)response.StatusCode; - var detail = KavenegarHttpError(errorCode); - _logger.LogWarning("Kavenegar {Op} HTTP {Code}: {Detail}", operation, errorCode, detail); - throw new InvalidOperationException($"Kavenegar {operation} failed (HTTP {errorCode}): {detail}"); - } - - var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - if (body?.Return?.Status is not 200) - { - var status = body?.Return?.Status ?? -1; - _logger.LogWarning("Kavenegar {Op} returned status {Status}: {Message}", operation, status, body?.Return?.Message); - throw new InvalidOperationException($"Kavenegar {operation} failed (status {status}): {body?.Return?.Message}"); - } - } - // Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx) private static string NormalizePhone(string phone) { @@ -200,35 +184,6 @@ public class KavenegarSmsService : ISmsService return p; } - private static string KavenegarHttpError(int code) => code switch - { - 400 => "Missing or invalid parameters", - 401 => "Account is inactive", - 403 => "Invalid API key", - 404 => "Method not found", - 405 => "Wrong HTTP method", - 406 => "Recipient is on the blacklist or number is deactivated", - 411 => "Invalid recipient number", - 412 => "Invalid sender number", - 413 => "Message empty or too long", - 414 => "Too many recipients", - 415 => "Server error on Kavenegar side", - 416 => "Recipient is invalid, blacklisted, or deactivated", - 417 => "Invalid scheduled date", - 418 => "Insufficient credit", - 419 => "OTP token already used or expired", - 420 => "IP not allowed", - 421 => "Message could not be sent", - 422 => "Invalid characters in message", - 423 => "Kavenegar server unreachable", - 424 => "OTP template not found — check template name in Kavenegar panel", - 426 => "IP is not whitelisted", - 428 => "Voice call requires numeric token", - 431 => "SMS sending is disabled on this account", - 432 => "Code parameter missing in OTP template", - _ => $"Undocumented Kavenegar error {code}" - }; - private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct) { var enabled = await _platform.GetAsync(DbKeyEnabled, ct); @@ -250,42 +205,4 @@ public class KavenegarSmsService : ISmsService return (apiKey, sender, template); } - - // ── Response models ─────────────────────────────────────────────────────── - - private sealed class KavenegarReturnEnvelope - { - [JsonPropertyName("return")] - public KavenegarReturn? Return { get; set; } - } - - private sealed class KavenegarReturn - { - [JsonPropertyName("status")] - public int Status { get; set; } - - [JsonPropertyName("message")] - public string? Message { get; set; } - } - - private sealed class KavenegarAccountInfoResponse - { - [JsonPropertyName("return")] - public KavenegarReturn? Return { get; set; } - - [JsonPropertyName("entries")] - public KavenegarAccountEntries? Entries { get; set; } - } - - private sealed class KavenegarAccountEntries - { - [JsonPropertyName("remaincredit")] - public long RemainCredit { get; set; } - - [JsonPropertyName("expiredate")] - public long ExpireDate { get; set; } - - [JsonPropertyName("type")] - public string? Type { get; set; } - } } diff --git a/src/Meezi.Infrastructure/Meezi.Infrastructure.csproj b/src/Meezi.Infrastructure/Meezi.Infrastructure.csproj index 98893f2..fab6c5c 100644 --- a/src/Meezi.Infrastructure/Meezi.Infrastructure.csproj +++ b/src/Meezi.Infrastructure/Meezi.Infrastructure.csproj @@ -14,6 +14,7 @@ all +