diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs index 7137da37d..b2b891d13 100644 --- a/SlashCommandsExample/DiscordClient.cs +++ b/SlashCommandsExample/DiscordClient.cs @@ -4,6 +4,7 @@ using Discord.SlashCommands; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; @@ -28,6 +29,8 @@ namespace SlashCommandsExample socketClient.Log += SocketClient_Log; _commands.Log += SocketClient_Log; socketClient.InteractionCreated += InteractionHandler; + socketClient.Ready += RegisterCommand; + // This is for dev purposes. // To avoid the situation in which you accidentally push your bot token to upstream, you can use // EnviromentVariables to store your key. @@ -45,12 +48,92 @@ namespace SlashCommandsExample // EnvironmentVariableTarget.User); } + public async Task RegisterCommand() + { + // Use this to manually register a command for testing. + return; + await socketClient.Rest.CreateGuildCommand(new SlashCommandCreationProperties() + { + Name = "root", + Description = "Root Command", + Options = new List() + { + new ApplicationCommandOptionProperties() + { + Name = "usr", + Description = "User Folder", + Type = ApplicationCommandOptionType.SubCommandGroup, + Options = new List() + { + // This doesn't work. This is good! + //new ApplicationCommandOptionProperties() + //{ + // Name = "strstr", + // Description = "Some random string I guess.", + // Type = ApplicationCommandOptionType.String, + //}, + new ApplicationCommandOptionProperties() + { + Name = "zero", + Description = "Zero's Home Folder - COMMAND", + Type = ApplicationCommandOptionType.SubCommand, + Options = new List() + { + new ApplicationCommandOptionProperties() + { + Name = "file", + Description = "the file you want accessed.", + Type = ApplicationCommandOptionType.String + } + } + }, + new ApplicationCommandOptionProperties() + { + Name = "johhny", + Description = "Johnny Test's Home Folder - COMMAND", + Type = ApplicationCommandOptionType.SubCommand, + Options = new List() + { + new ApplicationCommandOptionProperties() + { + Name = "file", + Description = "the file you want accessed.", + Type = ApplicationCommandOptionType.String + } + } + } + } + }, + new ApplicationCommandOptionProperties() + { + Name = "random", + Description = "Random things", + Type = ApplicationCommandOptionType.SubCommand + } + } + }, 386658607338618891) ; + } + public async Task RunAsync() { await socketClient.LoginAsync(TokenType.Bot, botToken); await socketClient.StartAsync(); await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + await _commands.RegisterCommandsAsync(socketClient, new List() + { + 386658607338618891 + }, + 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 new file mode 100644 index 000000000..9d4949007 --- /dev/null +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -0,0 +1,104 @@ +using Discord.Commands; +using Discord.Commands.SlashCommands.Types; +using Discord.SlashCommands; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace SlashCommandsExample.Modules +{ + // You can make the whole module Global + //[Global] + public class DevModule : SlashCommandModule + { + [SlashCommand("ping", "Ping the bot to see if it's alive!")] + [Global] + public async Task PingAsync() + { + await Reply(":white_check_mark: **Bot Online**"); + } + + [SlashCommand("echo", "I'll repeate everything you said to me, word for word.")] + public async Task EchoAsync( + [Description("The message you want repetead")] + [Required] + string message) + { + await Reply($"{Interaction.Member?.Nickname ?? Interaction.Member?.Username} told me to say this: \r\n{message}"); + } + + [SlashCommand("overload","Just hit me with every type of data you got, man!")] + public async Task OverloadAsync( + [ParameterName("var1")] + bool? boolean, + [ParameterName("var2")] + int? integer, + [ParameterName("var3")] + string myString, + SocketGuildChannel channel, + SocketGuildUser user, + SocketRole role + ) + { + await Reply($"You gave me:\r\n {boolean}, {integer}, {myString}, <#{channel?.Id}>, {user?.Mention}, {role?.Mention}"); + + } + + [SlashCommand("stats","Get the stats from Game(tm) for players or teams.")] + public async Task GetStatsAsync( + [Required] + [Choice("XBOX","xbox")] + [Choice("PlayStation","ps")] + [Choice("PC","pc")] + string platform, + [Choice("Player",1)] + [Choice("Team",2)] + int searchType + ) + { + await Reply($"Well I got this: {platform}, {searchType}"); + } + + [CommandGroup("root")] + //[Global] + public class DevModule_Root : SlashCommandModule + { + [SlashCommand("rng", "Gives you a random number from this \"machine\"")] + public async Task RNGAsync() + { + var rand = new Random(); + await Reply(rand.Next(0, 101).ToString()); + } + + [CommandGroup("usr")] + public class DevModule_Root_Usr : SlashCommandModule + { + [SlashCommand("zero", "Gives you a file from user zero from this \"machine\"")] + public async Task ZeroAsync([Description("The file you want.")] string file) + { + await Reply($"You don't have permissiont to access {file} from user \"zero\"."); + } + [SlashCommand("johnny", "Gives you a file from user Johnny Test from this \"machine\"")] + public async Task JohnnyAsync([Description("The file you want.")] string file) + { + await Reply($"You don't have permissiont to access {file} from user \"johnny\"."); + } + } + } + } +} +/* +The base way of defining a command using the regular command service: + +public class PingModule : ModuleBase +{ + [Command("ping")] + [Summary("Pong! Check if the bot is alive.")] + public async Task PingAsync() + { + await ReplyAsync(":white_check_mark: **Bot Online**"); + } +} +*/ diff --git a/SlashCommandsExample/Modules/PingCommand.cs b/SlashCommandsExample/Modules/PingCommand.cs deleted file mode 100644 index 7df135afd..000000000 --- a/SlashCommandsExample/Modules/PingCommand.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Discord.Commands; -using Discord.Commands.SlashCommands.Types; -using Discord.SlashCommands; -using Discord.WebSocket; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading.Tasks; - -namespace SlashCommandsExample.Modules -{ - public class PingCommand : SlashCommandModule - { - [SlashCommand("johnny-test", "Ping the bot to see if it is alive!")] - public async Task PingAsync() - { - await Interaction.FollowupAsync(":white_check_mark: **Bot Online**"); - } - } -} -/* -The base way of defining a command using the regular command service: - -public class PingModule : ModuleBase -{ - [Command("ping")] - [Summary("Pong! Check if the bot is alive.")] - public async Task PingAsync() - { - await ReplyAsync(":white_check_mark: **Bot Online**"); - } -} -*/ diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/Choice.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/Choice.cs new file mode 100644 index 000000000..a71c16b23 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/Choice.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + /// + /// Defines the parameter as a choice. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true)] + public class Choice : Attribute + { + /// + /// The internal value of this choice. + /// + public string choiceStringValue; + + /// + /// The internal value of this choice. + /// + public int? choiceIntValue = null; + + /// + /// The display value of this choice. + /// + public string choiceName; + + public Choice(string choiceName, string choiceValue) + { + this.choiceName = choiceName; + this.choiceStringValue = choiceValue; + } + public Choice(string choiceName, int choiceValue) + { + this.choiceName = choiceName; + this.choiceIntValue = choiceValue; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/CommandGroup.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/CommandGroup.cs new file mode 100644 index 000000000..8555862b5 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/CommandGroup.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + /// + /// Defines the current as being a group of slash commands. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class CommandGroup : Attribute + { + /// + /// The name of this slash command. + /// + public string groupName; + + /// + /// The description of this slash command. + /// + public string description; + + /// + /// Tells the that this class/function is a slash command. + /// + /// The name of this slash command. + public CommandGroup(string groupName, string description = "No description.") + { + this.groupName = groupName.ToLower(); + this.description = description; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/Description.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/Description.cs new file mode 100644 index 000000000..e6fffc1cd --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/Description.cs @@ -0,0 +1,31 @@ +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 description. + /// + [AttributeUsage(AttributeTargets.Parameter , AllowMultiple = false)] + public class Description : Attribute + { + public static string DefaultDescription = "No description."; + /// + /// The description of this slash command parameter. + /// + public string description; + + /// + /// Tells the that this parameter has a description. + /// + /// The name of this slash command. + public Description(string description) + { + this.description = description; + } + } + +} diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/Global.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/Global.cs new file mode 100644 index 000000000..7fd35e898 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/Global.cs @@ -0,0 +1,16 @@ +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 description. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] + public class Global : Attribute + { + } +} 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/Attributes/Required.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/Required.cs new file mode 100644 index 000000000..4cbd5b2f8 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/Required.cs @@ -0,0 +1,16 @@ +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 description. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public class Required : Attribute + { + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs index 3e7110aac..d8adf797f 100644 --- a/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs @@ -9,7 +9,7 @@ namespace Discord.SlashCommands /// /// Defines the current class or function as a slash command. /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class SlashCommand : Attribute { /// @@ -28,7 +28,7 @@ namespace Discord.SlashCommands /// The name of this slash command. public SlashCommand(string commandName, string description = "No description.") { - this.commandName = commandName; + this.commandName = commandName.ToLower(); this.description = description; } } diff --git a/src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs b/src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs similarity index 96% rename from src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs rename to src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs index 6087825b4..e9c0384bc 100644 --- a/src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs +++ b/src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs @@ -377,8 +377,8 @@ namespace Discord.Commands.Builders { bool isSubType = this.Type == ApplicationCommandOptionType.SubCommand || this.Type == ApplicationCommandOptionType.SubCommandGroup; - if (isSubType && (Options == null || !Options.Any())) - throw new ArgumentException(nameof(Options), "SubCommands/SubCommandGroups must have at least one option"); + if (this.Type == ApplicationCommandOptionType.SubCommandGroup && (Options == null || !Options.Any())) + throw new ArgumentException(nameof(Options), "SubCommandGroups must have at least one option"); if (!isSubType && (Options != null && Options.Any())) throw new ArgumentException(nameof(Options), $"Cannot have options on {Type} type"); @@ -448,20 +448,9 @@ namespace Discord.Commands.Builders return this; } - public SlashCommandOptionBuilder WithName(string Name, int Value) + public SlashCommandOptionBuilder WithName(string Name) { - if (Choices == null) - Choices = new List(); - - if (Choices.Count >= MaxChoiceCount) - throw new ArgumentOutOfRangeException(nameof(Choices), $"Cannot add more than {MaxChoiceCount} choices!"); - - Choices.Add(new ApplicationCommandOptionChoiceProperties() - { - Name = Name, - Value = Value - }); - + this.Name = Name; return this; } diff --git a/src/Discord.Net.Commands/SlashCommands/CommandRegistration/CommandRegistrationOptions.cs b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/CommandRegistrationOptions.cs new file mode 100644 index 000000000..a5f61237c --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/CommandRegistrationOptions.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + /// + /// The options that should be kept in mind when registering the slash commands to discord. + /// + public class CommandRegistrationOptions + { + /// + /// The options that should be kept in mind when registering the slash commands to discord. + /// + /// What to do with the old commands that are already registered with discord + /// What to do with the old commands (if they weren't wiped) that we re-define. + public CommandRegistrationOptions(OldCommandOptions oldCommands, ExistingCommandOptions existingCommands) + { + OldCommands = oldCommands; + ExistingCommands = existingCommands; + } + /// + /// What to do with the old commands that are already registered with discord + /// + public OldCommandOptions OldCommands { get; set; } + /// + /// What to do with the old commands (if they weren't wiped) that we re-define. + /// + public ExistingCommandOptions ExistingCommands { get; set; } + + /// + /// The default, and reccomended options - Keep the old commands, and overwrite existing commands we re-defined. + /// + public static CommandRegistrationOptions Default => + new CommandRegistrationOptions(OldCommandOptions.KEEP, ExistingCommandOptions.OVERWRITE); + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs new file mode 100644 index 000000000..fd6b6c522 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + public enum ExistingCommandOptions + { + OVERWRITE, + KEEP_EXISTING + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/CommandRegistration/OldCommandOptions.cs b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/OldCommandOptions.cs new file mode 100644 index 000000000..603e0a288 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/OldCommandOptions.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + public enum OldCommandOptions + { + /// + /// Keep the old commands intact - do nothing to them. + /// + KEEP, + /// + /// Delete the old commands that we won't be re-defined this time around. + /// + DELETE_UNUSED, + /// + /// Delete everything discord has. + /// + WIPE + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs index 710d85d41..f5542df52 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs @@ -1,4 +1,6 @@ using Discord.Commands; +using Discord.Commands.Builders; +using Discord.WebSocket; using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +23,12 @@ namespace Discord.SlashCommands /// Gets the name of the command. /// public string Description { get; } + /// + /// The parameters we are expecting - an extension of SlashCommandOptionBuilder + /// + public List Parameters { get; } + public bool isGlobal { get; } /// /// The user method as a delegate. We need to use Delegate because there is an unknown number of parameters /// @@ -31,12 +38,14 @@ namespace Discord.SlashCommands /// public Func> callback; - public SlashCommandInfo(SlashModuleInfo module, string name, string description, Delegate userMethod) + public SlashCommandInfo(SlashModuleInfo module, string name, string description,List parameters , Delegate userMethod , bool isGlobal = false) { Module = module; Name = name; Description = description; + Parameters = parameters; this.userMethod = userMethod; + this.isGlobal = isGlobal; this.callback = new Func>(async (args) => { // Try-catch it and see what we get - error or success @@ -56,9 +65,96 @@ namespace Discord.SlashCommands }); } - public async Task ExecuteAsync(object[] args) + /// + /// Execute the function based on the interaction data we get. + /// + /// Interaction data from interaction + public async Task ExecuteAsync(IReadOnlyCollection data) + { + // List of arguments to be passed to the Delegate + List args = new List(); + try + { + foreach (var parameter in Parameters) + { + // For each parameter to try find its coresponding DataOption based on the name. + + // !!! names from `data` will always be lowercase regardless if we defined the command with any + // number of upercase letters !!! + if (TryGetInteractionDataOption(data, parameter.Name, out SocketInteractionDataOption dataOption)) + { + // Parse the dataOption to one corresponding argument type, and then add it to the list of arguments + args.Add(parameter.Parse(dataOption)); + } + else + { + // There was no input from the user on this field. + args.Add(null); + } + } + } + catch(Exception e) + { + return ExecuteResult.FromError(e); + } + + return await callback.Invoke(args.ToArray()).ConfigureAwait(false); + } + /// + /// Get the interaction data from the name of the parameter we want to fill in. + /// + private bool TryGetInteractionDataOption(IReadOnlyCollection data, string name, out SocketInteractionDataOption dataOption) + { + if (data == null) + { + dataOption = null; + return false; + } + foreach (var option in data) + { + if (option.Name == name.ToLower()) + { + dataOption = option; + return true; + } + } + dataOption = null; + return false; + } + + /// + /// Build the command and put it in a state in which we can use to define it to Discord. + /// + public SlashCommandCreationProperties BuildCommand() { - return await callback.Invoke(args).ConfigureAwait(false); + SlashCommandBuilder builder = new SlashCommandBuilder(); + builder.WithName(Name); + builder.WithDescription(Description); + builder.Options = new List(); + foreach (var parameter in Parameters) + { + builder.AddOptions(parameter); + } + + return builder.Build(); + } + + /// + /// Build the command AS A SUBCOMMAND and put it in a state in which we can use to define it to Discord. + /// + public SlashCommandOptionBuilder BuildSubCommand() + { + SlashCommandOptionBuilder builder = new SlashCommandOptionBuilder(); + builder.WithName(Name); + builder.WithDescription(Description); + builder.WithType(ApplicationCommandOptionType.SubCommand); + builder.Options = new List(); + foreach (var parameter in Parameters) + { + builder.AddOption(parameter); + } + + return builder; } } } diff --git a/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs index baf4010f4..b62e11122 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs @@ -1,6 +1,10 @@ +using Discord.Commands; +using Discord.Commands.Builders; +using Discord.WebSocket; using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text; using System.Threading.Tasks; @@ -8,11 +12,23 @@ namespace Discord.SlashCommands { public class SlashModuleInfo { + public const string PathSeperator = "//"; + public const string RootModuleName = "TOP"; + public const string RootCommandPrefix = RootModuleName + PathSeperator; + public SlashModuleInfo(SlashCommandService service) { Service = service; } + public bool isCommandGroup { get; set; } = false; + public CommandGroup commandGroupInfo { get; set; } + + public SlashModuleInfo parent { get; set; } + public List commandGroups { get; set; } + public string Path { get; set; } = RootModuleName; + + public bool isGlobal { get; set; } = false; /// /// Gets the command service associated with this module. /// @@ -27,7 +43,7 @@ namespace Discord.SlashCommands /// Used to set context. /// public ISlashCommandModule userCommandModule; - + public Type moduleType; public void SetCommands(List commands) { @@ -43,5 +59,84 @@ namespace Discord.SlashCommands this.userCommandModule = userCommandModule as ISlashCommandModule; } } + public void SetType(Type type) + { + moduleType = type; + } + + public void MakeCommandGroup(CommandGroup commandGroupInfo, SlashModuleInfo parent) + { + isCommandGroup = true; + this.commandGroupInfo = commandGroupInfo; + this.parent = parent; + } + public void SetSubCommandGroups(List subCommandGroups) + { + // this.commandGroups = new List(subCommandGroups); + this.commandGroups = subCommandGroups; + } + + public void MakePath() + { + Path = parent.Path + SlashModuleInfo.PathSeperator + commandGroupInfo.groupName; + } + + public List BuildCommands() + { + List builtCommands = new List(); + foreach (var command in Commands) + { + var builtCommand = command.BuildCommand(); + if (isGlobal || command.isGlobal) + { + builtCommand.Global = true; + } + builtCommands.Add(builtCommand); + } + foreach(var commandGroup in commandGroups) + { + var builtCommand = commandGroup.BuildTopLevelCommandGroup(); + if (isGlobal || commandGroup.isGlobal) + { + builtCommand.Global = true; + } + builtCommands.Add(builtCommand); + } + return builtCommands; + } + + public SlashCommandCreationProperties BuildTopLevelCommandGroup() + { + SlashCommandBuilder builder = new SlashCommandBuilder(); + builder.WithName(commandGroupInfo.groupName); + builder.WithDescription(commandGroupInfo.description); + foreach (var command in Commands) + { + builder.AddOption(command.BuildSubCommand()); + } + foreach (var commandGroup in commandGroups) + { + builder.AddOption(commandGroup.BuildNestedCommandGroup()); + } + return builder.Build(); + } + + private SlashCommandOptionBuilder BuildNestedCommandGroup() + { + SlashCommandOptionBuilder builder = new SlashCommandOptionBuilder(); + builder.WithName(commandGroupInfo.groupName); + builder.WithDescription(commandGroupInfo.description); + builder.WithType(ApplicationCommandOptionType.SubCommandGroup); + foreach (var command in Commands) + { + builder.AddOption(command.BuildSubCommand()); + } + foreach (var commandGroup in commandGroups) + { + builder.AddOption(commandGroup.BuildNestedCommandGroup()); + } + + return builder; + } } } diff --git a/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs new file mode 100644 index 000000000..f2d8a5ce9 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs @@ -0,0 +1,45 @@ +using Discord.Commands.Builders; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + public class SlashParameterInfo : SlashCommandOptionBuilder + { + public bool Nullable { get; internal set; } + + public object Parse(SocketInteractionDataOption dataOption) + { + switch (Type) + { + case ApplicationCommandOptionType.Boolean: + if (Nullable) + return (bool?)dataOption; + else + return (bool)dataOption; + case ApplicationCommandOptionType.Integer: + if(Nullable) + return (int?)dataOption; + else + return (int)dataOption; + case ApplicationCommandOptionType.String: + return (string)dataOption; + case ApplicationCommandOptionType.Channel: + return (SocketGuildChannel)dataOption; + case ApplicationCommandOptionType.Role: + return (SocketRole)dataOption; + case ApplicationCommandOptionType.User: + return (SocketGuildUser)dataOption; + case ApplicationCommandOptionType.SubCommandGroup: + throw new NotImplementedException(); + case ApplicationCommandOptionType.SubCommand: + throw new NotImplementedException(); + } + throw new NotImplementedException($"There is no such type of data... unless we missed it. Please report this error on the Discord.Net github page! Type: {Type}"); + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs index 2db136243..c227758a7 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -34,11 +34,6 @@ namespace Discord.SlashCommands _logger = new Logger(_logManager, "SlshCommand"); } - public void AddAssembly() - { - - } - /// /// Execute a slash command. /// @@ -46,23 +41,120 @@ namespace Discord.SlashCommands /// public async Task ExecuteAsync(SocketInteraction interaction) { - // First, get the info about this command, if it exists SlashCommandInfo commandInfo; - if (commandDefs.TryGetValue(interaction.Data.Name, out 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)) { - // TODO: implement everything that has to do with parameters :) - // Then, set the context in which the command will be executed commandInfo.Module.userCommandModule.SetContext(interaction); - // Then run the command (with no parameters) - return await commandInfo.ExecuteAsync(new object[] { }).ConfigureAwait(false); + // Then run the command and pass the interaction data over to the CommandInfo class + return await commandInfo.ExecuteAsync(resultingOptions).ConfigureAwait(false); } else { return SearchResult.FromError(CommandError.UnknownCommand, $"There is no registered slash command with the name {interaction.Data.Name}"); } } - + /// + /// Get the name of the command we want to search for - be it a normal slash command or a sub command. Returns as out the options to be given to the method. + /// /// + /// + /// + /// + 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) + { + string newName = nameToSearch + SlashModuleInfo.PathSeperator + GetFirstOption(options).Name; + if (AnyKeyContains(commandDefs,newName)) + { + nameToSearch = newName; + options = GetFirstOption(options).Options; + } + else + { + break; + } + } + 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) + { + if (pair.Key.Contains(newName)) + return true; + } + return false; + } + + private SocketInteractionDataOption GetFirstOption(IReadOnlyCollection options) + { + var it = options.GetEnumerator(); + it.MoveNext(); + return it.Current; + } + + /// + /// Registers with discord all previously scanned commands. + /// + public async Task RegisterCommandsAsync(DiscordSocketClient socketClient, List guildIDs, CommandRegistrationOptions registrationOptions) + { + // 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); + + try + { + // Build and register all of the commands. + await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this, guildIDs, registrationOptions).ConfigureAwait(false); + } + finally + { + _moduleLock.Release(); + } + 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 + /// public async Task AddModulesAsync(Assembly assembly, IServiceProvider services) { // First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business @@ -75,9 +167,7 @@ namespace Discord.SlashCommands // Then, based on that, make an instance out of each of them, and get the resulting SlashModuleInfo s moduleDefs = await SlashCommandServiceHelper.InstantiateModules(types, this).ConfigureAwait(false); // After that, internally register all of the commands into SlashCommandInfo - commandDefs = await SlashCommandServiceHelper.PrepareAsync(types,moduleDefs,this).ConfigureAwait(false); - // TODO: And finally, register the commands with discord. - await SlashCommandServiceHelper.RegisterCommands(commandDefs, this, services).ConfigureAwait(false); + commandDefs = await SlashCommandServiceHelper.CreateCommandInfos(types,moduleDefs,this).ConfigureAwait(false); } finally { diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs index d76c61a55..2239758dc 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -1,3 +1,5 @@ +using Discord.Commands.Builders; +using Discord.WebSocket; using System; using System.Collections.Generic; using System.Linq; @@ -38,7 +40,8 @@ namespace Discord.SlashCommands { // See if the base type (SlashCommandInfo) implements interface ISlashCommandModule return typeInfo.BaseType.GetInterfaces() - .Any(n => n == typeof(ISlashCommandModule)); + .Any(n => n == typeof(ISlashCommandModule)) && + typeInfo.GetCustomAttributes(typeof(CommandGroup)).Count() == 0; } /// @@ -47,56 +50,155 @@ 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 and instantiate them. foreach (Type userModuleType in types) { SlashModuleInfo moduleInfo = new SlashModuleInfo(slashCommandService); + moduleInfo.SetType(userModuleType); // If they want a constructor with different parameters, this is the place to add them. object instance = userModuleType.GetConstructor(Type.EmptyTypes).Invoke(null); moduleInfo.SetCommandModule(instance); + moduleInfo.isGlobal = IsCommandModuleGlobal(userModuleType); + moduleInfo.SetSubCommandGroups(InstantiateSubModules(userModuleType, moduleInfo, slashCommandService)); result.Add(userModuleType, moduleInfo); } return result; } + 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()) + { + if(TryGetCommandGroupAttribute(commandGroupType, out CommandGroup commandGroup)) + { + SlashModuleInfo groupInfo = new SlashModuleInfo(slashCommandService); + groupInfo.SetType(commandGroupType); + + object instance = commandGroupType.GetConstructor(Type.EmptyTypes).Invoke(null); + groupInfo.SetCommandModule(instance); + + groupInfo.MakeCommandGroup(commandGroup,rootModuleInfo); + groupInfo.MakePath(); + groupInfo.isGlobal = IsCommandModuleGlobal(commandGroupType); + + groupInfo.SetSubCommandGroups(InstantiateSubModules(commandGroupType, groupInfo, slashCommandService)); + commandGroups.Add(groupInfo); + } + } + return commandGroups; + } + public static bool TryGetCommandGroupAttribute(Type module, out CommandGroup commandGroup) + { + if(!module.IsPublic && !module.IsNestedPublic) + { + commandGroup = null; + return false; + } + var commandGroupAttributes = module.GetCustomAttributes(typeof(CommandGroup)); + if( commandGroupAttributes.Count() == 0) + { + commandGroup = null; + return false; + } + else if(commandGroupAttributes.Count() > 1) + { + throw new Exception($"Too many CommandGroup attributes on a single class ({module.FullName}). It can only contain one!"); + } + else + { + commandGroup = commandGroupAttributes.First() as CommandGroup; + return true; + } + } + public static bool IsCommandModuleGlobal(Type userModuleType) + { + // Verify that we only have one [Global] attribute + IEnumerable slashCommandAttributes = userModuleType.GetCustomAttributes(typeof(Global)); + if (slashCommandAttributes.Count() > 1) + { + throw new Exception("Too many Global attributes on a single method. It can only contain one!"); + } + // And at least one + if (slashCommandAttributes.Count() == 0) + { + return false; + } + return true; + } /// /// Prepare all of the commands and register them internally. /// - public static async Task> PrepareAsync(IReadOnlyList types, Dictionary moduleDefs, SlashCommandService slashCommandService) + public static async Task> CreateCommandInfos(IReadOnlyList types, Dictionary moduleDefs, SlashCommandService slashCommandService) { + // Create the resulting dictionary ahead of time var result = new Dictionary(); - // fore each user-defined module + // For each user-defined module ... foreach (var userModule in types) { - // Get its associated information + // Get its associated information. If there isn't any it means something went wrong, but it's not a critical error. SlashModuleInfo moduleInfo; if (moduleDefs.TryGetValue(userModule, out moduleInfo)) { - // and get all of its method, and check if they are valid, and if so create a new SlashCommandInfo for them. - var commandMethods = userModule.GetMethods(); - List commandInfos = new List(); - foreach (var commandMethod in commandMethods) - { - SlashCommand slashCommand; - if (IsValidSlashCommand(commandMethod, out slashCommand)) - { - Delegate delegateMethod = CreateDelegate(commandMethod, moduleInfo.userCommandModule); - SlashCommandInfo commandInfo = new SlashCommandInfo( - module: moduleInfo, - name: slashCommand.commandName, - description: slashCommand.description, - userMethod: delegateMethod - ); - result.Add(slashCommand.commandName, commandInfo); - commandInfos.Add(commandInfo); - } - } + // 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); } } return result; } + public static void CreateSubCommandInfos(Dictionary result, List subCommandGroups, SlashCommandService slashCommandService) + { + 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); + } + } + private static List CreateSameLevelCommands(Dictionary result, TypeInfo userModule, SlashModuleInfo moduleInfo) + { + var commandMethods = userModule.GetMethods(); + List commandInfos = new List(); + foreach (var commandMethod in commandMethods) + { + // Get the SlashCommand attribute + SlashCommand slashCommand; + if (IsValidSlashCommand(commandMethod, out slashCommand)) + { + // Create the delegate for the method we want to call once the user interacts with the bot. + // We use a delegate because of the unknown number and type of parameters we will have. + Delegate delegateMethod = CreateDelegate(commandMethod, moduleInfo.userCommandModule); + + SlashCommandInfo commandInfo = new SlashCommandInfo( + module: moduleInfo, + name: slashCommand.commandName, + description: slashCommand.description, + // Generate the parameters. Due to it's complicated way the algorithm has been moved to its own function. + parameters: ConstructCommandParameters(commandMethod), + userMethod: delegateMethod, + isGlobal: IsCommandGlobal(commandMethod) + ); + + result.Add(commandInfo.Module.Path + SlashModuleInfo.PathSeperator + commandInfo.Name, commandInfo); + commandInfos.Add(commandInfo); + } + } + + return commandInfos; + } + + /// + /// Determines wheater a method can be clasified as a slash command + /// private static bool IsValidSlashCommand(MethodInfo method, out SlashCommand slashCommand) { // Verify that we only have one [SlashCommand(...)] attribute @@ -116,10 +218,146 @@ namespace Discord.SlashCommands return true; } /// + /// Determins if the method has a [Global] Attribute. + /// + private static bool IsCommandGlobal(MethodInfo method) + { + // Verify that we only have one [Global] attribute + IEnumerable slashCommandAttributes = method.GetCustomAttributes(typeof(Global)); + if (slashCommandAttributes.Count() > 1) + { + throw new Exception("Too many Global attributes on a single method. It can only contain one!"); + } + // And at least one + if (slashCommandAttributes.Count() == 0) + { + return false; + } + return true; + } + /// + /// Process the parameters of this method, including all the attributes. + /// + private static List ConstructCommandParameters(MethodInfo method) + { + // Prepare the final list of parameters + List finalParameters = new List(); + + // For each mehod parameter ... + // ex: ... MyCommand(string abc, int myInt) + // `abc` and `myInt` are parameters + foreach (var methodParameter in method.GetParameters()) + { + SlashParameterInfo newParameter = new SlashParameterInfo(); + + // 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 + // 0 -> then use the default description + // 1 -> Use the value from that attribute + // 2+ -> Throw an error. This shouldn't normaly happen, but we check for sake of sanity + var descriptions = methodParameter.GetCustomAttributes(typeof(Description)); + if (descriptions.Count() == 0) + newParameter.Description = Description.DefaultDescription; + else if (descriptions.Count() > 1) + throw new Exception($"Too many Description attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!"); + else + newParameter.Description = (descriptions.First() as Description).description; + + // 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); + + // 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!"); + + // 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)) + { + throw new Exception($"Parameter ({method.Name} -> {methodParameter.Name}) is of type string, but choice is of type int!"); + } + 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) + { + throw new Exception($"Parameter ({method.Name} -> {methodParameter.Name}) is of type int, but choice is of type string!"); + } + newParameter.AddChoice(choice.choiceName, (int)choice.choiceIntValue); + } + } + + finalParameters.Add(newParameter); + } + return finalParameters; + } + /// + /// Get the type of command option from a method parameter info. + /// + private static ApplicationCommandOptionType TypeFromMethodParameter(ParameterInfo methodParameter) + { + // Can't do switch -- who knows why? + 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) || + methodParameter.ParameterType == typeof(bool?)) + return ApplicationCommandOptionType.Boolean; + if (methodParameter.ParameterType == typeof(SocketGuildChannel)) + return ApplicationCommandOptionType.Channel; + if (methodParameter.ParameterType == typeof(SocketRole)) + return ApplicationCommandOptionType.Role; + if (methodParameter.ParameterType == typeof(SocketGuildUser)) + return ApplicationCommandOptionType.User; + + 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))); @@ -143,9 +381,98 @@ namespace Discord.SlashCommands return Delegate.CreateDelegate(getType(types.ToArray()), target, methodInfo.Name); } - public static async Task RegisterCommands(Dictionary commandDefs, SlashCommandService slashCommandService, IServiceProvider services) + 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. + // 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)); + foreach (ulong guildID in guildIDs) + { + 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) + { + builtCommands.RemoveAll(x => (!x.Global && x.Name == existingCommand.Name)); + } + foreach (var existingCommand in existingGlobalCommands) + { + builtCommands.RemoveAll(x => (x.Global && x.Name == existingCommand.Name)); + } + } + + // 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 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))) + { + await existingCommand.DeleteAsync(); + } + } + foreach (var existingCommand in existingGlobalCommands) + { + // 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 || + // There are no commands which contain this existing command. + !builtCommands.Any(x => x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name))) + { + await existingCommand.DeleteAsync(); + } + } + } + + // 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) + { + await socketClient.Rest.CreateGlobalCommand(builtCommand).ConfigureAwait(false); + } + else + { + foreach (ulong guildID in guildIDs) + { + await socketClient.Rest.CreateGuildCommand(builtCommand, guildID).ConfigureAwait(false); + } + } + } + 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.Commands/SlashCommands/Types/ISlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/ISlashCommandModule.cs similarity index 100% rename from src/Discord.Net.Commands/SlashCommands/Types/ISlashCommandModule.cs rename to src/Discord.Net.Commands/SlashCommands/Types/CommandModule/ISlashCommandModule.cs diff --git a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs similarity index 58% rename from src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs rename to src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs index 2249ff08d..b0dd34a90 100644 --- a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs +++ b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs @@ -1,5 +1,7 @@ using Discord.Commands.SlashCommands.Types; +using Discord.WebSocket; using System; +using System.Threading.Tasks; namespace Discord.SlashCommands { @@ -17,5 +19,16 @@ namespace Discord.SlashCommands var newValue = interaction as T; Interaction = newValue ?? throw new InvalidOperationException($"Invalid interaction type. Expected {typeof(T).Name}, got {interaction.GetType().Name}."); } + + + public async Task Reply(string text = null, bool isTTS = false, Embed embed = null, InteractionResponseType Type = InteractionResponseType.ChannelMessageWithSource, + AllowedMentions allowedMentions = null, RequestOptions options = null) + { + if(Interaction is SocketInteraction) + { + return await (Interaction as SocketInteraction).FollowupAsync(text, isTTS, embed, Type, allowedMentions, options); + } + return null; + } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommandCreationProperties.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommandCreationProperties.cs index df9f39809..5894ab655 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommandCreationProperties.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommandCreationProperties.cs @@ -21,6 +21,10 @@ namespace Discord /// public string Description { get; set; } + /// + /// If the command should be defined as a global command. + /// + public bool Global { get; set; } = false; /// /// Gets or sets the options for this command. diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs index 789c5549d..cde33f6d6 100644 --- a/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs @@ -67,7 +67,9 @@ namespace Discord.API ? option.Options.Select(x => new ApplicationCommandOption(x)).ToArray() : Optional.Unspecified; - this.Required = option.Required.Value; + this.Required = option.Required.HasValue + ? option.Required.Value + : Optional.Unspecified; this.Default = option.Default.HasValue ? option.Default.Value : Optional.Unspecified; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs index 683998272..3a612388d 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs @@ -30,7 +30,7 @@ namespace Discord.WebSocket internal SocketInteractionDataOption() { } internal SocketInteractionDataOption(Model model, DiscordSocketClient discord, ulong guild) { - this.Name = Name; + this.Name = model.Name; this.Value = model.Value.IsSpecified ? model.Value.Value : null; this.discord = discord; this.guild = guild; @@ -44,14 +44,32 @@ namespace Discord.WebSocket // Converters public static explicit operator bool(SocketInteractionDataOption option) => (bool)option.Value; + // The default value is of type long, so an implementaiton of of the long option is trivial public static explicit operator int(SocketInteractionDataOption option) - => (int)option.Value; + => unchecked( + (int)( (long)option.Value ) + ); 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 (option.Value is ulong id) + if (ulong.TryParse((string)option.Value, out ulong id)) { var guild = option.discord.GetGuild(option.guild); @@ -66,7 +84,7 @@ namespace Discord.WebSocket public static explicit operator SocketRole(SocketInteractionDataOption option) { - if (option.Value is ulong id) + if (ulong.TryParse((string)option.Value, out ulong id)) { var guild = option.discord.GetGuild(option.guild); @@ -81,7 +99,7 @@ namespace Discord.WebSocket public static explicit operator SocketGuildUser(SocketInteractionDataOption option) { - if(option.Value is ulong id) + if (ulong.TryParse((string)option.Value, out ulong id)) { var guild = option.discord.GetGuild(option.guild);