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