diff --git a/Discord.Net.sln b/Discord.Net.sln index 084d8a834..95590e68b 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -42,6 +42,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Examples", "src EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "idn", "samples\idn\idn.csproj", "{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlashCommandsExample", "SlashCommandsExample\SlashCommandsExample.csproj", "{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}" + ProjectSection(ProjectDependencies) = postProject + {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} = {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} + {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} = {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} + {BFC6DC28-0351-4573-926A-D4124244C04F} = {BFC6DC28-0351-4573-926A-D4124244C04F} + {E169E15A-E82C-45BF-8C24-C2CADB7093AA} = {E169E15A-E82C-45BF-8C24-C2CADB7093AA} + {47820065-3CFB-401C-ACEA-862BD564A404} = {47820065-3CFB-401C-ACEA-862BD564A404} + {DBF8B16E-5967-4480-8EDE-15D98A0DF0C4} = {DBF8B16E-5967-4480-8EDE-15D98A0DF0C4} + {FC67057C-E92F-4E1C-98BE-46F839C8AD71} = {FC67057C-E92F-4E1C-98BE-46F839C8AD71} + {91E9E7BD-75C9-4E98-84AA-2C271922E5C2} = {91E9E7BD-75C9-4E98-84AA-2C271922E5C2} + {688FD1D8-7F01-4539-B2E9-F473C5D699C7} = {688FD1D8-7F01-4539-B2E9-F473C5D699C7} + {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C} = {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C} + {BBA8E7FB-C834-40DC-822F-B112CB7F0140} = {BBA8E7FB-C834-40DC-822F-B112CB7F0140} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -232,6 +247,18 @@ Global {4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x64.Build.0 = Release|Any CPU {4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x86.ActiveCfg = Release|Any CPU {4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x86.Build.0 = Release|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x64.ActiveCfg = Debug|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x64.Build.0 = Debug|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x86.ActiveCfg = Debug|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x86.Build.0 = Debug|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|Any CPU.Build.0 = Release|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x64.ActiveCfg = Release|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x64.Build.0 = Release|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x86.ActiveCfg = Release|Any CPU + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -251,6 +278,7 @@ Global {FC67057C-E92F-4E1C-98BE-46F839C8AD71} = {C7CF5621-7D36-433B-B337-5A2E3C101A71} {47820065-3CFB-401C-ACEA-862BD564A404} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {4A03840B-9EBE-47E3-89AB-E0914DF21AFB} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} + {2CB2A016-CCEB-4A67-BC7B-098F114D7C27} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs new file mode 100644 index 000000000..b2b891d13 --- /dev/null +++ b/SlashCommandsExample/DiscordClient.cs @@ -0,0 +1,155 @@ +using Discord; +using Discord.Commands; +using Discord.SlashCommands; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +namespace SlashCommandsExample +{ + class DiscordClient + { + public static DiscordSocketClient socketClient { get; set; } = new DiscordSocketClient(); + public static SlashCommandService _commands { get; set; } + public static IServiceProvider _services { get; set; } + + private string botToken = ""; + + public DiscordClient() + { + _commands = new SlashCommandService(); + _services = new ServiceCollection() + .AddSingleton(socketClient) + .AddSingleton(_commands) + .BuildServiceProvider(); + + 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. + botToken = Environment.GetEnvironmentVariable("DiscordSlashCommandsBotToken", EnvironmentVariableTarget.User); + // Uncomment the next line of code to set the environment variable. + // ------------------------------------------------------------------ + // | WARNING! | + // | | + // | MAKE SURE TO DELETE YOUR TOKEN AFTER YOU HAVE SET THE VARIABLE | + // | | + // ------------------------------------------------------------------ + + //Environment.SetEnvironmentVariable("DiscordSlashCommandsBotToken", + // "[YOUR TOKEN GOES HERE DELETE & COMMENT AFTER USE]", + // 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); + } + + private async Task InteractionHandler(SocketInteraction arg) + { + if(arg.Type == InteractionType.ApplicationCommand) + { + await _commands.ExecuteAsync(arg); + } + } + + private Task SocketClient_Log(LogMessage arg) + { + Console.WriteLine("[Discord] " + arg.ToString()); + return Task.CompletedTask; + } + } +} 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/InvalidModule.cs b/SlashCommandsExample/Modules/InvalidModule.cs new file mode 100644 index 000000000..34f85f399 --- /dev/null +++ b/SlashCommandsExample/Modules/InvalidModule.cs @@ -0,0 +1,20 @@ +using Discord.SlashCommands; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Text; + +namespace SlashCommandsExample.Modules +{ + // Doesn't inherit from SlashCommandModule + public class InvalidDefinition : Object + { + // commands + } + + // Isn't public + class PrivateDefinition : SlashCommandModule + { + // commands + } +} diff --git a/SlashCommandsExample/Program.cs b/SlashCommandsExample/Program.cs new file mode 100644 index 000000000..4f35ec96d --- /dev/null +++ b/SlashCommandsExample/Program.cs @@ -0,0 +1,26 @@ +/* + * This project, is at this moment used for testing and debugging the new and experimental Slash Commands. + * After all testing has been done, and the project is ready to be integrated into the main Discord.Net ecosystem + * this project should be re-made into one that could be used as an example usage of the new Slash Command Service. + */ +using System; +using System.Threading.Tasks; +using Discord; +using Discord.Commands; +using Discord.SlashCommands; +using Discord.WebSocket; + +namespace SlashCommandsExample +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Hello World!"); + + DiscordClient discordClient = new DiscordClient(); + // This could instead be handled in another thread, if for whatever reason you want to continue execution in the main Thread. + discordClient.RunAsync().GetAwaiter().GetResult(); + } + } +} diff --git a/SlashCommandsExample/Properties/launchSettings.json b/SlashCommandsExample/Properties/launchSettings.json new file mode 100644 index 000000000..4cfd08821 --- /dev/null +++ b/SlashCommandsExample/Properties/launchSettings.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "SlashCommandsExample": { + "commandName": "Project" + } + } +} \ No newline at end of file diff --git a/SlashCommandsExample/SlashCommandsExample.csproj b/SlashCommandsExample/SlashCommandsExample.csproj new file mode 100644 index 000000000..a8bb5b9be --- /dev/null +++ b/SlashCommandsExample/SlashCommandsExample.csproj @@ -0,0 +1,22 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + + + + + + + + diff --git a/src/Discord.Net.Commands/Discord.Net.Commands.csproj b/src/Discord.Net.Commands/Discord.Net.Commands.csproj index d64678d7c..0fc066a02 100644 --- a/src/Discord.Net.Commands/Discord.Net.Commands.csproj +++ b/src/Discord.Net.Commands/Discord.Net.Commands.csproj @@ -10,6 +10,7 @@ + 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 8b60eabc1..d8adf797f 100644 --- a/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs @@ -9,21 +9,27 @@ 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 { /// /// The name of this slash command. /// - public string CommandName; + public string commandName; + + /// + /// 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 SlashCommand(string CommandName) + /// 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/Types/SlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs similarity index 67% rename from src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs rename to src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs index 6e54e920a..fd6b6c522 100644 --- a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs +++ b/src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs @@ -1,14 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Text; using System.Threading.Tasks; namespace Discord.SlashCommands { - internal class SlashCommandModule + 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 new file mode 100644 index 000000000..f5542df52 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs @@ -0,0 +1,160 @@ +using Discord.Commands; +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 SlashCommandInfo + { + /// + /// Gets the module that the command belongs in. + /// + public SlashModuleInfo Module { get; } + /// + /// Gets the name of the command. + /// + public string Name { get; } + /// + /// 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 + /// + public Delegate userMethod; + /// + /// The callback that we call to start the delegate. + /// + public Func> callback; + + 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 + try + { + await Task.Run(() => + { + userMethod.DynamicInvoke(args); + }).ConfigureAwait(false); + } + catch(Exception e) + { + return ExecuteResult.FromError(e); + } + return ExecuteResult.FromSuccess(); + + }); + } + + /// + /// 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() + { + 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 new file mode 100644 index 000000000..b62e11122 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs @@ -0,0 +1,142 @@ +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; + +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. + /// + public SlashCommandService Service { get; } + /// + /// Gets a read-only list of commands associated with this module. + /// + public List Commands { get; private set; } + + /// + /// The user command module defined as the interface ISlashCommandModule + /// Used to set context. + /// + public ISlashCommandModule userCommandModule; + public Type moduleType; + + public void SetCommands(List commands) + { + if (this.Commands == null) + { + this.Commands = commands; + } + } + public void SetCommandModule(object userCommandModule) + { + if (this.userCommandModule == null) + { + 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 f075dd833..c227758a7 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -1,30 +1,178 @@ using Discord.Commands; +using Discord.Logging; +using Discord.WebSocket; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace Discord.SlashCommands { public class SlashCommandService { - private List _modules; + // This semaphore is used to prevent race conditions. + private readonly SemaphoreSlim _moduleLock; + // This contains a dictionary of all definde SlashCommands, based on it's name + public Dictionary commandDefs; + // This contains a list of all slash command modules defined by their user in their assembly. + public Dictionary moduleDefs; + + // This is such a complicated method to log stuff... + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + internal readonly AsyncEvent> _logEvent = new AsyncEvent>(); + internal Logger _logger; + internal LogManager _logManager; public SlashCommandService() // TODO: possible config? { + // max one thread + _moduleLock = new SemaphoreSlim(1, 1); + + _logManager = new LogManager(LogSeverity.Info); + _logManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + _logger = new Logger(_logManager, "SlshCommand"); + } + + /// + /// Execute a slash command. + /// + /// Interaction data recieved from discord. + /// + public async Task ExecuteAsync(SocketInteraction interaction) + { + 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 + commandInfo.Module.userCommandModule.SetContext(interaction); + // 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; } - public void AddAssembly() + /// + /// 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); } - public async Task ExecuteAsync() + /// + /// 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() { - // TODO: handle execution - return null; + // 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 + await _moduleLock.WaitAsync().ConfigureAwait(false); + + try + { + // Get all of the modules that were properly defined by the user. + IReadOnlyList types = await SlashCommandServiceHelper.GetValidModuleClasses(assembly, this).ConfigureAwait(false); + // 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.CreateCommandInfos(types,moduleDefs,this).ConfigureAwait(false); + } + finally + { + _moduleLock.Release(); + } } } } diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs new file mode 100644 index 000000000..2239758dc --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -0,0 +1,478 @@ +using Discord.Commands.Builders; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + internal static class SlashCommandServiceHelper + { + /// + /// Get all of the valid user-defined slash command modules + /// + public static async Task> GetValidModuleClasses(Assembly assembly, SlashCommandService service) + { + var result = new List(); + + foreach (TypeInfo typeInfo in assembly.DefinedTypes) + { + if (IsValidModuleDefinition(typeInfo)) + { + // To simplify our lives, we need the modules to be public. + if (typeInfo.IsPublic || typeInfo.IsNestedPublic) + { + result.Add(typeInfo); + } + else + { + await service._logger.WarningAsync($"Found class {typeInfo.FullName} as a valid SlashCommand Module, but it's not public!"); + } + } + } + + return result; + } + private static bool IsValidModuleDefinition(TypeInfo typeInfo) + { + // See if the base type (SlashCommandInfo) implements interface ISlashCommandModule + return typeInfo.BaseType.GetInterfaces() + .Any(n => n == typeof(ISlashCommandModule)) && + typeInfo.GetCustomAttributes(typeof(CommandGroup)).Count() == 0; + } + + /// + /// Create an instance of each user-defined module + /// + 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> CreateCommandInfos(IReadOnlyList types, Dictionary moduleDefs, SlashCommandService slashCommandService) + { + // Create the resulting dictionary ahead of time + var result = new Dictionary(); + // For each user-defined module ... + foreach (var userModule in types) + { + // 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)) + { + // 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 + IEnumerable slashCommandAttributes = method.GetCustomAttributes(typeof(SlashCommand)); + if (slashCommandAttributes.Count() > 1) + { + throw new Exception("Too many SlashCommand attributes on a single method. It can only contain one!"); + } + // And at least one + if (slashCommandAttributes.Count() == 0) + { + slashCommand = null; + return false; + } + // And return the first (and only) attribute + 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 + 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 + /// + private static Delegate CreateDelegate(MethodInfo methodInfo, object target) + { + Func getType; + var isAction = methodInfo.ReturnType.Equals((typeof(void))); + var types = methodInfo.GetParameters().Select(p => p.ParameterType); + + if (isAction) + { + getType = Expression.GetActionType; + } + else + { + getType = Expression.GetFuncType; + types = types.Concat(new[] { methodInfo.ReturnType }); + } + + if (methodInfo.IsStatic) + { + return Delegate.CreateDelegate(getType(types.ToArray()), methodInfo); + } + + return Delegate.CreateDelegate(getType(types.ToArray()), target, methodInfo.Name); + } + + 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/CommandModule/ISlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/ISlashCommandModule.cs new file mode 100644 index 000000000..557e0fc69 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/ISlashCommandModule.cs @@ -0,0 +1,21 @@ +using Discord.Commands.Builders; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + + public interface ISlashCommandModule + { + void SetContext(IDiscordInteraction interaction); + + //void BeforeExecute(CommandInfo command); + + //void AfterExecute(CommandInfo command); + + //void OnModuleBuilding(CommandService commandService, ModuleBuilder builder); + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs new file mode 100644 index 000000000..b0dd34a90 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs @@ -0,0 +1,34 @@ +using Discord.Commands.SlashCommands.Types; +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + public class SlashCommandModule : ISlashCommandModule where T : class, IDiscordInteraction + { + /// + /// The underlying interaction of the command. + /// + /// + /// + public T Interaction { get; private set; } + + void ISlashCommandModule.SetContext(IDiscordInteraction interaction) + { + 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.Rest/Entities/Interactions/RestApplicationCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs index 67a530d50..d418ba213 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs @@ -60,7 +60,7 @@ namespace Discord.Rest this.Description = model.Description; this.Options = model.Options.IsSpecified - ? model.Options.Value.Select(x => RestApplicationCommandOption.Create(x)).ToImmutableArray() + ? model.Options.Value.Select(x => RestApplicationCommandOption.Create(x)).ToImmutableArray().ToReadOnlyCollection() : null; } diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs index a8e37873e..89449722d 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs @@ -60,11 +60,11 @@ namespace Discord.Rest this.Required = model.Required.Value; this.Options = model.Options.IsSpecified - ? model.Options.Value.Select(x => Create(x)).ToImmutableArray() + ? model.Options.Value.Select(x => Create(x)).ToImmutableArray().ToReadOnlyCollection() : null; this.Choices = model.Choices.IsSpecified - ? model.Choices.Value.Select(x => new RestApplicationCommandChoice(x)).ToImmutableArray() + ? model.Choices.Value.Select(x => new RestApplicationCommandChoice(x)).ToImmutableArray().ToReadOnlyCollection() : null; } diff --git a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs index f8676c783..b20cc88d3 100644 --- a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs +++ b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs @@ -37,8 +37,8 @@ namespace Discord.Rest public static RoleTags ToEntity(this API.RoleTags model) { return new RoleTags( - model.BotId.IsSpecified ? model.BotId.Value : null, - model.IntegrationId.IsSpecified ? model.IntegrationId.Value : null, + model.BotId.IsSpecified ? model.BotId.Value : (ulong?)null, + model.IntegrationId.IsSpecified ? model.IntegrationId.Value : (ulong?)null, model.IsPremiumSubscriber.IsSpecified ? true : false); } public static API.Embed ToModel(this Embed entity) diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketApplicationCommandOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketApplicationCommandOption.cs index 0a90a8073..8f8222f0b 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketApplicationCommandOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketApplicationCommandOption.cs @@ -54,19 +54,19 @@ namespace Discord.WebSocket this.Default = model.Default.IsSpecified ? model.Default.Value - : null; + : (bool?)null; this.Required = model.Required.IsSpecified ? model.Required.Value - : null; + : (bool?)null; this.Choices = model.Choices.IsSpecified - ? model.Choices.Value.Select(x => SocketApplicationCommandChoice.Create(x)).ToImmutableArray() - : new ImmutableArray(); + ? model.Choices.Value.Select(x => SocketApplicationCommandChoice.Create(x)).ToImmutableArray().ToReadOnlyCollection() + : null; this.Options = model.Options.IsSpecified - ? model.Options.Value.Select(x => SocketApplicationCommandOption.Create(x)).ToImmutableArray() - : new ImmutableArray(); + ? model.Options.Value.Select(x => SocketApplicationCommandOption.Create(x)).ToImmutableArray().ToReadOnlyCollection() + : null; } IReadOnlyCollection IApplicationCommandOption.Choices => Choices; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionData.cs index eef7e5ab4..d30b64b3b 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionData.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionData.cs @@ -38,7 +38,7 @@ namespace Discord.WebSocket this.guildId = guildId; this.Options = model.Options.IsSpecified - ? model.Options.Value.Select(x => new SocketInteractionDataOption(x, this.Discord, guildId)).ToImmutableArray() + ? model.Options.Value.Select(x => new SocketInteractionDataOption(x, this.Discord, guildId)).ToImmutableArray().ToReadOnlyCollection() : null; } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs index fea0bf3b6..3a612388d 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs @@ -30,13 +30,13 @@ 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; this.Options = model.Options.IsSpecified - ? model.Options.Value.Select(x => new SocketInteractionDataOption(x, discord, guild)).ToImmutableArray() + ? model.Options.Value.Select(x => new SocketInteractionDataOption(x, discord, guild)).ToImmutableArray().ToReadOnlyCollection() : null; } @@ -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);