|
|
|
@@ -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,7 +9,7 @@ using Microsoft.Extensions.Logging;
|
|
|
|
|
namespace Meezi.Infrastructure.ExternalServices;
|
|
|
|
|
|
|
|
|
|
/// <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
|
|
|
|
|
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
|
|
|
|
|
/// </summary>
|
|
|
|
@@ -21,23 +21,19 @@ public class KavenegarSmsService : ISmsService
|
|
|
|
|
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;
|
|
|
|
|
private readonly IHostEnvironment _environment;
|
|
|
|
|
private readonly ILogger<KavenegarSmsService> _logger;
|
|
|
|
|
|
|
|
|
|
public KavenegarSmsService(
|
|
|
|
|
HttpClient httpClient,
|
|
|
|
|
IConfiguration configuration,
|
|
|
|
|
IPlatformRuntimeConfig platform,
|
|
|
|
|
IHostEnvironment environment,
|
|
|
|
|
ILogger<KavenegarSmsService> 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<string, string>
|
|
|
|
|
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<BulkSendResult> 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<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");
|
|
|
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <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 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
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; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|