diff --git a/Discord.Net.SlashCommands/Builders/SlashCommandBuilder.cs b/Discord.Net.SlashCommands/Builders/SlashCommandBuilder.cs new file mode 100644 index 000000000..75995525a --- /dev/null +++ b/Discord.Net.SlashCommands/Builders/SlashCommandBuilder.cs @@ -0,0 +1,491 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Discord.SlashCommands.Builders +{ + /// + /// A class used to build slash commands. + /// + public class SlashCommandBuilder + { + /// + /// Returns the maximun length a commands name allowed by Discord + /// + public const int MaxNameLength = 32; + /// + /// Returns the maximum length of a commands description allowed by Discord. + /// + public const int MaxDescriptionLength = 100; + /// + /// Returns the maximum count of command options allowed by Discord + /// + public const int MaxOptionsCount = 10; + + /// + /// The name of this slash command. + /// + public string Name + { + get + { + return _name; + } + set + { + Preconditions.NotNullOrEmpty(value, nameof(Name)); + Preconditions.AtLeast(value.Length, 3, nameof(Name)); + Preconditions.AtMost(value.Length, MaxNameLength, nameof(Name)); + + // Discord updated the docs, this regex prevents special characters like @!$%(... etc, + // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand + if (!Regex.IsMatch(value, @"^[\w-]{3,32}$")) + throw new ArgumentException("Command name cannot contian any special characters or whitespaces!"); + + _name = value; + } + } + + /// + /// A 1-100 length description of this slash command + /// + public string Description + { + get + { + return _description; + } + set + { + Preconditions.AtLeast(value.Length, 1, nameof(Description)); + Preconditions.AtMost(value.Length, MaxDescriptionLength, nameof(Description)); + + _description = value; + } + } + + public ulong GuildId + { + get + { + return _guildId ?? 0; + } + set + { + if (value == 0) + { + throw new ArgumentException("Guild ID cannot be 0!"); + } + + _guildId = value; + + if (isGlobal) + isGlobal = false; + } + } + /// + /// Gets or sets the options for this command. + /// + public List Options + { + get + { + return _options; + } + set + { + if (value != null) + if (value.Count > MaxOptionsCount) + throw new ArgumentException(message: $"Option count must be less than or equal to {MaxOptionsCount}.", paramName: nameof(Options)); + + _options = value; + } + } + + private ulong? _guildId { get; set; } + private string _name { get; set; } + private string _description { get; set; } + private List _options { get; set; } + + internal bool isGlobal { get; set; } + + + public SlashCommandCreationProperties Build() + { + SlashCommandCreationProperties props = new SlashCommandCreationProperties() + { + Name = this.Name, + Description = this.Description, + }; + + if(this.Options != null || this.Options.Any()) + { + var options = new List(); + + this.Options.ForEach(x => options.Add(x.Build())); + + props.Options = options; + } + + return props; + + } + + /// + /// Makes this command a global application command . + /// + /// The current builder. + public SlashCommandBuilder MakeGlobal() + { + this.isGlobal = true; + return this; + } + + /// + /// Makes this command a guild specific command. + /// + /// The Id of the target guild. + /// The current builder. + public SlashCommandBuilder ForGuild(ulong GuildId) + { + this.GuildId = GuildId; + return this; + } + + public SlashCommandBuilder WithName(string Name) + { + this.Name = Name; + return this; + } + + /// + /// Sets the description of the current command. + /// + /// The description of this command. + /// The current builder. + public SlashCommandBuilder WithDescription(string Description) + { + this.Description = Description; + return this; + } + + /// + /// Adds an option to the current slash command. + /// + /// The name of the option to add. + /// The type of this option. + /// The description of this option. + /// If this option is required for this command. + /// If this option is the default option. + /// The options of the option to add. + /// The choices of this option. + /// The current builder. + public SlashCommandBuilder AddOption(string Name, ApplicationCommandOptionType Type, + string Description, bool Required = true, bool Default = false, List Options = null, params ApplicationCommandOptionChoiceProperties[] Choices) + { + // Make sure the name matches the requirements from discord + Preconditions.NotNullOrEmpty(Name, nameof(Name)); + Preconditions.AtLeast(Name.Length, 3, nameof(Name)); + Preconditions.AtMost(Name.Length, MaxNameLength, nameof(Name)); + + // Discord updated the docs, this regex prevents special characters like @!$%( and s p a c e s.. etc, + // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand + if (!Regex.IsMatch(Name, @"^[\w-]{3,32}$")) + throw new ArgumentException("Command name cannot contian any special characters or whitespaces!", nameof(Name)); + + // same with description + Preconditions.NotNullOrEmpty(Description, nameof(Description)); + Preconditions.AtLeast(Description.Length, 3, nameof(Description)); + Preconditions.AtMost(Description.Length, MaxDescriptionLength, nameof(Description)); + + // make sure theres only one option with default set to true + if (Default) + { + if (this.Options != null) + if (this.Options.Any(x => x.Default)) + throw new ArgumentException("There can only be one command option with default set to true!", nameof(Default)); + } + + SlashCommandOptionBuilder option = new SlashCommandOptionBuilder(); + option.Name = Name; + option.Description = Description; + option.Required = Required; + option.Default = Default; + option.Options = Options; + option.Choices = Choices != null ? new List(Choices) : null; + + return AddOption(option); + } + + /// + /// Adds an option to the current slash command. + /// + /// The name of the option to add. + /// The type of this option. + /// The description of this option. + /// If this option is required for this command. + /// If this option is the default option. + /// The choices of this option. + /// The current builder. + public SlashCommandBuilder AddOption(string Name, ApplicationCommandOptionType Type, + string Description, bool Required = true, bool Default = false, params ApplicationCommandOptionChoiceProperties[] Choices) + => AddOption(Name, Type, Description, Required, Default, null, Choices); + + /// + /// Adds an option to the current slash command. + /// + /// The name of the option to add. + /// The type of this option. + /// The sescription of this option. + /// The current builder. + public SlashCommandBuilder AddOption(string Name, ApplicationCommandOptionType Type, string Description) + => AddOption(Name, Type, Description, Options: null, Choices: null); + + /// + /// Adds an option to this slash command. + /// + /// The option to add. + /// The current builder. + public SlashCommandBuilder AddOption(SlashCommandOptionBuilder Option) + { + if (this.Options == null) + this.Options = new List(); + + if (this.Options.Count >= MaxOptionsCount) + throw new ArgumentOutOfRangeException(nameof(Options), $"Cannot have more than {MaxOptionsCount} options!"); + + if (Option == null) + throw new ArgumentNullException(nameof(Option), "Option cannot be null"); + + this.Options.Add(Option); + return this; + } + /// + /// Adds a collection of options to the current slash command. + /// + /// The collection of options to add. + /// The current builder. + public SlashCommandBuilder AddOptions(params SlashCommandOptionBuilder[] Options) + { + if (Options == null) + throw new ArgumentNullException(nameof(Options), "Options cannot be null!"); + + if (Options.Length == 0) + throw new ArgumentException(nameof(Options), "Options cannot be empty!"); + + if (this.Options == null) + this.Options = new List(); + + if (this.Options.Count + Options.Length > MaxOptionsCount) + throw new ArgumentOutOfRangeException(nameof(Options), $"Cannot have more than {MaxOptionsCount} options!"); + + this.Options.AddRange(Options); + return this; + } + } + + /// + /// Represents a class used to build options for the . + /// + public class SlashCommandOptionBuilder + { + /// + /// The max length of a choice's name allowed by Discord. + /// + public const int ChoiceNameMaxLength = 100; + + /// + /// The maximum number of choices allowed by Discord. + /// + public const int MaxChoiceCount = 10; + + private string _name; + private string _description; + + /// + /// The name of this option. + /// + public string Name + { + get => _name; + set + { + if (value?.Length > SlashCommandBuilder.MaxNameLength) + throw new ArgumentException("Name length must be less than or equal to 32"); + if(value?.Length < 3) + throw new ArgumentException("Name length must at least 3 characters in length"); + + // Discord updated the docs, this regex prevents special characters like @!$%(... etc, + // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand + if (!Regex.IsMatch(value, @"^[\w-]{3,32}$")) + throw new ArgumentException("Option name cannot contian any special characters or whitespaces!"); + + _name = value; + } + } + + /// + /// The description of this option. + /// + public string Description + { + get => _description; + set + { + if (value?.Length > SlashCommandBuilder.MaxDescriptionLength) + throw new ArgumentException("Description length must be less than or equal to 100"); + if (value?.Length < 1) + throw new ArgumentException("Name length must at least 1 character in length"); + + _description = value; + } + } + + /// + /// The type of this option. + /// + public ApplicationCommandOptionType Type { get; set; } + + /// + /// The first required option for the user to complete. only one option can be default. + /// + public bool Default { get; set; } + + /// + /// if this option is required for this command, otherwise . + /// + public bool Required { get; set; } + + /// + /// choices for string and int types for the user to pick from. + /// + public List Choices { get; set; } + + /// + /// If the option is a subcommand or subcommand group type, this nested options will be the parameters. + /// + public List Options { get; set; } + + /// + /// Builds the current option. + /// + /// The build version of this option + public ApplicationCommandOptionProperties Build() + { + bool isSubType = this.Type == ApplicationCommandOptionType.SubCommand || this.Type == ApplicationCommandOptionType.SubCommandGroup; + + if (isSubType && (Options == null || !Options.Any())) + throw new ArgumentException(nameof(Options), "SubCommands/SubCommandGroups must have at least one option"); + + if (!isSubType && (Options != null && Options.Any())) + throw new ArgumentException(nameof(Options), $"Cannot have options on {Type} type"); + + return new ApplicationCommandOptionProperties() + { + Name = this.Name, + Description = this.Description, + Default = this.Default, + Required = this.Required, + Type = this.Type, + Options = new List(this.Options.Select(x => x.Build())), + Choices = this.Choices + }; + } + + /// + /// Adds a sub + /// + /// + /// + public SlashCommandOptionBuilder AddOption(SlashCommandOptionBuilder option) + { + if (this.Options == null) + this.Options = new List(); + + if (this.Options.Count >= SlashCommandBuilder.MaxOptionsCount) + throw new ArgumentOutOfRangeException(nameof(Choices), $"There can only be {SlashCommandBuilder.MaxOptionsCount} options per sub command group!"); + + if (option == null) + throw new ArgumentNullException(nameof(option), "Option cannot be null"); + + Options.Add(option); + return this; + } + + public SlashCommandOptionBuilder AddChoice(string Name, int Value) + { + if (Choices == null) + Choices = new List(); + + if (Choices.Count >= MaxChoiceCount) + throw new ArgumentOutOfRangeException(nameof(Choices), $"Cannot add more than {MaxChoiceCount} choices!"); + + Choices.Add(new ApplicationCommandOptionChoiceProperties() + { + Name = Name, + Value = Value + }); + + return this; + } + public SlashCommandOptionBuilder AddChoice(string Name, string Value) + { + if (Choices == null) + Choices = new List(); + + if (Choices.Count >= MaxChoiceCount) + throw new ArgumentOutOfRangeException(nameof(Choices), $"Cannot add more than {MaxChoiceCount} choices!"); + + Choices.Add(new ApplicationCommandOptionChoiceProperties() + { + Name = Name, + Value = Value + }); + + return this; + } + + public SlashCommandOptionBuilder WithName(string Name, int Value) + { + if (Choices == null) + Choices = new List(); + + if (Choices.Count >= MaxChoiceCount) + throw new ArgumentOutOfRangeException(nameof(Choices), $"Cannot add more than {MaxChoiceCount} choices!"); + + Choices.Add(new ApplicationCommandOptionChoiceProperties() + { + Name = Name, + Value = Value + }); + + return this; + } + + public SlashCommandOptionBuilder WithDescription(string Description) + { + this.Description = Description; + return this; + } + + public SlashCommandOptionBuilder WithRequired(bool value) + { + this.Required = value; + return this; + } + + public SlashCommandOptionBuilder WithDefault(bool value) + { + this.Default = value; + return this; + } + public SlashCommandOptionBuilder WithType(ApplicationCommandOptionType Type) + { + this.Type = Type; + return this; + } + } +} diff --git a/Discord.Net.SlashCommands/Discord.Net.SlashCommands.csproj b/Discord.Net.SlashCommands/Discord.Net.SlashCommands.csproj new file mode 100644 index 000000000..70b29c74b --- /dev/null +++ b/Discord.Net.SlashCommands/Discord.Net.SlashCommands.csproj @@ -0,0 +1,15 @@ + + + + Discord.Net.SlashCommands + Discord.SlashCommands + A Discord.Net extension adding support for slash commands. + net461;netstandard2.0;netstandard2.1 + netstandard2.0;netstandard2.1 + + + + + + + diff --git a/Discord.Net.sln b/Discord.Net.sln index 1a32f1270..084d8a834 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -40,7 +40,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Analyzers.Tests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Examples", "src\Discord.Net.Examples\Discord.Net.Examples.csproj", "{47820065-3CFB-401C-ACEA-862BD564A404}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "idn", "samples\idn\idn.csproj", "{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "idn", "samples\idn\idn.csproj", "{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs b/src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs index 523137858..6087825b4 100644 --- a/src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs +++ b/src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs @@ -390,7 +390,7 @@ namespace Discord.Commands.Builders Default = this.Default, Required = this.Required, Type = this.Type, - Options = new List(this.Options.Select(x => x.Build())), + Options = Options != null ? new List(this.Options.Select(x => x.Build())) : null, Choices = this.Choices }; } diff --git a/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs new file mode 100644 index 000000000..8b60eabc1 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + /// + /// Defines the current class or function as a slash command. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] + public class SlashCommand : Attribute + { + /// + /// The name of this slash command. + /// + public string CommandName; + + /// + /// Tells the that this class/function is a slash command. + /// + /// The name of this slash command. + public SlashCommand(string CommandName) + { + this.CommandName = CommandName; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs new file mode 100644 index 000000000..f075dd833 --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs @@ -0,0 +1,30 @@ +using Discord.Commands; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + public class SlashCommandService + { + private List _modules; + + public SlashCommandService() // TODO: possible config? + { + + } + + public void AddAssembly() + { + + } + + public async Task ExecuteAsync() + { + // TODO: handle execution + return null; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandBase.cs b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandBase.cs new file mode 100644 index 000000000..0fe947e6d --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandBase.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Commands.SlashCommands.Types +{ + /// + /// The base class to inherit for your slash command class's. + /// + /// The type of the interaction. + public abstract class SlashCommandBase where T : IDiscordInteraction + { + public event Func, Task> CommandExecuted; + public SlashCommandBase() + { + + } + + public void Register(SlashCommandCreationProperties command) + { + // TODO: register it, make sure that this command is what discord is expecting + } + + internal Task ExecuteInternalAsync(IDiscordInteraction interaction) + { + // try catch? + return this.CommandExecuted?.Invoke(new SlashCommandContext(interaction)); + } + } + + /// + /// The base class to inherit for your slash command class's. + /// + public abstract class SlashCommandBase : SlashCommandBase { } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandContext.cs b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandContext.cs new file mode 100644 index 000000000..edb6b9f3f --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandContext.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Commands.SlashCommands.Types +{ + public class SlashCommandContext where T : IDiscordInteraction + { + public T Interaction; + + public IGuild Guild + => Interaction.Guild; + + internal SlashCommandContext(IDiscordInteraction interaction) + { + this.Interaction = (T)interaction; + } + } +} diff --git a/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs new file mode 100644 index 000000000..6e54e920a --- /dev/null +++ b/src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.SlashCommands +{ + internal class SlashCommandModule + { + + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs index 2cc0faf4f..037852eff 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs @@ -20,6 +20,11 @@ namespace Discord /// ulong Id { get; } + /// + /// + /// + IGuild Guild { get; } + /// /// The type of this . /// diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs index 4a8277fd4..06a5d53c7 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs @@ -211,5 +211,6 @@ namespace Discord.WebSocket } IApplicationCommandInteractionData IDiscordInteraction.Data => Data; + IGuild IDiscordInteraction.Guild => Guild; } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs index 62097689d..fea0bf3b6 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs @@ -38,6 +38,7 @@ namespace Discord.WebSocket this.Options = model.Options.IsSpecified ? model.Options.Value.Select(x => new SocketInteractionDataOption(x, discord, guild)).ToImmutableArray() : null; + } // Converters