Migrate Kavenegar SMS to official .NET SDK

Replace the raw HttpClient implementation with the Kavenegar NuGet SDK
(v1.2.4) for OTP, single, and bulk sends plus account info, wrapping the
synchronous SDK calls and translating its exceptions. Register the
service as scoped instead of via AddHttpClient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-30 00:28:28 +03:30
parent e8cd6d3282
commit b6e4f83035
5 changed files with 65 additions and 147 deletions
+1
View File
@@ -24,6 +24,7 @@
<PackageVersion Include="QuestPDF" Version="2024.12.3" /> <PackageVersion Include="QuestPDF" Version="2024.12.3" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Kavenegar" Version="1.2.4" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" /> <PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" /> <PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
@@ -6,7 +6,6 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Meezi.API.Models.Auth; using Meezi.API.Models.Auth;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.API.Services;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Shared; using Meezi.Shared;
@@ -29,7 +29,7 @@ public static class DependencyInjection
services.AddScoped<IPlatformCatalogService, PlatformCatalogService>(); services.AddScoped<IPlatformCatalogService, PlatformCatalogService>();
services.AddScoped<ISupportTicketService, SupportTicketService>(); services.AddScoped<ISupportTicketService, SupportTicketService>();
services.AddHttpClient<ISmsService, KavenegarSmsService>(); services.AddScoped<ISmsService, KavenegarSmsService>();
services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>(); services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>();
services.AddHttpClient<ISnappPayGateway, SnappPayGateway>(); services.AddHttpClient<ISnappPayGateway, SnappPayGateway>();
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>(); services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
@@ -1,5 +1,5 @@
using System.Net.Http.Json; using Kavenegar;
using System.Text.Json.Serialization; using Kavenegar.Exceptions;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -9,35 +9,31 @@ using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices; namespace Meezi.Infrastructure.ExternalServices;
/// <summary> /// <summary>
/// Kavenegar SMS gateway implementation. /// Kavenegar SMS gateway implementation using the official Kavenegar .NET SDK.
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back /// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.). /// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
/// </summary> /// </summary>
public class KavenegarSmsService : ISmsService public class KavenegarSmsService : ISmsService
{ {
// ── DB config keys ──────────────────────────────────────────────────────── // ── DB config keys ────────────────────────────────────────────────────────
private const string DbKeyApiKey = "integrations.kavenegar.apiKey"; private const string DbKeyApiKey = "integrations.kavenegar.apiKey";
private const string DbKeyEnabled = "integrations.kavenegar.enabled"; private const string DbKeyEnabled = "integrations.kavenegar.enabled";
private const string DbKeySender = "integrations.kavenegar.senderNumber"; private const string DbKeySender = "integrations.kavenegar.senderNumber";
private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate"; 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 IConfiguration _configuration;
private readonly IPlatformRuntimeConfig _platform; private readonly IPlatformRuntimeConfig _platform;
private readonly IHostEnvironment _environment; private readonly IHostEnvironment _environment;
private readonly ILogger<KavenegarSmsService> _logger; private readonly ILogger<KavenegarSmsService> _logger;
public KavenegarSmsService( public KavenegarSmsService(
HttpClient httpClient,
IConfiguration configuration, IConfiguration configuration,
IPlatformRuntimeConfig platform, IPlatformRuntimeConfig platform,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<KavenegarSmsService> logger) ILogger<KavenegarSmsService> logger)
{ {
_httpClient = httpClient;
_configuration = configuration; _configuration = configuration;
_platform = platform; _platform = platform;
_environment = environment; _environment = environment;
@@ -61,16 +57,11 @@ public class KavenegarSmsService : ISmsService
return; return;
} }
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json"; var receptor = NormalizePhone(phone);
var content = new FormUrlEncodedContent(new Dictionary<string, string> await RunSdkAsync(apiKey, api =>
{ {
["receptor"] = NormalizePhone(phone), api.VerifyLookup(receptor, otp, null, null, template);
["token"] = otp, }, "OTP");
["template"] = template,
});
var response = await _httpClient.PostAsync(url, content, cancellationToken);
await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken);
} }
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default) public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
@@ -82,11 +73,11 @@ public class KavenegarSmsService : ISmsService
return; return;
} }
var url = $"{BaseUrl}/{apiKey}/sms/send.json"; var receptor = NormalizePhone(phone);
var content = BuildSendForm(phone, message, sender); await RunSdkAsync(apiKey, api =>
{
var response = await _httpClient.PostAsync(url, content, cancellationToken); api.Send(sender, receptor, message);
await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken); }, "Send");
} }
public async Task<BulkSendResult> SendBulkAsync( public async Task<BulkSendResult> SendBulkAsync(
@@ -103,17 +94,18 @@ public class KavenegarSmsService : ISmsService
return new BulkSendResult(0, phones.Count); return new BulkSendResult(0, phones.Count);
} }
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
int sent = 0, failed = 0; int sent = 0, failed = 0;
foreach (var batch in phones.Chunk(MaxBatchSize)) foreach (var batch in phones.Chunk(MaxBatchSize))
{ {
try try
{ {
// Kavenegar /sms/send.json accepts comma-separated receptors var receptors = batch.Select(NormalizePhone).ToList();
var content = BuildSendForm(string.Join(",", batch), message, sender); await RunSdkAsync(apiKey, api =>
var response = await _httpClient.PostAsync(url, content, cancellationToken); {
await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken); api.Send(sender, receptors, message);
}, "BulkSend");
sent += batch.Length; sent += batch.Length;
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length); _logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
} }
@@ -134,20 +126,12 @@ public class KavenegarSmsService : ISmsService
try try
{ {
var url = $"{BaseUrl}/{apiKey}/account/info.json"; return await Task.Run(() =>
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{ {
_logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode); var api = new KavenegarApi(apiKey);
return null; var info = api.AccountInfo();
} return new KavenegarAccountInfo(info.RemainCredit, info.Type ?? "master");
}, cancellationToken);
var body = await response.Content.ReadFromJsonAsync<KavenegarAccountInfoResponse>(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");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,42 +140,42 @@ public class KavenegarSmsService : ISmsService
} }
} }
// ── SDK runner ────────────────────────────────────────────────────────────
/// <summary>
/// Runs a synchronous Kavenegar SDK call on the thread pool.
/// Translates SDK exceptions to logged InvalidOperationException.
/// </summary>
private async Task RunSdkAsync(string apiKey, Action<KavenegarApi> 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 ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender)
{
var dict = new Dictionary<string, string>
{
["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<KavenegarReturnEnvelope>(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) // Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx)
private static string NormalizePhone(string phone) private static string NormalizePhone(string phone)
{ {
@@ -200,35 +184,6 @@ public class KavenegarSmsService : ISmsService
return p; 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) private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
{ {
var enabled = await _platform.GetAsync(DbKeyEnabled, ct); var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
@@ -250,42 +205,4 @@ public class KavenegarSmsService : ISmsService
return (apiKey, sender, template); 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; }
}
} }
@@ -14,6 +14,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Kavenegar" />
<PackageReference Include="System.Security.Cryptography.Xml" /> <PackageReference Include="System.Security.Cryptography.Xml" />
</ItemGroup> </ItemGroup>