using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationService;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
///
/// SMS channel delivery adapter for the central notification outbox (T9 pivot).
///
/// Calls Twilio directly over its REST API — no SDK — honouring the project's
/// no-new-NuGet-package rule. The adapter resolves the notification's list, keeps
/// only SMS-eligible recipients (non-empty ),
/// loads the central , composes a plain-text body, and
/// issues one POST .../Messages.json per recipient (Twilio has no BCC). Each
/// response is classified via and rolled up into the
/// outbox's three-way per design D6.
///
/// Mirrors the Email adapter's structure (load list → recipients → config, build
/// message, classify outcome). "Accepted by Twilio" (HTTP 2xx) is treated as
/// Delivered, exactly as the Email adapter treats "accepted by the SMTP server";
/// true delivery confirmation needs a status-callback webhook (out of scope).
///
///
/// The Twilio Auth Token is NEVER allowed to appear in a returned
/// or in any log: every outbound error string is
/// passed through with the token before it is
/// logged or returned.
///
///
public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter
{
/// Provider default Twilio REST base URL, used when the config row leaves ApiBaseUrl unset.
private const string DefaultApiBaseUrl = "https://api.twilio.com";
/// The named registered for Twilio in ServiceCollectionExtensions.
public const string HttpClientName = "Twilio";
private readonly INotificationRepository _repository;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
private readonly SmsOptions _options;
/// Initializes a new instance of .
/// Repository for resolving notification list recipients and the SMS configuration.
/// Factory creating the named "Twilio" HTTP client per delivery attempt.
/// Logger instance.
/// SMS options providing the documented message-length cap and timeout fallback.
public SmsNotificationDeliveryAdapter(
INotificationRepository repository,
IHttpClientFactory httpClientFactory,
ILogger logger,
IOptions options)
{
_repository = repository;
_httpClientFactory = httpClientFactory;
_logger = logger;
// SmsOptions supplies the documented body cap and the timeout fallback used
// when a deployed SmsConfiguration row leaves ConnectionTimeoutSeconds unset.
_options = options?.Value ?? new SmsOptions();
}
///
public NotificationType Type => NotificationType.Sms;
///
public async Task DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
var list = await _repository.GetListByNameAsync(notification.ListName, cancellationToken);
if (list == null)
{
return DeliveryOutcome.Permanent(
$"Notification list '{notification.ListName}' not found");
}
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
var phoneNumbers = recipients
.Where(r => !string.IsNullOrWhiteSpace(r.PhoneNumber))
.Select(r => r.PhoneNumber!.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (phoneNumbers.Count == 0)
{
return DeliveryOutcome.Permanent(
$"Notification list '{notification.ListName}' has no SMS recipients");
}
var smsConfig = await _repository.GetSmsConfigurationAsync(cancellationToken);
if (smsConfig == null)
{
return DeliveryOutcome.Permanent("No SMS configuration available");
}
// The Account SID + Auth Token are mandatory (they form the Basic-auth header
// and the SID is on the URL path). A From number is required UNLESS a
// Messaging Service SID is supplied, in which case From is optional. None of
// these can be fixed by retrying, so a missing field is a permanent failure.
if (string.IsNullOrWhiteSpace(smsConfig.AccountSid)
|| string.IsNullOrWhiteSpace(smsConfig.AuthToken))
{
return DeliveryOutcome.Permanent(
"SMS configuration is incomplete: AccountSid and AuthToken are required");
}
var hasMessagingService = !string.IsNullOrWhiteSpace(smsConfig.MessagingServiceSid);
if (!hasMessagingService && string.IsNullOrWhiteSpace(smsConfig.FromNumber))
{
return DeliveryOutcome.Permanent(
"SMS configuration is incomplete: a FromNumber or MessagingServiceSid is required");
}
var authToken = smsConfig.AuthToken!;
var body = ComposeBody(notification.Subject, notification.Body);
var baseUrl = string.IsNullOrWhiteSpace(smsConfig.ApiBaseUrl)
? DefaultApiBaseUrl
: smsConfig.ApiBaseUrl!.TrimEnd('/');
var requestUri = $"{baseUrl}/2010-04-01/Accounts/{smsConfig.AccountSid}/Messages.json";
// Config-row value overrides the SmsOptions fallback for the per-request timeout.
var timeoutSeconds = smsConfig.ConnectionTimeoutSeconds > 0
? smsConfig.ConnectionTimeoutSeconds
: _options.ConnectionTimeoutSeconds;
var authHeader = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{smsConfig.AccountSid}:{authToken}")));
var httpClient = _httpClientFactory.CreateClient(HttpClientName);
var accepted = new List();
var permanentlyFailed = new List();
string? firstTransientError = null;
foreach (var phone in phoneNumbers)
{
// A caller-requested cancellation propagates; it is neither a success nor
// a delivery failure (mirrors the Email adapter).
cancellationToken.ThrowIfCancellationRequested();
var attempt = await SendOneAsync(
httpClient, requestUri, authHeader, smsConfig, hasMessagingService,
phone, body, timeoutSeconds, authToken, cancellationToken);
switch (attempt.Class)
{
case SmsErrorClass.Transient:
firstTransientError ??= attempt.Detail;
break;
case SmsErrorClass.Permanent:
permanentlyFailed.Add(phone);
break;
default:
accepted.Add(phone);
break;
}
}
return RollUp(notification.ListName, accepted, permanentlyFailed, firstTransientError);
}
///
/// Issues one Twilio POST for a single recipient and classifies the result.
/// Transport exceptions are caught and classified here; a caller-requested
/// cancellation is re-thrown so it propagates out of .
///
private async Task SendOneAsync(
HttpClient httpClient,
string requestUri,
AuthenticationHeaderValue authHeader,
SmsConfiguration smsConfig,
bool hasMessagingService,
string toNumber,
string body,
int timeoutSeconds,
string authToken,
CancellationToken cancellationToken)
{
// Per-request timeout layered over the caller's token via a linked CTS, so a
// slow Twilio response is classified transient (HttpClient surfaces the cancel
// as a TaskCanceledException with no caller-cancel) while a genuine caller
// cancel still propagates.
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
try
{
var form = new List>
{
new("To", toNumber),
new("Body", body),
};
if (hasMessagingService)
{
form.Add(new KeyValuePair(
"MessagingServiceSid", smsConfig.MessagingServiceSid!));
}
else
{
form.Add(new KeyValuePair("From", smsConfig.FromNumber));
}
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new FormUrlEncodedContent(form),
};
request.Headers.Authorization = authHeader;
using var response = await httpClient.SendAsync(request, linkedCts.Token);
if (response.IsSuccessStatusCode)
{
return new SmsAttempt(SmsErrorClass.Unknown, null);
}
var cls = SmsErrorClassifier.Classify(response.StatusCode);
var detail = CredentialRedactor.Scrub(
$"Twilio returned HTTP {(int)response.StatusCode} ({response.StatusCode}) for {toNumber}",
authToken);
if (cls == SmsErrorClass.Transient)
{
_logger.LogWarning(
"Transient SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
}
else
{
_logger.LogWarning(
"Permanent SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
}
return new SmsAttempt(cls, detail);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// A caller-requested cancellation propagates; the per-request timeout
// cancel (timeoutCts) is NOT a caller cancel and falls through to the
// transient classifier below.
throw;
}
catch (Exception ex) when (SmsErrorClassifier.IsTransient(ex, cancellationToken))
{
var detail = CredentialRedactor.Scrub(
$"SMS transport failure for {toNumber} ({ex.GetType().Name}): {ex.Message}",
authToken);
_logger.LogWarning(
"Transient SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
toNumber, ex.GetType().Name, detail);
return new SmsAttempt(SmsErrorClass.Transient, detail);
}
catch (Exception ex)
{
// An unclassified failure is treated as permanent: retrying a malformed
// request burns Twilio calls (mirrors the Email adapter's default-permanent
// stance for unclassified failures).
var detail = CredentialRedactor.Scrub(
$"SMS delivery failed for {toNumber} ({ex.GetType().Name}): {ex.Message}",
authToken);
_logger.LogError(
"Unclassified SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
toNumber, ex.GetType().Name, detail);
return new SmsAttempt(SmsErrorClass.Permanent, detail);
}
}
///
/// Rolls the per-recipient outcomes up into a single notification-level outcome
/// per design D6: any transient → Transient (whole notification retries); else any
/// accepted → Success (the permanently-failed numbers are noted, NOT parked); else
/// (all permanent / zero accepted) → Permanent.
///
private DeliveryOutcome RollUp(
string listName,
IReadOnlyList accepted,
IReadOnlyList permanentlyFailed,
string? firstTransientError)
{
// Any transient failure parks the WHOLE notification for retry at the fixed
// interval; numbers already accepted on this attempt get re-texted on retry
// (the same "re-send to all" characteristic the Email/BCC path already has).
if (firstTransientError != null)
{
_logger.LogWarning(
"SMS notification to list '{List}' has a transient failure; the notification will retry. {Detail}",
listName, firstTransientError);
return DeliveryOutcome.Transient(firstTransientError);
}
// No transient failures. If anything was accepted, do NOT park even when some
// numbers permanently failed — record the bad numbers and report success.
if (accepted.Count > 0)
{
var targets = string.Join(", ", accepted);
if (permanentlyFailed.Count > 0)
{
var note =
$"Delivered to {accepted.Count} recipient(s); " +
$"{permanentlyFailed.Count} permanently failed: {string.Join(", ", permanentlyFailed)}";
_logger.LogWarning(
"SMS notification to list '{List}' partially delivered: {Note}", listName, note);
// ResolvedTargets carries the accepted numbers AND the failed-number note
// so the audit row preserves the permanently-failed recipients.
return DeliveryOutcome.Success($"{targets} [{note}]");
}
return DeliveryOutcome.Success(targets);
}
// Nothing accepted and no transient failures — every recipient permanently
// failed. Park the notification.
var error = permanentlyFailed.Count > 0
? $"All SMS recipients permanently failed: {string.Join(", ", permanentlyFailed)}"
: "SMS delivery produced no accepted recipients";
_logger.LogError(
"SMS notification to list '{List}' failed permanently: {Error}", listName, error);
return DeliveryOutcome.Permanent(error);
}
///
/// Composes the plain-text SMS body: Subject + newline + Body
/// (skipping whichever is empty), truncated to
/// with a trailing ellipsis when over the cap. SMS has no subject line.
///
private string ComposeBody(string? subject, string? body)
{
var parts = new List(2);
if (!string.IsNullOrWhiteSpace(subject))
{
parts.Add(subject);
}
if (!string.IsNullOrWhiteSpace(body))
{
parts.Add(body);
}
var composed = string.Join("\n", parts);
var max = _options.MaxMessageLength;
if (composed.Length <= max)
{
return composed;
}
// Truncate to the cap and append an ellipsis (counted inside the cap so the
// result never exceeds MaxMessageLength). The ellipsis is a single char.
const string ellipsis = "…";
if (max <= ellipsis.Length)
{
return composed[..max];
}
return string.Concat(composed.AsSpan(0, max - ellipsis.Length), ellipsis);
}
/// The classified outcome of a single per-recipient Twilio attempt.
///
/// = accepted (HTTP 2xx); otherwise the failure class.
///
/// A redacted, human-readable failure description, or null on accept.
private readonly record struct SmsAttempt(SmsErrorClass Class, string? Detail);
}