From 16f6a4c4822fd3e3e0f30466988a763075db3091 Mon Sep 17 00:00:00 2001 From: Cosma George Date: Wed, 10 Feb 2021 21:22:15 +0200 Subject: [PATCH 1/7] Fixed build errors and started an example project for development purposes and to be a starting-off point for newcomers to the slash command ecosystem, --- Discord.Net.sln | 28 +++++++++++++++++++ SlashCommandsExample/Program.cs | 17 +++++++++++ .../Properties/launchSettings.json | 7 +++++ .../SlashCommandsExample.csproj | 8 ++++++ .../Interactions/RestApplicationCommand.cs | 2 +- .../RestApplicationCommandOption.cs | 4 +-- .../Extensions/EntityExtensions.cs | 4 +-- .../SocketApplicationCommandOption.cs | 12 ++++---- .../Interaction/SocketInteractionData.cs | 2 +- .../SocketInteractionDataOption.cs | 2 +- 10 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 SlashCommandsExample/Program.cs create mode 100644 SlashCommandsExample/Properties/launchSettings.json create mode 100644 SlashCommandsExample/SlashCommandsExample.csproj 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/Program.cs b/SlashCommandsExample/Program.cs new file mode 100644 index 000000000..7863ea16f --- /dev/null +++ b/SlashCommandsExample/Program.cs @@ -0,0 +1,17 @@ +/* + * 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; + +namespace SlashCommandsExample +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Hello World!"); + } + } +} 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..c73e0d169 --- /dev/null +++ b/SlashCommandsExample/SlashCommandsExample.csproj @@ -0,0 +1,8 @@ + + + + Exe + netcoreapp3.1 + + + 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..683998272 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs @@ -36,7 +36,7 @@ namespace Discord.WebSocket 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; } From f4b321429a43eb86686a272a60f4ab81778ca392 Mon Sep 17 00:00:00 2001 From: Cosma George Date: Thu, 11 Feb 2021 16:37:47 +0200 Subject: [PATCH 2/7] Implemented the most basic form of the command service. Does not yet parse arguments, nor does it register commands. Also added to the aforementioned dev-test project. --- SlashCommandsExample/DiscordClient.cs | 72 +++++++++ SlashCommandsExample/Modules/InvalidModule.cs | 20 +++ SlashCommandsExample/Modules/PingCommand.cs | 33 ++++ SlashCommandsExample/Program.cs | 15 +- .../SlashCommandsExample.csproj | 14 ++ .../Discord.Net.Commands.csproj | 1 + .../SlashCommands/Attributes/SlashCommand.cs | 14 +- .../SlashCommands/Info/SlashCommandInfo.cs | 64 ++++++++ .../SlashCommands/Info/SlashModuleInfo.cs | 47 ++++++ .../SlashCommands/SlashCommandService.cs | 72 ++++++++- .../SlashCommandServiceHelper.cs | 151 ++++++++++++++++++ .../Types/ISlashCommandModule.cs | 21 +++ .../SlashCommands/Types/SlashCommandModule.cs | 21 ++- 13 files changed, 524 insertions(+), 21 deletions(-) create mode 100644 SlashCommandsExample/DiscordClient.cs create mode 100644 SlashCommandsExample/Modules/InvalidModule.cs create mode 100644 SlashCommandsExample/Modules/PingCommand.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/Types/ISlashCommandModule.cs diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs new file mode 100644 index 000000000..7137da37d --- /dev/null +++ b/SlashCommandsExample/DiscordClient.cs @@ -0,0 +1,72 @@ +using Discord; +using Discord.Commands; +using Discord.SlashCommands; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using System; +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; + // 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 RunAsync() + { + await socketClient.LoginAsync(TokenType.Bot, botToken); + await socketClient.StartAsync(); + + await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + + 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/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/Modules/PingCommand.cs b/SlashCommandsExample/Modules/PingCommand.cs new file mode 100644 index 000000000..7df135afd --- /dev/null +++ b/SlashCommandsExample/Modules/PingCommand.cs @@ -0,0 +1,33 @@ +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/SlashCommandsExample/Program.cs b/SlashCommandsExample/Program.cs index 7863ea16f..4f35ec96d 100644 --- a/SlashCommandsExample/Program.cs +++ b/SlashCommandsExample/Program.cs @@ -4,14 +4,23 @@ * 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) + 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/SlashCommandsExample.csproj b/SlashCommandsExample/SlashCommandsExample.csproj index c73e0d169..a8bb5b9be 100644 --- a/SlashCommandsExample/SlashCommandsExample.csproj +++ b/SlashCommandsExample/SlashCommandsExample.csproj @@ -5,4 +5,18 @@ 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/SlashCommand.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs index 8b60eabc1..3e7110aac 100644 --- a/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs @@ -15,15 +15,21 @@ namespace Discord.SlashCommands /// /// 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; + this.description = description; } } } 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..710d85d41 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs @@ -0,0 +1,64 @@ +using Discord.Commands; +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 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, Delegate userMethod) + { + Module = module; + Name = name; + Description = description; + this.userMethod = userMethod; + 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(); + + }); + } + + public async Task ExecuteAsync(object[] args) + { + return await callback.Invoke(args).ConfigureAwait(false); + } + } +} 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..baf4010f4 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + public class SlashModuleInfo + { + public SlashModuleInfo(SlashCommandService service) + { + Service = service; + } + + /// + /// 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 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; + } + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs index f075dd833..2db136243 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -1,19 +1,37 @@ 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"); } public void AddAssembly() @@ -21,10 +39,50 @@ namespace Discord.SlashCommands } - public async Task ExecuteAsync() + /// + /// Execute a slash command. + /// + /// Interaction data recieved from discord. + /// + 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)) + { + // 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); + } + else + { + return SearchResult.FromError(CommandError.UnknownCommand, $"There is no registered slash command with the name {interaction.Data.Name}"); + } + } + + public async Task AddModulesAsync(Assembly assembly, IServiceProvider services) { - // 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); + + 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.PrepareAsync(types,moduleDefs,this).ConfigureAwait(false); + // TODO: And finally, register the commands with discord. + await SlashCommandServiceHelper.RegisterCommands(commandDefs, this, services).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..d76c61a55 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -0,0 +1,151 @@ +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)); + } + + /// + /// Create an instance of each user-defined module + /// + public static async Task> InstantiateModules(IReadOnlyList types, SlashCommandService slashCommandService) + { + var result = new Dictionary(); + foreach (Type userModuleType in types) + { + SlashModuleInfo moduleInfo = new SlashModuleInfo(slashCommandService); + + // 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); + + result.Add(userModuleType, moduleInfo); + } + return result; + } + + /// + /// Prepare all of the commands and register them internally. + /// + public static async Task> PrepareAsync(IReadOnlyList types, Dictionary moduleDefs, SlashCommandService slashCommandService) + { + var result = new Dictionary(); + // fore each user-defined module + foreach (var userModule in types) + { + // Get its associated information + 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); + } + } + moduleInfo.SetCommands(commandInfos); + } + } + return result; + } + 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; + } + /// + /// Creae a delegate from methodInfo. Taken from + /// https://stackoverflow.com/a/40579063/8455128 + /// + public 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(Dictionary commandDefs, SlashCommandService slashCommandService, IServiceProvider services) + { + return; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Types/ISlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/ISlashCommandModule.cs new file mode 100644 index 000000000..557e0fc69 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Types/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/SlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs index 6e54e920a..2249ff08d 100644 --- a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs +++ b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs @@ -1,14 +1,21 @@ +using Discord.Commands.SlashCommands.Types; 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 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}."); + } } } From 4223fa8ff1c1beeeaa0666f4ebe91e8c7754a999 Mon Sep 17 00:00:00 2001 From: Cosma George Date: Sun, 14 Feb 2021 16:20:49 +0200 Subject: [PATCH 3/7] Implemented basic parameter recognition and passing them to the method. Details: Subcommands and Subcommand groups not yet implemented, they will require for some parts of the code to be re-done. More attributes can and should be implemented, such as [Required] and [Choice(... , ...)]. Breakdown: * Rectified line endings to LF, as per the settings of the project. * Added a new command to SlashCommandService and SlashCommandServiceHelper to register the found commands to discord. * Implemented CommandRegistrationOptions that can be used to configure the behaviour on registration - what to do with old commands, and with commands that already exist with the same name. A default version exists and can be accessed with CommandRegistrationOptions.Default * Modified the sample program to reflect the changes made to the SlashCommandService and to also register a new command that tests all 6 types of CommandOptions (except subcommand and subcommand group) * At the moment all commands are registered in my test guild, because the update for global commands is not instant. See SlashCommandServiceHelper.RegisterCommands(...) or line 221. * Modified SlashCommandInfo to parse arguments given from Interaction, unde in ExecuteAsync, and added method BuilDcommand that returns SlashCommandCreationProperties - which can be registered to Discord. * Renamed in the sample project PingCommand.cs to DevModule.cs * Added custom attribute Description for the command method's parameters. * Implemented SlashParameterInfo - and extension of the OptionBuilder that implements a method name Parse - takes DataOptions and gives out a cast object to be passed to the command Delegate. Planning on doing more with it. * Moved SlashCommandBuilder.cs to the same directory structure * Moved SlashCommandModule.cs and ISlashCommandModule.cs to its own folder. --- SlashCommandsExample/DiscordClient.cs | 1 + SlashCommandsExample/Modules/DevModule.cs | 53 ++++++++ SlashCommandsExample/Modules/PingCommand.cs | 33 ----- .../SlashCommands/Attributes/Description.cs | 31 +++++ .../Builders/SlashCommandBuilder.cs | 0 .../CommandRegistrationOptions.cs | 39 ++++++ .../ExistingCommandOptions.cs | 14 ++ .../CommandRegistration/OldCommandOptions.cs | 24 ++++ .../SlashCommands/Info/SlashCommandInfo.cs | 82 +++++++++++- .../SlashCommands/Info/SlashParameterInfo.cs | 37 ++++++ .../SlashCommands/SlashCommandService.cs | 39 ++++-- .../SlashCommandServiceHelper.cs | 124 +++++++++++++++++- .../ISlashCommandModule.cs | 0 .../{ => CommandModule}/SlashCommandModule.cs | 13 ++ .../SocketInteractionDataOption.cs | 13 +- 15 files changed, 444 insertions(+), 59 deletions(-) create mode 100644 SlashCommandsExample/Modules/DevModule.cs delete mode 100644 SlashCommandsExample/Modules/PingCommand.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/Attributes/Description.cs rename src/Discord.Net.Commands/{ => SlashCommands}/Builders/SlashCommandBuilder.cs (100%) create mode 100644 src/Discord.Net.Commands/SlashCommands/CommandRegistration/CommandRegistrationOptions.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/CommandRegistration/OldCommandOptions.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs rename src/Discord.Net.Commands/SlashCommands/Types/{ => CommandModule}/ISlashCommandModule.cs (100%) rename src/Discord.Net.Commands/SlashCommands/Types/{ => CommandModule}/SlashCommandModule.cs (58%) diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs index 7137da37d..408c3870b 100644 --- a/SlashCommandsExample/DiscordClient.cs +++ b/SlashCommandsExample/DiscordClient.cs @@ -51,6 +51,7 @@ namespace SlashCommandsExample await socketClient.StartAsync(); await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + await _commands.RegisterCommandsAsync(socketClient, CommandRegistrationOptions.Default); await Task.Delay(-1); } diff --git a/SlashCommandsExample/Modules/DevModule.cs b/SlashCommandsExample/Modules/DevModule.cs new file mode 100644 index 000000000..37ef108ec --- /dev/null +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -0,0 +1,53 @@ +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 DevModule : SlashCommandModule + { + [SlashCommand("ping", "Ping the bot to see if it's alive!")] + 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")]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( + bool boolean, + int integer, + string myString, + SocketGuildChannel channel, + SocketGuildUser user, + SocketRole role + ) + { + await Reply($"You gave me:\r\n {boolean}, {integer}, {myString}, <#{channel?.Id}>, {user?.Mention}, {role?.Mention}"); + + } + } +} +/* +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/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/Builders/SlashCommandBuilder.cs b/src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs similarity index 100% rename from src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs rename to src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs 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..eee54d32b 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,6 +23,10 @@ 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; } /// /// The user method as a delegate. We need to use Delegate because there is an unknown number of parameters @@ -31,11 +37,12 @@ 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) { Module = module; Name = name; Description = description; + Parameters = parameters; this.userMethod = userMethod; this.callback = new Func>(async (args) => { @@ -56,9 +63,78 @@ 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(SocketInteractionData data) { - return await callback.Invoke(args).ConfigureAwait(false); + // 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(SocketInteractionData data, string name, out SocketInteractionDataOption dataOption) + { + if (data.Options == null) + { + dataOption = null; + return false; + } + foreach (var option in data.Options) + { + 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(); } } } 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..0b43ac058 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs @@ -0,0 +1,37 @@ +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 object Parse(SocketInteractionDataOption dataOption) + { + switch (Type) + { + case ApplicationCommandOptionType.Boolean: + return (bool)dataOption; + case ApplicationCommandOptionType.Integer: + 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..cddc2350e 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. /// @@ -50,19 +45,39 @@ namespace Discord.SlashCommands SlashCommandInfo commandInfo; if (commandDefs.TryGetValue(interaction.Data.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(interaction.Data).ConfigureAwait(false); } else { return SearchResult.FromError(CommandError.UnknownCommand, $"There is no registered slash command with the name {interaction.Data.Name}"); } } - + + /// + /// Registers all previously scanned commands. + /// + public async Task RegisterCommandsAsync(DiscordSocketClient socketClient, 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 + { + await SlashCommandServiceHelper.RegisterCommands(socketClient, commandDefs, this,registrationOptions).ConfigureAwait(false); + } + finally + { + _moduleLock.Release(); + } + await _logger.InfoAsync("All commands have been registered!").ConfigureAwait(false); + } + + /// + /// 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 +90,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..2d40185f3 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; @@ -63,17 +65,19 @@ namespace Discord.SlashCommands /// /// 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. + // TODO: handle sub-command groups + // 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) @@ -81,13 +85,19 @@ namespace Discord.SlashCommands 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 ); + result.Add(slashCommand.commandName, commandInfo); commandInfos.Add(commandInfo); } @@ -97,6 +107,9 @@ namespace Discord.SlashCommands } return result; } + /// + /// 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 @@ -115,6 +128,66 @@ namespace Discord.SlashCommands slashCommand = slashCommandAttributes.First() as SlashCommand; return true; } + 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(); + + // Set the parameter name to that of the method + // TODO: Implement an annotation that lets the user choose a custom name + newParameter.Name = methodParameter.Name; + + // 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; + + // And get the parameter type + newParameter.Type = TypeFromMethodParameter(methodParameter); + + // TODO: implement more attributes, such as [Required] + + 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)) + return ApplicationCommandOptionType.Integer; + if (methodParameter.ParameterType == typeof(string)) + return ApplicationCommandOptionType.String; + if (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}"); + } + /// /// Creae a delegate from methodInfo. Taken from /// https://stackoverflow.com/a/40579063/8455128 @@ -143,8 +216,49 @@ 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 commandDefs, SlashCommandService slashCommandService, CommandRegistrationOptions options) { + // Get existing commmands + ulong devGuild = 386658607338618891; + var existingCommands = await socketClient.Rest.GetGuildApplicationCommands(devGuild).ConfigureAwait(false); + List existingCommandNames = new List(); + foreach (var existingCommand in existingCommands) + { + existingCommandNames.Add(existingCommand.Name); + } + + // Delete old ones that we want to re-implement + if (options.OldCommands == OldCommandOptions.DELETE_UNUSED || + options.OldCommands == OldCommandOptions.WIPE) + { + foreach (var existingCommand in existingCommands) + { + // If we want to wipe all commands + // or if the existing command isn't re-defined (probably code deleted by user) + // remove it from discord. + if(options.OldCommands == OldCommandOptions.WIPE || + !commandDefs.ContainsKey(existingCommand.Name)) + { + await existingCommand.DeleteAsync(); + } + } + } + foreach (var entry in commandDefs) + { + if (existingCommandNames.Contains(entry.Value.Name) && + options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING) + { + continue; + } + // If it's a new command or we want to overwrite an old one... + else + { + SlashCommandInfo slashCommandInfo = entry.Value; + SlashCommandCreationProperties command = slashCommandInfo.BuildCommand(); + // TODO: Implement Global and Guild Commands. + await socketClient.Rest.CreateGuildCommand(command, devGuild).ConfigureAwait(false); + } + } return; } } 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.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs index 683998272..bd8324eb7 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,17 @@ 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 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 +69,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 +84,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); From 50a88e09cde88f21ca5cb02f8d167c08edba56a6 Mon Sep 17 00:00:00 2001 From: Cosma George Date: Mon, 15 Feb 2021 22:45:24 +0200 Subject: [PATCH 4/7] Implemented SubCommands and SubCommandGroups properly. Details: To implement them I had to get creative. First thing i did was manually register a command that uses sub commands and sub command groups. Two things I noticed immediately: 1) I can create a subcommand on a "root" command - where no SubCommandGroup is used 2) The current implementation of the Interactions doesn't know what type of value an option is. Good thing is that there is only 1 option when querying subcommands and subcommand groups, so I can find out what the "path" of the subcommand is. TOP/root/rng TOP/root/usr/zero TOP/root/usr/johnny (i misspelled it in the source files, woops) [See SlashCommandsExample/DiscordClient.cs] Next I wanted to make command groups (I'll use this term as to mean a slash command with subcommands and regular slash command groups) to be implemented in code in a sort of hierarchical manner - so I made them classes with attributes. Unfortunately to make this work I had to make them re-inherit the same things as the base module - UGLY but I see no other option to do this other than making them inherit from another class that remembers the instance of the upper class and implements the same methods aka a whole mess that I decided I won't want to partake in. [See SlashCommandsExample/Modules/DevModule.cs] Next-up is to search for these sub-groups. I decided that the most intuitive way of implementing these was to make SlashModuleInfo have children and parent of the same type -- from which arose different problems, but we'll get to that. So I gave them some children and a parent and a reference to the CommandGroup attribute they have on themselves. The boolean isCommandGroup is unused, but could be useful in the future... maybe. Also I've added a path variable to internally store structure. I wanted (after the whole reflections business) for commands to be easly accessed and deal WITH NO REFLECTION because those are slow, so I changed the final string - SlashCommandInfo dictionary to containt paths instead of command infos, something like what I exemplefied above. In any case, I edited the service helper (the search for modules method) to ignore command groups and only store top level commands. After that I made a command to instantiate command groups, and the command creation and registration were changed as to be recursive - because recurion is the simpest way to do this and it's efficient enough for what we want - we only run this once anyway. The biggest change was with command building - commands no longer build themselves, but now we command each module to build itself. There are 3 cases: Top-Level commands Top-Level subcommands (or level 1 command group) subcommands within slash command groups The code is uncommented, untidy and I'll fix that in a future commit. One last thing to note is that SlashCommands can have 0 options! - fixed that bug. Also SlashCommandBuilder.WithName() for some reason was implemented wrongly - I pressume a copy-paste error, Also I implemented 0 types of enforcing rules - I'm going to leave this to other people to do. --- SlashCommandsExample/DiscordClient.cs | 69 ++++++++ SlashCommandsExample/Modules/DevModule.cs | 26 +++ .../SlashCommands/Attributes/CommandGroup.cs | 35 ++++ .../SlashCommands/Attributes/SlashCommand.cs | 4 +- .../Builders/SlashCommandBuilder.cs | 19 +-- .../SlashCommands/Info/SlashCommandInfo.cs | 26 ++- .../SlashCommands/Info/SlashModuleInfo.cs | 86 +++++++++- .../SlashCommands/SlashCommandService.cs | 52 +++++- .../SlashCommandServiceHelper.cs | 160 +++++++++++++----- .../API/Common/ApplicationCommandOption.cs | 4 +- 10 files changed, 413 insertions(+), 68 deletions(-) create mode 100644 src/Discord.Net.Commands/SlashCommands/Attributes/CommandGroup.cs diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs index 408c3870b..309cbc1a6 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,6 +48,72 @@ 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); diff --git a/SlashCommandsExample/Modules/DevModule.cs b/SlashCommandsExample/Modules/DevModule.cs index 37ef108ec..b8c6cb08a 100644 --- a/SlashCommandsExample/Modules/DevModule.cs +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -36,6 +36,32 @@ namespace SlashCommandsExample.Modules await Reply($"You gave me:\r\n {boolean}, {integer}, {myString}, <#{channel?.Id}>, {user?.Mention}, {role?.Mention}"); } + + [CommandGroup("root")] + 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\"."); + } + } + } } } /* 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/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/SlashCommands/Builders/SlashCommandBuilder.cs b/src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs index 6087825b4..e9c0384bc 100644 --- a/src/Discord.Net.Commands/SlashCommands/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/Info/SlashCommandInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs index eee54d32b..c475a9578 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs @@ -67,7 +67,7 @@ namespace Discord.SlashCommands /// Execute the function based on the interaction data we get. /// /// Interaction data from interaction - public async Task ExecuteAsync(SocketInteractionData data) + public async Task ExecuteAsync(IReadOnlyCollection data) { // List of arguments to be passed to the Delegate List args = new List(); @@ -101,14 +101,14 @@ namespace Discord.SlashCommands /// /// Get the interaction data from the name of the parameter we want to fill in. /// - private bool TryGetInteractionDataOption(SocketInteractionData data, string name, out SocketInteractionDataOption dataOption) + private bool TryGetInteractionDataOption(IReadOnlyCollection data, string name, out SocketInteractionDataOption dataOption) { - if (data.Options == null) + if (data == null) { dataOption = null; return false; } - foreach (var option in data.Options) + foreach (var option in data) { if (option.Name == name.ToLower()) { @@ -136,5 +136,23 @@ namespace Discord.SlashCommands 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..ffed450dc 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,22 @@ 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; + /// /// Gets the command service associated with this module. /// @@ -27,7 +42,7 @@ namespace Discord.SlashCommands /// Used to set context. /// public ISlashCommandModule userCommandModule; - + public Type moduleType; public void SetCommands(List commands) { @@ -43,5 +58,74 @@ 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) + { + builtCommands.Add(command.BuildCommand()); + } + foreach(var commandGroup in commandGroups) + { + builtCommands.Add(commandGroup.BuildTopLevelCommandGroup()); + } + 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/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs index cddc2350e..c14c1527c 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -41,20 +41,64 @@ 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); + 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(interaction.Data).ConfigureAwait(false); + 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. + /// /// + /// + /// + /// + private string GetSearchName(SocketInteractionData interactionData, out IReadOnlyCollection resultingOptions) + { + 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; + } + + 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 all previously scanned commands. @@ -66,7 +110,7 @@ namespace Discord.SlashCommands try { - await SlashCommandServiceHelper.RegisterCommands(socketClient, commandDefs, this,registrationOptions).ConfigureAwait(false); + await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this,registrationOptions).ConfigureAwait(false); } finally { diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs index 2d40185f3..0cef8f8a4 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -40,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; } /// @@ -49,18 +50,70 @@ 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 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.SetSubCommandGroups(InstantiateSubCommands(userModuleType, moduleInfo, slashCommandService)); + result.Add(userModuleType, moduleInfo); } return result; } + public static List InstantiateSubCommands(Type rootModule,SlashModuleInfo rootModuleInfo, SlashCommandService slashCommandService) + { + 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.SetSubCommandGroups(InstantiateSubCommands(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; + } + } /// /// Prepare all of the commands and register them internally. @@ -76,37 +129,52 @@ namespace Discord.SlashCommands SlashModuleInfo moduleInfo; if (moduleDefs.TryGetValue(userModule, out moduleInfo)) { - // TODO: handle sub-command groups - // 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)) - { - // 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 - ); - - result.Add(slashCommand.commandName, commandInfo); - commandInfos.Add(commandInfo); - } - } + var commandInfos = RegisterSameLevelCommands(result, userModule, moduleInfo); moduleInfo.SetCommands(commandInfos); + CreateSubCommandInfos(result, moduleInfo.commandGroups, slashCommandService); } } return result; } + public static void CreateSubCommandInfos(Dictionary result, List subCommandGroups, SlashCommandService slashCommandService) + { + foreach (var subCommandGroup in subCommandGroups) + { + var commandInfos = RegisterSameLevelCommands(result, subCommandGroup.moduleType.GetTypeInfo(), subCommandGroup); + subCommandGroup.SetCommands(commandInfos); + CreateSubCommandInfos(result, subCommandGroup.commandGroups, slashCommandService); + } + } + private static List RegisterSameLevelCommands(Dictionary result, TypeInfo userModule, SlashModuleInfo moduleInfo) + { + var commandMethods = userModule.GetMethods(); + List commandInfos = new List(); + foreach (var commandMethod in commandMethods) + { + 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 + ); + + 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 /// @@ -187,7 +255,6 @@ namespace Discord.SlashCommands throw new Exception($"Got parameter type other than int, string, bool, guild, role, or user. {methodParameter.Name}"); } - /// /// Creae a delegate from methodInfo. Taken from /// https://stackoverflow.com/a/40579063/8455128 @@ -216,7 +283,7 @@ namespace Discord.SlashCommands return Delegate.CreateDelegate(getType(types.ToArray()), target, methodInfo.Name); } - public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary commandDefs, SlashCommandService slashCommandService, CommandRegistrationOptions options) + public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary rootModuleInfos, Dictionary commandDefs, SlashCommandService slashCommandService, CommandRegistrationOptions options) { // Get existing commmands ulong devGuild = 386658607338618891; @@ -237,28 +304,39 @@ namespace Discord.SlashCommands // or if the existing command isn't re-defined (probably code deleted by user) // remove it from discord. if(options.OldCommands == OldCommandOptions.WIPE || - !commandDefs.ContainsKey(existingCommand.Name)) + !commandDefs.ContainsKey(SlashModuleInfo.RootCommandPrefix + existingCommand.Name)) { await existingCommand.DeleteAsync(); } } } - foreach (var entry in commandDefs) + //foreach (var entry in commandDefs) + //{ + // if (existingCommandNames.Contains(entry.Value.Name) && + // options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING) + // { + // continue; + // } + // // If it's a new command or we want to overwrite an old one... + // else + // { + // SlashCommandInfo slashCommandInfo = entry.Value; + // SlashCommandCreationProperties command = slashCommandInfo.BuildCommand(); + // + // await socketClient.Rest.CreateGuildCommand(command, devGuild).ConfigureAwait(false); + // } + //} + foreach (var pair in rootModuleInfos) { - if (existingCommandNames.Contains(entry.Value.Name) && - options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING) - { - continue; - } - // If it's a new command or we want to overwrite an old one... - else + var rootModuleInfo = pair.Value; + List builtCommands = rootModuleInfo.BuildCommands(); + foreach (var builtCommand in builtCommands) { - SlashCommandInfo slashCommandInfo = entry.Value; - SlashCommandCreationProperties command = slashCommandInfo.BuildCommand(); // TODO: Implement Global and Guild Commands. - await socketClient.Rest.CreateGuildCommand(command, devGuild).ConfigureAwait(false); + await socketClient.Rest.CreateGuildCommand(builtCommand, devGuild).ConfigureAwait(false); } } + return; } } 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; From c76fdec8d6e8da992577386ffa5736d38dd96092 Mon Sep 17 00:00:00 2001 From: Cosma George Date: Tue, 16 Feb 2021 15:24:41 +0200 Subject: [PATCH 5/7] Implemented Global Attribute and added a way to register all commands in particualr guilds. Details: There's nothing much to say, just added a variable to track with modules/commands/command groups have the global attribute and depending on that I register them globally or locally. There is currently no way to implement two commands with the same name, but one on the guild level and one on the global level. There is also currently no implemented way to register only some commands to only some guilds - this could be done through attributes, but another solution for users who want to do complex stuff would be to just give them the built commands and let them maually register them as they see fit. --- SlashCommandsExample/DiscordClient.cs | 6 +- SlashCommandsExample/Modules/DevModule.cs | 4 + .../SlashCommands/Attributes/Global.cs | 16 +++ .../SlashCommands/Info/SlashCommandInfo.cs | 4 +- .../SlashCommands/Info/SlashModuleInfo.cs | 15 +- .../SlashCommands/SlashCommandService.cs | 4 +- .../SlashCommandServiceHelper.cs | 132 ++++++++++++------ .../SlashCommandCreationProperties.cs | 4 + 8 files changed, 137 insertions(+), 48 deletions(-) create mode 100644 src/Discord.Net.Commands/SlashCommands/Attributes/Global.cs diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs index 309cbc1a6..bbdc88b14 100644 --- a/SlashCommandsExample/DiscordClient.cs +++ b/SlashCommandsExample/DiscordClient.cs @@ -120,7 +120,11 @@ namespace SlashCommandsExample await socketClient.StartAsync(); await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); - await _commands.RegisterCommandsAsync(socketClient, CommandRegistrationOptions.Default); + await _commands.RegisterCommandsAsync(socketClient, new List() + { + 386658607338618891 + }, + new CommandRegistrationOptions(OldCommandOptions.DELETE_UNUSED,ExistingCommandOptions.OVERWRITE)); await Task.Delay(-1); } diff --git a/SlashCommandsExample/Modules/DevModule.cs b/SlashCommandsExample/Modules/DevModule.cs index b8c6cb08a..4ff82e0de 100644 --- a/SlashCommandsExample/Modules/DevModule.cs +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -9,9 +9,12 @@ 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**"); @@ -38,6 +41,7 @@ namespace SlashCommandsExample.Modules } [CommandGroup("root")] + //[Global] public class DevModule_Root : SlashCommandModule { [SlashCommand("rng", "Gives you a random number from this \"machine\"")] 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/Info/SlashCommandInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs index c475a9578..f5542df52 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs @@ -28,6 +28,7 @@ namespace Discord.SlashCommands /// 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 /// @@ -37,13 +38,14 @@ namespace Discord.SlashCommands /// public Func> callback; - public SlashCommandInfo(SlashModuleInfo module, string name, string description,List parameters , 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 diff --git a/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs index ffed450dc..b62e11122 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs @@ -28,6 +28,7 @@ namespace Discord.SlashCommands 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. /// @@ -85,11 +86,21 @@ namespace Discord.SlashCommands List builtCommands = new List(); foreach (var command in Commands) { - builtCommands.Add(command.BuildCommand()); + var builtCommand = command.BuildCommand(); + if (isGlobal || command.isGlobal) + { + builtCommand.Global = true; + } + builtCommands.Add(builtCommand); } foreach(var commandGroup in commandGroups) { - builtCommands.Add(commandGroup.BuildTopLevelCommandGroup()); + var builtCommand = commandGroup.BuildTopLevelCommandGroup(); + if (isGlobal || commandGroup.isGlobal) + { + builtCommand.Global = true; + } + builtCommands.Add(builtCommand); } return builtCommands; } diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs index c14c1527c..ce7721769 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -103,14 +103,14 @@ namespace Discord.SlashCommands /// /// Registers all previously scanned commands. /// - public async Task RegisterCommandsAsync(DiscordSocketClient socketClient, CommandRegistrationOptions registrationOptions) + 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 { - await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this,registrationOptions).ConfigureAwait(false); + await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this, guildIDs, registrationOptions).ConfigureAwait(false); } finally { diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs index 0cef8f8a4..a63455ff3 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -59,10 +59,9 @@ namespace Discord.SlashCommands // 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(InstantiateSubCommands(userModuleType, moduleInfo, slashCommandService)); - result.Add(userModuleType, moduleInfo); } return result; @@ -82,9 +81,9 @@ namespace Discord.SlashCommands groupInfo.MakeCommandGroup(commandGroup,rootModuleInfo); groupInfo.MakePath(); + groupInfo.isGlobal = IsCommandModuleGlobal(commandGroupType); groupInfo.SetSubCommandGroups(InstantiateSubCommands(commandGroupType, groupInfo, slashCommandService)); - commandGroups.Add(groupInfo); } } @@ -114,7 +113,21 @@ namespace Discord.SlashCommands 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. /// @@ -129,7 +142,7 @@ namespace Discord.SlashCommands SlashModuleInfo moduleInfo; if (moduleDefs.TryGetValue(userModule, out moduleInfo)) { - var commandInfos = RegisterSameLevelCommands(result, userModule, moduleInfo); + var commandInfos = CreateSameLevelCommands(result, userModule, moduleInfo); moduleInfo.SetCommands(commandInfos); CreateSubCommandInfos(result, moduleInfo.commandGroups, slashCommandService); } @@ -140,12 +153,12 @@ namespace Discord.SlashCommands { foreach (var subCommandGroup in subCommandGroups) { - var commandInfos = RegisterSameLevelCommands(result, subCommandGroup.moduleType.GetTypeInfo(), subCommandGroup); + var commandInfos = CreateSameLevelCommands(result, subCommandGroup.moduleType.GetTypeInfo(), subCommandGroup); subCommandGroup.SetCommands(commandInfos); CreateSubCommandInfos(result, subCommandGroup.commandGroups, slashCommandService); } } - private static List RegisterSameLevelCommands(Dictionary result, TypeInfo userModule, SlashModuleInfo moduleInfo) + private static List CreateSameLevelCommands(Dictionary result, TypeInfo userModule, SlashModuleInfo moduleInfo) { var commandMethods = userModule.GetMethods(); List commandInfos = new List(); @@ -164,7 +177,8 @@ namespace Discord.SlashCommands 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 + userMethod: delegateMethod, + isGlobal: IsCommandGlobal(commandMethod) ); result.Add(commandInfo.Module.Path + SlashModuleInfo.PathSeperator + commandInfo.Name, commandInfo); @@ -196,6 +210,21 @@ namespace Discord.SlashCommands slashCommand = slashCommandAttributes.First() as SlashCommand; return true; } + 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; + } private static List ConstructCommandParameters(MethodInfo method) { // Prepare the final list of parameters @@ -283,57 +312,76 @@ namespace Discord.SlashCommands return Delegate.CreateDelegate(getType(types.ToArray()), target, methodInfo.Name); } - public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary rootModuleInfos, Dictionary commandDefs, SlashCommandService slashCommandService, CommandRegistrationOptions options) + public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary rootModuleInfos, Dictionary commandDefs, SlashCommandService slashCommandService, List guildIDs,CommandRegistrationOptions options) { - // Get existing commmands - ulong devGuild = 386658607338618891; - var existingCommands = await socketClient.Rest.GetGuildApplicationCommands(devGuild).ConfigureAwait(false); - List existingCommandNames = new List(); - foreach (var existingCommand in existingCommands) + // TODO: see how we should handle if user wants to register two commands with the same name, one global and one not. + List builtCommands = new List(); + foreach (var pair in rootModuleInfos) { - existingCommandNames.Add(existingCommand.Name); + var rootModuleInfo = pair.Value; + builtCommands.AddRange(rootModuleInfo.BuildCommands()); + } + + 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 (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)); + } } - // Delete old ones that we want to re-implement if (options.OldCommands == OldCommandOptions.DELETE_UNUSED || options.OldCommands == OldCommandOptions.WIPE) { - foreach (var existingCommand in existingCommands) + foreach (var existingCommand in existingGuildCommands) { // If we want to wipe all commands - // or if the existing command isn't re-defined (probably code deleted by user) + // or if the existing command isn't re-defined and re-built // remove it from discord. - if(options.OldCommands == OldCommandOptions.WIPE || - !commandDefs.ContainsKey(SlashModuleInfo.RootCommandPrefix + existingCommand.Name)) + 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 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 entry in commandDefs) - //{ - // if (existingCommandNames.Contains(entry.Value.Name) && - // options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING) - // { - // continue; - // } - // // If it's a new command or we want to overwrite an old one... - // else - // { - // SlashCommandInfo slashCommandInfo = entry.Value; - // SlashCommandCreationProperties command = slashCommandInfo.BuildCommand(); - // - // await socketClient.Rest.CreateGuildCommand(command, devGuild).ConfigureAwait(false); - // } - //} - foreach (var pair in rootModuleInfos) + + foreach (var builtCommand in builtCommands) { - var rootModuleInfo = pair.Value; - List builtCommands = rootModuleInfo.BuildCommands(); - foreach (var builtCommand in builtCommands) + if (builtCommand.Global) + { + await socketClient.Rest.CreateGlobalCommand(builtCommand).ConfigureAwait(false); + } + else { - // TODO: Implement Global and Guild Commands. - await socketClient.Rest.CreateGuildCommand(builtCommand, devGuild).ConfigureAwait(false); + foreach (ulong guildID in guildIDs) + { + await socketClient.Rest.CreateGuildCommand(builtCommand, guildID).ConfigureAwait(false); + } } } 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. From 6ba456a1d681562c07f62881b831f6c39dcfc5cd Mon Sep 17 00:00:00 2001 From: Cosma George Date: Tue, 16 Feb 2021 17:36:18 +0200 Subject: [PATCH 6/7] Implemented [Choice( [] , [] )] and [Required] Attributes. Details: There is nothing really to be said, it's simple and self-explanatory. The only thing I would want to implement is to add support for bool? and int? param types because, as of now, you don't know if the user passed False or just didn't pass anything. In any case, I personally don't suspect it's going to be hard to do. After I implement this I will create a PR on the original Discord.Net Repo. --- SlashCommandsExample/Modules/DevModule.cs | 20 ++++++++- .../SlashCommands/Attributes/Choice.cs | 41 +++++++++++++++++++ .../SlashCommands/Attributes/Required.cs | 16 ++++++++ .../SlashCommandServiceHelper.cs | 28 ++++++++++++- 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/Discord.Net.Commands/SlashCommands/Attributes/Choice.cs create mode 100644 src/Discord.Net.Commands/SlashCommands/Attributes/Required.cs diff --git a/SlashCommandsExample/Modules/DevModule.cs b/SlashCommandsExample/Modules/DevModule.cs index 4ff82e0de..d2079573f 100644 --- a/SlashCommandsExample/Modules/DevModule.cs +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -21,7 +21,10 @@ namespace SlashCommandsExample.Modules } [SlashCommand("echo", "I'll repeate everything you said to me, word for word.")] - public async Task EchoAsync([Description("The message you want repetead")]string message) + 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}"); } @@ -40,6 +43,21 @@ namespace SlashCommandsExample.Modules } + [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 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/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/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs index a63455ff3..e49846193 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -257,7 +257,33 @@ namespace Discord.SlashCommands // And get the parameter type newParameter.Type = TypeFromMethodParameter(methodParameter); - // TODO: implement more attributes, such as [Required] + // [Required] Parameter + var requiredAttributes = methodParameter.GetCustomAttributes(typeof(Required)); + if (requiredAttributes.Count() == 1) + newParameter.Required = true; + else if (requiredAttributes.Count() > 1) + throw new Exception($"Too many Required attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!"); + + // [Choice] Parameter + foreach (Choice choice in methodParameter.GetCustomAttributes(typeof(Choice))) + { + 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 (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); } From 4019d512c3066bda82c37fde1e0523c0441bacdb Mon Sep 17 00:00:00 2001 From: Cosma George Date: Wed, 17 Feb 2021 16:39:51 +0200 Subject: [PATCH 7/7] Implemented ParameterName for custom names, implemented a public BuildCommands for those who want to register commands themselves and added support for bool? and int?. There will be a PR soon. --- SlashCommandsExample/DiscordClient.cs | 9 ++ SlashCommandsExample/Modules/DevModule.cs | 7 +- .../SlashCommands/Attributes/ParameterName.cs | 29 +++++ .../SlashCommands/Info/SlashParameterInfo.cs | 12 +- .../SlashCommands/SlashCommandService.cs | 39 ++++++- .../SlashCommandServiceHelper.cs | 107 ++++++++++++++---- .../SocketInteractionDataOption.cs | 15 +++ 7 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs diff --git a/SlashCommandsExample/DiscordClient.cs b/SlashCommandsExample/DiscordClient.cs index bbdc88b14..b2b891d13 100644 --- a/SlashCommandsExample/DiscordClient.cs +++ b/SlashCommandsExample/DiscordClient.cs @@ -126,6 +126,15 @@ namespace SlashCommandsExample }, new CommandRegistrationOptions(OldCommandOptions.DELETE_UNUSED,ExistingCommandOptions.OVERWRITE)); + // If you would like to register your commands manually use: + //-----------------------------------------// + // + // await _commands.BuildCommands(); + // + //-----------------------------------------// + // Though I wouldn't highly recommend it unless you want to do something very specific with them + // such as only registering some commands on only some guilds, or editing them manually. + await Task.Delay(-1); } diff --git a/SlashCommandsExample/Modules/DevModule.cs b/SlashCommandsExample/Modules/DevModule.cs index d2079573f..9d4949007 100644 --- a/SlashCommandsExample/Modules/DevModule.cs +++ b/SlashCommandsExample/Modules/DevModule.cs @@ -31,8 +31,11 @@ namespace SlashCommandsExample.Modules [SlashCommand("overload","Just hit me with every type of data you got, man!")] public async Task OverloadAsync( - bool boolean, - int integer, + [ParameterName("var1")] + bool? boolean, + [ParameterName("var2")] + int? integer, + [ParameterName("var3")] string myString, SocketGuildChannel channel, SocketGuildUser user, diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs new file mode 100644 index 000000000..c277012bb --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + /// + /// An Attribute that gives the command parameter a custom name. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public class ParameterName : Attribute + { + /// + /// The name of this slash command parameter. + /// + public string name; + + /// + /// Tells the that this parameter has a custom name. + /// + /// The name of this slash command. + public ParameterName(string name) + { + this.name = name; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs index 0b43ac058..f2d8a5ce9 100644 --- a/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs +++ b/src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs @@ -10,14 +10,22 @@ namespace Discord.SlashCommands { public class SlashParameterInfo : SlashCommandOptionBuilder { + public bool Nullable { get; internal set; } + public object Parse(SocketInteractionDataOption dataOption) { switch (Type) { case ApplicationCommandOptionType.Boolean: - return (bool)dataOption; + if (Nullable) + return (bool?)dataOption; + else + return (bool)dataOption; case ApplicationCommandOptionType.Integer: - return (int)dataOption; + if(Nullable) + return (int?)dataOption; + else + return (int)dataOption; case ApplicationCommandOptionType.String: return (string)dataOption; case ApplicationCommandOptionType.Channel: diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs index ce7721769..c227758a7 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -44,6 +44,7 @@ namespace Discord.SlashCommands SlashCommandInfo commandInfo; // Get the name of the actual command - be it a normal slash command or subcommand, and return the options we can give it. string name = GetSearchName(interaction.Data, out var resultingOptions); + // We still need to make sure it is registerd. if (commandDefs.TryGetValue(name, out commandInfo)) { // Then, set the context in which the command will be executed @@ -62,8 +63,16 @@ namespace Discord.SlashCommands /// /// /// - private string GetSearchName(SocketInteractionData interactionData, out IReadOnlyCollection resultingOptions) + public string GetSearchName(SocketInteractionData interactionData, out IReadOnlyCollection resultingOptions) { + // The names are stored as such: + // TOP//top-level-command-name + // TOP//command-group//command-group//sub-command-name + // What we are looking for is to get from the interaction the specific (sub)command and what we need to pass to the method. + // So we start the search at TOP//{interactionData.name} + // because we are going to go through each sub-option it has. If it is a subcommand/ command group then it's going to be + // inside the dictionary as TOP//{interactionData.name}//{option.name} + // If the option is a parameter we then know that we've reached the end of the call chain - this should be our coomand! string nameToSearch = SlashModuleInfo.RootCommandPrefix + interactionData.Name; var options = interactionData.Options; while(options != null && options.Count == 1) @@ -82,7 +91,9 @@ namespace Discord.SlashCommands resultingOptions = options; return nameToSearch; } - + /// + /// Test to see if any string key contains another string inside it. + /// private bool AnyKeyContains(Dictionary commandDefs, string newName) { foreach (var pair in commandDefs) @@ -101,7 +112,7 @@ namespace Discord.SlashCommands } /// - /// Registers all previously scanned commands. + /// Registers with discord all previously scanned commands. /// public async Task RegisterCommandsAsync(DiscordSocketClient socketClient, List guildIDs, CommandRegistrationOptions registrationOptions) { @@ -110,6 +121,7 @@ namespace Discord.SlashCommands try { + // Build and register all of the commands. await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this, guildIDs, registrationOptions).ConfigureAwait(false); } finally @@ -119,6 +131,27 @@ namespace Discord.SlashCommands await _logger.InfoAsync("All commands have been registered!").ConfigureAwait(false); } + /// + /// Build all the commands and return them, for manual registration with Discord. This is automatically done in + /// + /// A list of all the valid commands found within this Assembly. + public async Task> BuildCommands() + { + // First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business + await _moduleLock.WaitAsync().ConfigureAwait(false); + List result; + try + { + result = await SlashCommandServiceHelper.BuildCommands(moduleDefs).ConfigureAwait(false); + } + finally + { + _moduleLock.Release(); + } + await _logger.InfoAsync("All commands have been built!").ConfigureAwait(false); + return result; + } + /// /// Scans the program for Attribute-based SlashCommandModules /// diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs index e49846193..2239758dc 100644 --- a/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs @@ -50,7 +50,7 @@ namespace Discord.SlashCommands public static async Task> InstantiateModules(IReadOnlyList types, SlashCommandService slashCommandService) { var result = new Dictionary(); - // Here we get all modules thate are NOT sub command groups + // Here we get all modules thate are NOT sub command groups and instantiate them. foreach (Type userModuleType in types) { SlashModuleInfo moduleInfo = new SlashModuleInfo(slashCommandService); @@ -61,13 +61,14 @@ namespace Discord.SlashCommands moduleInfo.SetCommandModule(instance); moduleInfo.isGlobal = IsCommandModuleGlobal(userModuleType); - moduleInfo.SetSubCommandGroups(InstantiateSubCommands(userModuleType, moduleInfo, slashCommandService)); + moduleInfo.SetSubCommandGroups(InstantiateSubModules(userModuleType, moduleInfo, slashCommandService)); result.Add(userModuleType, moduleInfo); } return result; } - public static List InstantiateSubCommands(Type rootModule,SlashModuleInfo rootModuleInfo, SlashCommandService slashCommandService) + public static List InstantiateSubModules(Type rootModule,SlashModuleInfo rootModuleInfo, SlashCommandService slashCommandService) { + // Instantiate all of the nested modules. List commandGroups = new List(); foreach(Type commandGroupType in rootModule.GetNestedTypes()) { @@ -83,7 +84,7 @@ namespace Discord.SlashCommands groupInfo.MakePath(); groupInfo.isGlobal = IsCommandModuleGlobal(commandGroupType); - groupInfo.SetSubCommandGroups(InstantiateSubCommands(commandGroupType, groupInfo, slashCommandService)); + groupInfo.SetSubCommandGroups(InstantiateSubModules(commandGroupType, groupInfo, slashCommandService)); commandGroups.Add(groupInfo); } } @@ -142,8 +143,10 @@ namespace Discord.SlashCommands SlashModuleInfo moduleInfo; if (moduleDefs.TryGetValue(userModule, out moduleInfo)) { + // Create the root-level commands var commandInfos = CreateSameLevelCommands(result, userModule, moduleInfo); moduleInfo.SetCommands(commandInfos); + // Then create all of the command groups it has. CreateSubCommandInfos(result, moduleInfo.commandGroups, slashCommandService); } } @@ -153,8 +156,11 @@ namespace Discord.SlashCommands { foreach (var subCommandGroup in subCommandGroups) { + // Create the commands that is on the same hierarchical level as this ... var commandInfos = CreateSameLevelCommands(result, subCommandGroup.moduleType.GetTypeInfo(), subCommandGroup); subCommandGroup.SetCommands(commandInfos); + + // ... and continue with the lower sub command groups. CreateSubCommandInfos(result, subCommandGroup.commandGroups, slashCommandService); } } @@ -164,6 +170,7 @@ namespace Discord.SlashCommands List commandInfos = new List(); foreach (var commandMethod in commandMethods) { + // Get the SlashCommand attribute SlashCommand slashCommand; if (IsValidSlashCommand(commandMethod, out slashCommand)) { @@ -210,6 +217,9 @@ namespace Discord.SlashCommands slashCommand = slashCommandAttributes.First() as SlashCommand; return true; } + /// + /// Determins if the method has a [Global] Attribute. + /// private static bool IsCommandGlobal(MethodInfo method) { // Verify that we only have one [Global] attribute @@ -225,6 +235,9 @@ namespace Discord.SlashCommands } return true; } + /// + /// Process the parameters of this method, including all the attributes. + /// private static List ConstructCommandParameters(MethodInfo method) { // Prepare the final list of parameters @@ -237,9 +250,15 @@ namespace Discord.SlashCommands { SlashParameterInfo newParameter = new SlashParameterInfo(); - // Set the parameter name to that of the method - // TODO: Implement an annotation that lets the user choose a custom name - newParameter.Name = methodParameter.Name; + // Test for the [ParameterName] Attribute. If we have it, then use that as the name, + // if not just use the parameter name as the option name. + var customNameAttributes = methodParameter.GetCustomAttributes(typeof(ParameterName)); + if (customNameAttributes.Count() == 0) + newParameter.Name = methodParameter.Name; + else if (customNameAttributes.Count() > 1) + throw new Exception($"Too many ParameterName attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!"); + else + newParameter.Name = (customNameAttributes.First() as ParameterName).name; // Get to see if it has a Description Attribute. // If it has @@ -254,19 +273,26 @@ namespace Discord.SlashCommands else newParameter.Description = (descriptions.First() as Description).description; - // And get the parameter type + // Set the Type of the parameter. + // In the case of int and int? it returns the same type - INTEGER. + // Same with bool and bool?. newParameter.Type = TypeFromMethodParameter(methodParameter); - // [Required] Parameter + // If we have a nullble type (int? or bool?) mark it as such. + newParameter.Nullable = GetNullableStatus(methodParameter); + + // Test for the [Required] Attribute var requiredAttributes = methodParameter.GetCustomAttributes(typeof(Required)); if (requiredAttributes.Count() == 1) newParameter.Required = true; else if (requiredAttributes.Count() > 1) throw new Exception($"Too many Required attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!"); - // [Choice] Parameter + // Test for the [Choice] Attribute + // A parameter cna have multiple Choice attributes, and for each we're going to add it's key-value pair. foreach (Choice choice in methodParameter.GetCustomAttributes(typeof(Choice))) { + // If the parameter expects a string but the value of the choice is of type int, then throw an error. if (newParameter.Type == ApplicationCommandOptionType.String) { if(String.IsNullOrEmpty(choice.choiceStringValue)) @@ -275,6 +301,7 @@ namespace Discord.SlashCommands } newParameter.AddChoice(choice.choiceName, choice.choiceStringValue); } + // If the parameter expects a int but the value of the choice is of type string, then throw an error. if (newParameter.Type == ApplicationCommandOptionType.Integer) { if (choice.choiceIntValue == null) @@ -295,11 +322,13 @@ namespace Discord.SlashCommands private static ApplicationCommandOptionType TypeFromMethodParameter(ParameterInfo methodParameter) { // Can't do switch -- who knows why? - if (methodParameter.ParameterType == typeof(int)) + if (methodParameter.ParameterType == typeof(int) || + methodParameter.ParameterType == typeof(int?)) return ApplicationCommandOptionType.Integer; if (methodParameter.ParameterType == typeof(string)) return ApplicationCommandOptionType.String; - if (methodParameter.ParameterType == typeof(bool)) + if (methodParameter.ParameterType == typeof(bool) || + methodParameter.ParameterType == typeof(bool?)) return ApplicationCommandOptionType.Boolean; if (methodParameter.ParameterType == typeof(SocketGuildChannel)) return ApplicationCommandOptionType.Channel; @@ -310,11 +339,25 @@ namespace Discord.SlashCommands throw new Exception($"Got parameter type other than int, string, bool, guild, role, or user. {methodParameter.Name}"); } + + /// + /// Gets whater the parameter can be set as null, in the case that parameter type usually does not allow null. + /// More specifically tests to see if it is a type of 'int?' or 'bool?', + /// + private static bool GetNullableStatus(ParameterInfo methodParameter) + { + if(methodParameter.ParameterType == typeof(int?) || + methodParameter.ParameterType == typeof(bool?)) + { + return true; + } + return false; + } /// /// Creae a delegate from methodInfo. Taken from /// https://stackoverflow.com/a/40579063/8455128 /// - public static Delegate CreateDelegate(MethodInfo methodInfo, object target) + private static Delegate CreateDelegate(MethodInfo methodInfo, object target) { Func getType; var isAction = methodInfo.ReturnType.Equals((typeof(void))); @@ -341,13 +384,10 @@ namespace Discord.SlashCommands public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary rootModuleInfos, Dictionary commandDefs, SlashCommandService slashCommandService, List guildIDs,CommandRegistrationOptions options) { // TODO: see how we should handle if user wants to register two commands with the same name, one global and one not. - List builtCommands = new List(); - foreach (var pair in rootModuleInfos) - { - var rootModuleInfo = pair.Value; - builtCommands.AddRange(rootModuleInfo.BuildCommands()); - } + // Build the commands + List builtCommands = await BuildCommands(rootModuleInfos).ConfigureAwait(false); + // Scan for each existing command on discord so we know what is already there. List existingGuildCommands = new List(); List existingGlobalCommands = new List(); existingGlobalCommands.AddRange(await socketClient.Rest.GetGlobalApplicationCommands().ConfigureAwait(false)); @@ -355,6 +395,9 @@ namespace Discord.SlashCommands { existingGuildCommands.AddRange(await socketClient.Rest.GetGuildApplicationCommands(guildID).ConfigureAwait(false)); } + + // If we want to keep the existing commands that are already registered + // remove the commands that share the same name from the builtCommands list as to not overwrite. if (options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING) { foreach (var existingCommand in existingGuildCommands) @@ -367,24 +410,26 @@ namespace Discord.SlashCommands } } + // If we want to delete commands that are not going to be re-implemented in builtCommands + // or if we just want a blank slate if (options.OldCommands == OldCommandOptions.DELETE_UNUSED || options.OldCommands == OldCommandOptions.WIPE) - { + { foreach (var existingCommand in existingGuildCommands) { - // If we want to wipe all commands + // If we want to wipe all GUILD commands // or if the existing command isn't re-defined and re-built // remove it from discord. if (options.OldCommands == OldCommandOptions.WIPE || // There are no commands which contain this existing command. - !builtCommands.Any( x => !x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name))) + !builtCommands.Any(x => !x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name))) { await existingCommand.DeleteAsync(); } } foreach (var existingCommand in existingGlobalCommands) { - // If we want to wipe all commands + // If we want to wipe all GLOBAL commands // or if the existing command isn't re-defined and re-built // remove it from discord. if (options.OldCommands == OldCommandOptions.WIPE || @@ -396,6 +441,8 @@ namespace Discord.SlashCommands } } + // And now register them. Globally if the 'Global' flag is set. + // If not then just register them as guild commands on all of the guilds given to us. foreach (var builtCommand in builtCommands) { if (builtCommand.Global) @@ -413,5 +460,19 @@ namespace Discord.SlashCommands return; } + /// + /// Build and return all of the commands this assembly contians. + /// + public static async Task> BuildCommands(Dictionary rootModuleInfos) + { + List builtCommands = new List(); + foreach (var pair in rootModuleInfos) + { + var rootModuleInfo = pair.Value; + builtCommands.AddRange(rootModuleInfo.BuildCommands()); + } + + return builtCommands; + } } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs index bd8324eb7..3a612388d 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs @@ -52,6 +52,21 @@ namespace Discord.WebSocket public static explicit operator string(SocketInteractionDataOption option) => option.Value.ToString(); + public static explicit operator bool?(SocketInteractionDataOption option) + { + if (option.Value == null) + return null; + else + return (bool)option; + } + public static explicit operator int?(SocketInteractionDataOption option) + { + if (option.Value == null) + return null; + else + return (int)option; + } + public static explicit operator SocketGuildChannel(SocketInteractionDataOption option) { if (ulong.TryParse((string)option.Value, out ulong id))