#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)..]; }