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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user