#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;
///
/// Real over the Kavenegar Iranian SMS gateway (refinement-phase-8, 5.1 —
/// launch-critical). OTP delivery uses Kavenegar's verify/lookup pattern API (a pre-approved template,
/// no marketing pre-clearance needed for transactional OTPs); free-form transactional messages use
/// sms/send from the registered sender line. Selected by Seams:Sms:Provider = kavenegar; the seam
/// is swapped by a registration change only, so no handler is touched.
///
/// The OTP is never logged. 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.
///
public sealed class KavenegarSmsSender(
HttpClient httpClient,
IOptions options,
ILogger 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
{
["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
{
["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 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 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)..];
}