diff --git a/DisCatSharp.ApplicationCommands/ApplicationCommandsConfiguration.cs b/DisCatSharp.ApplicationCommands/ApplicationCommandsConfiguration.cs index 7132ac807..6ab119fdb 100644 --- a/DisCatSharp.ApplicationCommands/ApplicationCommandsConfiguration.cs +++ b/DisCatSharp.ApplicationCommands/ApplicationCommandsConfiguration.cs @@ -1,40 +1,46 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using Microsoft.Extensions.DependencyInjection; namespace DisCatSharp.ApplicationCommands { /// /// A configuration for a /// public class ApplicationCommandsConfiguration { /// /// Sets the service provider. /// Objects in this provider are used when instantiating application command modules. This allows passing data around without resorting to static members. /// Defaults to null. /// - public IServiceProvider Services { internal get; set; } = new ServiceCollection().BuildServiceProvider(true); + public IServiceProvider ServiceProvider { internal get; set; } = new ServiceCollection().BuildServiceProvider(true); + + [ActivatorUtilitiesConstructor] + public ApplicationCommandsConfiguration(IServiceProvider provider) + { + this.ServiceProvider = provider; + } } } diff --git a/DisCatSharp.ApplicationCommands/ApplicationCommandsExtension.cs b/DisCatSharp.ApplicationCommands/ApplicationCommandsExtension.cs index 370715ab5..31edf8feb 100644 --- a/DisCatSharp.ApplicationCommands/ApplicationCommandsExtension.cs +++ b/DisCatSharp.ApplicationCommands/ApplicationCommandsExtension.cs @@ -1,1352 +1,1352 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Reflection; using System.Threading.Tasks; using System.Collections.Generic; using DisCatSharp.Entities; using System.Linq; using DisCatSharp.EventArgs; using Microsoft.Extensions.Logging; using DisCatSharp.Common.Utilities; using Microsoft.Extensions.DependencyInjection; using DisCatSharp.ApplicationCommands.EventArgs; using DisCatSharp.Exceptions; using DisCatSharp.Enums; using DisCatSharp.ApplicationCommands.Attributes; using System.Text.RegularExpressions; using DisCatSharp.Common; namespace DisCatSharp.ApplicationCommands { /// /// A class that handles slash commands for a client. /// public sealed class ApplicationCommandsExtension : BaseExtension { /// /// A list of methods for top level commands. /// private static List _commandMethods { get; set; } = new List(); /// /// List of groups. /// private static List _groupCommands { get; set; } = new List(); /// /// List of groups with subgroups. /// private static List _subGroupCommands { get; set; } = new List(); /// /// List of context menus. /// private static List _contextMenuCommands { get; set; } = new List(); /// /// Singleton modules. /// private static List _singletonModules { get; set; } = new List(); /// /// List of modules to register. /// private List> _updateList { get; set; } = new List>(); /// /// Configuration for Discord. /// private readonly ApplicationCommandsConfiguration _configuration; /// /// Set to true if anything fails when registering. /// private static bool _errored { get; set; } = false; /// /// Gets a list of registered commands. The key is the guild id (null if global). /// public IReadOnlyList>> RegisteredCommands => _registeredCommands; private static List>> _registeredCommands = new(); /// /// Initializes a new instance of the class. /// /// The configuration. internal ApplicationCommandsExtension(ApplicationCommandsConfiguration configuration) { this._configuration = configuration; } /// /// Runs setup. DO NOT RUN THIS MANUALLY. DO NOT DO ANYTHING WITH THIS. /// /// The client to setup on. protected internal override void Setup(DiscordClient client) { if (this.Client != null) throw new InvalidOperationException("What did I tell you?"); this.Client = client; this._slashError = new AsyncEvent("SLASHCOMMAND_ERRORED", TimeSpan.Zero, null); this._slashExecuted = new AsyncEvent("SLASHCOMMAND_EXECUTED", TimeSpan.Zero, null); this._contextMenuErrored = new AsyncEvent("CONTEXTMENU_ERRORED", TimeSpan.Zero, null); this._contextMenuExecuted = new AsyncEvent("CONTEXTMENU_EXECUTED", TimeSpan.Zero, null); this.Client.Ready += this.Update; this.Client.InteractionCreated += this.InteractionHandler; this.Client.ContextMenuInteractionCreated += this.ContextMenuHandler; } /// /// Registers a command class. /// /// The command class to register. /// The guild id to register it on. If you want global commands, leave it null. public void RegisterCommands(ulong? guildId = null) where T : ApplicationCommandsModule { if (this.Client.ShardId == 0) this._updateList.Add(new KeyValuePair(guildId, new ApplicationCommandsModuleConfiguration(typeof(T)))); } /// /// Registers a command class. /// /// The of the command class to register. /// The guild id to register it on. If you want global commands, leave it null. public void RegisterCommands(Type type, ulong? guildId = null) { if (!typeof(ApplicationCommandsModule).IsAssignableFrom(type)) throw new ArgumentException("Command classes have to inherit from ApplicationCommandsModule", nameof(type)); //If sharding, only register for shard 0 if (this.Client.ShardId == 0) this._updateList.Add(new KeyValuePair(guildId, new ApplicationCommandsModuleConfiguration(type))); } /// /// Registers a command class with permission setup. /// /// The command class to register. /// The guild id to register it on. /// A callback to setup permissions with. public void RegisterCommands(ulong guildId, Action permissionSetup = null) where T : ApplicationCommandsModule { if (this.Client.ShardId == 0) this._updateList.Add(new KeyValuePair(guildId, new ApplicationCommandsModuleConfiguration(typeof(T), permissionSetup))); } /// /// Registers a command class with permission setup. /// /// The of the command class to register. /// The guild id to register it on. /// A callback to setup permissions with. public void RegisterCommands(Type type, ulong guildId, Action permissionSetup = null) { if (!typeof(ApplicationCommandsModule).IsAssignableFrom(type)) throw new ArgumentException("Command classes have to inherit from ApplicationCommandsModule", nameof(type)); //If sharding, only register for shard 0 if (this.Client.ShardId == 0) this._updateList.Add(new KeyValuePair(guildId, new ApplicationCommandsModuleConfiguration(type, permissionSetup))); } /* /// /// Registers a command class with permission setup but without a guild id. /// /// The command class to register. /// A callback to setup permissions with. public void RegisterCommands(Action permissionSetup = null) where T : ApplicationCommandsModule { if (this.Client.ShardId == 0) this._updateList.Add(new KeyValuePair(null, new ApplicationCommandsModuleConfiguration(typeof(T), permissionSetup))); } /// /// Registers a command class with permission setup but without a guild id. /// /// The of the command class to register. /// A callback to setup permissions with. public void RegisterCommands(Type type, Action permissionSetup = null) { if (!typeof(ApplicationCommandsModule).IsAssignableFrom(type)) throw new ArgumentException("Command classes have to inherit from ApplicationCommandsModule", nameof(type)); //If sharding, only register for shard 0 if (this.Client.ShardId == 0) this._updateList.Add(new KeyValuePair(null, new ApplicationCommandsModuleConfiguration(type, permissionSetup))); } */ /// /// To be run on ready. /// /// The client. /// The ready event args. internal Task Update(DiscordClient client, ReadyEventArgs e) => this.Update(); /// /// Actual method for registering, used for RegisterCommands and on Ready. /// internal Task Update() { //Only update for shard 0 if (this.Client.ShardId == 0) { //Groups commands by guild id or global foreach (var key in this._updateList.Select(x => x.Key).Distinct()) { this.RegisterCommands(this._updateList.Where(x => x.Key == key).Select(x => x.Value), key); } } return Task.CompletedTask; } /// /// Method for registering commands for a target from modules. /// /// The types. /// The optional guild id. private void RegisterCommands(IEnumerable types, ulong? guildid) { //Initialize empty lists to be added to the global ones at the end var commandMethods = new List(); var groupCommands = new List(); var subGroupCommands = new List(); var contextMenuCommands = new List(); var updateList = new List(); var commandTypeSources = new List>(); _ = Task.Run(async () => { //Iterates over all the modules foreach (var config in types) { var type = config.Type; try { var module = type.GetTypeInfo(); var classes = new List(); //Add module to classes list if it's a group if (module.GetCustomAttribute() != null) { classes.Add(module); } else { //Otherwise add the nested groups classes = module.DeclaredNestedTypes.Where(x => x.GetCustomAttribute() != null).ToList(); } //Handles groups foreach (var subclassinfo in classes) { //Gets the attribute and methods in the group var groupAttribute = subclassinfo.GetCustomAttribute(); var submethods = subclassinfo.DeclaredMethods.Where(x => x.GetCustomAttribute() != null); var subclasses = subclassinfo.DeclaredNestedTypes.Where(x => x.GetCustomAttribute() != null); if (subclasses.Any() && submethods.Any()) { throw new ArgumentException("Slash command groups cannot have both subcommands and subgroups!"); } //Initializes the command var payload = new DiscordApplicationCommand(groupAttribute.Name, groupAttribute.Description, default_permission: groupAttribute.DefaultPermission); commandTypeSources.Add(new KeyValuePair(type, type)); var commandmethods = new List>(); //Handles commands in the group foreach (var submethod in submethods) { var commandAttribute = submethod.GetCustomAttribute(); //Gets the paramaters and accounts for InteractionContext var parameters = submethod.GetParameters(); if (parameters.Length == 0 || parameters == null || !ReferenceEquals(parameters.First().ParameterType, typeof(InteractionContext))) throw new ArgumentException($"The first argument must be an InteractionContext!"); parameters = parameters.Skip(1).ToArray(); var options = await this.ParseParameters(parameters, guildid); //Creates the subcommand and adds it to the main command var subpayload = new DiscordApplicationCommandOption(commandAttribute.Name, commandAttribute.Description, ApplicationCommandOptionType.SubCommand, null, null, options); payload = new DiscordApplicationCommand(payload.Name, payload.Description, payload.Options?.Append(subpayload) ?? new[] { subpayload }, payload.DefaultPermission); commandTypeSources.Add(new KeyValuePair(subclassinfo, type)); //Adds it to the method lists commandmethods.Add(new KeyValuePair(commandAttribute.Name, submethod)); groupCommands.Add(new GroupCommand { Name = groupAttribute.Name, Methods = commandmethods }); } var command = new SubGroupCommand { Name = groupAttribute.Name }; //Handles subgroups foreach (var subclass in subclasses) { var subGroupAttribute = subclass.GetCustomAttribute(); //I couldn't think of more creative naming var subsubmethods = subclass.DeclaredMethods.Where(x => x.GetCustomAttribute() != null); var options = new List(); var currentMethods = new List>(); //Similar to the one for regular groups foreach (var subsubmethod in subsubmethods) { var suboptions = new List(); var commatt = subsubmethod.GetCustomAttribute(); var parameters = subsubmethod.GetParameters(); if (parameters.Length == 0 || parameters == null || !ReferenceEquals(parameters.First().ParameterType, typeof(InteractionContext))) throw new ArgumentException($"The first argument must be an InteractionContext!"); parameters = parameters.Skip(1).ToArray(); suboptions = suboptions.Concat(await this.ParseParameters(parameters, guildid)).ToList(); var subsubpayload = new DiscordApplicationCommandOption(commatt.Name, commatt.Description, ApplicationCommandOptionType.SubCommand, null, null, suboptions); options.Add(subsubpayload); commandmethods.Add(new KeyValuePair(commatt.Name, subsubmethod)); currentMethods.Add(new KeyValuePair(commatt.Name, subsubmethod)); } //Adds the group to the command and method lists var subpayload = new DiscordApplicationCommandOption(subGroupAttribute.Name, subGroupAttribute.Description, ApplicationCommandOptionType.SubCommandGroup, null, null, options); command.SubCommands.Add(new GroupCommand { Name = subGroupAttribute.Name, Methods = currentMethods }); payload = new DiscordApplicationCommand(payload.Name, payload.Description, payload.Options?.Append(subpayload) ?? new[] { subpayload }, payload.DefaultPermission); commandTypeSources.Add(new KeyValuePair(subclass, type)); //Accounts for lifespans for the sub group if (subclass.GetCustomAttribute() != null) { if (subclass.GetCustomAttribute().Lifespan == ApplicationCommandModuleLifespan.Singleton) { - _singletonModules.Add(this.CreateInstance(subclass, this._configuration?.Services)); + _singletonModules.Add(this.CreateInstance(subclass, this._configuration?.ServiceProvider)); } } } if (command.SubCommands.Any()) subGroupCommands.Add(command); updateList.Add(payload); //Accounts for lifespans if (subclassinfo.GetCustomAttribute() != null) { if (subclassinfo.GetCustomAttribute().Lifespan == ApplicationCommandModuleLifespan.Singleton) { - _singletonModules.Add(this.CreateInstance(subclassinfo, this._configuration?.Services)); + _singletonModules.Add(this.CreateInstance(subclassinfo, this._configuration?.ServiceProvider)); } } } //Handles methods and context menus, only if the module isn't a group itself if (module.GetCustomAttribute() == null) { //Slash commands (again, similar to the one for groups) var methods = module.DeclaredMethods.Where(x => x.GetCustomAttribute() != null); foreach (var method in methods) { var commandattribute = method.GetCustomAttribute(); var parameters = method.GetParameters(); if (parameters.Length == 0 || parameters == null || !ReferenceEquals(parameters.FirstOrDefault()?.ParameterType, typeof(InteractionContext))) throw new ArgumentException($"The first argument must be an InteractionContext!"); parameters = parameters.Skip(1).ToArray(); var options = await this.ParseParameters(parameters, guildid); commandMethods.Add(new CommandMethod { Method = method, Name = commandattribute.Name }); var payload = new DiscordApplicationCommand(commandattribute.Name, commandattribute.Description, options, commandattribute.DefaultPermission); updateList.Add(payload); commandTypeSources.Add(new KeyValuePair(type, type)); } //Context Menus var contextMethods = module.DeclaredMethods.Where(x => x.GetCustomAttribute() != null); foreach (var contextMethod in contextMethods) { var contextAttribute = contextMethod.GetCustomAttribute(); var command = new DiscordApplicationCommand(contextAttribute.Name, null, type: contextAttribute.Type, default_permission: contextAttribute.DefaultPermission); var parameters = contextMethod.GetParameters(); if (parameters.Length == 0 || parameters == null || !ReferenceEquals(parameters.FirstOrDefault()?.ParameterType, typeof(ContextMenuContext))) throw new ArgumentException($"The first argument must be a ContextMenuContext!"); if (parameters.Length > 1) throw new ArgumentException($"A context menu cannot have parameters!"); contextMenuCommands.Add(new ContextMenuCommand { Method = contextMethod, Name = contextAttribute.Name }); updateList.Add(command); commandTypeSources.Add(new KeyValuePair(type, type)); } //Accounts for lifespans if (module.GetCustomAttribute() != null) { if (module.GetCustomAttribute().Lifespan == ApplicationCommandModuleLifespan.Singleton) { - _singletonModules.Add(this.CreateInstance(module, this._configuration?.Services)); + _singletonModules.Add(this.CreateInstance(module, this._configuration?.ServiceProvider)); } } } } catch (Exception ex) { //This isn't really much more descriptive but I added a separate case for it anyway if (ex is BadRequestException brex) this.Client.Logger.LogCritical(brex, $"There was an error registering application commands: {brex.JsonMessage}"); else this.Client.Logger.LogCritical(ex, $"There was an error registering application commands"); _errored = true; } } if (!_errored) { try { async Task UpdateCommandPermission(ulong commandId, string commandName, Type commandDeclaringType, Type commandRootType) { if (guildid == null) { //throw new NotImplementedException("You can't set global permissions till yet. See https://discord.com/developers/docs/interactions/application-commands#permissions"); } else { var ctx = new ApplicationCommandsPermissionContext(commandDeclaringType, commandName); var conf = types.First(t => t.Type == commandRootType); conf.Setup?.Invoke(ctx); if (ctx.Permissions.Count == 0) return; await this.Client.OverwriteGuildApplicationCommandPermissionsAsync(guildid.Value, commandId, ctx.Permissions); } } async Task UpdateCommandPermissionGroup(GroupCommand groupCommand) { foreach (var com in groupCommand.Methods) { var source = commandTypeSources.FirstOrDefault(f => f.Key == com.Value.DeclaringType); await UpdateCommandPermission(groupCommand.CommandId, com.Key, source.Key, source.Value); } } var commands = guildid == null ? await this.Client.BulkOverwriteGlobalApplicationCommandsAsync(updateList) : (IEnumerable)await this.Client.BulkOverwriteGuildApplicationCommandsAsync(guildid.Value, updateList); //Creates a guild command if a guild id is specified, otherwise global //Checks against the ids and adds them to the command method lists foreach (var command in commands) { if (commandMethods.Any(x => x.Name == command.Name)) { var com = commandMethods.First(x => x.Name == command.Name); com.CommandId = command.Id; var source = commandTypeSources.FirstOrDefault(f => f.Key == com.Method.DeclaringType); await UpdateCommandPermission(command.Id, com.Name, source.Value, source.Key); } else if (groupCommands.Any(x => x.Name == command.Name)) { var com = groupCommands.First(x => x.Name == command.Name); com.CommandId = command.Id; await UpdateCommandPermissionGroup(com); } else if (subGroupCommands.Any(x => x.Name == command.Name)) { var com = subGroupCommands.First(x => x.Name == command.Name); com.CommandId = command.Id; foreach (var groupComs in com.SubCommands) await UpdateCommandPermissionGroup(groupComs); } else if (contextMenuCommands.Any(x => x.Name == command.Name)) { var com = contextMenuCommands.First(x => x.Name == command.Name); com.CommandId = command.Id; var source = commandTypeSources.First(f => f.Key == com.Method.DeclaringType); await UpdateCommandPermission(command.Id, com.Name, source.Value, source.Key); } } //Adds to the global lists finally _commandMethods.AddRange(commandMethods); _groupCommands.AddRange(groupCommands); _subGroupCommands.AddRange(subGroupCommands); _contextMenuCommands.AddRange(contextMenuCommands); _registeredCommands.Add(new KeyValuePair>(guildid, commands.ToList())); foreach (var command in commandMethods) { var app = types.First(t => t.Type == command.Method.DeclaringType); } } catch (Exception ex) { if (ex is BadRequestException brex) this.Client.Logger.LogCritical(brex, $"There was an error registering application commands: {brex.JsonMessage}"); else this.Client.Logger.LogCritical(ex, $"There was an error registering application commands"); _errored = true; } } }); } /// /// Interaction handler. /// /// The client. /// The event args. private Task InteractionHandler(DiscordClient client, InteractionCreateEventArgs e) { _ = Task.Run(async () => { if (e.Interaction.Type == InteractionType.ApplicationCommand) { //Creates the context var context = new InteractionContext { Interaction = e.Interaction, Channel = e.Interaction.Channel, Guild = e.Interaction.Guild, User = e.Interaction.User, Client = client, ApplicationCommandsExtension = this, CommandName = e.Interaction.Data.Name, InteractionId = e.Interaction.Id, Token = e.Interaction.Token, - Services = this._configuration?.Services, + Services = this._configuration?.ServiceProvider, ResolvedUserMentions = e.Interaction.Data.Resolved?.Users?.Values.ToList(), ResolvedRoleMentions = e.Interaction.Data.Resolved?.Roles?.Values.ToList(), ResolvedChannelMentions = e.Interaction.Data.Resolved?.Channels?.Values.ToList(), Type = ApplicationCommandType.ChatInput }; try { if (_errored) throw new InvalidOperationException("Slash commands failed to register properly on startup."); var methods = _commandMethods.Where(x => x.CommandId == e.Interaction.Data.Id); var groups = _groupCommands.Where(x => x.CommandId == e.Interaction.Data.Id); var subgroups = _subGroupCommands.Where(x => x.CommandId == e.Interaction.Data.Id); if (!methods.Any() && !groups.Any() && !subgroups.Any()) throw new InvalidOperationException("A slash command was executed, but no command was registered for it."); if (methods.Any()) { var method = methods.First().Method; var args = await this.ResolveInteractionCommandParameters(e, context, method, e.Interaction.Data.Options); await this.RunCommandAsync(context, method, args); } else if (groups.Any()) { var command = e.Interaction.Data.Options.First(); var method = groups.First().Methods.First(x => x.Key == command.Name).Value; var args = await this.ResolveInteractionCommandParameters(e, context, method, e.Interaction.Data.Options.First().Options); await this.RunCommandAsync(context, method, args); } else if (subgroups.Any()) { var command = e.Interaction.Data.Options.First(); var group = subgroups.First().SubCommands.First(x => x.Name == command.Name); var method = group.Methods.First(x => x.Key == command.Options.First().Name).Value; var args = await this.ResolveInteractionCommandParameters(e, context, method, e.Interaction.Data.Options.First().Options.First().Options); await this.RunCommandAsync(context, method, args); } await this._slashExecuted.InvokeAsync(this, new SlashCommandExecutedEventArgs(this.Client.ServiceProvider) { Context = context }); } catch (Exception ex) { await this._slashError.InvokeAsync(this, new SlashCommandErrorEventArgs(this.Client.ServiceProvider) { Context = context, Exception = ex }); } } else if (e.Interaction.Type == InteractionType.AutoComplete) { if (_errored) throw new InvalidOperationException("Slash commands failed to register properly on startup."); var methods = _commandMethods.Where(x => x.CommandId == e.Interaction.Data.Id); var groups = _groupCommands.Where(x => x.CommandId == e.Interaction.Data.Id); var subgroups = _subGroupCommands.Where(x => x.CommandId == e.Interaction.Data.Id); if (!methods.Any() && !groups.Any() && !subgroups.Any()) throw new InvalidOperationException("An autocomplete interaction was created, but no command was registered for it."); try { if (methods.Any()) { var focusedOption = e.Interaction.Data.Options.First(o => o.Focused); var method = methods.First().Method; var option = method.GetParameters().Skip(1).First(p => p.GetCustomAttribute().Name == focusedOption.Name); var provider = option.GetCustomAttribute().ProviderType; var providerMethod = provider.GetMethod(nameof(IAutocompleteProvider.Provider)); var providerInstance = Activator.CreateInstance(provider); var context = new AutocompleteContext { Interaction = e.Interaction, Options = e.Interaction.Data.Options.ToList(), FocusedOption = focusedOption }; var choices = await (Task>) providerMethod.Invoke(providerInstance, new[] { context }); await e.Interaction.CreateResponseAsync(InteractionResponseType.AutoCompleteResult, new DiscordInteractionResponseBuilder().AddAutoCompleteChoices(choices)); } else if (groups.Any()) { var command = e.Interaction.Data.Options.First(); var group = groups.First().Methods.First(x => x.Key == command.Name).Value; var focusedOption = command.Options.First(o => o.Focused); var option = group.GetParameters().Skip(1).First(p => p.GetCustomAttribute().Name == focusedOption.Name); var provider = option.GetCustomAttribute().ProviderType; var providerMethod = provider.GetMethod(nameof(IAutocompleteProvider.Provider)); var providerInstance = Activator.CreateInstance(provider); var context = new AutocompleteContext { Interaction = e.Interaction, Options = command.Options.ToList(), FocusedOption = focusedOption }; var choices = await (Task>) providerMethod.Invoke(providerInstance, new[] { context }); await e.Interaction.CreateResponseAsync(InteractionResponseType.AutoCompleteResult, new DiscordInteractionResponseBuilder().AddAutoCompleteChoices(choices)); } /*else if (subgroups.Any()) { var command = e.Interaction.Data.Options.First(); var method = methods.First().Method; var group = subgroups.First().SubCommands.First(x => x.Name == command.Name); var focusedOption = command.Options.First(x => x.Name == group.Name).Options.First(o => o.Focused); this.Client.Logger.LogDebug("SUBGROUP::" + focusedOption.Name + ": " + focusedOption.RawValue); var option = group.Methods.First(p => p.Value.GetCustomAttribute().Name == focusedOption.Name).Value; var provider = option.GetCustomAttribute().ProviderType; var providerMethod = provider.GetMethod(nameof(IAutocompleteProvider.Provider)); var providerInstance = Activator.CreateInstance(provider); var context = new AutocompleteContext { Interaction = e.Interaction, Options = command.Options.First(x => x.Name == group.Name).Options.ToList(), FocusedOption = focusedOption }; var choices = await (Task>) providerMethod.Invoke(providerInstance, new[] { context }); await e.Interaction.CreateResponseAsync(InteractionResponseType.AutoCompleteResult, new DiscordInteractionResponseBuilder().AddAutoCompleteChoices(choices)); }*/ } catch (Exception ex) { this.Client.Logger.LogError(ex, "Error in autocomplete interaction"); } } }); return Task.CompletedTask; } /// /// Context menu handler. /// /// The client. /// The event args. private Task ContextMenuHandler(DiscordClient client, ContextMenuInteractionCreateEventArgs e) { _ = Task.Run(async () => { //Creates the context var context = new ContextMenuContext { Interaction = e.Interaction, Channel = e.Interaction.Channel, Client = client, - Services = this._configuration?.Services, + Services = this._configuration?.ServiceProvider, CommandName = e.Interaction.Data.Name, ApplicationCommandsExtension = this, Guild = e.Interaction.Guild, InteractionId = e.Interaction.Id, User = e.Interaction.User, Token = e.Interaction.Token, TargetUser = e.TargetUser, TargetMessage = e.TargetMessage, Type = e.Type }; try { if (_errored) throw new InvalidOperationException("Context menus failed to register properly on startup."); //Gets the method for the command var method = _contextMenuCommands.FirstOrDefault(x => x.CommandId == e.Interaction.Data.Id); if (method == null) throw new InvalidOperationException("A context menu was executed, but no command was registered for it."); await this.RunCommandAsync(context, method.Method, new[] { context }); await this._contextMenuExecuted.InvokeAsync(this, new ContextMenuExecutedEventArgs(this.Client.ServiceProvider) { Context = context }); } catch (Exception ex) { await this._contextMenuErrored.InvokeAsync(this, new ContextMenuErrorEventArgs(this.Client.ServiceProvider) { Context = context, Exception = ex }); } }); return Task.CompletedTask; } /// /// Runs a command. /// /// The base context. /// The method info. /// The arguments. [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0066:Convert switch statement to expression", Justification = "")] internal async Task RunCommandAsync(BaseContext context, MethodInfo method, IEnumerable args) { object classInstance; //Accounts for lifespans var moduleLifespan = (method.DeclaringType.GetCustomAttribute() != null ? method.DeclaringType.GetCustomAttribute()?.Lifespan : ApplicationCommandModuleLifespan.Transient) ?? ApplicationCommandModuleLifespan.Transient; switch (moduleLifespan) { case ApplicationCommandModuleLifespan.Scoped: //Accounts for static methods and adds DI - classInstance = method.IsStatic ? ActivatorUtilities.CreateInstance(this._configuration?.Services.CreateScope().ServiceProvider, method.DeclaringType) : this.CreateInstance(method.DeclaringType, this._configuration?.Services.CreateScope().ServiceProvider); + classInstance = method.IsStatic ? ActivatorUtilities.CreateInstance(this._configuration?.ServiceProvider.CreateScope().ServiceProvider, method.DeclaringType) : this.CreateInstance(method.DeclaringType, this._configuration?.ServiceProvider.CreateScope().ServiceProvider); break; case ApplicationCommandModuleLifespan.Transient: //Accounts for static methods and adds DI - classInstance = method.IsStatic ? ActivatorUtilities.CreateInstance(this._configuration?.Services, method.DeclaringType) : this.CreateInstance(method.DeclaringType, this._configuration?.Services); + classInstance = method.IsStatic ? ActivatorUtilities.CreateInstance(this._configuration?.ServiceProvider, method.DeclaringType) : this.CreateInstance(method.DeclaringType, this._configuration?.ServiceProvider); break; //If singleton, gets it from the singleton list case ApplicationCommandModuleLifespan.Singleton: classInstance = _singletonModules.First(x => ReferenceEquals(x.GetType(), method.DeclaringType)); break; default: throw new Exception($"An unknown {nameof(ApplicationCommandModuleLifespanAttribute)} scope was specified on command {context.CommandName}"); } ApplicationCommandsModule module = null; if (classInstance is ApplicationCommandsModule mod) module = mod; // Slash commands if (context is InteractionContext slashContext) { await this.RunPreexecutionChecksAsync(method, slashContext); var shouldExecute = await (module?.BeforeSlashExecutionAsync(slashContext) ?? Task.FromResult(true)); if (shouldExecute) { await (Task)method.Invoke(classInstance, args.ToArray()); await (module?.AfterSlashExecutionAsync(slashContext) ?? Task.CompletedTask); } } // Context menus if (context is ContextMenuContext contextMenuContext) { await this.RunPreexecutionChecksAsync(method, contextMenuContext); var shouldExecute = await (module?.BeforeContextMenuExecutionAsync(contextMenuContext) ?? Task.FromResult(true)); if (shouldExecute) { await (Task)method.Invoke(classInstance, args.ToArray()); await (module?.AfterContextMenuExecutionAsync(contextMenuContext) ?? Task.CompletedTask); } } } /// /// Property injection copied over from CommandsNext /// /// The type. /// The services. internal object CreateInstance(Type t, IServiceProvider services) { var ti = t.GetTypeInfo(); var constructors = ti.DeclaredConstructors .Where(xci => xci.IsPublic) .ToArray(); if (constructors.Length != 1) throw new ArgumentException("Specified type does not contain a public constructor or contains more than one public constructor."); var constructor = constructors[0]; var constructorArgs = constructor.GetParameters(); var args = new object[constructorArgs.Length]; if (constructorArgs.Length != 0 && services == null) throw new InvalidOperationException("Dependency collection needs to be specified for parameterized constructors."); // inject via constructor if (constructorArgs.Length != 0) for (var i = 0; i < args.Length; i++) args[i] = services.GetRequiredService(constructorArgs[i].ParameterType); var moduleInstance = Activator.CreateInstance(t, args); // inject into properties var props = t.GetRuntimeProperties().Where(xp => xp.CanWrite && xp.SetMethod != null && !xp.SetMethod.IsStatic && xp.SetMethod.IsPublic); foreach (var prop in props) { if (prop.GetCustomAttribute() != null) continue; var service = services.GetService(prop.PropertyType); if (service == null) continue; prop.SetValue(moduleInstance, service); } // inject into fields var fields = t.GetRuntimeFields().Where(xf => !xf.IsInitOnly && !xf.IsStatic && xf.IsPublic); foreach (var field in fields) { if (field.GetCustomAttribute() != null) continue; var service = services.GetService(field.FieldType); if (service == null) continue; field.SetValue(moduleInstance, service); } return moduleInstance; } /// /// Resolves the slash command parameters. /// /// The event arguments. /// The interaction context. /// The method info. /// The options. private async Task> ResolveInteractionCommandParameters(InteractionCreateEventArgs e, InteractionContext context, MethodInfo method, IEnumerable options) { var args = new List { context }; var parameters = method.GetParameters().Skip(1); for (var i = 0; i < parameters.Count(); i++) { var parameter = parameters.ElementAt(i); //Accounts for optional arguments without values given if (parameter.IsOptional && (options == null || (!options?.Any(x => x.Name == parameter.GetCustomAttribute().Name.ToLower()) ?? true))) args.Add(parameter.DefaultValue); else { var option = options.Single(x => x.Name == parameter.GetCustomAttribute().Name.ToLower()); //Checks the type and casts/references resolved and adds the value to the list //This can probably reference the slash command's type property that didn't exist when I wrote this and it could use a cleaner switch instead, but if it works it works if (parameter.ParameterType == typeof(string)) args.Add(option.Value.ToString()); else if (parameter.ParameterType.IsEnum) args.Add(Enum.Parse(parameter.ParameterType, (string)option.Value)); else if (parameter.ParameterType == typeof(long) || parameter.ParameterType == typeof(long?)) args.Add((long?)option.Value); else if (parameter.ParameterType == typeof(bool) || parameter.ParameterType == typeof(bool?)) args.Add((bool?)option.Value); else if (parameter.ParameterType == typeof(double) || parameter.ParameterType == typeof(double?)) args.Add((double?)option.Value); else if (parameter.ParameterType == typeof(DiscordUser)) { //Checks through resolved if (e.Interaction.Data.Resolved.Members != null && e.Interaction.Data.Resolved.Members.TryGetValue((ulong)option.Value, out var member)) args.Add(member); else if (e.Interaction.Data.Resolved.Users != null && e.Interaction.Data.Resolved.Users.TryGetValue((ulong)option.Value, out var user)) args.Add(user); else args.Add(await this.Client.GetUserAsync((ulong)option.Value)); } else if (parameter.ParameterType == typeof(DiscordChannel)) { //Checks through resolved if (e.Interaction.Data.Resolved.Channels != null && e.Interaction.Data.Resolved.Channels.TryGetValue((ulong)option.Value, out var channel)) args.Add(channel); else args.Add(e.Interaction.Guild.GetChannel((ulong)option.Value)); } else if (parameter.ParameterType == typeof(DiscordRole)) { //Checks through resolved if (e.Interaction.Data.Resolved.Roles != null && e.Interaction.Data.Resolved.Roles.TryGetValue((ulong)option.Value, out var role)) args.Add(role); else args.Add(e.Interaction.Guild.GetRole((ulong)option.Value)); } else if (parameter.ParameterType == typeof(SnowflakeObject)) { //Checks through resolved if (e.Interaction.Data.Resolved.Roles != null && e.Interaction.Data.Resolved.Roles.TryGetValue((ulong)option.Value, out var role)) args.Add(role); else if (e.Interaction.Data.Resolved.Members != null && e.Interaction.Data.Resolved.Members.TryGetValue((ulong)option.Value, out var member)) args.Add(member); else if (e.Interaction.Data.Resolved.Users != null && e.Interaction.Data.Resolved.Users.TryGetValue((ulong)option.Value, out var user)) args.Add(user); else throw new ArgumentException("Error resolving mentionable option."); } else throw new ArgumentException($"Error resolving interaction."); } } return args; } /// /// Runs the preexecution checks. /// /// The method info. /// The basecontext. private async Task RunPreexecutionChecksAsync(MethodInfo method, BaseContext context) { if (context is InteractionContext ctx) { //Gets all attributes from parent classes as well and stuff var attributes = new List(); attributes.AddRange(method.GetCustomAttributes(true)); attributes.AddRange(method.DeclaringType.GetCustomAttributes()); if (method.DeclaringType.DeclaringType != null) { attributes.AddRange(method.DeclaringType.DeclaringType.GetCustomAttributes()); if (method.DeclaringType.DeclaringType.DeclaringType != null) { attributes.AddRange(method.DeclaringType.DeclaringType.DeclaringType.GetCustomAttributes()); } } var dict = new Dictionary(); foreach (var att in attributes) { //Runs the check and adds the result to a list var result = await att.ExecuteChecksAsync(ctx); dict.Add(att, result); } //Checks if any failed, and throws an exception if (dict.Any(x => x.Value == false)) throw new SlashExecutionChecksFailedException { FailedChecks = dict.Where(x => x.Value == false).Select(x => x.Key).ToList() }; } if (context is ContextMenuContext CMctx) { var attributes = new List(); attributes.AddRange(method.GetCustomAttributes(true)); attributes.AddRange(method.DeclaringType.GetCustomAttributes()); if (method.DeclaringType.DeclaringType != null) { attributes.AddRange(method.DeclaringType.DeclaringType.GetCustomAttributes()); if (method.DeclaringType.DeclaringType.DeclaringType != null) { attributes.AddRange(method.DeclaringType.DeclaringType.DeclaringType.GetCustomAttributes()); } } var dict = new Dictionary(); foreach (var att in attributes) { //Runs the check and adds the result to a list var result = await att.ExecuteChecksAsync(CMctx); dict.Add(att, result); } //Checks if any failed, and throws an exception if (dict.Any(x => x.Value == false)) throw new ContextMenuExecutionChecksFailedException { FailedChecks = dict.Where(x => x.Value == false).Select(x => x.Key).ToList() }; } } /// /// Gets the choice attributes from choice provider. /// /// The custom attributes. /// private async Task> GetChoiceAttributesFromProvider(IEnumerable customAttributes, ulong? guildId = null) { var choices = new List(); foreach (var choiceProviderAttribute in customAttributes) { var method = choiceProviderAttribute.ProviderType.GetMethod(nameof(IChoiceProvider.Provider)); if (method == null) throw new ArgumentException("ChoiceProviders must inherit from IChoiceProvider."); else { var instance = Activator.CreateInstance(choiceProviderAttribute.ProviderType); // Abstract class offers more properties that can be set if (choiceProviderAttribute.ProviderType.IsSubclassOf(typeof(ChoiceProvider))) { choiceProviderAttribute.ProviderType.GetProperty(nameof(ChoiceProvider.GuildId)) ?.SetValue(instance, guildId); choiceProviderAttribute.ProviderType.GetProperty(nameof(ChoiceProvider.Services)) - ?.SetValue(instance, _configuration.Services); + ?.SetValue(instance, _configuration.ServiceProvider); } //Gets the choices from the method var result = await (Task>)method.Invoke(instance, null); if (result.Any()) { choices.AddRange(result); } } } return choices; } /// /// Gets the choice attributes from enum parameter. /// /// The enum parameter. private static List GetChoiceAttributesFromEnumParameter(Type enumParam) { var choices = new List(); foreach (Enum enumValue in Enum.GetValues(enumParam)) { choices.Add(new DiscordApplicationCommandOptionChoice(enumValue.GetName(), enumValue.ToString())); } return choices; } /// /// Gets the parameter type. /// /// The type. private ApplicationCommandOptionType GetParameterType(Type type) { var parametertype = type == typeof(string) ? ApplicationCommandOptionType.String : type == typeof(long) || type == typeof(long?) ? ApplicationCommandOptionType.Integer : type == typeof(bool) || type == typeof(bool?) ? ApplicationCommandOptionType.Boolean : type == typeof(double) || type == typeof(double?) ? ApplicationCommandOptionType.Number : type == typeof(DiscordChannel) ? ApplicationCommandOptionType.Channel : type == typeof(DiscordUser) ? ApplicationCommandOptionType.User : type == typeof(DiscordRole) ? ApplicationCommandOptionType.Role : type == typeof(SnowflakeObject) ? ApplicationCommandOptionType.Mentionable : type == typeof(DiscordAttachment) ? ApplicationCommandOptionType.Attachment : type.IsEnum ? ApplicationCommandOptionType.String : throw new ArgumentException("Cannot convert type! Argument types must be string, long, bool, double, DiscordChannel, DiscordUser, DiscordRole, SnowflakeObject, DiscordAttachment or an Enum."); return parametertype; } /// /// Gets the choice attributes from parameter. /// /// The choice attributes. private List GetChoiceAttributesFromParameter(IEnumerable choiceattributes) { return !choiceattributes.Any() ? null : choiceattributes.Select(att => new DiscordApplicationCommandOptionChoice(att.Name, att.Value)).ToList(); } /// /// Parses the parameters. /// /// The parameters. /// The guild id. /// A Task. private async Task> ParseParameters(ParameterInfo[] parameters, ulong? guildId) { var options = new List(); foreach (var parameter in parameters) { //Gets the attribute var optionattribute = parameter.GetCustomAttribute(); if (optionattribute == null) throw new ArgumentException("Arguments must have the Option attribute!"); var minimumValue = parameter.GetCustomAttribute()?.Value ?? null; var maximumValue = parameter.GetCustomAttribute()?.Value ?? null; var autocompleteAttribute = parameter.GetCustomAttribute(); if (optionattribute.Autocomplete && autocompleteAttribute == null) throw new ArgumentException("Autocomplete options must have the Autocomplete attribute!"); if (!optionattribute.Autocomplete && autocompleteAttribute != null) throw new ArgumentException("Setting an autocomplete provider requires the option to have autocomplete set to true!"); //Sets the type var type = parameter.ParameterType; var parametertype = this.GetParameterType(type); //Handles choices //From attributes var choices = this.GetChoiceAttributesFromParameter(parameter.GetCustomAttributes()); //From enums if (parameter.ParameterType.IsEnum) { choices = GetChoiceAttributesFromEnumParameter(parameter.ParameterType); } //From choice provider var choiceProviders = parameter.GetCustomAttributes(); if (choiceProviders.Any()) { choices = await this.GetChoiceAttributesFromProvider(choiceProviders, guildId); } var channelTypes = parameter.GetCustomAttribute()?.ChannelTypes ?? null; options.Add(new DiscordApplicationCommandOption(optionattribute.Name, optionattribute.Description, parametertype, !parameter.IsOptional, choices, null, channelTypes, optionattribute.Autocomplete, minimumValue, maximumValue)); } return options; } /// /// Refreshes your commands, used for refreshing choice providers or applying commands registered after the ready event on the discord client. /// Should only be run on the slash command extension linked to shard 0 if sharding. /// Not recommended and should be avoided since it can make slash commands be unresponsive for a while. /// public async Task RefreshCommandsAsync() { _commandMethods.Clear(); _groupCommands.Clear(); _subGroupCommands.Clear(); _registeredCommands.Clear(); _contextMenuCommands.Clear(); await this.Update(); } /// /// Fires when the execution of a slash command fails. /// public event AsyncEventHandler SlashCommandErrored { add { this._slashError.Register(value); } remove { this._slashError.Unregister(value); } } private AsyncEvent _slashError; /// /// Fires when the execution of a slash command is successful. /// public event AsyncEventHandler SlashCommandExecuted { add { this._slashExecuted.Register(value); } remove { this._slashExecuted.Unregister(value); } } private AsyncEvent _slashExecuted; /// /// Fires when the execution of a context menu fails. /// public event AsyncEventHandler ContextMenuErrored { add { this._contextMenuErrored.Register(value); } remove { this._contextMenuErrored.Unregister(value); } } private AsyncEvent _contextMenuErrored; /// /// Fire when the execution of a context menu is successful. /// public event AsyncEventHandler ContextMenuExecuted { add { this._contextMenuExecuted.Register(value); } remove { this._contextMenuExecuted.Unregister(value); } } private AsyncEvent _contextMenuExecuted; } /// /// Holds configuration data for setting up an application command. /// internal class ApplicationCommandsModuleConfiguration { /// /// The type of the command module. /// public Type Type { get; } /// /// The permission setup. /// public Action Setup { get; } /// /// Creates a new command configuration. /// /// The type of the command module. /// The permission setup callback. public ApplicationCommandsModuleConfiguration(Type type, Action setup = null) { this.Type = type; this.Setup = setup; } } /// /// Links a command to its original command module. /// internal class ApplicationCommandSourceLink { /// /// The command. /// public DiscordApplicationCommand ApplicationCommand { get; set; } /// /// The base/root module the command is contained in. /// public Type RootCommandContainerType { get; set; } /// /// The direct group the command is contained in. /// public Type CommandContainerType { get; set; } } /// /// The command method. /// internal class CommandMethod { /// /// Gets or sets the command id. /// public ulong CommandId { get; set; } /// /// Gets or sets the name. /// public string Name { get; set; } /// /// Gets or sets the method. /// public MethodInfo Method { get; set; } } /// /// The group command. /// internal class GroupCommand { /// /// Gets or sets the command id. /// public ulong CommandId { get; set; } /// /// Gets or sets the name. /// public string Name { get; set; } /// /// Gets or sets the methods. /// public List> Methods { get; set; } = null; } /// /// The sub group command. /// internal class SubGroupCommand { /// /// Gets or sets the command id. /// public ulong CommandId { get; set; } /// /// Gets or sets the name. /// public string Name { get; set; } /// /// Gets or sets the sub commands. /// public List SubCommands { get; set; } = new List(); } /// /// The context menu command. /// internal class ContextMenuCommand { /// /// Gets or sets the command id. /// public ulong CommandId { get; set; } /// /// Gets or sets the name. /// public string Name { get; set; } /// /// Gets or sets the method. /// public MethodInfo Method { get; set; } } } diff --git a/DisCatSharp.CommandsNext/CommandsNextConfiguration.cs b/DisCatSharp.CommandsNext/CommandsNextConfiguration.cs index defb14682..1ba977af9 100644 --- a/DisCatSharp.CommandsNext/CommandsNextConfiguration.cs +++ b/DisCatSharp.CommandsNext/CommandsNextConfiguration.cs @@ -1,150 +1,156 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using DisCatSharp.CommandsNext.Attributes; using DisCatSharp.CommandsNext.Converters; using DisCatSharp.Entities; using Microsoft.Extensions.DependencyInjection; namespace DisCatSharp.CommandsNext { /// /// Represents a delegate for a function that takes a message, and returns the position of the start of command invocation in the message. It has to return -1 if prefix is not present. /// /// It is recommended that helper methods and /// be used internally for checking. Their output can be passed through. /// /// /// Message to check for prefix. /// Position of the command invocation or -1 if not present. public delegate Task PrefixResolverDelegate(DiscordMessage msg); /// /// Represents a configuration for . /// public sealed class CommandsNextConfiguration { /// /// Sets the string prefixes used for commands. /// Defaults to no value (disabled). /// public IEnumerable StringPrefixes { internal get; set; } /// /// Sets the custom prefix resolver used for commands. /// Defaults to none (disabled). /// public PrefixResolverDelegate PrefixResolver { internal get; set; } = null; /// /// Sets whether to allow mentioning the bot to be used as command prefix. /// Defaults to true. /// public bool EnableMentionPrefix { internal get; set; } = true; /// /// Sets whether strings should be matched in a case-sensitive manner. /// This switch affects the behaviour of default prefix resolver, command searching, and argument conversion. /// Defaults to false. /// public bool CaseSensitive { internal get; set; } = false; /// /// Sets whether to enable default help command. /// Disabling this will allow you to make your own help command. /// - /// Modifying default help can be achieved via custom help formatters (see and for more details). + /// Modifying default help can be achieved via custom help formatters (see and for more details). /// It is recommended to use help formatter instead of disabling help. /// /// Defaults to true. /// public bool EnableDefaultHelp { internal get; set; } = true; /// /// Controls whether the default help will be sent via DMs or not. /// Enabling this will make the bot respond with help via direct messages. /// Defaults to false. /// public bool DmHelp { internal get; set; } = false; /// /// Sets the default pre-execution checks for the built-in help command. /// Only applicable if default help is enabled. /// Defaults to null. /// public IEnumerable DefaultHelpChecks { internal get; set; } = null; /// /// Sets whether commands sent via direct messages should be processed. /// Defaults to true. /// public bool EnableDms { internal get; set; } = true; /// /// Sets the service provider for this CommandsNext instance. /// Objects in this provider are used when instantiating command modules. This allows passing data around without resorting to static members. /// Defaults to null. /// - public IServiceProvider Services { internal get; set; } = new ServiceCollection().BuildServiceProvider(true); + public IServiceProvider ServiceProvider { internal get; set; } = new ServiceCollection().BuildServiceProvider(true); /// /// Gets whether any extra arguments passed to commands should be ignored or not. If this is set to false, extra arguments will throw, otherwise they will be ignored. /// Defaults to false. /// public bool IgnoreExtraArguments { internal get; set; } = false; /// /// Gets or sets whether to automatically enable handling commands. /// If this is set to false, you will need to manually handle each incoming message and pass it to CommandsNext. /// Defaults to true. /// public bool UseDefaultCommandHandler { internal get; set; } = true; /// /// Creates a new instance of . /// public CommandsNextConfiguration() { } + [ActivatorUtilitiesConstructor] + public CommandsNextConfiguration(IServiceProvider provider) + { + this.ServiceProvider = provider; + } + /// /// Creates a new instance of , copying the properties of another configuration. /// /// Configuration the properties of which are to be copied. public CommandsNextConfiguration(CommandsNextConfiguration other) { this.CaseSensitive = other.CaseSensitive; this.PrefixResolver = other.PrefixResolver; this.DefaultHelpChecks = other.DefaultHelpChecks; this.EnableDefaultHelp = other.EnableDefaultHelp; this.EnableDms = other.EnableDms; this.EnableMentionPrefix = other.EnableMentionPrefix; this.IgnoreExtraArguments = other.IgnoreExtraArguments; this.UseDefaultCommandHandler = other.UseDefaultCommandHandler; - this.Services = other.Services; + this.ServiceProvider = other.ServiceProvider; this.StringPrefixes = other.StringPrefixes?.ToArray(); this.DmHelp = other.DmHelp; } } } diff --git a/DisCatSharp.CommandsNext/CommandsNextExtension.cs b/DisCatSharp.CommandsNext/CommandsNextExtension.cs index 270aafb70..e8079de3a 100644 --- a/DisCatSharp.CommandsNext/CommandsNextExtension.cs +++ b/DisCatSharp.CommandsNext/CommandsNextExtension.cs @@ -1,1083 +1,1083 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reflection; using System.Threading.Tasks; using DisCatSharp.CommandsNext.Attributes; using DisCatSharp.CommandsNext.Builders; using DisCatSharp.CommandsNext.Converters; using DisCatSharp.CommandsNext.Entities; using DisCatSharp.CommandsNext.Exceptions; using DisCatSharp.Entities; using DisCatSharp.EventArgs; using DisCatSharp.Common.Utilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace DisCatSharp.CommandsNext { /// /// This is the class which handles command registration, management, and execution. /// public class CommandsNextExtension : BaseExtension { /// /// Gets the config. /// private CommandsNextConfiguration Config { get; } /// /// Gets the help formatter. /// private HelpFormatterFactory HelpFormatter { get; } /// /// Gets the convert generic. /// private MethodInfo ConvertGeneric { get; } /// /// Gets the user friendly type names. /// private Dictionary UserFriendlyTypeNames { get; } /// /// Gets the argument converters. /// internal Dictionary ArgumentConverters { get; } /// /// Gets the service provider this CommandsNext module was configured with. /// public IServiceProvider Services - => this.Config.Services; + => this.Config.ServiceProvider; /// /// Initializes a new instance of the class. /// /// The cfg. internal CommandsNextExtension(CommandsNextConfiguration cfg) { this.Config = new CommandsNextConfiguration(cfg); this.TopLevelCommands = new Dictionary(); this._registeredCommandsLazy = new Lazy>(() => new ReadOnlyDictionary(this.TopLevelCommands)); this.HelpFormatter = new HelpFormatterFactory(); this.HelpFormatter.SetFormatterType(); this.ArgumentConverters = new Dictionary { [typeof(string)] = new StringConverter(), [typeof(bool)] = new BoolConverter(), [typeof(sbyte)] = new Int8Converter(), [typeof(byte)] = new Uint8Converter(), [typeof(short)] = new Int16Converter(), [typeof(ushort)] = new Uint16Converter(), [typeof(int)] = new Int32Converter(), [typeof(uint)] = new Uint32Converter(), [typeof(long)] = new Int64Converter(), [typeof(ulong)] = new Uint64Converter(), [typeof(float)] = new Float32Converter(), [typeof(double)] = new Float64Converter(), [typeof(decimal)] = new Float128Converter(), [typeof(DateTime)] = new DateTimeConverter(), [typeof(DateTimeOffset)] = new DateTimeOffsetConverter(), [typeof(TimeSpan)] = new TimeSpanConverter(), [typeof(Uri)] = new UriConverter(), [typeof(DiscordUser)] = new DiscordUserConverter(), [typeof(DiscordMember)] = new DiscordMemberConverter(), [typeof(DiscordRole)] = new DiscordRoleConverter(), [typeof(DiscordChannel)] = new DiscordChannelConverter(), [typeof(DiscordGuild)] = new DiscordGuildConverter(), [typeof(DiscordMessage)] = new DiscordMessageConverter(), [typeof(DiscordEmoji)] = new DiscordEmojiConverter(), [typeof(DiscordThreadChannel)] = new DiscordThreadChannelConverter(), [typeof(DiscordInvite)] = new DiscordInviteConverter(), [typeof(DiscordColor)] = new DiscordColorConverter() }; this.UserFriendlyTypeNames = new Dictionary() { [typeof(string)] = "string", [typeof(bool)] = "boolean", [typeof(sbyte)] = "signed byte", [typeof(byte)] = "byte", [typeof(short)] = "short", [typeof(ushort)] = "unsigned short", [typeof(int)] = "int", [typeof(uint)] = "unsigned int", [typeof(long)] = "long", [typeof(ulong)] = "unsigned long", [typeof(float)] = "float", [typeof(double)] = "double", [typeof(decimal)] = "decimal", [typeof(DateTime)] = "date and time", [typeof(DateTimeOffset)] = "date and time", [typeof(TimeSpan)] = "time span", [typeof(Uri)] = "URL", [typeof(DiscordUser)] = "user", [typeof(DiscordMember)] = "member", [typeof(DiscordRole)] = "role", [typeof(DiscordChannel)] = "channel", [typeof(DiscordGuild)] = "guild", [typeof(DiscordMessage)] = "message", [typeof(DiscordEmoji)] = "emoji", [typeof(DiscordThreadChannel)] = "thread", [typeof(DiscordInvite)] = "invite", [typeof(DiscordColor)] = "color" }; var ncvt = typeof(NullableConverter<>); var nt = typeof(Nullable<>); var cvts = this.ArgumentConverters.Keys.ToArray(); foreach (var xt in cvts) { var xti = xt.GetTypeInfo(); if (!xti.IsValueType) continue; var xcvt = ncvt.MakeGenericType(xt); var xnt = nt.MakeGenericType(xt); if (this.ArgumentConverters.ContainsKey(xcvt)) continue; var xcv = Activator.CreateInstance(xcvt) as IArgumentConverter; this.ArgumentConverters[xnt] = xcv; this.UserFriendlyTypeNames[xnt] = this.UserFriendlyTypeNames[xt]; } var t = typeof(CommandsNextExtension); var ms = t.GetTypeInfo().DeclaredMethods; var m = ms.FirstOrDefault(xm => xm.Name == "ConvertArgument" && xm.ContainsGenericParameters && !xm.IsStatic && xm.IsPublic); this.ConvertGeneric = m; } /// /// Sets the help formatter to use with the default help command. /// /// Type of the formatter to use. public void SetHelpFormatter() where T : BaseHelpFormatter => this.HelpFormatter.SetFormatterType(); #region DiscordClient Registration /// /// DO NOT USE THIS MANUALLY. /// /// DO NOT USE THIS MANUALLY. /// protected internal override void Setup(DiscordClient client) { if (this.Client != null) throw new InvalidOperationException("What did I tell you?"); this.Client = client; this._executed = new AsyncEvent("COMMAND_EXECUTED", TimeSpan.Zero, this.Client.EventErrorHandler); this._error = new AsyncEvent("COMMAND_ERRORED", TimeSpan.Zero, this.Client.EventErrorHandler); if (this.Config.UseDefaultCommandHandler) this.Client.MessageCreated += this.HandleCommandsAsync; else this.Client.Logger.LogWarning(CommandsNextEvents.Misc, "Not attaching default command handler - if this is intentional, you can ignore this message"); if (this.Config.EnableDefaultHelp) { this.RegisterCommands(typeof(DefaultHelpModule), null, null, out var tcmds); if (this.Config.DefaultHelpChecks != null) { var checks = this.Config.DefaultHelpChecks.ToArray(); for (var i = 0; i < tcmds.Count; i++) tcmds[i].WithExecutionChecks(checks); } if (tcmds != null) foreach (var xc in tcmds) this.AddToCommandDictionary(xc.Build(null)); } } #endregion #region Command Handling /// /// Handles the commands async. /// /// The sender. /// The e. /// A Task. private async Task HandleCommandsAsync(DiscordClient sender, MessageCreateEventArgs e) { if (e.Author.IsBot) // bad bot return; if (!this.Config.EnableDms && e.Channel.IsPrivate) return; var mpos = -1; if (this.Config.EnableMentionPrefix) mpos = e.Message.GetMentionPrefixLength(this.Client.CurrentUser); if (this.Config.StringPrefixes?.Any() == true) foreach (var pfix in this.Config.StringPrefixes) if (mpos == -1 && !string.IsNullOrWhiteSpace(pfix)) mpos = e.Message.GetStringPrefixLength(pfix, this.Config.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); if (mpos == -1 && this.Config.PrefixResolver != null) mpos = await this.Config.PrefixResolver(e.Message).ConfigureAwait(false); if (mpos == -1) return; var pfx = e.Message.Content.Substring(0, mpos); var cnt = e.Message.Content.Substring(mpos); var __ = 0; var fname = cnt.ExtractNextArgument(ref __); var cmd = this.FindCommand(cnt, out var args); var ctx = this.CreateContext(e.Message, pfx, cmd, args); if (cmd == null) { await this._error.InvokeAsync(this, new CommandErrorEventArgs(this.Client.ServiceProvider) { Context = ctx, Exception = new CommandNotFoundException(fname) }).ConfigureAwait(false); return; } _ = Task.Run(async () => await this.ExecuteCommandAsync(ctx).ConfigureAwait(false)); } /// /// Finds a specified command by its qualified name, then separates arguments. /// /// Qualified name of the command, optionally with arguments. /// Separated arguments. /// Found command or null if none was found. public Command FindCommand(string commandString, out string rawArguments) { rawArguments = null; var ignoreCase = !this.Config.CaseSensitive; var pos = 0; var next = commandString.ExtractNextArgument(ref pos); if (next == null) return null; if (!this.RegisteredCommands.TryGetValue(next, out var cmd)) { if (!ignoreCase) return null; next = next.ToLowerInvariant(); var cmdKvp = this.RegisteredCommands.FirstOrDefault(x => x.Key.ToLowerInvariant() == next); if (cmdKvp.Value == null) return null; cmd = cmdKvp.Value; } if (!(cmd is CommandGroup)) { rawArguments = commandString.Substring(pos).Trim(); return cmd; } while (cmd is CommandGroup) { var cm2 = cmd as CommandGroup; var oldPos = pos; next = commandString.ExtractNextArgument(ref pos); if (next == null) break; if (ignoreCase) { next = next.ToLowerInvariant(); cmd = cm2.Children.FirstOrDefault(x => x.Name.ToLowerInvariant() == next || x.Aliases?.Any(xx => xx.ToLowerInvariant() == next) == true); } else { cmd = cm2.Children.FirstOrDefault(x => x.Name == next || x.Aliases?.Contains(next) == true); } if (cmd == null) { cmd = cm2; pos = oldPos; break; } } rawArguments = commandString.Substring(pos).Trim(); return cmd; } /// /// Creates a command execution context from specified arguments. /// /// Message to use for context. /// Command prefix, used to execute commands. /// Command to execute. /// Raw arguments to pass to command. /// Created command execution context. public CommandContext CreateContext(DiscordMessage msg, string prefix, Command cmd, string rawArguments = null) { var ctx = new CommandContext { Client = this.Client, Command = cmd, Message = msg, Config = this.Config, RawArgumentString = rawArguments ?? "", Prefix = prefix, CommandsNext = this, Services = this.Services }; if (cmd != null && (cmd.Module is TransientCommandModule || cmd.Module == null)) { var scope = ctx.Services.CreateScope(); ctx.ServiceScopeContext = new CommandContext.ServiceContext(ctx.Services, scope); ctx.Services = scope.ServiceProvider; } return ctx; } /// /// Executes specified command from given context. /// /// Context to execute command from. /// public async Task ExecuteCommandAsync(CommandContext ctx) { try { var cmd = ctx.Command; await this.RunAllChecksAsync(cmd, ctx).ConfigureAwait(false); var res = await cmd.ExecuteAsync(ctx).ConfigureAwait(false); if (res.IsSuccessful) await this._executed.InvokeAsync(this, new CommandExecutionEventArgs(this.Client.ServiceProvider) { Context = res.Context }).ConfigureAwait(false); else await this._error.InvokeAsync(this, new CommandErrorEventArgs(this.Client.ServiceProvider) { Context = res.Context, Exception = res.Exception }).ConfigureAwait(false); } catch (Exception ex) { await this._error.InvokeAsync(this, new CommandErrorEventArgs(this.Client.ServiceProvider) { Context = ctx, Exception = ex }).ConfigureAwait(false); } finally { if (ctx.ServiceScopeContext.IsInitialized) ctx.ServiceScopeContext.Dispose(); } } /// /// Runs the all checks async. /// /// The cmd. /// The ctx. /// A Task. private async Task RunAllChecksAsync(Command cmd, CommandContext ctx) { if (cmd.Parent != null) await this.RunAllChecksAsync(cmd.Parent, ctx).ConfigureAwait(false); var fchecks = await cmd.RunChecksAsync(ctx, false).ConfigureAwait(false); if (fchecks.Any()) throw new ChecksFailedException(cmd, ctx, fchecks); } #endregion #region Command Registration /// /// Gets a dictionary of registered top-level commands. /// public IReadOnlyDictionary RegisteredCommands => this._registeredCommandsLazy.Value; /// /// Gets or sets the top level commands. /// private Dictionary TopLevelCommands { get; set; } private readonly Lazy> _registeredCommandsLazy; /// /// Registers all commands from a given assembly. The command classes need to be public to be considered for registration. /// /// Assembly to register commands from. public void RegisterCommands(Assembly assembly) { var types = assembly.ExportedTypes.Where(xt => { var xti = xt.GetTypeInfo(); return xti.IsModuleCandidateType() && !xti.IsNested; }); foreach (var xt in types) this.RegisterCommands(xt); } /// /// Registers all commands from a given command class. /// /// Class which holds commands to register. public void RegisterCommands() where T : BaseCommandModule { var t = typeof(T); this.RegisterCommands(t); } /// /// Registers all commands from a given command class. /// /// Type of the class which holds commands to register. public void RegisterCommands(Type t) { if (t == null) throw new ArgumentNullException(nameof(t), "Type cannot be null."); if (!t.IsModuleCandidateType()) throw new ArgumentNullException(nameof(t), "Type must be a class, which cannot be abstract or static."); this.RegisterCommands(t, null, null, out var tempCommands); if (tempCommands != null) foreach (var command in tempCommands) this.AddToCommandDictionary(command.Build(null)); } /// /// Registers the commands. /// /// The type. /// The current parent. /// The inherited checks. /// The found commands. private void RegisterCommands(Type t, CommandGroupBuilder currentParent, IEnumerable inheritedChecks, out List foundCommands) { var ti = t.GetTypeInfo(); var lifespan = ti.GetCustomAttribute(); var moduleLifespan = lifespan != null ? lifespan.Lifespan : ModuleLifespan.Singleton; var module = new CommandModuleBuilder() .WithType(t) .WithLifespan(moduleLifespan) .Build(this.Services); // restrict parent lifespan to more or equally restrictive if (currentParent?.Module is TransientCommandModule && moduleLifespan != ModuleLifespan.Transient) throw new InvalidOperationException("In a transient module, child modules can only be transient."); // check if we are anything var groupBuilder = new CommandGroupBuilder(module); var isModule = false; var moduleAttributes = ti.GetCustomAttributes(); var moduleHidden = false; var moduleChecks = new List(); foreach (var xa in moduleAttributes) { switch (xa) { case GroupAttribute g: isModule = true; var moduleName = g.Name; if (moduleName == null) { moduleName = ti.Name; if (moduleName.EndsWith("Group") && moduleName != "Group") moduleName = moduleName.Substring(0, moduleName.Length - 5); else if (moduleName.EndsWith("Module") && moduleName != "Module") moduleName = moduleName.Substring(0, moduleName.Length - 6); else if (moduleName.EndsWith("Commands") && moduleName != "Commands") moduleName = moduleName.Substring(0, moduleName.Length - 8); } if (!this.Config.CaseSensitive) moduleName = moduleName.ToLowerInvariant(); groupBuilder.WithName(moduleName); if (inheritedChecks != null) foreach (var chk in inheritedChecks) groupBuilder.WithExecutionCheck(chk); foreach (var mi in ti.DeclaredMethods.Where(x => x.IsCommandCandidate(out _) && x.GetCustomAttribute() != null)) groupBuilder.WithOverload(new CommandOverloadBuilder(mi)); break; case AliasesAttribute a: foreach (var xalias in a.Aliases) groupBuilder.WithAlias(this.Config.CaseSensitive ? xalias : xalias.ToLowerInvariant()); break; case HiddenAttribute h: groupBuilder.WithHiddenStatus(true); moduleHidden = true; break; case DescriptionAttribute d: groupBuilder.WithDescription(d.Description); break; case CheckBaseAttribute c: moduleChecks.Add(c); groupBuilder.WithExecutionCheck(c); break; default: groupBuilder.WithCustomAttribute(xa); break; } } if (!isModule) { groupBuilder = null; if (inheritedChecks != null) moduleChecks.AddRange(inheritedChecks); } // candidate methods var methods = ti.DeclaredMethods; var commands = new List(); var commandBuilders = new Dictionary(); foreach (var m in methods) { if (!m.IsCommandCandidate(out _)) continue; var attrs = m.GetCustomAttributes(); if (attrs.FirstOrDefault(xa => xa is CommandAttribute) is not CommandAttribute cattr) continue; var commandName = cattr.Name; if (commandName == null) { commandName = m.Name; if (commandName.EndsWith("Async") && commandName != "Async") commandName = commandName.Substring(0, commandName.Length - 5); } if (!this.Config.CaseSensitive) commandName = commandName.ToLowerInvariant(); if (!commandBuilders.TryGetValue(commandName, out var commandBuilder)) { commandBuilders.Add(commandName, commandBuilder = new CommandBuilder(module).WithName(commandName)); if (!isModule) if (currentParent != null) currentParent.WithChild(commandBuilder); else commands.Add(commandBuilder); else groupBuilder.WithChild(commandBuilder); } commandBuilder.WithOverload(new CommandOverloadBuilder(m)); if (!isModule && moduleChecks.Any()) foreach (var chk in moduleChecks) commandBuilder.WithExecutionCheck(chk); foreach (var xa in attrs) { switch (xa) { case AliasesAttribute a: foreach (var xalias in a.Aliases) commandBuilder.WithAlias(this.Config.CaseSensitive ? xalias : xalias.ToLowerInvariant()); break; case CheckBaseAttribute p: commandBuilder.WithExecutionCheck(p); break; case DescriptionAttribute d: commandBuilder.WithDescription(d.Description); break; case HiddenAttribute h: commandBuilder.WithHiddenStatus(true); break; default: commandBuilder.WithCustomAttribute(xa); break; } } if (!isModule && moduleHidden) commandBuilder.WithHiddenStatus(true); } // candidate types var types = ti.DeclaredNestedTypes .Where(xt => xt.IsModuleCandidateType() && xt.DeclaredConstructors.Any(xc => xc.IsPublic)); foreach (var type in types) { this.RegisterCommands(type.AsType(), groupBuilder, !isModule ? moduleChecks : null, out var tempCommands); if (isModule) foreach (var chk in moduleChecks) groupBuilder.WithExecutionCheck(chk); if (isModule && tempCommands != null) foreach (var xtcmd in tempCommands) groupBuilder.WithChild(xtcmd); else if (tempCommands != null) commands.AddRange(tempCommands); } if (isModule && currentParent == null) commands.Add(groupBuilder); else if (isModule) currentParent.WithChild(groupBuilder); foundCommands = commands; } /// /// Builds and registers all supplied commands. /// /// Commands to build and register. public void RegisterCommands(params CommandBuilder[] cmds) { foreach (var cmd in cmds) this.AddToCommandDictionary(cmd.Build(null)); } /// /// Unregisters specified commands from CommandsNext. /// /// Commands to unregister. public void UnregisterCommands(params Command[] cmds) { if (cmds.Any(x => x.Parent != null)) throw new InvalidOperationException("Cannot unregister nested commands."); var keys = this.RegisteredCommands.Where(x => cmds.Contains(x.Value)).Select(x => x.Key).ToList(); foreach (var key in keys) this.TopLevelCommands.Remove(key); } /// /// Adds the to command dictionary. /// /// The cmd. private void AddToCommandDictionary(Command cmd) { if (cmd.Parent != null) return; if (this.TopLevelCommands.ContainsKey(cmd.Name) || (cmd.Aliases != null && cmd.Aliases.Any(xs => this.TopLevelCommands.ContainsKey(xs)))) throw new DuplicateCommandException(cmd.QualifiedName); this.TopLevelCommands[cmd.Name] = cmd; if (cmd.Aliases != null) foreach (var xs in cmd.Aliases) this.TopLevelCommands[xs] = cmd; } #endregion #region Default Help /// /// Represents the default help module. /// [ModuleLifespan(ModuleLifespan.Transient)] public class DefaultHelpModule : BaseCommandModule { /// /// Defaults the help async. /// /// The ctx. /// The command. /// A Task. [Command("help"), Description("Displays command help.")] public async Task DefaultHelpAsync(CommandContext ctx, [Description("Command to provide help for.")] params string[] command) { var topLevel = ctx.CommandsNext.TopLevelCommands.Values.Distinct(); var helpBuilder = ctx.CommandsNext.HelpFormatter.Create(ctx); if (command != null && command.Any()) { Command cmd = null; var searchIn = topLevel; foreach (var c in command) { if (searchIn == null) { cmd = null; break; } cmd = ctx.Config.CaseSensitive ? searchIn.FirstOrDefault(xc => xc.Name == c || (xc.Aliases != null && xc.Aliases.Contains(c))) : searchIn.FirstOrDefault(xc => xc.Name.ToLowerInvariant() == c.ToLowerInvariant() || (xc.Aliases != null && xc.Aliases.Select(xs => xs.ToLowerInvariant()).Contains(c.ToLowerInvariant()))); if (cmd == null) break; var failedChecks = await cmd.RunChecksAsync(ctx, true).ConfigureAwait(false); if (failedChecks.Any()) throw new ChecksFailedException(cmd, ctx, failedChecks); searchIn = cmd is CommandGroup ? (cmd as CommandGroup).Children : null; } if (cmd == null) throw new CommandNotFoundException(string.Join(" ", command)); helpBuilder.WithCommand(cmd); if (cmd is CommandGroup group) { var commandsToSearch = group.Children.Where(xc => !xc.IsHidden); var eligibleCommands = new List(); foreach (var candidateCommand in commandsToSearch) { if (candidateCommand.ExecutionChecks == null || !candidateCommand.ExecutionChecks.Any()) { eligibleCommands.Add(candidateCommand); continue; } var candidateFailedChecks = await candidateCommand.RunChecksAsync(ctx, true).ConfigureAwait(false); if (!candidateFailedChecks.Any()) eligibleCommands.Add(candidateCommand); } if (eligibleCommands.Any()) helpBuilder.WithSubcommands(eligibleCommands.OrderBy(xc => xc.Name)); } } else { var commandsToSearch = topLevel.Where(xc => !xc.IsHidden); var eligibleCommands = new List(); foreach (var sc in commandsToSearch) { if (sc.ExecutionChecks == null || !sc.ExecutionChecks.Any()) { eligibleCommands.Add(sc); continue; } var candidateFailedChecks = await sc.RunChecksAsync(ctx, true).ConfigureAwait(false); if (!candidateFailedChecks.Any()) eligibleCommands.Add(sc); } if (eligibleCommands.Any()) helpBuilder.WithSubcommands(eligibleCommands.OrderBy(xc => xc.Name)); } var helpMessage = helpBuilder.Build(); var builder = new DiscordMessageBuilder().WithContent(helpMessage.Content).WithEmbed(helpMessage.Embed); if (!ctx.Config.DmHelp || ctx.Channel is DiscordDmChannel || ctx.Guild == null) await ctx.RespondAsync(builder).ConfigureAwait(false); else await ctx.Member.SendMessageAsync(builder).ConfigureAwait(false); } } #endregion #region Sudo /// /// Creates a fake command context to execute commands with. /// /// The user or member to use as message author. /// The channel the message is supposed to appear from. /// Contents of the message. /// Command prefix, used to execute commands. /// Command to execute. /// Raw arguments to pass to command. /// Created fake context. public CommandContext CreateFakeContext(DiscordUser actor, DiscordChannel channel, string messageContents, string prefix, Command cmd, string rawArguments = null) { var epoch = new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero); var now = DateTimeOffset.UtcNow; var timeSpan = (ulong)(now - epoch).TotalMilliseconds; // create fake message var msg = new DiscordMessage { Discord = this.Client, Author = actor, ChannelId = channel.Id, Content = messageContents, Id = timeSpan << 22, Pinned = false, MentionEveryone = messageContents.Contains("@everyone"), IsTTS = false, _attachments = new List(), _embeds = new List(), TimestampRaw = now.ToString("yyyy-MM-ddTHH:mm:sszzz"), _reactions = new List() }; var mentionedUsers = new List(); var mentionedRoles = msg.Channel.Guild != null ? new List() : null; var mentionedChannels = msg.Channel.Guild != null ? new List() : null; if (!string.IsNullOrWhiteSpace(msg.Content)) { if (msg.Channel.Guild != null) { mentionedUsers = Utilities.GetUserMentions(msg).Select(xid => msg.Channel.Guild._members.TryGetValue(xid, out var member) ? member : null).Cast().ToList(); mentionedRoles = Utilities.GetRoleMentions(msg).Select(xid => msg.Channel.Guild.GetRole(xid)).ToList(); mentionedChannels = Utilities.GetChannelMentions(msg).Select(xid => msg.Channel.Guild.GetChannel(xid)).ToList(); } else { mentionedUsers = Utilities.GetUserMentions(msg).Select(this.Client.GetCachedOrEmptyUserInternal).ToList(); } } msg._mentionedUsers = mentionedUsers; msg._mentionedRoles = mentionedRoles; msg._mentionedChannels = mentionedChannels; var ctx = new CommandContext { Client = this.Client, Command = cmd, Message = msg, Config = this.Config, RawArgumentString = rawArguments ?? "", Prefix = prefix, CommandsNext = this, Services = this.Services }; if (cmd != null && (cmd.Module is TransientCommandModule || cmd.Module == null)) { var scope = ctx.Services.CreateScope(); ctx.ServiceScopeContext = new CommandContext.ServiceContext(ctx.Services, scope); ctx.Services = scope.ServiceProvider; } return ctx; } #endregion #region Type Conversion /// /// Converts a string to specified type. /// /// Type to convert to. /// Value to convert. /// Context in which to convert to. /// Converted object. #pragma warning disable IDE1006 // Naming Styles public async Task ConvertArgument(string value, CommandContext ctx) #pragma warning restore IDE1006 // Naming Styles { var t = typeof(T); if (!this.ArgumentConverters.ContainsKey(t)) throw new ArgumentException("There is no converter specified for given type.", nameof(T)); if (this.ArgumentConverters[t] is not IArgumentConverter cv) throw new ArgumentException("Invalid converter registered for this type.", nameof(T)); var cvr = await cv.ConvertAsync(value, ctx).ConfigureAwait(false); return !cvr.HasValue ? throw new ArgumentException("Could not convert specified value to given type.", nameof(value)) : cvr.Value; } /// /// Converts a string to specified type. /// /// Value to convert. /// Context in which to convert to. /// Type to convert to. /// Converted object. #pragma warning disable IDE1006 // Naming Styles public async Task ConvertArgument(string value, CommandContext ctx, Type type) #pragma warning restore IDE1006 // Naming Styles { var m = this.ConvertGeneric.MakeGenericMethod(type); try { return await (m.Invoke(this, new object[] { value, ctx }) as Task).ConfigureAwait(false); } catch (TargetInvocationException ex) { throw ex.InnerException; } } /// /// Registers an argument converter for specified type. /// /// Type for which to register the converter. /// Converter to register. public void RegisterConverter(IArgumentConverter converter) { if (converter == null) throw new ArgumentNullException(nameof(converter), "Converter cannot be null."); var t = typeof(T); var ti = t.GetTypeInfo(); this.ArgumentConverters[t] = converter; if (!ti.IsValueType) return; var nullableConverterType = typeof(NullableConverter<>).MakeGenericType(t); var nullableType = typeof(Nullable<>).MakeGenericType(t); if (this.ArgumentConverters.ContainsKey(nullableType)) return; var nullableConverter = Activator.CreateInstance(nullableConverterType) as IArgumentConverter; this.ArgumentConverters[nullableType] = nullableConverter; } /// /// Unregisters an argument converter for specified type. /// /// Type for which to unregister the converter. public void UnregisterConverter() { var t = typeof(T); var ti = t.GetTypeInfo(); if (this.ArgumentConverters.ContainsKey(t)) this.ArgumentConverters.Remove(t); if (this.UserFriendlyTypeNames.ContainsKey(t)) this.UserFriendlyTypeNames.Remove(t); if (!ti.IsValueType) return; var nullableType = typeof(Nullable<>).MakeGenericType(t); if (!this.ArgumentConverters.ContainsKey(nullableType)) return; this.ArgumentConverters.Remove(nullableType); this.UserFriendlyTypeNames.Remove(nullableType); } /// /// Registers a user-friendly type name. /// /// Type to register the name for. /// Name to register. public void RegisterUserFriendlyTypeName(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentNullException(nameof(value), "Name cannot be null or empty."); var t = typeof(T); var ti = t.GetTypeInfo(); if (!this.ArgumentConverters.ContainsKey(t)) throw new InvalidOperationException("Cannot register a friendly name for a type which has no associated converter."); this.UserFriendlyTypeNames[t] = value; if (!ti.IsValueType) return; var nullableType = typeof(Nullable<>).MakeGenericType(t); this.UserFriendlyTypeNames[nullableType] = value; } /// /// Converts a type into user-friendly type name. /// /// Type to convert. /// User-friendly type name. public string GetUserFriendlyTypeName(Type t) { if (this.UserFriendlyTypeNames.ContainsKey(t)) return this.UserFriendlyTypeNames[t]; var ti = t.GetTypeInfo(); if (ti.IsGenericTypeDefinition && t.GetGenericTypeDefinition() == typeof(Nullable<>)) { var tn = ti.GenericTypeArguments[0]; return this.UserFriendlyTypeNames.ContainsKey(tn) ? this.UserFriendlyTypeNames[tn] : tn.Name; } return t.Name; } #endregion #region Helpers /// /// Gets the configuration-specific string comparer. This returns or , /// depending on whether is set to or . /// /// A string comparer. internal IEqualityComparer GetStringComparer() => this.Config.CaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; #endregion #region Events /// /// Triggered whenever a command executes successfully. /// public event AsyncEventHandler CommandExecuted { add { this._executed.Register(value); } remove { this._executed.Unregister(value); } } private AsyncEvent _executed; /// /// Triggered whenever a command throws an exception during execution. /// public event AsyncEventHandler CommandErrored { add { this._error.Register(value); } remove { this._error.Unregister(value); } } private AsyncEvent _error; /// /// Ons the command executed. /// /// The e. /// A Task. private Task OnCommandExecuted(CommandExecutionEventArgs e) => this._executed.InvokeAsync(this, e); /// /// Ons the command errored. /// /// The e. /// A Task. private Task OnCommandErrored(CommandErrorEventArgs e) => this._error.InvokeAsync(this, e); #endregion } } diff --git a/DisCatSharp.Configuration/ConfigurationExtensions.cs b/DisCatSharp.Configuration/ConfigurationExtensions.cs index c7ece00b6..20032bf16 100644 --- a/DisCatSharp.Configuration/ConfigurationExtensions.cs +++ b/DisCatSharp.Configuration/ConfigurationExtensions.cs @@ -1,287 +1,311 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections; using System.Linq; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace DisCatSharp.Configuration { /// /// The configuration extensions. /// internal static class ConfigurationExtensions { /// /// The factory error message. /// private const string FactoryErrorMessage = "Require a function which provides a default entity to work with"; /// /// The default root lib. /// public const string DefaultRootLib = "DisCatSharp"; /// /// The config suffix. /// private const string ConfigSuffix = "Configuration"; /// /// Easily piece together paths that will work within /// /// (not used - only for adding context based functionality) /// The strings to piece together /// Strings joined together via ':' public static string ConfigPath(this IConfiguration config, params string[] values) => string.Join(":", values); /// /// Skims over the configuration section and only overrides values that are explicitly defined within the config /// /// Instance of config /// Section which contains values for private static void HydrateInstance(ref object config, ConfigSection section) { var props = config.GetType().GetProperties(); foreach (var prop in props) { // Must have a set method for this to work, otherwise continue on if (prop.SetMethod == null) continue; var entry = section.GetValue(prop.Name); object? value = null; if (typeof(string) == prop.PropertyType) { // We do NOT want to override value if nothing was provided if(!string.IsNullOrEmpty(entry)) prop.SetValue(config, entry); continue; } // We need to address collections a bit differently // They can come in the form of "root:section:name" with a string representation OR // "root:section:name:0" <--- this is not detectable when checking the above path if (typeof(IEnumerable).IsAssignableFrom(prop.PropertyType)) { value = string.IsNullOrEmpty(section.GetValue(prop.Name)) ? section.Config .GetSection(section.GetPath(prop.Name)).Get(prop.PropertyType) : Newtonsoft.Json.JsonConvert.DeserializeObject(entry, prop.PropertyType); if (value == null) continue; prop.SetValue(config, value); } // From this point onward we require the 'entry' value to have something useful if (string.IsNullOrEmpty(entry)) continue; try { // Primitive types are simple to convert if (prop.PropertyType.IsPrimitive) value = Convert.ChangeType(entry, prop.PropertyType); else { // The following types require a different approach if (prop.PropertyType.IsEnum) value = Enum.Parse(prop.PropertyType, entry); else if (typeof(TimeSpan) == prop.PropertyType) value = TimeSpan.Parse(entry); else if (typeof(DateTime) == prop.PropertyType) value = DateTime.Parse(entry); else if (typeof(DateTimeOffset) == prop.PropertyType) value = DateTimeOffset.Parse(entry); } // Update value within our config instance prop.SetValue(config, value); } catch (Exception ex) { Console.Error.WriteLine( $"Unable to convert value of '{entry}' to type '{prop.PropertyType.Name}' for prop '{prop.Name}' in config '{config.GetType().Name}'\n\t\t{ex.Message}"); } } } /// /// Instantiate an entity using then walk through the specified /// and translate user-defined config values to the instantiated instance from /// /// Section containing values for targeted config /// Function which generates a default entity /// Hydrated instance of an entity which contains user-defined values (if any) /// When is null public static object ExtractConfig(this ConfigSection section, Func factory) { if (factory == null) throw new ArgumentNullException(nameof(factory),FactoryErrorMessage); // Create default instance var config = factory(); HydrateInstance(ref config, section); return config; } /// /// Instantiate an entity using then walk through the specified /// in . Translate user-defined config values to the instantiated instance from /// /// Loaded App Configuration /// Name of section to load /// Function which creates a default entity to work with /// (Optional) Used when section is nested within another. Default value is /// Hydrated instance of an entity which contains user-defined values (if any) /// When is null public static object ExtractConfig(this IConfiguration config, string sectionName, Func factory, string? rootSectionName = DefaultRootLib) { if (factory == null) throw new ArgumentNullException(nameof(factory), FactoryErrorMessage); // create default instance var instance = factory(); HydrateInstance(ref instance, new ConfigSection(ref config, sectionName, rootSectionName)); return instance; } + /// + /// Instantiate a new instance of , then walk through the specified + /// in . Translate user-defined config values to the instance. + /// + /// Loaded App Configuration + /// + /// Name of section to load + /// (Optional) Used when section is nested with another. Default value is + /// Type of instance that represents + /// Hydrated instance of which contains the user-defined values (if any). + public static TConfig ExtractConfig(this IConfiguration config, IServiceProvider serviceProvider, string sectionName, string? rootSectionName = DefaultRootLib) + where TConfig : new() + { + // Default values should hopefully be provided from the constructor + object configInstance = ActivatorUtilities.CreateInstance(serviceProvider, typeof(TConfig)); + + HydrateInstance(ref configInstance, new ConfigSection(ref config, sectionName, rootSectionName)); + + return (TConfig) configInstance; + } + /// /// Instantiate a new instance of , then walk through the specified /// in . Translate user-defined config values to the instance. /// /// Loaded App Configuration /// Name of section to load /// (Optional) Used when section is nested with another. Default value is /// Type of instance that represents /// Hydrated instance of which contains the user-defined values (if any). public static TConfig ExtractConfig(this IConfiguration config, string sectionName, string? rootSectionName = DefaultRootLib) where TConfig : new() { // Default values should hopefully be provided from the constructor object configInstance = new TConfig(); HydrateInstance(ref configInstance, new ConfigSection(ref config, sectionName, rootSectionName)); return (TConfig) configInstance; } /// /// Determines if contains a particular section/object (not value) /// /// /// /// { /// "Discord": { // this is a section/object /// /// }, /// "Value": "something" // this is not a section/object /// } /// /// /// /// /// True if section exists, otherwise false public static bool HasSection(this IConfiguration config, params string[] values) { if (!values.Any()) return false; if (values.Length == 1) return config.GetChildren().Any(x => x.Key == values[0]); if (config.GetChildren().All(x => x.Key != values[0])) return false; var current = config.GetSection(values[0]); for (var i = 1; i < values.Length - 1; i++) { if (current.GetChildren().All(x => x.Key != values[i])) return false; current = current.GetSection(values[i]); } return current.GetChildren().Any(x=>x.Key == values[^1]); } /// /// Instantiates an instance of , then consumes any custom /// configuration from user/developer from .
/// View remarks for more info ///
/// /// This is an example of how your JSON structure should look if you wish /// to override one or more of the default values from /// /// { /// "DisCatSharp": { /// "Discord": { } /// } /// } /// ///
/// Alternatively, you can use the type name itself /// /// { /// "DisCatSharp": { /// "DiscordConfiguration": { } /// } /// } /// /// /// { /// "botSectionName": { /// "DiscordConfiguration": { } /// } /// } /// ///
/// + /// /// /// Instance of - public static DiscordClient BuildClient(this IConfiguration config, string botSectionName = DefaultRootLib) + public static DiscordClient BuildClient(this IConfiguration config, IServiceProvider serviceProvider, + string botSectionName = DefaultRootLib) { var section = config.HasSection(botSectionName, "Discord") ? "Discord" : config.HasSection(botSectionName, $"Discord{ConfigSuffix}") ? $"Discord:{ConfigSuffix}" : null; return string.IsNullOrEmpty(section) - ? new DiscordClient(new()) - : new DiscordClient(config.ExtractConfig(section, botSectionName)); + ? new DiscordClient(new(serviceProvider)) + : new DiscordClient(config.ExtractConfig(serviceProvider, section, botSectionName)); } } } diff --git a/DisCatSharp.Docs/articles/basics/web_app.md b/DisCatSharp.Docs/articles/basics/web_app.md index 583bf05d3..3110eebe5 100644 --- a/DisCatSharp.Docs/articles/basics/web_app.md +++ b/DisCatSharp.Docs/articles/basics/web_app.md @@ -1,216 +1,270 @@ --- uid: basics_web_app title: Bot as Hosted Service --- # Prerequisites Install the following packages: - DisCatSharp - DisCatSharp.Hosting +## Notes +Please be aware that this approach relies on Dependency Injection. You can either use one of Microsoft's default project templates for .Net Core Web App, or get a head start by using the +`DisCatSharp.Hosting.ProjectTemplates` pack which contains a Bot Template to jumpstart your development. If you do the latter, majority of this is done for you. + # Bot.cs -Create a new class called `Bot` which inherits from `DiscordHostedService`. +For the sake of example, create a new class called `Bot` which inherits from `DiscordHostedService`. You're welcome to replace `Bot` with whatever you want. ```cs public class Bot : DiscordHostedService { - public Bot(IConfiguration config, ILogger logger, IServiceProvider provider, IHostApplicationLifetime appLifetime) : base(config, logger, provider, appLifetime) + public Bot(IConfiguration config, + ILogger logger, + IServiceProvider provider, + IHostApplicationLifetime appLifetime) : base(config, logger, provider, appLifetime) { } } ``` +If you want to host a variety of bots it is important to provide a custom name into the `base` constructor. This indicates the JSON / Key within `IConfiguration` that will be used for +configuring your bot. + +```cs +public class Bot : DiscordHostedService +{ + public Bot(IConfiguration config, + ILogger logger, + IServiceProvider provider, + IHostApplicationLifetime appLifetime) : base(config, logger, provider, appLifetime, "Bot") + { + } +} +``` +Note... the only difference is `"Bot"` being added, the default value for this is `"DisCatSharp"` + # Startup.cs By using the `DisCatSharp.Hosting.DependencyInjection` module, this 1 line is enough to get your basic bot running... ```cs public void ConfigureServices(IServiceCollection services) { services.AddDiscordHostedService(); } ``` If you prefer another DI approach / the manual route -- the following two -lines are all you need! +lines are all you need! For example sake, this bot doesn't have anything fancy going on. +You're welcome to create your own interface which inherits from `IDiscordHostedService`. ```cs public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); services.AddHostedService(provider => provider.GetRequiredService()); } ``` Singleton - we only want 1 instance of Bot to ever run during runtime.
Then we take the registered singleton to run as a `HostedService`. # How to reference +Within a DI environment, whether it's via constructor or an `IServiceProvider` + +### If explicitly registered as `Bot` +You either put `Bot` as part of your constructor. Or from a provider you do +```cs +Bot bot = provider.GetRequiredService(); +``` + +### Interface + Bot +This approach means you are mapping the Interface to your `Bot`. However, you might notice that +```cs +Bot bot = provider.GetRequiredService(); +``` + +or via constructor - you will get an exception indicating that `Bot` has not been registered. Well... it's true. It's looking for a key within the collection that matches the type you asked for. +When you use the Interface/Implementation combination it behaves **almost** like a dictionary -- `Bot` is not a valid key in this scenario. + +So to retrieve your `Bot` reference you have to use the interface. + +```cs +IBot bot = provider.GetRequiredService(); +``` + +If you go down this path of mapping interface to implementation you shouldn't be casting your interface to Bot, or whatever. You'd be better off just using the explicitly registered type. +The reasoning behind this approach is to allow you to swap out the implementation type in **ONE** place, and **NOT** have to update any other code. + +For instance, logging... there are SO many ways to do logging. You might notice, or be familiar with `ILogger`. So long as something implements this interface it doesn't matter. It could be Serilog, +or a custom logger you created, or another package from the internet. If later in a project you are dissatisfied with your custom-built logger (which inherits from `ILogger`) you could +easily swap it out with `Serilog` in one place. This makes swapping between packages extremely easy - a simple 1 to 2 line change compared to a project-wide impact. + Within a DI environment, when you want to reference your `Bot` all you have to do is add `IDiscordHostedService` as a parameter in the constructor. # How to Configure You must provide a token in order for the bot to work. Add the following to `appsettings.json` ```json { "DisCatSharp": { "Discord": { "Token": "YOUR TOKEN HERE" } } } ``` ## Dependency Injection The ServiceProvider where you register the `DiscordHostedService` is automatically copied to the DiscordClient. Therefore, if you want to use any services in your [event handlers](xref:beyond_basics_events), you can simply register them before the `DiscordHostedService`: ```cs public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); - + services.AddDiscordHostedService(); } ``` In this case, `YourService` will be available in all your Discord event handlers. ## Initialization errors handling During the initialization of bots, various exceptions can be thrown. For example: invalid token. By default, the exception will be displayed in the console, after which the application will shutdown. You can handle exceptions by overriding method `OnInitializationError` in your `DiscordHostedService`. ```cs protected override void OnInitializationError(Exception ex) { // your code here base.OnInitializationError(ex); } ``` ## Extensions If you wish to add additional modules/extensions you can do so one of two ways. 1. Use the full namespace name 2. Namespace without the `DisCatSharp` prefix - because we assume the extension starts with DisCatSharp. To add the extensions `Interactivity` and `CommandsNext`: ```json { "DisCatSharp": { "Using": [ "DisCatSharp.Interactivity", "CommandsNext" ], "Discord": { "Token": "YOUR TOKEN HERE" }, "Interactivity": { "PollBehaviour": "KeepEmojis" }, "CommandsNext": { "StringPrefixes": [ "!" ] } } } ``` Note: to configure an extension, you simply add a section for it under `DisCatSharp` in `appsettings.json`. You only have to include values you **WISH TO OVERRIDE**. There is no need to include all config options if you only need to change 1 value. For more info on which values are available checkout the following classes: - `ApplicationCommandsConfiguration` - `CommandsNextConfiguration` - `DiscordConfiguration` - `InteractivityConfiguration` - `LavalinkConfiguration` - `VoiceNextConfiguration` For more information, you can also see the [example](https://github.com/Aiko-IT-Systems/DisCatSharp.Examples/tree/main/Hosting). ## Multiple bots In case you need to use multiple bots in one application, you need to use different names for them in the `appsettings.json`: ```json { "BotOne": { "Discord": { "Token": "YOUR TOKEN HERE" } }, "BotTwo": { "Discord": { "Token": "YOUR TOKEN HERE" } } } ``` Next, you need to create a new `DiscordHostedService` for each of the bots. ```cs public class BotOne : DiscordHostedService { public BotOne(IConfiguration config, ILogger logger, IServiceProvider provider, IHostApplicationLifetime appLifetime) : base(config, logger, provider, appLifetime, "BotOne") { } } public class BotTwo : DiscordHostedService { public BotTwo(IConfiguration config, ILogger logger, IServiceProvider provider, IHostApplicationLifetime appLifetime) : base(config, logger, provider, appLifetime, "BotTwo") { } } ``` Note: you must also specify the name of the bot in the constructor, which must match the one specified in the config. Now, you can simply register them in the usual way: ```cs public void ConfigureServices(IServiceCollection services) { services.AddDiscordHostedService(); services.AddDiscordHostedService(); } ``` ____ ## Values It's worth mentioning the required formats for certain value types ### Enum - Single Flag/Value - "`Value`" - Multiple Flags - "`Flag1|Flag2|Flag3`" #### Example ```json { "DisCatSharp": { "Discord": { "Intents": "GuildMembers|GuildsBans" } } } ``` ### TimeSpan Hours:Minutes:Seconds "`HH:mm:ss`" #### Example HttpTimeout of 5 minutes ```json { "DisCatSharp": { "Discord": { "HttpTimeout": "00:05:00" } } } ``` diff --git a/DisCatSharp.Hosting.Tests/HostTests.cs b/DisCatSharp.Hosting.Tests/HostTests.cs index f513fcc18..4a575d636 100644 --- a/DisCatSharp.Hosting.Tests/HostTests.cs +++ b/DisCatSharp.Hosting.Tests/HostTests.cs @@ -1,256 +1,264 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Generic; using DisCatSharp.Interactivity; using DisCatSharp.Lavalink; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Xunit; namespace DisCatSharp.Hosting.Tests { public sealed class Bot : DiscordHostedService { public Bot(IConfiguration config, ILogger logger, IServiceProvider provider, IHostApplicationLifetime lifetime) : base(config, logger, provider, lifetime) { - this.PreConnectAsync().GetAwaiter().GetResult(); - this.PostConnectAsync().GetAwaiter().GetResult(); + this.ConfigureAsync().GetAwaiter().GetResult(); + this.ConfigureExtensionsAsync().GetAwaiter().GetResult(); } } public sealed class MyCustomBot : DiscordHostedService { public MyCustomBot(IConfiguration config, ILogger logger, IServiceProvider provider, IHostApplicationLifetime lifetime) : base(config, logger, provider, lifetime, "MyCustomBot") { - this.PreConnectAsync().GetAwaiter().GetResult(); - this.PostConnectAsync().GetAwaiter().GetResult(); + this.ConfigureAsync().GetAwaiter().GetResult(); + this.ConfigureExtensionsAsync().GetAwaiter().GetResult(); } } public interface IBotTwoService : IDiscordHostedService { string GiveMeAResponse(); } public sealed class BotTwoService : DiscordHostedService, IBotTwoService { public BotTwoService(IConfiguration config, ILogger logger, IServiceProvider provider, IHostApplicationLifetime lifetime) : base(config, logger, provider, lifetime, "BotTwo") { - this.PreConnectAsync().Wait(); + this.ConfigureAsync().GetAwaiter().GetResult(); + this.ConfigureExtensionsAsync().GetAwaiter().GetResult(); } public string GiveMeAResponse() => "I'm working"; } public class HostTests { private Dictionary DefaultDiscord() => new() { { "DisCatSharp:Discord:Token", "1234567890" }, { "DisCatSharp:Discord:TokenType", "Bot" }, { "DisCatSharp:Discord:MinimumLogLevel", "Information" }, { "DisCatSharp:Discord:UseRelativeRateLimit", "true" }, { "DisCatSharp:Discord:LogTimestampFormat", "yyyy-MM-dd HH:mm:ss zzz" }, { "DisCatSharp:Discord:LargeThreshold", "250" }, { "DisCatSharp:Discord:AutoReconnect", "true" }, { "DisCatSharp:Discord:ShardId", "123123" }, { "DisCatSharp:Discord:GatewayCompressionLevel", "Stream" }, { "DisCatSharp:Discord:MessageCacheSize", "1024" }, { "DisCatSharp:Discord:HttpTimeout", "00:00:20" }, { "DisCatSharp:Discord:ReconnectIndefinitely", "false" }, { "DisCatSharp:Discord:AlwaysCacheMembers", "true" }, { "DisCatSharp:Discord:DiscordIntents", "AllUnprivileged" }, { "DisCatSharp:Discord:MobileStatus", "false" }, { "DisCatSharp:Discord:UseCanary", "false" }, { "DisCatSharp:Discord:AutoRefreshChannelCache", "false" }, { "DisCatSharp:Discord:Intents", "AllUnprivileged" } }; public Dictionary DiscordInteractivity() => new (this.DefaultDiscord()) { {"DisCatSharp:Using","[\"DisCatSharp.Interactivity\"]"}, }; public Dictionary DiscordInteractivityAndLavalink() => new (this.DefaultDiscord()) { {"DisCatSharp:Using","[\"DisCatSharp.Interactivity\", \"DisCatSharp.Lavalink\"]"}, }; IHostBuilder Create(Dictionary configValues) => Host.CreateDefaultBuilder() .ConfigureServices(services => services.AddSingleton()) .ConfigureHostConfiguration(builder => builder.AddInMemoryCollection(configValues)); IHostBuilder Create(string filename) => Host.CreateDefaultBuilder() .ConfigureServices(services => services.AddSingleton()) .ConfigureHostConfiguration(builder => builder.AddJsonFile(filename)); IHostBuilder Create(string filename) where TInterface : class, IDiscordHostedService where TBot : class, TInterface, IDiscordHostedService => Host.CreateDefaultBuilder() .ConfigureServices(services => services.AddSingleton()) .ConfigureHostConfiguration(builder => builder.AddJsonFile(filename)); [Fact] public void TestBotCustomInterface() { IHost? host = null; try { host = this.Create("BotTwo.json").Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); var response = service.GiveMeAResponse(); Assert.Equal("I'm working", response); } finally { host?.Dispose(); } } [Fact] public void TestDifferentSection_InteractivityOnly() { IHost? host = null; try { host = this.Create("interactivity-different-section.json").Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); Assert.Null(service.Client.GetExtension()); var intents = DiscordIntents.GuildEmojisAndStickers | DiscordIntents.GuildMembers | DiscordIntents.Guilds; Assert.Equal(intents, service.Client.Intents); var interactivity = service.Client.GetExtension(); Assert.NotNull(interactivity); + + Assert.NotNull(host.Services); + Assert.NotNull(service.Client.ServiceProvider); } finally { host?.Dispose(); } } [Fact] public void TestDifferentSection_LavalinkOnly() { IHost? host = null; try { host = this.Create("lavalink-different-section.json").Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); Assert.NotNull(service.Client.GetExtension()); Assert.Null(service.Client.GetExtension()); var intents = DiscordIntents.Guilds; Assert.Equal(intents, service.Client.Intents); + Assert.NotNull(service.Client.ServiceProvider); } finally { host?.Dispose(); } } [Fact] public void TestNoExtensions() { IHost? host = null; try { host = this.Create(this.DefaultDiscord()).Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); + Assert.NotNull(service.Client.ServiceProvider); } finally { host?.Dispose(); } } [Fact] public void TestInteractivityExtension() { IHost? host = null; try { host = this.Create(this.DiscordInteractivity()).Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); Assert.NotNull(service.Client.GetExtension()); + Assert.NotNull(service.Client.ServiceProvider); } finally { host?.Dispose(); } } [Fact] public void TestInteractivityLavalinkExtensions() { IHost? host = null; try { host = this.Create(this.DiscordInteractivityAndLavalink()).Build(); var service = host.Services.GetRequiredService(); Assert.NotNull(service); Assert.NotNull(service.Client); Assert.NotNull(service.Client.GetExtension()); Assert.NotNull(service.Client.GetExtension()); + Assert.NotNull(service.Client.ServiceProvider); } finally { host?.Dispose(); } } } } diff --git a/DisCatSharp.Hosting/BaseHostedService.cs b/DisCatSharp.Hosting/BaseHostedService.cs index 88e6f4760..1fe8db97e 100644 --- a/DisCatSharp.Hosting/BaseHostedService.cs +++ b/DisCatSharp.Hosting/BaseHostedService.cs @@ -1,182 +1,196 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using DisCatSharp.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace DisCatSharp.Hosting { /// /// Contains the common logic between having a or /// as a Hosted Service /// public abstract class BaseHostedService : BackgroundService { protected readonly ILogger Logger; protected readonly IHostApplicationLifetime ApplicationLifetime; protected readonly IConfiguration Configuration; protected readonly IServiceProvider ServiceProvider; protected readonly string BotSection; internal BaseHostedService(IConfiguration config, ILogger logger, IServiceProvider serviceProvider, IHostApplicationLifetime applicationLifetime, string configBotSection = DisCatSharp.Configuration.ConfigurationExtensions.DefaultRootLib) { this.Configuration = config; this.Logger = logger; this.ApplicationLifetime = applicationLifetime; this.ServiceProvider = serviceProvider; this.BotSection = configBotSection; } /// /// When the bot(s) fail to start, this method will be invoked. (Default behavior is to shutdown) /// /// The exception/reason for not starting protected virtual void OnInitializationError(Exception ex) => this.ApplicationLifetime.StopApplication(); /// /// Connect your client(s) to Discord /// /// Task protected abstract Task ConnectAsync(); /// /// Default behavior is to dynamically load extensions by using and /// /// /// Client to add extension method(s) to /// Task - protected virtual Task InitializeExtensions(DiscordClient client) + protected Task InitializeExtensions(DiscordClient client) { var typeMap = this.Configuration.FindImplementedExtensions(this.BotSection); this.Logger.LogDebug($"Found the following config types: {string.Join("\n\t", typeMap.Keys)}"); foreach (var typePair in typeMap) try { /* If section is null --> utilize the default constructor This means the extension was explicitly added in the 'Using' array, but user did not wish to override any value(s) in the extension's config */ var configInstance = typePair.Value.Section.HasValue ? typePair.Value.Section.Value.ExtractConfig(() => ActivatorUtilities.CreateInstance(this.ServiceProvider, typePair.Value.ConfigType)) : ActivatorUtilities.CreateInstance(this.ServiceProvider, typePair.Value.ConfigType); /* Explanation for bindings Internal Constructors --> NonPublic Public Constructors --> Public Constructors --> Instance */ var flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance; var ctors = typePair.Value.ImplementationType.GetConstructors(flags); var instance = ctors.Any(x => x.GetParameters().Length == 1 && x.GetParameters().First().ParameterType == typePair.Value.ConfigType) ? Activator.CreateInstance(typePair.Value.ImplementationType, flags, null, new[] { configInstance }, null) : Activator.CreateInstance(typePair.Value.ImplementationType, true); /* Certain extensions do not require a configuration argument Those who do -- pass config instance in, Those who don't -- simply instantiate ActivatorUtilities requires a public constructor, anything with internal breaks */ if (instance == null) { this.Logger.LogError($"Unable to instantiate '{typePair.Value.ImplementationType.Name}'"); continue; } // Add an easy reference to our extensions for later use client.AddExtension((BaseExtension)instance); } catch (Exception ex) { this.Logger.LogError($"Unable to register '{typePair.Value.ImplementationType.Name}': \n\t{ex.Message}"); this.OnInitializationError(ex); } return Task.CompletedTask; } /// - /// Runs just prior to . Should initialize your - /// or here + /// Configure / Initialize the or + /// /// /// - protected abstract Task PreConnectAsync(); + protected abstract Task ConfigureAsync(); + + /// + /// Configure the extensions for your or + /// + /// + /// + protected abstract Task ConfigureExtensionsAsync(); + + /// + /// Runs just prior to . + /// + /// + protected virtual Task PreConnectAsync() => Task.CompletedTask; /// /// Runs immediately after . - /// Recommended to initialize your extensions here /// /// Task - protected abstract Task PostConnectAsync(); + protected virtual Task PostConnectAsync() => Task.CompletedTask; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { + await this.ConfigureAsync(); await this.PreConnectAsync(); await this.ConnectAsync(); + await this.ConfigureExtensionsAsync(); await this.PostConnectAsync(); } catch (Exception ex) { /* * Anything before DOTNET 6 will * fail silently despite throwing an exception in this method * So to overcome this obstacle we need to log what happens and * manually exit */ this.Logger.LogError(($"Was unable to start {this.GetType().Name} Bot as a Hosted Service")); // Power given to developer for handling exception this.OnInitializationError(ex); } // Wait indefinitely -- but use stopping token so we can properly cancel if needed await Task.Delay(-1, stoppingToken); } } } diff --git a/DisCatSharp.Hosting/DiscordHostedService.cs b/DisCatSharp.Hosting/DiscordHostedService.cs index 7201ce17b..f00326f36 100644 --- a/DisCatSharp.Hosting/DiscordHostedService.cs +++ b/DisCatSharp.Hosting/DiscordHostedService.cs @@ -1,80 +1,78 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Threading.Tasks; using DisCatSharp.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace DisCatSharp.Hosting { /// /// Simple implementation for to work as a /// public abstract class DiscordHostedService : BaseHostedService, IDiscordHostedService { /// public DiscordClient Client { get; protected set; } #pragma warning disable 8618 /// IConfiguration provided via Dependency Injection. Aggregate method to access configuration files /// An ILogger to work with, provided via Dependency Injection /// ServiceProvider reference which contains all items currently registered for Dependency Injection /// Contains the appropriate methods for disposing / stopping BackgroundServices during runtime /// The name of the JSON/Config Key which contains the configuration for this Discord Service protected DiscordHostedService(IConfiguration config, ILogger logger, IServiceProvider serviceProvider, IHostApplicationLifetime applicationLifetime, string configBotSection = DisCatSharp.Configuration.ConfigurationExtensions.DefaultRootLib) : base(config, logger, serviceProvider, applicationLifetime, configBotSection) { } - protected override Task PreConnectAsync() + protected sealed override Task ConfigureAsync() { try { - this.Client = this.Configuration.BuildClient(this.BotSection); - this.Client.ServiceProvider = this.ServiceProvider; + this.Client = this.Configuration.BuildClient(this.ServiceProvider, this.BotSection); } catch (Exception ex) { this.Logger.LogError($"Was unable to build {nameof(DiscordClient)} for {this.GetType().Name}"); this.OnInitializationError(ex); } return Task.CompletedTask; } + protected sealed override async Task ConnectAsync() => await this.Client.ConnectAsync(); - protected override async Task ConnectAsync() => await this.Client.ConnectAsync(); - - protected override Task PostConnectAsync() + protected sealed override Task ConfigureExtensionsAsync() { this.InitializeExtensions(this.Client); return Task.CompletedTask; } } } diff --git a/DisCatSharp.Hosting/DiscordSharedHostedService.cs b/DisCatSharp.Hosting/DiscordSharedHostedService.cs index e5fa0665b..11f23d499 100644 --- a/DisCatSharp.Hosting/DiscordSharedHostedService.cs +++ b/DisCatSharp.Hosting/DiscordSharedHostedService.cs @@ -1,84 +1,83 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Threading.Tasks; using DisCatSharp.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace DisCatSharp.Hosting { /// /// Simple Implementation for to work as a /// public abstract class DiscordShardedHostedService : BaseHostedService, IDiscordHostedShardService { public DiscordShardedClient ShardedClient { get; protected set; } #pragma warning disable 8618 protected DiscordShardedHostedService(IConfiguration config, ILogger logger, IServiceProvider serviceProvider, IHostApplicationLifetime applicationLifetime, string configBotSection = DisCatSharp.Configuration.ConfigurationExtensions.DefaultRootLib) : base(config, logger, serviceProvider, applicationLifetime, configBotSection) { } #pragma warning restore 8618 - protected override Task PreConnectAsync() + protected sealed override Task ConfigureAsync() { try { - var config = this.Configuration.ExtractConfig("Discord", this.BotSection); + var config = this.Configuration.ExtractConfig(this.ServiceProvider, "Discord", this.BotSection); this.ShardedClient = new DiscordShardedClient(config); } catch (Exception ex) { this.Logger.LogError($"Was unable to build {nameof(DiscordShardedClient)} for {this.GetType().Name}"); this.OnInitializationError(ex); } return Task.CompletedTask; } - protected override async Task ConnectAsync() + protected sealed override async Task ConnectAsync() { await this.ShardedClient.InitializeShardsAsync(); await this.ShardedClient.StartAsync(); } - protected override Task PostConnectAsync() + protected sealed override Task ConfigureExtensionsAsync() { foreach (var client in this.ShardedClient.ShardClients.Values) { - client.ServiceProvider = this.ServiceProvider; this.InitializeExtensions(client); } return Task.CompletedTask; } } } diff --git a/DisCatSharp.Interactivity/InteractivityConfiguration.cs b/DisCatSharp.Interactivity/InteractivityConfiguration.cs index 72e79ad4c..86a64b83c 100644 --- a/DisCatSharp.Interactivity/InteractivityConfiguration.cs +++ b/DisCatSharp.Interactivity/InteractivityConfiguration.cs @@ -1,113 +1,115 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using DisCatSharp.Interactivity.Enums; using DisCatSharp.Interactivity.EventHandling; +using Microsoft.Extensions.DependencyInjection; namespace DisCatSharp.Interactivity { /// /// Configuration class for your Interactivity extension /// public sealed class InteractivityConfiguration { /// /// Sets the default interactivity action timeout. /// Defaults to 1 minute. /// public TimeSpan Timeout { internal get; set; } = TimeSpan.FromMinutes(1); /// /// What to do after the poll ends /// public PollBehaviour PollBehaviour { internal get; set; } = PollBehaviour.DeleteEmojis; /// /// Emojis to use for pagination /// public PaginationEmojis PaginationEmojis { internal get; set; } = new(); /// /// Buttons to use for pagination. /// public PaginationButtons PaginationButtons { internal get; set; } = new(); /// /// Whether interactivity should ACK buttons that are pushed. Setting this to will also prevent subsequent event handlers from running. /// public bool AckPaginationButtons { internal get; set; } /// /// How to handle buttons after pagination ends. /// public ButtonPaginationBehavior ButtonBehavior { internal get; set; } = new(); /// /// How to handle pagination. Defaults to WrapAround. /// public PaginationBehaviour PaginationBehaviour { internal get; set; } = PaginationBehaviour.WrapAround; /// /// How to handle pagination deletion. Defaults to DeleteEmojis. /// public PaginationDeletion PaginationDeletion { internal get; set; } = PaginationDeletion.DeleteEmojis; /// /// How to handle invalid interactions. Defaults to Ignore. /// public InteractionResponseBehavior ResponseBehavior { internal get; set; } = InteractionResponseBehavior.Ignore; /// /// The message to send to the user when processing invalid interactions. Ignored if is not set to . /// public string ResponseMessage { internal get; set; } /// /// Creates a new instance of . /// + [ActivatorUtilitiesConstructor] public InteractivityConfiguration() { } /// /// Creates a new instance of , copying the properties of another configuration. /// /// Configuration the properties of which are to be copied. public InteractivityConfiguration(InteractivityConfiguration other) { this.AckPaginationButtons = other.AckPaginationButtons; this.PaginationButtons = other.PaginationButtons; this.ButtonBehavior = other.ButtonBehavior; this.PaginationBehaviour = other.PaginationBehaviour; this.PaginationDeletion = other.PaginationDeletion; this.ResponseBehavior = other.ResponseBehavior; this.PaginationEmojis = other.PaginationEmojis; this.ResponseMessage = other.ResponseMessage; this.PollBehaviour = other.PollBehaviour; this.Timeout = other.Timeout; if (this.ResponseBehavior is InteractionResponseBehavior.Respond && string.IsNullOrWhiteSpace(this.ResponseMessage)) throw new ArgumentException($"{nameof(this.ResponseMessage)} cannot be null, empty, or whitespace when {nameof(this.ResponseBehavior)} is set to respond."); } } } diff --git a/DisCatSharp.Lavalink/LavalinkConfiguration.cs b/DisCatSharp.Lavalink/LavalinkConfiguration.cs index 66d103264..d9ded3b1d 100644 --- a/DisCatSharp.Lavalink/LavalinkConfiguration.cs +++ b/DisCatSharp.Lavalink/LavalinkConfiguration.cs @@ -1,113 +1,115 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using DisCatSharp.Entities; using DisCatSharp.Net; +using Microsoft.Extensions.DependencyInjection; namespace DisCatSharp.Lavalink { /// /// Lavalink connection configuration. /// public sealed class LavalinkConfiguration { /// /// Sets the endpoint for Lavalink REST. /// Defaults to 127.0.0.1 on port 2333. /// public ConnectionEndpoint RestEndpoint { internal get; set; } = new ConnectionEndpoint("127.0.0.1", 2333); /// /// Sets the endpoint for the Lavalink Websocket connection. /// Defaults to 127.0.0.1 on port 2333. /// public ConnectionEndpoint SocketEndpoint { internal get; set; } = new ConnectionEndpoint("127.0.0.1", 2333); /// /// Sets whether the connection wrapper should attempt automatic reconnects should the connection drop. /// Defaults to true. /// public bool SocketAutoReconnect { internal get; set; } = true; /// /// Sets the password for the Lavalink connection. /// Defaults to "youshallnotpass". /// public string Password { internal get; set; } = "youshallnotpass"; /// /// Sets the resume key for the Lavalink connection. /// This will allow existing voice sessions to continue for a certain time after the client is disconnected. /// public string ResumeKey { internal get; set; } /// /// Sets the time in seconds when all voice sessions are closed after the client disconnects. /// Defaults to 60 seconds. /// public int ResumeTimeout { internal get; set; } = 60; /// /// Sets the time in miliseconds to wait for Lavalink's voice WebSocket to close after leaving a voice channel. /// This will be the delay before the guild connection is removed. /// Defaults to 3000 miliseconds. /// public int WebSocketCloseTimeout { internal get; set; } = 3000; /// /// Sets the voice region ID for the Lavalink connection. /// This should be used if nodes should be filtered by region with . /// public DiscordVoiceRegion Region { internal get; set; } /// /// Creates a new instance of . /// + [ActivatorUtilitiesConstructor] public LavalinkConfiguration() { } /// /// Creates a new instance of , copying the properties of another configuration. /// /// Configuration the properties of which are to be copied. public LavalinkConfiguration(LavalinkConfiguration other) { this.RestEndpoint = new ConnectionEndpoint { Hostname = other.RestEndpoint.Hostname, Port = other.RestEndpoint.Port, Secured = other.RestEndpoint.Secured }; this.SocketEndpoint = new ConnectionEndpoint { Hostname = other.SocketEndpoint.Hostname, Port = other.SocketEndpoint.Port, Secured = other.SocketEndpoint.Secured }; this.Password = other.Password; this.ResumeKey = other.ResumeKey; this.ResumeTimeout = other.ResumeTimeout; this.SocketAutoReconnect = other.SocketAutoReconnect; this.Region = other.Region; this.WebSocketCloseTimeout = other.WebSocketCloseTimeout; } } } diff --git a/DisCatSharp.VoiceNext/VoiceNextConfiguration.cs b/DisCatSharp.VoiceNext/VoiceNextConfiguration.cs index aa592e367..86f587029 100644 --- a/DisCatSharp.VoiceNext/VoiceNextConfiguration.cs +++ b/DisCatSharp.VoiceNext/VoiceNextConfiguration.cs @@ -1,63 +1,66 @@ // This file is part of the DisCatSharp project. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using Microsoft.Extensions.DependencyInjection; + namespace DisCatSharp.VoiceNext { /// /// VoiceNext client configuration. /// public sealed class VoiceNextConfiguration { /// /// Sets the audio format for Opus. This will determine the quality of the audio output. /// Defaults to . /// public AudioFormat AudioFormat { internal get; set; } = AudioFormat.Default; /// /// Sets whether incoming voice receiver should be enabled. /// Defaults to false. /// public bool EnableIncoming { internal get; set; } = false; /// /// Sets the size of the packet queue. /// Defaults to 25 or ~500ms. /// public int PacketQueueSize { internal get; set; } = 25; /// /// Creates a new instance of . /// + [ActivatorUtilitiesConstructor] public VoiceNextConfiguration() { } /// /// Creates a new instance of , copying the properties of another configuration. /// /// Configuration the properties of which are to be copied. public VoiceNextConfiguration(VoiceNextConfiguration other) { this.AudioFormat = new AudioFormat(other.AudioFormat.SampleRate, other.AudioFormat.ChannelCount, other.AudioFormat.VoiceApplication); this.EnableIncoming = other.EnableIncoming; } } }