Fully implement Kavenegar SMS support

Core changes:
- ISmsService: add SendBulkAsync (batches of 200) + GetAccountInfoAsync
- KavenegarSmsService: POST requests, sender number config, bulk send
  via comma-separated receptors, account balance, full error code mapping
  (HTTP 400-432), enabled-flag check before any send
- SmsMarketingService: replaced per-recipient loop with SendBulkAsync
- SmsController: new GET /sms/balance endpoint returns Kavenegar credit
- SmsDtos: SmsBalanceDto
- IntegrationDtos + PlatformIntegrationService: SenderNumber field
- appsettings.json + docker-compose: Kavenegar__SenderNumber = 90005671

Dashboard:
- sms-screen: char counter, SMS parts indicator (Persian 70/67 chars,
  Latin 160/153), account balance card, sender line display, result banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 02:38:06 +03:30
parent b78f2affb6
commit 42d7667735
14 changed files with 446 additions and 110 deletions
@@ -1,13 +1,28 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices;
/// <summary>
/// Kavenegar SMS gateway implementation.
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
/// </summary>
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 BaseUrl = "https://api.kavenegar.com/v1";
private const int MaxBatchSize = 200;
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly IPlatformRuntimeConfig _platform;
@@ -25,64 +40,194 @@ public class KavenegarSmsService : ISmsService
_logger = logger;
}
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
{
var apiKey = await GetApiKeyAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogInformation(
"Kavenegar API key not configured — SMS to {Phone}: {Message}",
phone,
message);
return;
}
var url =
$"https://api.kavenegar.com/v1/{apiKey}/sms/send.json" +
$"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(message)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Kavenegar SMS send failed with status {StatusCode}", response.StatusCode);
throw new InvalidOperationException("SMS delivery failed.");
}
var body = await response.Content.ReadFromJsonAsync<KavenegarResponse>(cancellationToken: cancellationToken);
if (body?.Return?.Status is not 200)
throw new InvalidOperationException("SMS delivery failed.");
}
// ── Public interface ──────────────────────────────────────────────────────
public async Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default)
{
var apiKey = await GetApiKeyAsync(cancellationToken);
var template = await GetOtpTemplateAsync(cancellationToken);
var (apiKey, _, template) = await GetConfigAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogInformation("Kavenegar API key not configured — OTP for {Phone} (dev only, not sent via SMS)", phone);
_logger.LogInformation("Kavenegar not configured — OTP for {Phone} not sent", phone);
return;
}
var url = $"https://api.kavenegar.com/v1/{apiKey}/verify/lookup.json" +
$"?receptor={Uri.EscapeDataString(phone)}&token={otp}&template={Uri.EscapeDataString(template)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json";
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
_logger.LogWarning("Kavenegar OTP send failed with status {StatusCode}", response.StatusCode);
throw new InvalidOperationException("SMS delivery failed.");
["receptor"] = phone,
["token"] = 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)
{
var (apiKey, sender, _) = await GetConfigAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogInformation("Kavenegar not configured — SMS to {Phone}: {Message}", phone, message);
return;
}
var body = await response.Content.ReadFromJsonAsync<KavenegarResponse>(cancellationToken: cancellationToken);
if (body?.Return?.Status is not 200)
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);
}
public async Task<BulkSendResult> SendBulkAsync(
IReadOnlyList<string> phones,
string message,
CancellationToken cancellationToken = default)
{
if (phones.Count == 0) return new BulkSendResult(0, 0);
var (apiKey, sender, _) = await GetConfigAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogWarning("Kavenegar returned status {Status}", body?.Return?.Status);
throw new InvalidOperationException("SMS delivery failed.");
_logger.LogInformation("Kavenegar not configured — bulk SMS skipped ({Count} recipients)", phones.Count);
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);
sent += batch.Length;
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Kavenegar bulk batch failed ({Count} recipients)", batch.Length);
failed += batch.Length;
}
}
return new BulkSendResult(sent, failed);
}
public async Task<KavenegarAccountInfo?> GetAccountInfoAsync(CancellationToken cancellationToken = default)
{
var (apiKey, _, _) = await GetConfigAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(apiKey)) return null;
try
{
var url = $"{BaseUrl}/{apiKey}/account/info.json";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode);
return null;
}
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)
{
_logger.LogWarning(ex, "Failed to fetch Kavenegar account info");
return null;
}
}
private sealed class KavenegarResponse
// ── 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}");
}
}
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",
411 => "Invalid recipient number",
412 => "Invalid sender number",
413 => "Message empty or too long",
414 => "Too many recipients",
417 => "Invalid scheduled date",
418 => "Insufficient credit",
422 => "Invalid characters in message",
424 => "OTP template not found",
426 => "IP is not whitelisted",
428 => "Voice call requires numeric token",
432 => "Code parameter missing in OTP template",
_ => "Unknown error"
};
private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
{
var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
// If explicitly disabled in DB, short-circuit
if (enabled is "false")
return (null, string.Empty, "verify");
var apiKey = await _platform.GetAsync(DbKeyApiKey, ct);
if (string.IsNullOrWhiteSpace(apiKey))
apiKey = _configuration["Kavenegar:ApiKey"];
var sender = await _platform.GetAsync(DbKeySender, ct);
if (string.IsNullOrWhiteSpace(sender))
sender = _configuration["Kavenegar:SenderNumber"] ?? string.Empty;
var template = await _platform.GetAsync(DbKeyOtpTemplate, ct);
if (string.IsNullOrWhiteSpace(template))
template = _configuration["Kavenegar:OtpTemplate"] ?? "verify";
return (apiKey, sender, template);
}
// ── Response models ───────────────────────────────────────────────────────
private sealed class KavenegarReturnEnvelope
{
[JsonPropertyName("return")]
public KavenegarReturn? Return { get; set; }
@@ -92,21 +237,29 @@ public class KavenegarSmsService : ISmsService
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
private async Task<string?> GetApiKeyAsync(CancellationToken cancellationToken)
private sealed class KavenegarAccountInfoResponse
{
var fromDb = await _platform.GetAsync("integrations.kavenegar.apiKey", cancellationToken);
if (!string.IsNullOrWhiteSpace(fromDb))
return fromDb;
return _configuration["Kavenegar:ApiKey"];
[JsonPropertyName("return")]
public KavenegarReturn? Return { get; set; }
[JsonPropertyName("entries")]
public KavenegarAccountEntries? Entries { get; set; }
}
private async Task<string> GetOtpTemplateAsync(CancellationToken cancellationToken)
private sealed class KavenegarAccountEntries
{
var fromDb = await _platform.GetAsync("integrations.kavenegar.otpTemplate", cancellationToken);
if (!string.IsNullOrWhiteSpace(fromDb))
return fromDb;
return _configuration["Kavenegar:OtpTemplate"] ?? "verify";
[JsonPropertyName("remaincredit")]
public long RemainCredit { get; set; }
[JsonPropertyName("expiredate")]
public long ExpireDate { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
}
}