feat(sms): Twilio SmsNotificationDeliveryAdapter + classifier + options + DI (S3)

This commit is contained in:
Joseph Doherty
2026-06-19 10:14:52 -04:00
parent 609bdb37ef
commit a1d484a5ff
7 changed files with 1069 additions and 7 deletions
@@ -0,0 +1,112 @@
using System.Net;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
/// <summary>
/// Classification of a Twilio-REST SMS delivery failure. This decides whether a
/// failure is retried (the whole notification is re-queued) or surfaced as permanent
/// (the notification is parked), so it is part of the system's correctness-relevant
/// behaviour.
/// </summary>
public enum SmsErrorClass
{
/// <summary>Cancellation or an unrecognised exception — caller decides.</summary>
Unknown,
/// <summary>Retryable failure (HTTP 5xx / 408 / 429, transport/timeout error).</summary>
Transient,
/// <summary>Non-retryable failure (other 4xx — bad creds, invalid number) — must not be retried.</summary>
Permanent,
}
/// <summary>
/// Classifies a Twilio-REST SMS delivery failure. Mirrors
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService.SmtpErrorClassifier"/>'s
/// enum + static-method shape so the SMS adapter routes every failure through one
/// local policy (no cross-project reference to the External System Gateway). The
/// classification is keyed off the numeric HTTP status code (Twilio returns rich
/// status codes) and the typed transport exceptions rather than locale-dependent
/// substring matching.
/// <para>
/// Transient: HTTP 5xx, 408, 429; <see cref="HttpRequestException"/>,
/// <see cref="TimeoutException"/> and <see cref="TaskCanceledException"/> that are
/// NOT a caller-requested cancellation. Everything else (other 4xx — 400 invalid /
/// unsubscribed number, 401/403 bad credentials) is permanent.
/// </para>
/// </summary>
public static class SmsErrorClassifier
{
/// <summary>
/// Classifies an HTTP response status code. Twilio accepts a message with a 2xx;
/// 5xx / 408 / 429 are transient (provider-side / rate-limited); any other 4xx is
/// permanent (the request itself is wrong and retrying cannot fix it).
/// </summary>
/// <param name="statusCode">The HTTP status code returned by the Twilio Messages endpoint.</param>
/// <returns>The <see cref="SmsErrorClass"/> describing the failure (never <see cref="SmsErrorClass.Unknown"/> for a status code).</returns>
public static SmsErrorClass Classify(HttpStatusCode statusCode)
{
var code = (int)statusCode;
if (code == 408 || code == 429 || (code >= 500 && code < 600))
{
return SmsErrorClass.Transient;
}
// Any non-2xx status that is not 408/429/5xx is a permanent client error.
return SmsErrorClass.Permanent;
}
/// <summary>
/// Classifies a transport-layer exception thrown while issuing the Twilio request.
/// A caller-requested cancellation is never treated as a transient SMS error.
/// </summary>
/// <param name="ex">The exception thrown by the HTTP send.</param>
/// <param name="cancellationToken">
/// The token governing the send; a requested cancellation classifies as
/// <see cref="SmsErrorClass.Unknown"/> so the caller can re-throw it.
/// </param>
/// <returns>The <see cref="SmsErrorClass"/> describing whether the failure is transient or unknown.</returns>
public static SmsErrorClass Classify(Exception ex, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(ex);
// A deliberate cancellation is not an SMS error at all — TaskCanceledException
// derives from OperationCanceledException, so this guard catches both an
// HttpClient request-timeout cancel and a caller cancel; only the latter is
// honoured here so the caller can re-throw.
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
{
return SmsErrorClass.Unknown;
}
// A connection failure, a request-level timeout (HttpClient surfaces its own
// timeout as a TaskCanceledException with no caller cancel) and an explicit
// TimeoutException are all retryable — the message has not been rejected.
if (ex is HttpRequestException or TimeoutException or TaskCanceledException)
{
return SmsErrorClass.Transient;
}
return SmsErrorClass.Unknown;
}
/// <summary>
/// Convenience predicate: true when the HTTP status code classifies as
/// <see cref="SmsErrorClass.Transient"/>.
/// </summary>
/// <param name="statusCode">The HTTP status code to classify.</param>
/// <returns><c>true</c> when <see cref="Classify(HttpStatusCode)"/> returns <see cref="SmsErrorClass.Transient"/>.</returns>
public static bool IsTransient(HttpStatusCode statusCode)
=> Classify(statusCode) == SmsErrorClass.Transient;
/// <summary>
/// Convenience predicate: true when the exception classifies as
/// <see cref="SmsErrorClass.Transient"/>.
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <param name="cancellationToken">Cancellation token passed to <see cref="Classify(Exception, CancellationToken)"/>.</param>
/// <returns><c>true</c> when <see cref="Classify(Exception, CancellationToken)"/> returns <see cref="SmsErrorClass.Transient"/>.</returns>
public static bool IsTransient(Exception ex, CancellationToken cancellationToken)
=> Classify(ex, cancellationToken) == SmsErrorClass.Transient;
}
@@ -0,0 +1,370 @@
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);
}
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
@@ -15,6 +16,9 @@ public static class ServiceCollectionExtensions
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
public const string OptionsSection = "ScadaBridge:NotificationOutbox";
/// <summary>Configuration section bound to <see cref="SmsOptions"/>.</summary>
public const string SmsOptionsSection = "ScadaBridge:Sms";
/// <summary>
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
/// binding and the channel delivery adapters.
@@ -32,6 +36,13 @@ public static class ServiceCollectionExtensions
/// directly. The <see cref="NotificationOutboxActor"/> resolves the adapters from a fresh
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
/// the singleton actor.
///
/// The <see cref="SmsNotificationDeliveryAdapter"/> (Twilio-REST) is registered the same
/// way alongside the Email adapter, so both resolve from
/// <c>GetServices&lt;INotificationDeliveryAdapter&gt;()</c>. It also binds + validates
/// <see cref="SmsOptions"/> (<c>ScadaBridge:Sms</c>) and the named <c>"Twilio"</c>
/// <see cref="HttpClient"/>; unlike the Email adapter it needs no NotificationService
/// machinery (a single outbound HTTPS POST per recipient).
/// </summary>
/// <param name="services">The DI service collection to register notification outbox services into.</param>
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
@@ -42,13 +53,33 @@ public static class ServiceCollectionExtensions
services.AddOptions<NotificationOutboxOptions>()
.BindConfiguration(OptionsSection);
// Scoped: the adapter holds a scoped INotificationRepository. Registered both under
// the interface (so the dispatch sweep can enumerate every channel adapter) and as
// the concrete type (so callers and tests can resolve it directly).
// Bind + validate SmsOptions (ScadaBridge:Sms): the SMS adapter's body cap and
// per-request timeout fallback. ValidateOnStart surfaces a bad option at host
// startup rather than at first delivery; the validator is registered via
// TryAddEnumerable so a re-call of AddNotificationOutbox does not double-register it.
services.AddOptions<SmsOptions>()
.BindConfiguration(SmsOptionsSection)
.ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SmsOptions>, SmsOptionsValidator>());
// Named HTTP client backing the Twilio-REST SMS adapter. Pooled handler lifetime
// is managed by IHttpClientFactory; the adapter applies the per-request timeout
// via a linked CTS (so the client's own Timeout stays at its default).
services.AddHttpClient(SmsNotificationDeliveryAdapter.HttpClientName);
// Scoped: the adapters hold a scoped INotificationRepository. Each is registered
// both under the interface (so the dispatch sweep can enumerate every channel
// adapter — both Email and SMS must resolve from GetServices<INotificationDeliveryAdapter>())
// and as the concrete type (so callers and tests can resolve it directly).
services.AddScoped<EmailNotificationDeliveryAdapter>();
services.AddScoped<INotificationDeliveryAdapter>(
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
services.AddScoped<SmsNotificationDeliveryAdapter>();
services.AddScoped<INotificationDeliveryAdapter>(
sp => sp.GetRequiredService<SmsNotificationDeliveryAdapter>());
// KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
@@ -0,0 +1,68 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
/// <summary>
/// Configuration options for the SMS (Twilio-REST) notification delivery adapter,
/// bound from the <c>ScadaBridge:Sms</c> configuration section.
///
/// SMS connection settings are primarily carried by the deployed
/// <c>SmsConfiguration</c> entity. These values supply the documented fallbacks /
/// caps used by the central Notification Outbox's
/// <c>SmsNotificationDeliveryAdapter</c>: <see cref="MaxMessageLength"/> caps the
/// composed body, and <see cref="ConnectionTimeoutSeconds"/> is the per-request
/// timeout used when the deployed <c>SmsConfiguration.ConnectionTimeoutSeconds</c>
/// field is left unset (non-positive) — a value present on the row always takes
/// precedence.
/// </summary>
public sealed class SmsOptions
{
/// <summary>
/// Maximum length of the composed SMS body; longer bodies are truncated with an
/// ellipsis before the Twilio POST. Default 1600 (Twilio's per-message maximum).
/// Must be strictly positive.
/// </summary>
public int MaxMessageLength { get; set; } = 1600;
/// <summary>
/// Per-request connection/response timeout (seconds) used when the deployed
/// <c>SmsConfiguration.ConnectionTimeoutSeconds</c> is unset (non-positive).
/// Default 30s. Must be strictly positive.
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
}
/// <summary>
/// Validates <see cref="SmsOptions"/> on startup. A non-positive
/// <see cref="SmsOptions.MaxMessageLength"/> would truncate every body to nothing,
/// and a non-positive <see cref="SmsOptions.ConnectionTimeoutSeconds"/> would make
/// the per-request timeout meaningless, so both are required to be strictly positive.
/// </summary>
public sealed class SmsOptionsValidator : IValidateOptions<SmsOptions>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, SmsOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var failures = new List<string>();
if (options.MaxMessageLength <= 0)
{
failures.Add(
$"ScadaBridge:Sms:{nameof(SmsOptions.MaxMessageLength)} ({options.MaxMessageLength}) " +
"must be > 0; it caps the composed SMS body length.");
}
if (options.ConnectionTimeoutSeconds <= 0)
{
failures.Add(
$"ScadaBridge:Sms:{nameof(SmsOptions.ConnectionTimeoutSeconds)} ({options.ConnectionTimeoutSeconds}) " +
"must be > 0; it is the per-request Twilio timeout fallback.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
@@ -10,6 +10,9 @@
<ItemGroup>
<PackageReference Include="Akka" />
<PackageReference Include="Akka.Cluster.Tools" />
<!-- SMS delivery adapter (Twilio-REST) uses IHttpClientFactory / AddHttpClient.
Version comes from Directory.Packages.props (central package management). -->
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>