Initial commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using Twilio;
|
||||
using Twilio.Rest.Api.V2010.Account;
|
||||
using Twilio.Types;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public class SmsService : ISmsService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<SmsService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SmsService"/>. Twilio credentials are read
|
||||
/// per call (not cached here) because they may be rotated without restarting the application,
|
||||
/// and Twilio's client initialization (<c>TwilioClient.Init</c>) is idempotent and cheap.
|
||||
/// </summary>
|
||||
public SmsService(IConfiguration configuration, ILogger<SmsService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an SMS message via the Twilio REST API and returns a success/error tuple.
|
||||
/// The method short-circuits with a logged warning (not an exception) when Twilio is not
|
||||
/// configured, so callers in notification workflows are not broken in development
|
||||
/// environments where Twilio credentials are absent. Phone numbers are normalized via
|
||||
/// <see cref="NormalizePhone"/> before sending because users enter phone numbers in many
|
||||
/// formats (with dashes, parentheses, spaces, or a leading "1") and Twilio requires strict
|
||||
/// E.164 format. The method returns the Twilio message SID on success so callers can log
|
||||
/// it for delivery tracking. Carrier regulations (TCPA in the US) require that the first
|
||||
/// SMS to a new number include opt-out instructions ("Reply STOP to unsubscribe") — this
|
||||
/// is enforced in the message body by the calling controller or notification service before
|
||||
/// this method is invoked; <see cref="SmsService"/> itself is transport-only and does not
|
||||
/// inspect or modify the message body.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string? ErrorMessage)> SendSmsAsync(string toPhone, string message)
|
||||
{
|
||||
var accountSid = _configuration["Twilio:AccountSid"];
|
||||
var authToken = _configuration["Twilio:AuthToken"];
|
||||
var fromNumber = _configuration["Twilio:FromNumber"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(accountSid) || accountSid.StartsWith("your-") ||
|
||||
string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-") ||
|
||||
string.IsNullOrWhiteSpace(fromNumber) || fromNumber.Contains("XXXXXXXXXX"))
|
||||
{
|
||||
_logger.LogWarning("Twilio is not configured. SMS to {ToPhone} skipped.", toPhone);
|
||||
return (false, "Twilio not configured");
|
||||
}
|
||||
|
||||
var normalizedPhone = NormalizePhone(toPhone);
|
||||
if (string.IsNullOrEmpty(normalizedPhone))
|
||||
{
|
||||
_logger.LogWarning("Invalid phone number: {ToPhone}", toPhone);
|
||||
return (false, "Invalid phone number");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TwilioClient.Init(accountSid, authToken);
|
||||
|
||||
var smsMessage = await MessageResource.CreateAsync(
|
||||
to: new PhoneNumber(normalizedPhone),
|
||||
from: new PhoneNumber(fromNumber),
|
||||
body: message);
|
||||
|
||||
_logger.LogInformation("SMS sent to {ToPhone}: SID={Sid}", normalizedPhone, smsMessage.Sid);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception sending SMS to {ToPhone}", normalizedPhone);
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a phone number string to E.164 format (e.g., "+12025551234") required by
|
||||
/// Twilio. Strips all non-digit characters, then applies three rules in priority order:
|
||||
/// an 11-digit string starting with "1" is assumed to be a North American number with
|
||||
/// country code already included; a 10-digit string is assumed to be a US/Canada number
|
||||
/// and gets "+1" prepended; a string that already starts with "+" and has more than 10
|
||||
/// digits is treated as an international number and reformatted with "+". Numbers that
|
||||
/// do not match any pattern return null, causing <see cref="SendSmsAsync"/> to abort with
|
||||
/// a warning rather than sending to a malformed number (which would generate a Twilio 400
|
||||
/// error and count against the account's error rate).
|
||||
/// </summary>
|
||||
private static string? NormalizePhone(string phone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
return null;
|
||||
|
||||
// Strip everything except digits
|
||||
var digits = new string(phone.Where(char.IsDigit).ToArray());
|
||||
|
||||
// Already E.164 with country code
|
||||
if (digits.Length == 11 && digits[0] == '1')
|
||||
return "+" + digits;
|
||||
|
||||
// 10-digit US number — prepend +1
|
||||
if (digits.Length == 10)
|
||||
return "+1" + digits;
|
||||
|
||||
// Already formatted (e.g. +44...)
|
||||
if (phone.StartsWith("+") && digits.Length > 10)
|
||||
return "+" + digits;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user