103 lines
4.3 KiB
C#
103 lines
4.3 KiB
C#
#nullable enable
|
|
using System.Net;
|
|
using System.Text.Json;
|
|
using Baya.Application.Contracts.Common;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
|
|
|
|
/// <summary>
|
|
/// Real <see cref="ISmsSender"/> over the <b>Kavenegar</b> Iranian SMS gateway (refinement-phase-8, 5.1 —
|
|
/// launch-critical). OTP delivery uses Kavenegar's <c>verify/lookup</c> pattern API (a pre-approved template,
|
|
/// no marketing pre-clearance needed for transactional OTPs); free-form transactional messages use
|
|
/// <c>sms/send</c> from the registered sender line. Selected by <c>Seams:Sms:Provider = kavenegar</c>; the seam
|
|
/// is swapped by a registration change only, so no handler is touched.
|
|
///
|
|
/// <para><b>The OTP is never logged.</b> Once this ships the Development OTP-in-logs/echo bridge is disabled
|
|
/// (the code only leaves the process over the SMS wire) — only the phone tail + gateway status are logged.</para>
|
|
/// </summary>
|
|
public sealed class KavenegarSmsSender(
|
|
HttpClient httpClient,
|
|
IOptions<SeamOptions> options,
|
|
ILogger<KavenegarSmsSender> logger) : ISmsSender
|
|
{
|
|
private readonly SmsOptions _options = options.Value.Sms;
|
|
|
|
public async Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
|
|
{
|
|
// verify/lookup: the code is delivered through the approved OTP template — never a free-form message,
|
|
// which is what keeps transactional OTPs deliverable without marketing pre-clearance.
|
|
var query = new Dictionary<string, string?>
|
|
{
|
|
["receptor"] = phone,
|
|
["token"] = code,
|
|
["template"] = _options.OtpTemplate,
|
|
};
|
|
|
|
await SendAsync($"v1/{_options.ApiKey}/verify/lookup.json", query, phone, cancellationToken);
|
|
}
|
|
|
|
public async Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
|
|
{
|
|
var query = new Dictionary<string, string?>
|
|
{
|
|
["receptor"] = phone,
|
|
["sender"] = _options.SenderLine,
|
|
["message"] = message,
|
|
};
|
|
|
|
await SendAsync($"v1/{_options.ApiKey}/sms/send.json", query, phone, cancellationToken);
|
|
}
|
|
|
|
private async Task SendAsync(
|
|
string path, IReadOnlyDictionary<string, string?> query, string phone, CancellationToken cancellationToken)
|
|
{
|
|
var url = QueryHelpers(path, query);
|
|
|
|
using var response = await httpClient.GetAsync(url, cancellationToken);
|
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
// Kavenegar always returns 200 for a well-formed request and carries the real outcome in `return.status`
|
|
// (200 = accepted). A non-accepted status (e.g. 411 invalid receptor, 418 credit) is a delivery failure —
|
|
// surfaced as an exception so the OTP command reports the send failure rather than silently "succeeding".
|
|
var status = TryReadReturnStatus(body);
|
|
if (response.StatusCode != HttpStatusCode.OK || status is not 200)
|
|
{
|
|
logger.LogWarning(
|
|
"Kavenegar SMS delivery failed for phone ending {PhoneTail} — http {Http}, return status {ReturnStatus}",
|
|
Tail(phone), (int)response.StatusCode, status);
|
|
throw new InvalidOperationException($"Kavenegar SMS delivery failed (return status {status}).");
|
|
}
|
|
|
|
logger.LogInformation("Kavenegar SMS accepted for phone ending {PhoneTail}", Tail(phone));
|
|
}
|
|
|
|
private static int? TryReadReturnStatus(string body)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(body);
|
|
return doc.RootElement.TryGetProperty("return", out var ret)
|
|
&& ret.TryGetProperty("status", out var status)
|
|
? status.GetInt32()
|
|
: null;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static string QueryHelpers(string path, IReadOnlyDictionary<string, string?> query)
|
|
{
|
|
var pairs = query
|
|
.Where(kvp => kvp.Value is not null)
|
|
.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}");
|
|
return $"{path}?{string.Join('&', pairs)}";
|
|
}
|
|
|
|
private static string Tail(string phone) =>
|
|
string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..];
|
|
}
|