using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class NotificationCommands
{
///
/// Builds the notification command group with sub-commands for managing notification lists and SMTP configuration.
///
/// Global --url option for the management API endpoint.
/// Global --format option for output format.
/// Global --username option for authentication.
/// Global --password option for authentication.
/// The configured notification command with all sub-commands registered.
public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var command = new Command("notification") { Description = "Manage notification lists" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRename(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRecipient(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Option RequiredId(string name, string description)
{
var option = new Option(name) { Description = description, Required = true };
option.Validators.Add(result => { if (result.GetValueOrDefault() <= 0) result.AddError($"{name} must be positive."); });
return option;
}
private static Command BuildRename(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var cmd = new Command("rename") { Description = "Rename a list, preserving all recipients and their IDs" };
var id = RequiredId("--id", "Notification list ID");
var name = new Option("--name") { Required = true, Description = "New list name" };
cmd.Add(id); cmd.Add(name);
cmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new RenameNotificationListCommand(result.GetValue(id), result.GetValue(name)!)));
return cmd;
}
private static Command BuildRecipient(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var group = new Command("recipient") { Description = "Manage individual named recipients without replacing the list" };
var add = new Command("add") { Description = "Add a named recipient (address must match the list delivery channel)" };
var listId = RequiredId("--list-id", "Notification list ID");
var name = new Option("--name") { Required = true, Description = "Recipient name" };
var email = new Option("--email") { Description = "Email address" };
var phone = new Option("--phone") { Description = "Phone number in E.164 format" };
add.Add(listId); add.Add(name); add.Add(email); add.Add(phone);
add.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(email)) && string.IsNullOrWhiteSpace(result.GetValue(phone)))
result.AddError("Provide --email and/or --phone for the recipient.");
});
add.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new AddNotificationRecipientCommand(result.GetValue(listId), result.GetValue(name)!, result.GetValue(email), result.GetValue(phone))));
var delete = new Command("delete") { Description = "Delete one recipient by its recipient ID" };
var id = RequiredId("--id", "Recipient ID");
delete.Add(id);
delete.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteNotificationRecipientCommand(result.GetValue(id))));
group.Add(add); group.Add(delete);
return group;
}
// ------------------------------------------------------------------------
// Notification list create/update options (static so the parsed values can
// be read back both from the SetAction and from the testable Build* helpers).
// ------------------------------------------------------------------------
private static readonly Option ListCreateNameOption =
new("--name") { Description = "Notification list name", Required = true };
private static readonly Option ListCreateEmailsOption =
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
private static readonly Option ListCreatePhonesOption =
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
private static readonly Option ListCreateTypeOption = CreateListTypeOption();
private static readonly Option ListUpdateIdOption =
new("--id") { Description = "Notification list ID", Required = true };
private static readonly Option ListUpdateNameOption =
new("--name") { Description = "List name", Required = true };
private static readonly Option ListUpdateEmailsOption =
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
private static readonly Option ListUpdatePhonesOption =
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
private static readonly Option ListUpdateTypeOption = CreateListTypeOption();
private static Option CreateListTypeOption()
{
var option = new Option("--type")
{
Description = "Delivery channel: email or sms (case-insensitive; default email)",
DefaultValueFactory = _ => "email",
};
return option;
}
private static Command BuildGet(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var idOption = new Option("--id") { Description = "Notification list ID", Required = true };
var cmd = new Command("get") { Description = "Get a notification list by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetNotificationListCommand(id));
});
return cmd;
}
private static Command BuildUpdate(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var cmd = new Command("update") { Description = "Update a notification list" };
cmd.Add(ListUpdateIdOption);
cmd.Add(ListUpdateNameOption);
cmd.Add(ListUpdateTypeOption);
cmd.Add(ListUpdateEmailsOption);
cmd.Add(ListUpdatePhonesOption);
cmd.SetAction(async (ParseResult result) =>
{
UpdateNotificationListCommand command;
try
{
command = BuildUpdateNotificationListCommand(result);
}
catch (ArgumentException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
});
return cmd;
}
///
/// Builds the from a parsed
/// notification list update invocation, applying the channel-aware
/// recipient validation. Throws when the
/// channel and the supplied recipient flags are inconsistent.
///
/// The parsed command-line result.
/// A validated .
internal static UpdateNotificationListCommand BuildUpdateNotificationListCommand(ParseResult result)
{
var id = result.GetValue(ListUpdateIdOption);
var name = result.GetValue(ListUpdateNameOption)!;
var (type, emails, phones) = ResolveListChannel(
result.GetValue(ListUpdateTypeOption)!,
result.GetValue(ListUpdateEmailsOption),
result.GetValue(ListUpdatePhonesOption));
return new UpdateNotificationListCommand(id, name, emails, type, phones);
}
private static Command BuildSmtp(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
var listCmd = new Command("list") { Description = "List SMTP configurations" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmtpConfigsCommand());
});
group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMTP configuration" };
foreach (var option in new Option[] { SmtpServerOption, SmtpPortOption, SmtpAuthModeOption, SmtpFromOption, SmtpTlsModeOption,
SmtpCredentialsOption, SmtpOAuth2AuthorityOption, SmtpOAuth2ScopeOption, SmtpTransportOption }) createCmd.Add(option);
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmtpConfigCommand(result.GetValue(SmtpServerOption)!, result.GetValue(SmtpPortOption), result.GetValue(SmtpAuthModeOption)!,
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption), result.GetValue(SmtpCredentialsOption),
result.GetValue(SmtpOAuth2AuthorityOption), result.GetValue(SmtpOAuth2ScopeOption), result.GetValue(SmtpTransportOption))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
updateCmd.Add(SmtpIdOption);
updateCmd.Add(SmtpServerOption);
updateCmd.Add(SmtpPortOption);
updateCmd.Add(SmtpAuthModeOption);
updateCmd.Add(SmtpFromOption);
updateCmd.Add(SmtpTlsModeOption);
updateCmd.Add(SmtpCredentialsOption);
updateCmd.Add(SmtpOAuth2AuthorityOption);
updateCmd.Add(SmtpOAuth2ScopeOption);
updateCmd.Add(SmtpTransportOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildUpdateSmtpConfigCommand(result));
});
group.Add(updateCmd);
return group;
}
// SMTP update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmtpConfigCommand (used by tests).
private static readonly Option SmtpIdOption =
new("--id") { Description = "SMTP config ID", Required = true };
private static readonly Option SmtpServerOption =
new("--server") { Description = "SMTP server", Required = true };
private static readonly Option SmtpPortOption =
new("--port") { Description = "SMTP port", Required = true };
private static readonly Option SmtpAuthModeOption =
new("--auth-mode") { Description = "Auth mode", Required = true };
private static readonly Option SmtpFromOption =
new("--from-address") { Description = "From email address", Required = true };
private static readonly Option SmtpTlsModeOption = CreateTlsModeOption();
private static readonly Option SmtpCredentialsOption =
new("--credentials")
{
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
"for OAuth2 (optional; preserves existing if omitted)",
};
private static readonly Option SmtpOAuth2AuthorityOption =
new("--oauth2-authority")
{
Description = "OAuth2 token-endpoint URL (optional; a '{tenant}' placeholder is " +
"substituted from the credential; preserves existing / M365 default if omitted)",
};
private static readonly Option SmtpOAuth2ScopeOption =
new("--oauth2-scope")
{
Description = "OAuth2 scope requested from the token endpoint " +
"(optional; preserves existing / M365 default if omitted)",
};
private static readonly Option SmtpTransportOption = CreateTransportOption();
private static Option CreateTransportOption()
{
var option = new Option("--transport")
{
Description = "Email transport: Smtp or Ews (optional; preserves existing if omitted). " +
"Under Ews, --server is the full EWS URL (https://…/ews/exchange.asmx), " +
"--auth-mode must be basic, and --port/--tls-mode are unused.",
};
option.AcceptOnlyFromAmong("Smtp", "Ews");
return option;
}
private static Option CreateTlsModeOption()
{
var option = new Option("--tls-mode")
{
Description = "TLS mode: None, StartTLS, or SSL (optional; preserves existing if omitted)",
};
option.AcceptOnlyFromAmong("None", "StartTLS", "SSL");
return option;
}
///
/// Builds the from a parsed smtp update
/// invocation. The optional --tls-mode / --credentials /
/// --oauth2-authority / --oauth2-scope / --transport flags map to
/// null when omitted so the server-side handler preserves the existing values.
///
/// The parsed command-line result from the smtp update invocation.
/// An populated from the parsed result.
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
{
var id = result.GetValue(SmtpIdOption);
var server = result.GetValue(SmtpServerOption)!;
var port = result.GetValue(SmtpPortOption);
var authMode = result.GetValue(SmtpAuthModeOption)!;
var from = result.GetValue(SmtpFromOption)!;
var tlsMode = result.GetValue(SmtpTlsModeOption);
var credentials = result.GetValue(SmtpCredentialsOption);
var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption);
var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption);
var transport = result.GetValue(SmtpTransportOption);
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials, oauth2Authority, oauth2Scope, transport);
}
private static Command BuildSms(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var group = new Command("sms") { Description = "Manage SMS (Twilio) configuration" };
var listCmd = new Command("list") { Description = "List SMS configurations (Auth Token shown as a presence flag only)" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmsConfigsCommand());
});
group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMS configuration (Auth Token required)" };
foreach (var option in new Option[] { SmsAccountSidOption, SmsFromNumberOption, SmsMessagingServiceSidOption, SmsApiBaseUrlOption,
SmsAuthTokenOption, SmsTimeoutOption, SmsMaxRetriesOption, SmsRetryDelayOption }) createCmd.Add(option);
createCmd.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption))) result.AddError("--auth-token is required when creating SMS configuration.");
if (string.IsNullOrWhiteSpace(result.GetValue(SmsFromNumberOption)) && string.IsNullOrWhiteSpace(result.GetValue(SmsMessagingServiceSidOption)))
result.AddError("Provide --from-number and/or --messaging-service-sid.");
});
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmsConfigCommand(result.GetValue(SmsAccountSidOption)!, result.GetValue(SmsFromNumberOption), result.GetValue(SmsMessagingServiceSidOption),
result.GetValue(SmsApiBaseUrlOption), result.GetValue(SmsAuthTokenOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMS configuration" };
updateCmd.Add(SmsIdOption);
updateCmd.Add(SmsAccountSidOption);
updateCmd.Add(SmsFromNumberOption);
updateCmd.Add(SmsMessagingServiceSidOption);
updateCmd.Add(SmsApiBaseUrlOption);
updateCmd.Add(SmsAuthTokenOption);
updateCmd.Add(SmsTimeoutOption);
updateCmd.Add(SmsMaxRetriesOption);
updateCmd.Add(SmsRetryDelayOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildUpdateSmsConfigCommand(result));
});
group.Add(updateCmd);
return group;
}
private static readonly Option SmsTimeoutOption = SmsPolicyOption("--connection-timeout-seconds", "Connection timeout in seconds", 1);
private static readonly Option SmsMaxRetriesOption = SmsPolicyOption("--max-retries", "Retry count (zero uses the outbox fallback)", 0);
private static readonly Option SmsRetryDelayOption = SmsPolicyOption("--retry-delay-seconds", "Retry delay in seconds (zero uses the outbox fallback)", 0);
private static Option SmsPolicyOption(string name, string description, int minimum)
{
var option = new Option(name) { Description = description + "; omission preserves updates or uses create defaults" };
option.Validators.Add(result =>
{
if (result.GetValueOrDefault() is { } value && value < minimum)
result.AddError($"{name} must be at least {minimum}.");
});
return option;
}
private static TimeSpan? SmsRetryDelay(ParseResult result) =>
result.GetValue(SmsRetryDelayOption) is { } seconds ? TimeSpan.FromSeconds(seconds) : null;
// SMS update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmsConfigCommand (used by tests).
private static readonly Option SmsIdOption =
new("--id") { Description = "SMS config ID", Required = true };
private static readonly Option SmsAccountSidOption =
new("--account-sid") { Description = "Twilio Account SID", Required = true };
private static readonly Option SmsFromNumberOption =
new("--from-number")
{
Description = "Sender phone number (E.164). Provide this and/or --messaging-service-sid.",
};
private static readonly Option SmsMessagingServiceSidOption =
new("--messaging-service-sid")
{
Description = "Twilio Messaging Service SID (optional; OMITTING IT CLEARS the stored value)",
};
private static readonly Option SmsApiBaseUrlOption =
new("--api-base-url")
{
Description = "API base URL override (optional; preserves existing if omitted)",
};
private static readonly Option SmsAuthTokenOption =
new("--auth-token")
{
Description = "Twilio Auth Token (optional; PRESERVES the stored token if omitted; never printed back)",
};
///
/// Builds the from a parsed sms update
/// invocation. Note the asymmetric preserve-vs-clear semantics enforced server-side:
/// omitting --auth-token / --api-base-url maps to null so the handler
/// PRESERVES the existing values, whereas omitting --messaging-service-sid also
/// maps to null but the handler OVERWRITES (clears) the stored value.
///
/// The parsed command-line result from the sms update invocation.
/// An populated from the parsed result.
internal static UpdateSmsConfigCommand BuildUpdateSmsConfigCommand(ParseResult result)
{
var id = result.GetValue(SmsIdOption);
var accountSid = result.GetValue(SmsAccountSidOption)!;
var fromNumber = result.GetValue(SmsFromNumberOption);
var messagingServiceSid = result.GetValue(SmsMessagingServiceSidOption);
var apiBaseUrl = result.GetValue(SmsApiBaseUrlOption);
var authToken = result.GetValue(SmsAuthTokenOption);
// Whole-replace update: the sender identity must be re-supplied. A Twilio config
// needs a From number and/or a Messaging Service SID, so reject the case where the
// update would leave neither (mirrors the UI + delivery-adapter validation).
if (string.IsNullOrWhiteSpace(fromNumber) && string.IsNullOrWhiteSpace(messagingServiceSid))
{
throw new ArgumentException(
"Provide --from-number and/or --messaging-service-sid (at least one sender identity is required).");
}
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken,
result.GetValue(SmsTimeoutOption), result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result));
}
private static Command BuildList(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var cmd = new Command("list") { Description = "List all notification lists" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListNotificationListsCommand());
});
return cmd;
}
private static Command BuildCreate(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var cmd = new Command("create") { Description = "Create a notification list" };
cmd.Add(ListCreateNameOption);
cmd.Add(ListCreateTypeOption);
cmd.Add(ListCreateEmailsOption);
cmd.Add(ListCreatePhonesOption);
cmd.SetAction(async (ParseResult result) =>
{
CreateNotificationListCommand command;
try
{
command = BuildCreateNotificationListCommand(result);
}
catch (ArgumentException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
});
return cmd;
}
///
/// Builds the from a parsed
/// notification list create invocation, applying the channel-aware
/// recipient validation. Throws when the
/// channel and the supplied recipient flags are inconsistent.
///
/// The parsed command-line result.
/// A validated .
internal static CreateNotificationListCommand BuildCreateNotificationListCommand(ParseResult result)
{
var name = result.GetValue(ListCreateNameOption)!;
var (type, emails, phones) = ResolveListChannel(
result.GetValue(ListCreateTypeOption)!,
result.GetValue(ListCreateEmailsOption),
result.GetValue(ListCreatePhonesOption));
return new CreateNotificationListCommand(name, emails, type, phones);
}
///
/// Parses the --type value (case-insensitive) and validates that the supplied
/// recipient flags match the channel: email requires --emails and rejects
/// --phones; sms requires --phones and rejects --emails.
///
/// The raw --type value.
/// The raw --emails value, or null when omitted.
/// The raw --phones value, or null when omitted.
/// The resolved channel and recipient lists (the off-channel list is empty).
/// Thrown when the type is unknown or the recipient flags are inconsistent with the channel.
private static (NotificationType Type, IReadOnlyList Emails, IReadOnlyList? Phones) ResolveListChannel(
string typeRaw, string? emailsRaw, string? phonesRaw)
{
if (!Enum.TryParse(typeRaw, ignoreCase: true, out var type))
{
throw new ArgumentException($"Invalid --type '{typeRaw}'. Expected 'email' or 'sms'.");
}
var emails = SplitRecipients(emailsRaw);
var phones = SplitRecipients(phonesRaw);
if (type == NotificationType.Email)
{
if (phones.Count > 0)
{
throw new ArgumentException("--phones is not valid for --type email; use --emails.");
}
if (emails.Count == 0)
{
throw new ArgumentException("--emails is required for --type email.");
}
return (type, emails, null);
}
// Sms
if (emails.Count > 0)
{
throw new ArgumentException("--emails is not valid for --type sms; use --phones.");
}
if (phones.Count == 0)
{
throw new ArgumentException("--phones is required for --type sms.");
}
return (type, Array.Empty(), phones);
}
private static IReadOnlyList SplitRecipients(string? raw) =>
string.IsNullOrWhiteSpace(raw)
? Array.Empty()
: raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
private static Command BuildDelete(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
var idOption = new Option("--id") { Description = "Notification list ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a notification list" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteNotificationListCommand(id));
});
return cmd;
}
}