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}."); + } } }