refinement phase 8
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
#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)..];
|
||||
}
|
||||
Reference in New Issue
Block a user