2026-06-04 16:40:05 +03:30
|
|
|
using System.Text.Json;
|
2026-06-04 10:27:21 +03:30
|
|
|
using JobsMedical.Web.Models;
|
|
|
|
|
|
|
|
|
|
namespace JobsMedical.Web.Services;
|
|
|
|
|
|
|
|
|
|
public interface ISmsSender
|
|
|
|
|
{
|
|
|
|
|
/// <summary>Send the OTP code. Returns false if not configured or the gateway call fails.</summary>
|
|
|
|
|
Task<bool> SendOtpAsync(string phone, string code, AppSetting settings, CancellationToken ct = default);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Kavenegar SMS gateway (Iran). Prefers the verify/lookup API (a pre-approved OTP template, no
|
|
|
|
|
/// dedicated line needed); falls back to plain sms/send if only a sender line is configured.
|
|
|
|
|
/// Credentials live in AppSetting (admin panel), so no redeploy to set them.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class KavenegarSmsSender : ISmsSender
|
|
|
|
|
{
|
|
|
|
|
private readonly IHttpClientFactory _http;
|
|
|
|
|
private readonly ILogger<KavenegarSmsSender> _log;
|
|
|
|
|
|
|
|
|
|
public KavenegarSmsSender(IHttpClientFactory http, ILogger<KavenegarSmsSender> log)
|
|
|
|
|
{
|
|
|
|
|
_http = http;
|
|
|
|
|
_log = log;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<bool> SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default)
|
|
|
|
|
{
|
|
|
|
|
if (!s.SmsEnabled || string.IsNullOrWhiteSpace(s.SmsApiKey)) return false;
|
2026-06-04 16:40:05 +03:30
|
|
|
|
|
|
|
|
// The API key is part of the URL path; clean it so a stray space/newline/slash
|
|
|
|
|
// doesn't turn the request into a 404 (a malformed path). It is NOT a query value,
|
|
|
|
|
// so don't percent-encode it — just strip whitespace/control chars.
|
|
|
|
|
var apiKey = new string(s.SmsApiKey.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
|
|
|
|
if (apiKey.Contains('/'))
|
|
|
|
|
{
|
|
|
|
|
_log.LogWarning("Kavenegar API key looks malformed (contains '/'). Check the value in admin settings.");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 10:27:21 +03:30
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var client = _http.CreateClient("sms");
|
|
|
|
|
client.Timeout = TimeSpan.FromSeconds(15);
|
2026-06-04 16:40:05 +03:30
|
|
|
string url; string method;
|
2026-06-04 10:27:21 +03:30
|
|
|
if (!string.IsNullOrWhiteSpace(s.SmsTemplate))
|
|
|
|
|
{
|
2026-06-04 16:40:05 +03:30
|
|
|
method = "verify/lookup";
|
2026-06-04 10:27:21 +03:30
|
|
|
// verify/lookup: template contains %token → the code
|
2026-06-04 16:40:05 +03:30
|
|
|
url = $"https://api.kavenegar.com/v1/{apiKey}/verify/lookup.json" +
|
2026-06-04 10:27:21 +03:30
|
|
|
$"?receptor={Uri.EscapeDataString(phone)}&token={Uri.EscapeDataString(code)}" +
|
2026-06-04 16:40:05 +03:30
|
|
|
$"&template={Uri.EscapeDataString(s.SmsTemplate.Trim())}";
|
2026-06-04 10:27:21 +03:30
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-06-04 16:40:05 +03:30
|
|
|
method = "sms/send";
|
2026-06-04 10:27:21 +03:30
|
|
|
var msg = $"کد ورود همکادر: {code}";
|
2026-06-04 16:40:05 +03:30
|
|
|
url = $"https://api.kavenegar.com/v1/{apiKey}/sms/send.json" +
|
2026-06-04 10:27:21 +03:30
|
|
|
$"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(msg)}" +
|
2026-06-04 16:40:05 +03:30
|
|
|
(string.IsNullOrWhiteSpace(s.SmsSender) ? "" : $"&sender={Uri.EscapeDataString(s.SmsSender.Trim())}");
|
2026-06-04 10:27:21 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var resp = await client.GetAsync(url, ct);
|
2026-06-04 16:40:05 +03:30
|
|
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
|
|
|
|
var (apiStatus, apiMessage) = ParseKavenegar(body);
|
|
|
|
|
|
|
|
|
|
// Kavenegar success = HTTP 2xx AND return.status == 200. A wrong key/template
|
|
|
|
|
// often comes back as HTTP 200 with an error status, so check both.
|
|
|
|
|
if (resp.IsSuccessStatusCode && apiStatus == 200) return true;
|
|
|
|
|
|
|
|
|
|
string hint = (int)resp.StatusCode == 404
|
|
|
|
|
? " — HTTP 404 معمولاً یعنی کلید API نادرست/خراب است یا متد اشتباه (مسیر آدرس معتبر نیست)."
|
|
|
|
|
: apiStatus switch
|
|
|
|
|
{
|
|
|
|
|
401 or 402 => " — کلید API نامعتبر یا حساب غیرفعال است.",
|
|
|
|
|
407 or 431 or 432 => " — متن/تمپلیت تأیید نشده یا نام تمپلیت اشتباه است.",
|
|
|
|
|
411 or 412 => " — شماره گیرنده یا فرستنده نامعتبر است.",
|
|
|
|
|
418 => " — اعتبار حساب کافی نیست.",
|
|
|
|
|
_ => ""
|
|
|
|
|
};
|
|
|
|
|
_log.LogWarning(
|
|
|
|
|
"Kavenegar OTP failed for {Phone} via {Method}: HTTP {Http}, apiStatus={ApiStatus}, message={Message}{Hint} | body: {Body}",
|
|
|
|
|
phone, method, (int)resp.StatusCode, apiStatus, apiMessage, hint, Truncate(body, 300));
|
|
|
|
|
return false;
|
2026-06-04 10:27:21 +03:30
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_log.LogWarning(ex, "Kavenegar OTP send failed for {Phone}", phone);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-04 16:40:05 +03:30
|
|
|
|
|
|
|
|
/// <summary>Parse Kavenegar's { "return": { "status": N, "message": "…" } } envelope.</summary>
|
|
|
|
|
private static (int? status, string? message) ParseKavenegar(string body)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(body)) return (null, null);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var doc = JsonDocument.Parse(body);
|
|
|
|
|
if (doc.RootElement.TryGetProperty("return", out var ret))
|
|
|
|
|
{
|
|
|
|
|
int? status = ret.TryGetProperty("status", out var st) && st.TryGetInt32(out var n) ? n : null;
|
|
|
|
|
string? msg = ret.TryGetProperty("message", out var m) ? m.GetString() : null;
|
|
|
|
|
return (status, msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException) { /* not JSON (e.g. a 404 HTML page) — fall through */ }
|
|
|
|
|
return (null, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string Truncate(string s, int max) =>
|
|
|
|
|
string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max] + "…");
|
2026-06-04 10:27:21 +03:30
|
|
|
}
|