371 lines
16 KiB
C#
371 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="NotificationRecipient.PhoneNumber"/>),
|
|
/// loads the central <see cref="SmsConfiguration"/>, composes a plain-text body, and
|
|
/// issues one <c>POST .../Messages.json</c> per recipient (Twilio has no BCC). Each
|
|
/// response is classified via <see cref="SmsErrorClassifier"/> and rolled up into the
|
|
/// outbox's three-way <see cref="DeliveryOutcome"/> per design D6.
|
|
/// <para>
|
|
/// 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).
|
|
/// </para>
|
|
/// <para>
|
|
/// The Twilio Auth Token is NEVER allowed to appear in a returned
|
|
/// <see cref="DeliveryOutcome.Error"/> or in any log: every outbound error string is
|
|
/// passed through <see cref="CredentialRedactor.Scrub"/> with the token before it is
|
|
/// logged or returned.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
|
{
|
|
/// <summary>Provider default Twilio REST base URL, used when the config row leaves <c>ApiBaseUrl</c> unset.</summary>
|
|
private const string DefaultApiBaseUrl = "https://api.twilio.com";
|
|
|
|
/// <summary>The named <see cref="HttpClient"/> registered for Twilio in <c>ServiceCollectionExtensions</c>.</summary>
|
|
public const string HttpClientName = "Twilio";
|
|
|
|
private readonly INotificationRepository _repository;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<SmsNotificationDeliveryAdapter> _logger;
|
|
private readonly SmsOptions _options;
|
|
|
|
/// <summary>Initializes a new instance of <see cref="SmsNotificationDeliveryAdapter"/>.</summary>
|
|
/// <param name="repository">Repository for resolving notification list recipients and the SMS configuration.</param>
|
|
/// <param name="httpClientFactory">Factory creating the named <c>"Twilio"</c> HTTP client per delivery attempt.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <param name="options">SMS options providing the documented message-length cap and timeout fallback.</param>
|
|
public SmsNotificationDeliveryAdapter(
|
|
INotificationRepository repository,
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<SmsNotificationDeliveryAdapter> logger,
|
|
IOptions<SmsOptions> 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();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public NotificationType Type => NotificationType.Sms;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeliveryOutcome> 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<string>();
|
|
var permanentlyFailed = new List<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="DeliverAsync"/>.
|
|
/// </summary>
|
|
private async Task<SmsAttempt> 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<KeyValuePair<string, string>>
|
|
{
|
|
new("To", toNumber),
|
|
new("Body", body),
|
|
};
|
|
if (hasMessagingService)
|
|
{
|
|
form.Add(new KeyValuePair<string, string>(
|
|
"MessagingServiceSid", smsConfig.MessagingServiceSid!));
|
|
}
|
|
else
|
|
{
|
|
form.Add(new KeyValuePair<string, string>("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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private DeliveryOutcome RollUp(
|
|
string listName,
|
|
IReadOnlyList<string> accepted,
|
|
IReadOnlyList<string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composes the plain-text SMS body: <c>Subject</c> + newline + <c>Body</c>
|
|
/// (skipping whichever is empty), truncated to <see cref="SmsOptions.MaxMessageLength"/>
|
|
/// with a trailing ellipsis when over the cap. SMS has no subject line.
|
|
/// </summary>
|
|
private string ComposeBody(string? subject, string? body)
|
|
{
|
|
var parts = new List<string>(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);
|
|
}
|
|
|
|
/// <summary>The classified outcome of a single per-recipient Twilio attempt.</summary>
|
|
/// <param name="Class">
|
|
/// <see cref="SmsErrorClass.Unknown"/> = accepted (HTTP 2xx); otherwise the failure class.
|
|
/// </param>
|
|
/// <param name="Detail">A redacted, human-readable failure description, or null on accept.</param>
|
|
private readonly record struct SmsAttempt(SmsErrorClass Class, string? Detail);
|
|
}
|