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); }