diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs index bbdc88b14..b2b891d13 100644 --- a/SlashCommandsExample/DiscordClient.cs +++ b/SlashCommandsExample/DiscordClient.cs @@ -126,6 +126,15 @@ namespace SlashCommandsExample }, new CommandRegistrationOptions(OldCommandOptions.DELETE_UNUSED,ExistingCommandOptions.OVERWRITE)); + // If you would like to register your commands manually use: + //-----------------------------------------// + // + // await _commands.BuildCommands(); + // + //-----------------------------------------// + // Though I wouldn't highly recommend it unless you want to do something very specific with them + // such as only registering some commands on only some guilds, or editing them manually. + await Task.Delay(-1); } diff --git a/SlashCommandsExample/Modules/DevModule.cs b/SlashCommandsExample/Modules/DevModule.cs index d2079573f..9d4949007 100644 --- a/SlashCommandsExample/Modules/DevModule.cs +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -31,8 +31,11 @@ namespace SlashCommandsExample.Modules [SlashCommand("overload","Just hit me with every type of data you got, man!")] public async Task OverloadAsync( - bool boolean, - int integer, + [ParameterName("var1")] + bool? boolean, + [ParameterName("var2")] + int? integer, + [ParameterName("var3")] string myString, SocketGuildChannel channel, SocketGuildUser user, diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs new file mode 100644 index 000000000..c277012bb --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + /// + /// An Attribute that gives the command parameter a custom name. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public class ParameterName : Attribute + { + /// + /// The name of this slash command parameter. + /// + public string name; + + /// + /// Tells the that this parameter has a custom name. + /// + /// The name of this slash command. + public ParameterName(string name) + { + this.name = name; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs index 0b43ac058..f2d8a5ce9 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs @@ -10,14 +10,22 @@ namespace Discord.SlashCommands { public class SlashParameterInfo : SlashCommandOptionBuilder { + public bool Nullable { get; internal set; } + public object Parse(SocketInteractionDataOption dataOption) { switch (Type) { case ApplicationCommandOptionType.Boolean: - return (bool)dataOption; + if (Nullable) + return (bool?)dataOption; + else + return (bool)dataOption; case ApplicationCommandOptionType.Integer: - return (int)dataOption; + if(Nullable) + return (int?)dataOption; + else + return (int)dataOption; case ApplicationCommandOptionType.String: return (string)dataOption; case ApplicationCommandOptionType.Channel: diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs index ce7721769..c227758a7 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -44,6 +44,7 @@ namespace Discord.SlashCommands SlashCommandInfo commandInfo; // Get the name of the actual command - be it a normal slash command or subcommand, and return the options we can give it. string name = GetSearchName(interaction.Data, out var resultingOptions); + // We still need to make sure it is registerd. if (commandDefs.TryGetValue(name, out commandInfo)) { // Then, set the context in which the command will be executed @@ -62,8 +63,16 @@ namespace Discord.SlashCommands /// /// /// - private string GetSearchName(SocketInteractionData interactionData, out IReadOnlyCollection resultingOptions) + public string GetSearchName(SocketInteractionData interactionData, out IReadOnlyCollection resultingOptions) { + // The names are stored as such: + // TOP//top-level-command-name + // TOP//command-group//command-group//sub-command-name + // What we are looking for is to get from the interaction the specific (sub)command and what we need to pass to the method. + // So we start the search at TOP//{interactionData.name} + // because we are going to go through each sub-option it has. If it is a subcommand/ command group then it's going to be + // inside the dictionary as TOP//{interactionData.name}//{option.name} + // If the option is a parameter we then know that we've reached the end of the call chain - this should be our coomand! string nameToSearch = SlashModuleInfo.RootCommandPrefix + interactionData.Name; var options = interactionData.Options; while(options != null && options.Count == 1) @@ -82,7 +91,9 @@ namespace Discord.SlashCommands resultingOptions = options; return nameToSearch; } - + /// + /// Test to see if any string key contains another string inside it. + /// private bool AnyKeyContains(Dictionary commandDefs, string newName) { foreach (var pair in commandDefs) @@ -101,7 +112,7 @@ namespace Discord.SlashCommands } /// - /// Registers all previously scanned commands. + /// Registers with discord all previously scanned commands. /// public async Task RegisterCommandsAsync(DiscordSocketClient socketClient, List guildIDs, CommandRegistrationOptions registrationOptions) { @@ -110,6 +121,7 @@ namespace Discord.SlashCommands try { + // Build and register all of the commands. await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this, guildIDs, registrationOptions).ConfigureAwait(false); } finally @@ -119,6 +131,27 @@ namespace Discord.SlashCommands await _logger.InfoAsync("All commands have been registered!").ConfigureAwait(false); } + /// + /// Build all the commands and return them, for manual registration with Discord. This is automatically done in + /// + /// A list of all the valid commands found within this Assembly. + public async Task> BuildCommands() + { + // First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business + await _moduleLock.WaitAsync().ConfigureAwait(false); + List result; + try + { + result = await SlashCommandServiceHelper.BuildCommands(moduleDefs).ConfigureAwait(false); + } + finally + { + _moduleLock.Release(); + } + await _logger.InfoAsync("All commands have been built!").ConfigureAwait(false); + return result; + } + /// /// Scans the program for Attribute-based SlashCommandModules /// diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs index e49846193..2239758dc 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -50,7 +50,7 @@ namespace Discord.SlashCommands public static async Task> InstantiateModules(IReadOnlyList types, SlashCommandService slashCommandService) { var result = new Dictionary(); - // Here we get all modules thate are NOT sub command groups + // Here we get all modules thate are NOT sub command groups and instantiate them. foreach (Type userModuleType in types) { SlashModuleInfo moduleInfo = new SlashModuleInfo(slashCommandService); @@ -61,13 +61,14 @@ namespace Discord.SlashCommands moduleInfo.SetCommandModule(instance); moduleInfo.isGlobal = IsCommandModuleGlobal(userModuleType); - moduleInfo.SetSubCommandGroups(InstantiateSubCommands(userModuleType, moduleInfo, slashCommandService)); + moduleInfo.SetSubCommandGroups(InstantiateSubModules(userModuleType, moduleInfo, slashCommandService)); result.Add(userModuleType, moduleInfo); } return result; } - public static List InstantiateSubCommands(Type rootModule,SlashModuleInfo rootModuleInfo, SlashCommandService slashCommandService) + public static List InstantiateSubModules(Type rootModule,SlashModuleInfo rootModuleInfo, SlashCommandService slashCommandService) { + // Instantiate all of the nested modules. List commandGroups = new List(); foreach(Type commandGroupType in rootModule.GetNestedTypes()) { @@ -83,7 +84,7 @@ namespace Discord.SlashCommands groupInfo.MakePath(); groupInfo.isGlobal = IsCommandModuleGlobal(commandGroupType); - groupInfo.SetSubCommandGroups(InstantiateSubCommands(commandGroupType, groupInfo, slashCommandService)); + groupInfo.SetSubCommandGroups(InstantiateSubModules(commandGroupType, groupInfo, slashCommandService)); commandGroups.Add(groupInfo); } } @@ -142,8 +143,10 @@ namespace Discord.SlashCommands SlashModuleInfo moduleInfo; if (moduleDefs.TryGetValue(userModule, out moduleInfo)) { + // Create the root-level commands var commandInfos = CreateSameLevelCommands(result, userModule, moduleInfo); moduleInfo.SetCommands(commandInfos); + // Then create all of the command groups it has. CreateSubCommandInfos(result, moduleInfo.commandGroups, slashCommandService); } } @@ -153,8 +156,11 @@ namespace Discord.SlashCommands { foreach (var subCommandGroup in subCommandGroups) { + // Create the commands that is on the same hierarchical level as this ... var commandInfos = CreateSameLevelCommands(result, subCommandGroup.moduleType.GetTypeInfo(), subCommandGroup); subCommandGroup.SetCommands(commandInfos); + + // ... and continue with the lower sub command groups. CreateSubCommandInfos(result, subCommandGroup.commandGroups, slashCommandService); } } @@ -164,6 +170,7 @@ namespace Discord.SlashCommands List commandInfos = new List(); foreach (var commandMethod in commandMethods) { + // Get the SlashCommand attribute SlashCommand slashCommand; if (IsValidSlashCommand(commandMethod, out slashCommand)) { @@ -210,6 +217,9 @@ namespace Discord.SlashCommands slashCommand = slashCommandAttributes.First() as SlashCommand; return true; } + /// + /// Determins if the method has a [Global] Attribute. + /// private static bool IsCommandGlobal(MethodInfo method) { // Verify that we only have one [Global] attribute @@ -225,6 +235,9 @@ namespace Discord.SlashCommands } return true; } + /// + /// Process the parameters of this method, including all the attributes. + /// private static List ConstructCommandParameters(MethodInfo method) { // Prepare the final list of parameters @@ -237,9 +250,15 @@ namespace Discord.SlashCommands { SlashParameterInfo newParameter = new SlashParameterInfo(); - // Set the parameter name to that of the method - // TODO: Implement an annotation that lets the user choose a custom name - newParameter.Name = methodParameter.Name; + // Test for the [ParameterName] Attribute. If we have it, then use that as the name, + // if not just use the parameter name as the option name. + var customNameAttributes = methodParameter.GetCustomAttributes(typeof(ParameterName)); + if (customNameAttributes.Count() == 0) + newParameter.Name = methodParameter.Name; + else if (customNameAttributes.Count() > 1) + throw new Exception($"Too many ParameterName attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!"); + else + newParameter.Name = (customNameAttributes.First() as ParameterName).name; // Get to see if it has a Description Attribute. // If it has @@ -254,19 +273,26 @@ namespace Discord.SlashCommands else newParameter.Description = (descriptions.First() as Description).description; - // And get the parameter type + // Set the Type of the parameter. + // In the case of int and int? it returns the same type - INTEGER. + // Same with bool and bool?. newParameter.Type = TypeFromMethodParameter(methodParameter); - // [Required] Parameter + // If we have a nullble type (int? or bool?) mark it as such. + newParameter.Nullable = GetNullableStatus(methodParameter); + + // Test for the [Required] Attribute var requiredAttributes = methodParameter.GetCustomAttributes(typeof(Required)); if (requiredAttributes.Count() == 1) newParameter.Required = true; else if (requiredAttributes.Count() > 1) throw new Exception($"Too many Required attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!"); - // [Choice] Parameter + // Test for the [Choice] Attribute + // A parameter cna have multiple Choice attributes, and for each we're going to add it's key-value pair. foreach (Choice choice in methodParameter.GetCustomAttributes(typeof(Choice))) { + // If the parameter expects a string but the value of the choice is of type int, then throw an error. if (newParameter.Type == ApplicationCommandOptionType.String) { if(String.IsNullOrEmpty(choice.choiceStringValue)) @@ -275,6 +301,7 @@ namespace Discord.SlashCommands } newParameter.AddChoice(choice.choiceName, choice.choiceStringValue); } + // If the parameter expects a int but the value of the choice is of type string, then throw an error. if (newParameter.Type == ApplicationCommandOptionType.Integer) { if (choice.choiceIntValue == null) @@ -295,11 +322,13 @@ namespace Discord.SlashCommands private static ApplicationCommandOptionType TypeFromMethodParameter(ParameterInfo methodParameter) { // Can't do switch -- who knows why? - if (methodParameter.ParameterType == typeof(int)) + if (methodParameter.ParameterType == typeof(int) || + methodParameter.ParameterType == typeof(int?)) return ApplicationCommandOptionType.Integer; if (methodParameter.ParameterType == typeof(string)) return ApplicationCommandOptionType.String; - if (methodParameter.ParameterType == typeof(bool)) + if (methodParameter.ParameterType == typeof(bool) || + methodParameter.ParameterType == typeof(bool?)) return ApplicationCommandOptionType.Boolean; if (methodParameter.ParameterType == typeof(SocketGuildChannel)) return ApplicationCommandOptionType.Channel; @@ -310,11 +339,25 @@ namespace Discord.SlashCommands throw new Exception($"Got parameter type other than int, string, bool, guild, role, or user. {methodParameter.Name}"); } + + /// + /// Gets whater the parameter can be set as null, in the case that parameter type usually does not allow null. + /// More specifically tests to see if it is a type of 'int?' or 'bool?', + /// + private static bool GetNullableStatus(ParameterInfo methodParameter) + { + if(methodParameter.ParameterType == typeof(int?) || + methodParameter.ParameterType == typeof(bool?)) + { + return true; + } + return false; + } /// /// Creae a delegate from methodInfo. Taken from /// https://stackoverflow.com/a/40579063/8455128 /// - public static Delegate CreateDelegate(MethodInfo methodInfo, object target) + private static Delegate CreateDelegate(MethodInfo methodInfo, object target) { Func getType; var isAction = methodInfo.ReturnType.Equals((typeof(void))); @@ -341,13 +384,10 @@ namespace Discord.SlashCommands public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary rootModuleInfos, Dictionary commandDefs, SlashCommandService slashCommandService, List guildIDs,CommandRegistrationOptions options) { // TODO: see how we should handle if user wants to register two commands with the same name, one global and one not. - List builtCommands = new List(); - foreach (var pair in rootModuleInfos) - { - var rootModuleInfo = pair.Value; - builtCommands.AddRange(rootModuleInfo.BuildCommands()); - } + // Build the commands + List builtCommands = await BuildCommands(rootModuleInfos).ConfigureAwait(false); + // Scan for each existing command on discord so we know what is already there. List existingGuildCommands = new List(); List existingGlobalCommands = new List(); existingGlobalCommands.AddRange(await socketClient.Rest.GetGlobalApplicationCommands().ConfigureAwait(false)); @@ -355,6 +395,9 @@ namespace Discord.SlashCommands { existingGuildCommands.AddRange(await socketClient.Rest.GetGuildApplicationCommands(guildID).ConfigureAwait(false)); } + + // If we want to keep the existing commands that are already registered + // remove the commands that share the same name from the builtCommands list as to not overwrite. if (options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING) { foreach (var existingCommand in existingGuildCommands) @@ -367,24 +410,26 @@ namespace Discord.SlashCommands } } + // If we want to delete commands that are not going to be re-implemented in builtCommands + // or if we just want a blank slate if (options.OldCommands == OldCommandOptions.DELETE_UNUSED || options.OldCommands == OldCommandOptions.WIPE) - { + { foreach (var existingCommand in existingGuildCommands) { - // If we want to wipe all commands + // If we want to wipe all GUILD commands // or if the existing command isn't re-defined and re-built // remove it from discord. if (options.OldCommands == OldCommandOptions.WIPE || // There are no commands which contain this existing command. - !builtCommands.Any( x => !x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name))) + !builtCommands.Any(x => !x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name))) { await existingCommand.DeleteAsync(); } } foreach (var existingCommand in existingGlobalCommands) { - // If we want to wipe all commands + // If we want to wipe all GLOBAL commands // or if the existing command isn't re-defined and re-built // remove it from discord. if (options.OldCommands == OldCommandOptions.WIPE || @@ -396,6 +441,8 @@ namespace Discord.SlashCommands } } + // And now register them. Globally if the 'Global' flag is set. + // If not then just register them as guild commands on all of the guilds given to us. foreach (var builtCommand in builtCommands) { if (builtCommand.Global) @@ -413,5 +460,19 @@ namespace Discord.SlashCommands return; } + /// + /// Build and return all of the commands this assembly contians. + /// + public static async Task> BuildCommands(Dictionary rootModuleInfos) + { + List builtCommands = new List(); + foreach (var pair in rootModuleInfos) + { + var rootModuleInfo = pair.Value; + builtCommands.AddRange(rootModuleInfo.BuildCommands()); + } + + return builtCommands; + } } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs index bd8324eb7..3a612388d 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs @@ -52,6 +52,21 @@ namespace Discord.WebSocket public static explicit operator string(SocketInteractionDataOption option) => option.Value.ToString(); + public static explicit operator bool?(SocketInteractionDataOption option) + { + if (option.Value == null) + return null; + else + return (bool)option; + } + public static explicit operator int?(SocketInteractionDataOption option) + { + if (option.Value == null) + return null; + else + return (int)option; + } + public static explicit operator SocketGuildChannel(SocketInteractionDataOption option) { if (ulong.TryParse((string)option.Value, out ulong id))