Browse Source

Merge pull request #1 from SlenderPlays/SlashCommandService

Basic Slash Command Service
pull/1733/head
quinchs GitHub 4 years ago
parent
commit
3e9b027f55
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1690 additions and 49 deletions
  1. +28
    -0
      Discord.Net.sln
  2. +155
    -0
      SlashCommandsExample/DiscordClient.cs
  3. +104
    -0
      SlashCommandsExample/Modules/DevModule.cs
  4. +20
    -0
      SlashCommandsExample/Modules/InvalidModule.cs
  5. +26
    -0
      SlashCommandsExample/Program.cs
  6. +7
    -0
      SlashCommandsExample/Properties/launchSettings.json
  7. +22
    -0
      SlashCommandsExample/SlashCommandsExample.csproj
  8. +1
    -0
      src/Discord.Net.Commands/Discord.Net.Commands.csproj
  9. +41
    -0
      src/Discord.Net.Commands/SlashCommands/Attributes/Choice.cs
  10. +35
    -0
      src/Discord.Net.Commands/SlashCommands/Attributes/CommandGroup.cs
  11. +31
    -0
      src/Discord.Net.Commands/SlashCommands/Attributes/Description.cs
  12. +16
    -0
      src/Discord.Net.Commands/SlashCommands/Attributes/Global.cs
  13. +29
    -0
      src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs
  14. +16
    -0
      src/Discord.Net.Commands/SlashCommands/Attributes/Required.cs
  15. +11
    -5
      src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs
  16. +4
    -15
      src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs
  17. +39
    -0
      src/Discord.Net.Commands/SlashCommands/CommandRegistration/CommandRegistrationOptions.cs
  18. +3
    -3
      src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs
  19. +24
    -0
      src/Discord.Net.Commands/SlashCommands/CommandRegistration/OldCommandOptions.cs
  20. +160
    -0
      src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs
  21. +142
    -0
      src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs
  22. +45
    -0
      src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs
  23. +155
    -7
      src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs
  24. +478
    -0
      src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs
  25. +21
    -0
      src/Discord.Net.Commands/SlashCommands/Types/CommandModule/ISlashCommandModule.cs
  26. +34
    -0
      src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs
  27. +4
    -0
      src/Discord.Net.Core/Entities/Interactions/SlashCommandCreationProperties.cs
  28. +3
    -1
      src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs
  29. +1
    -1
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs
  30. +2
    -2
      src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs
  31. +2
    -2
      src/Discord.Net.Rest/Extensions/EntityExtensions.cs
  32. +6
    -6
      src/Discord.Net.WebSocket/Entities/Interaction/SocketApplicationCommandOption.cs
  33. +1
    -1
      src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionData.cs
  34. +24
    -6
      src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs

+ 28
- 0
Discord.Net.sln View File

@@ -42,6 +42,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Examples", "src
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "idn", "samples\idn\idn.csproj", "{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlashCommandsExample", "SlashCommandsExample\SlashCommandsExample.csproj", "{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}"
ProjectSection(ProjectDependencies) = postProject
{6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} = {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7}
{9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} = {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}
{BFC6DC28-0351-4573-926A-D4124244C04F} = {BFC6DC28-0351-4573-926A-D4124244C04F}
{E169E15A-E82C-45BF-8C24-C2CADB7093AA} = {E169E15A-E82C-45BF-8C24-C2CADB7093AA}
{47820065-3CFB-401C-ACEA-862BD564A404} = {47820065-3CFB-401C-ACEA-862BD564A404}
{DBF8B16E-5967-4480-8EDE-15D98A0DF0C4} = {DBF8B16E-5967-4480-8EDE-15D98A0DF0C4}
{FC67057C-E92F-4E1C-98BE-46F839C8AD71} = {FC67057C-E92F-4E1C-98BE-46F839C8AD71}
{91E9E7BD-75C9-4E98-84AA-2C271922E5C2} = {91E9E7BD-75C9-4E98-84AA-2C271922E5C2}
{688FD1D8-7F01-4539-B2E9-F473C5D699C7} = {688FD1D8-7F01-4539-B2E9-F473C5D699C7}
{078DD7E6-943D-4D09-AFC2-D2BA58B76C9C} = {078DD7E6-943D-4D09-AFC2-D2BA58B76C9C}
{BBA8E7FB-C834-40DC-822F-B112CB7F0140} = {BBA8E7FB-C834-40DC-822F-B112CB7F0140}
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -232,6 +247,18 @@ Global
{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x64.Build.0 = Release|Any CPU
{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x86.ActiveCfg = Release|Any CPU
{4A03840B-9EBE-47E3-89AB-E0914DF21AFB}.Release|x86.Build.0 = Release|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x64.ActiveCfg = Debug|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x64.Build.0 = Debug|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x86.ActiveCfg = Debug|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Debug|x86.Build.0 = Debug|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|Any CPU.Build.0 = Release|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x64.ActiveCfg = Release|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x64.Build.0 = Release|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x86.ActiveCfg = Release|Any CPU
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -251,6 +278,7 @@ Global
{FC67057C-E92F-4E1C-98BE-46F839C8AD71} = {C7CF5621-7D36-433B-B337-5A2E3C101A71}
{47820065-3CFB-401C-ACEA-862BD564A404} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}
{4A03840B-9EBE-47E3-89AB-E0914DF21AFB} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}
{2CB2A016-CCEB-4A67-BC7B-098F114D7C27} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495}


+ 155
- 0
SlashCommandsExample/DiscordClient.cs View File

@@ -0,0 +1,155 @@
using Discord;
using Discord.Commands;
using Discord.SlashCommands;
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;

namespace SlashCommandsExample
{
class DiscordClient
{
public static DiscordSocketClient socketClient { get; set; } = new DiscordSocketClient();
public static SlashCommandService _commands { get; set; }
public static IServiceProvider _services { get; set; }

private string botToken = "<YOUR TOKEN HERE>";

public DiscordClient()
{
_commands = new SlashCommandService();
_services = new ServiceCollection()
.AddSingleton(socketClient)
.AddSingleton(_commands)
.BuildServiceProvider();

socketClient.Log += SocketClient_Log;
_commands.Log += SocketClient_Log;
socketClient.InteractionCreated += InteractionHandler;
socketClient.Ready += RegisterCommand;

// This is for dev purposes.
// To avoid the situation in which you accidentally push your bot token to upstream, you can use
// EnviromentVariables to store your key.
botToken = Environment.GetEnvironmentVariable("DiscordSlashCommandsBotToken", EnvironmentVariableTarget.User);
// Uncomment the next line of code to set the environment variable.
// ------------------------------------------------------------------
// | WARNING! |
// | |
// | MAKE SURE TO DELETE YOUR TOKEN AFTER YOU HAVE SET THE VARIABLE |
// | |
// ------------------------------------------------------------------

//Environment.SetEnvironmentVariable("DiscordSlashCommandsBotToken",
// "[YOUR TOKEN GOES HERE DELETE & COMMENT AFTER USE]",
// EnvironmentVariableTarget.User);
}

public async Task RegisterCommand()
{
// Use this to manually register a command for testing.
return;
await socketClient.Rest.CreateGuildCommand(new SlashCommandCreationProperties()
{
Name = "root",
Description = "Root Command",
Options = new List<ApplicationCommandOptionProperties>()
{
new ApplicationCommandOptionProperties()
{
Name = "usr",
Description = "User Folder",
Type = ApplicationCommandOptionType.SubCommandGroup,
Options = new List<ApplicationCommandOptionProperties>()
{
// This doesn't work. This is good!
//new ApplicationCommandOptionProperties()
//{
// Name = "strstr",
// Description = "Some random string I guess.",
// Type = ApplicationCommandOptionType.String,
//},
new ApplicationCommandOptionProperties()
{
Name = "zero",
Description = "Zero's Home Folder - COMMAND",
Type = ApplicationCommandOptionType.SubCommand,
Options = new List<ApplicationCommandOptionProperties>()
{
new ApplicationCommandOptionProperties()
{
Name = "file",
Description = "the file you want accessed.",
Type = ApplicationCommandOptionType.String
}
}
},
new ApplicationCommandOptionProperties()
{
Name = "johhny",
Description = "Johnny Test's Home Folder - COMMAND",
Type = ApplicationCommandOptionType.SubCommand,
Options = new List<ApplicationCommandOptionProperties>()
{
new ApplicationCommandOptionProperties()
{
Name = "file",
Description = "the file you want accessed.",
Type = ApplicationCommandOptionType.String
}
}
}
}
},
new ApplicationCommandOptionProperties()
{
Name = "random",
Description = "Random things",
Type = ApplicationCommandOptionType.SubCommand
}
}
}, 386658607338618891) ;
}

public async Task RunAsync()
{
await socketClient.LoginAsync(TokenType.Bot, botToken);
await socketClient.StartAsync();

await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
await _commands.RegisterCommandsAsync(socketClient, new List<ulong>()
{
386658607338618891
},
new CommandRegistrationOptions(OldCommandOptions.DELETE_UNUSED,ExistingCommandOptions.OVERWRITE));

// If you would like to register your commands manually use:
//-----------------------------------------//
//
// await _commands.BuildCommands();
//
//-----------------------------------------//
// Though I wouldn't highly recommend it unless you want to do something very specific with them
// such as only registering some commands on only some guilds, or editing them manually.

await Task.Delay(-1);
}

private async Task InteractionHandler(SocketInteraction arg)
{
if(arg.Type == InteractionType.ApplicationCommand)
{
await _commands.ExecuteAsync(arg);
}
}

private Task SocketClient_Log(LogMessage arg)
{
Console.WriteLine("[Discord] " + arg.ToString());
return Task.CompletedTask;
}
}
}

+ 104
- 0
SlashCommandsExample/Modules/DevModule.cs View File

@@ -0,0 +1,104 @@
using Discord.Commands;
using Discord.Commands.SlashCommands.Types;
using Discord.SlashCommands;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace SlashCommandsExample.Modules
{
// You can make the whole module Global
//[Global]
public class DevModule : SlashCommandModule<SocketInteraction>
{
[SlashCommand("ping", "Ping the bot to see if it's alive!")]
[Global]
public async Task PingAsync()
{
await Reply(":white_check_mark: **Bot Online**");
}

[SlashCommand("echo", "I'll repeate everything you said to me, word for word.")]
public async Task EchoAsync(
[Description("The message you want repetead")]
[Required]
string message)
{
await Reply($"{Interaction.Member?.Nickname ?? Interaction.Member?.Username} told me to say this: \r\n{message}");
}
[SlashCommand("overload","Just hit me with every type of data you got, man!")]
public async Task OverloadAsync(
[ParameterName("var1")]
bool? boolean,
[ParameterName("var2")]
int? integer,
[ParameterName("var3")]
string myString,
SocketGuildChannel channel,
SocketGuildUser user,
SocketRole role
)
{
await Reply($"You gave me:\r\n {boolean}, {integer}, {myString}, <#{channel?.Id}>, {user?.Mention}, {role?.Mention}");

}

[SlashCommand("stats","Get the stats from Game(tm) for players or teams.")]
public async Task GetStatsAsync(
[Required]
[Choice("XBOX","xbox")]
[Choice("PlayStation","ps")]
[Choice("PC","pc")]
string platform,
[Choice("Player",1)]
[Choice("Team",2)]
int searchType
)
{
await Reply($"Well I got this: {platform}, {searchType}");
}

[CommandGroup("root")]
//[Global]
public class DevModule_Root : SlashCommandModule<SocketInteraction>
{
[SlashCommand("rng", "Gives you a random number from this \"machine\"")]
public async Task RNGAsync()
{
var rand = new Random();
await Reply(rand.Next(0, 101).ToString());
}

[CommandGroup("usr")]
public class DevModule_Root_Usr : SlashCommandModule<SocketInteraction>
{
[SlashCommand("zero", "Gives you a file from user zero from this \"machine\"")]
public async Task ZeroAsync([Description("The file you want.")] string file)
{
await Reply($"You don't have permissiont to access {file} from user \"zero\".");
}
[SlashCommand("johnny", "Gives you a file from user Johnny Test from this \"machine\"")]
public async Task JohnnyAsync([Description("The file you want.")] string file)
{
await Reply($"You don't have permissiont to access {file} from user \"johnny\".");
}
}
}
}
}
/*
The base way of defining a command using the regular command service:

public class PingModule : ModuleBase<SocketCommandContext>
{
[Command("ping")]
[Summary("Pong! Check if the bot is alive.")]
public async Task PingAsync()
{
await ReplyAsync(":white_check_mark: **Bot Online**");
}
}
*/

+ 20
- 0
SlashCommandsExample/Modules/InvalidModule.cs View File

@@ -0,0 +1,20 @@
using Discord.SlashCommands;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Text;

namespace SlashCommandsExample.Modules
{
// Doesn't inherit from SlashCommandModule
public class InvalidDefinition : Object
{
// commands
}

// Isn't public
class PrivateDefinition : SlashCommandModule<SocketInteraction>
{
// commands
}
}

+ 26
- 0
SlashCommandsExample/Program.cs View File

@@ -0,0 +1,26 @@
/*
* This project, is at this moment used for testing and debugging the new and experimental Slash Commands.
* After all testing has been done, and the project is ready to be integrated into the main Discord.Net ecosystem
* this project should be re-made into one that could be used as an example usage of the new Slash Command Service.
*/
using System;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using Discord.SlashCommands;
using Discord.WebSocket;

namespace SlashCommandsExample
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");

DiscordClient discordClient = new DiscordClient();
// This could instead be handled in another thread, if for whatever reason you want to continue execution in the main Thread.
discordClient.RunAsync().GetAwaiter().GetResult();
}
}
}

+ 7
- 0
SlashCommandsExample/Properties/launchSettings.json View File

@@ -0,0 +1,7 @@
{
"profiles": {
"SlashCommandsExample": {
"commandName": "Project"
}
}
}

+ 22
- 0
SlashCommandsExample/SlashCommandsExample.csproj View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\Discord.Net.Analyzers\Discord.Net.Analyzers.csproj" />
<ProjectReference Include="..\src\Discord.Net.Commands\Discord.Net.Commands.csproj" />
<ProjectReference Include="..\src\Discord.Net.Core\Discord.Net.Core.csproj" />
<ProjectReference Include="..\src\Discord.Net.Providers.WS4Net\Discord.Net.Providers.WS4Net.csproj" />
<ProjectReference Include="..\src\Discord.Net.Rest\Discord.Net.Rest.csproj" />
<ProjectReference Include="..\src\Discord.Net.Webhook\Discord.Net.Webhook.csproj" />
<ProjectReference Include="..\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" />
</ItemGroup>

</Project>

+ 1
- 0
src/Discord.Net.Commands/Discord.Net.Commands.csproj View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Discord.Net.Core\Discord.Net.Core.csproj" />
<ProjectReference Include="..\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" />
</ItemGroup>

</Project>

+ 41
- 0
src/Discord.Net.Commands/SlashCommands/Attributes/Choice.cs View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// Defines the parameter as a choice.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true)]
public class Choice : Attribute
{
/// <summary>
/// The internal value of this choice.
/// </summary>
public string choiceStringValue;

/// <summary>
/// The internal value of this choice.
/// </summary>
public int? choiceIntValue = null;

/// <summary>
/// The display value of this choice.
/// </summary>
public string choiceName;

public Choice(string choiceName, string choiceValue)
{
this.choiceName = choiceName;
this.choiceStringValue = choiceValue;
}
public Choice(string choiceName, int choiceValue)
{
this.choiceName = choiceName;
this.choiceIntValue = choiceValue;
}
}
}

+ 35
- 0
src/Discord.Net.Commands/SlashCommands/Attributes/CommandGroup.cs View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// Defines the current as being a group of slash commands.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class CommandGroup : Attribute
{
/// <summary>
/// The name of this slash command.
/// </summary>
public string groupName;

/// <summary>
/// The description of this slash command.
/// </summary>
public string description;

/// <summary>
/// Tells the <see cref="SlashCommandService"/> that this class/function is a slash command.
/// </summary>
/// <param name="commandName">The name of this slash command.</param>
public CommandGroup(string groupName, string description = "No description.")
{
this.groupName = groupName.ToLower();
this.description = description;
}
}
}

+ 31
- 0
src/Discord.Net.Commands/SlashCommands/Attributes/Description.cs View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// An Attribute that gives the command parameter a description.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter , AllowMultiple = false)]
public class Description : Attribute
{
public static string DefaultDescription = "No description.";
/// <summary>
/// The description of this slash command parameter.
/// </summary>
public string description;

/// <summary>
/// Tells the <see cref="SlashCommandService"/> that this parameter has a description.
/// </summary>
/// <param name="commandName">The name of this slash command.</param>
public Description(string description)
{
this.description = description;
}
}

}

+ 16
- 0
src/Discord.Net.Commands/SlashCommands/Attributes/Global.cs View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// An Attribute that gives the command parameter a description.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
public class Global : Attribute
{
}
}

+ 29
- 0
src/Discord.Net.Commands/SlashCommands/Attributes/ParameterName.cs View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// An Attribute that gives the command parameter a custom name.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class ParameterName : Attribute
{
/// <summary>
/// The name of this slash command parameter.
/// </summary>
public string name;

/// <summary>
/// Tells the <see cref="SlashCommandService"/> that this parameter has a custom name.
/// </summary>
/// <param name="name">The name of this slash command.</param>
public ParameterName(string name)
{
this.name = name;
}
}
}

+ 16
- 0
src/Discord.Net.Commands/SlashCommands/Attributes/Required.cs View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// An Attribute that gives the command parameter a description.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class Required : Attribute
{
}
}

+ 11
- 5
src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs View File

@@ -9,21 +9,27 @@ namespace Discord.SlashCommands
/// <summary>
/// Defines the current class or function as a slash command.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class SlashCommand : Attribute
{
/// <summary>
/// The name of this slash command.
/// </summary>
public string CommandName;
public string commandName;

/// <summary>
/// The description of this slash command.
/// </summary>
public string description;

/// <summary>
/// Tells the <see cref="SlashCommandService"/> that this class/function is a slash command.
/// </summary>
/// <param name="CommandName">The name of this slash command.</param>
public SlashCommand(string CommandName)
/// <param name="commandName">The name of this slash command.</param>
public SlashCommand(string commandName, string description = "No description.")
{
this.CommandName = CommandName;
this.commandName = commandName.ToLower();
this.description = description;
}
}
}

src/Discord.Net.Commands/Builders/SlashCommandBuilder.cs → src/Discord.Net.Commands/SlashCommands/Builders/SlashCommandBuilder.cs View File

@@ -377,8 +377,8 @@ namespace Discord.Commands.Builders
{
bool isSubType = this.Type == ApplicationCommandOptionType.SubCommand || this.Type == ApplicationCommandOptionType.SubCommandGroup;

if (isSubType && (Options == null || !Options.Any()))
throw new ArgumentException(nameof(Options), "SubCommands/SubCommandGroups must have at least one option");
if (this.Type == ApplicationCommandOptionType.SubCommandGroup && (Options == null || !Options.Any()))
throw new ArgumentException(nameof(Options), "SubCommandGroups must have at least one option");

if (!isSubType && (Options != null && Options.Any()))
throw new ArgumentException(nameof(Options), $"Cannot have options on {Type} type");
@@ -448,20 +448,9 @@ namespace Discord.Commands.Builders
return this;
}

public SlashCommandOptionBuilder WithName(string Name, int Value)
public SlashCommandOptionBuilder WithName(string Name)
{
if (Choices == null)
Choices = new List<ApplicationCommandOptionChoiceProperties>();

if (Choices.Count >= MaxChoiceCount)
throw new ArgumentOutOfRangeException(nameof(Choices), $"Cannot add more than {MaxChoiceCount} choices!");

Choices.Add(new ApplicationCommandOptionChoiceProperties()
{
Name = Name,
Value = Value
});

this.Name = Name;
return this;
}


+ 39
- 0
src/Discord.Net.Commands/SlashCommands/CommandRegistration/CommandRegistrationOptions.cs View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
/// <summary>
/// The options that should be kept in mind when registering the slash commands to discord.
/// </summary>
public class CommandRegistrationOptions
{
/// <summary>
/// The options that should be kept in mind when registering the slash commands to discord.
/// </summary>
/// <param name="oldCommands">What to do with the old commands that are already registered with discord</param>
/// <param name="existingCommands"> What to do with the old commands (if they weren't wiped) that we re-define.</param>
public CommandRegistrationOptions(OldCommandOptions oldCommands, ExistingCommandOptions existingCommands)
{
OldCommands = oldCommands;
ExistingCommands = existingCommands;
}
/// <summary>
/// What to do with the old commands that are already registered with discord
/// </summary>
public OldCommandOptions OldCommands { get; set; }
/// <summary>
/// What to do with the old commands (if they weren't wiped) that we re-define.
/// </summary>
public ExistingCommandOptions ExistingCommands { get; set; }

/// <summary>
/// The default, and reccomended options - Keep the old commands, and overwrite existing commands we re-defined.
/// </summary>
public static CommandRegistrationOptions Default =>
new CommandRegistrationOptions(OldCommandOptions.KEEP, ExistingCommandOptions.OVERWRITE);
}
}

src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs → src/Discord.Net.Commands/SlashCommands/CommandRegistration/ExistingCommandOptions.cs View File

@@ -1,14 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
internal class SlashCommandModule
public enum ExistingCommandOptions
{
OVERWRITE,
KEEP_EXISTING
}
}

+ 24
- 0
src/Discord.Net.Commands/SlashCommands/CommandRegistration/OldCommandOptions.cs View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
public enum OldCommandOptions
{
/// <summary>
/// Keep the old commands intact - do nothing to them.
/// </summary>
KEEP,
/// <summary>
/// Delete the old commands that we won't be re-defined this time around.
/// </summary>
DELETE_UNUSED,
/// <summary>
/// Delete everything discord has.
/// </summary>
WIPE
}
}

+ 160
- 0
src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs View File

@@ -0,0 +1,160 @@
using Discord.Commands;
using Discord.Commands.Builders;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
public class SlashCommandInfo
{
/// <summary>
/// Gets the module that the command belongs in.
/// </summary>
public SlashModuleInfo Module { get; }
/// <summary>
/// Gets the name of the command.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the name of the command.
/// </summary>
public string Description { get; }
/// <summary>
/// The parameters we are expecting - an extension of SlashCommandOptionBuilder
/// </summary>
public List<SlashParameterInfo> Parameters { get; }

public bool isGlobal { get; }
/// <summary>
/// The user method as a delegate. We need to use Delegate because there is an unknown number of parameters
/// </summary>
public Delegate userMethod;
/// <summary>
/// The callback that we call to start the delegate.
/// </summary>
public Func<object[], Task<IResult>> callback;

public SlashCommandInfo(SlashModuleInfo module, string name, string description,List<SlashParameterInfo> parameters , Delegate userMethod , bool isGlobal = false)
{
Module = module;
Name = name;
Description = description;
Parameters = parameters;
this.userMethod = userMethod;
this.isGlobal = isGlobal;
this.callback = new Func<object[], Task<IResult>>(async (args) =>
{
// Try-catch it and see what we get - error or success
try
{
await Task.Run(() =>
{
userMethod.DynamicInvoke(args);
}).ConfigureAwait(false);
}
catch(Exception e)
{
return ExecuteResult.FromError(e);
}
return ExecuteResult.FromSuccess();

});
}

/// <summary>
/// Execute the function based on the interaction data we get.
/// </summary>
/// <param name="data">Interaction data from interaction</param>
public async Task<IResult> ExecuteAsync(IReadOnlyCollection<SocketInteractionDataOption> data)
{
// List of arguments to be passed to the Delegate
List<object> args = new List<object>();
try
{
foreach (var parameter in Parameters)
{
// For each parameter to try find its coresponding DataOption based on the name.

// !!! names from `data` will always be lowercase regardless if we defined the command with any
// number of upercase letters !!!
if (TryGetInteractionDataOption(data, parameter.Name, out SocketInteractionDataOption dataOption))
{
// Parse the dataOption to one corresponding argument type, and then add it to the list of arguments
args.Add(parameter.Parse(dataOption));
}
else
{
// There was no input from the user on this field.
args.Add(null);
}
}
}
catch(Exception e)
{
return ExecuteResult.FromError(e);
}

return await callback.Invoke(args.ToArray()).ConfigureAwait(false);
}
/// <summary>
/// Get the interaction data from the name of the parameter we want to fill in.
/// </summary>
private bool TryGetInteractionDataOption(IReadOnlyCollection<SocketInteractionDataOption> data, string name, out SocketInteractionDataOption dataOption)
{
if (data == null)
{
dataOption = null;
return false;
}
foreach (var option in data)
{
if (option.Name == name.ToLower())
{
dataOption = option;
return true;
}
}
dataOption = null;
return false;
}

/// <summary>
/// Build the command and put it in a state in which we can use to define it to Discord.
/// </summary>
public SlashCommandCreationProperties BuildCommand()
{
SlashCommandBuilder builder = new SlashCommandBuilder();
builder.WithName(Name);
builder.WithDescription(Description);
builder.Options = new List<SlashCommandOptionBuilder>();
foreach (var parameter in Parameters)
{
builder.AddOptions(parameter);
}

return builder.Build();
}

/// <summary>
/// Build the command AS A SUBCOMMAND and put it in a state in which we can use to define it to Discord.
/// </summary>
public SlashCommandOptionBuilder BuildSubCommand()
{
SlashCommandOptionBuilder builder = new SlashCommandOptionBuilder();
builder.WithName(Name);
builder.WithDescription(Description);
builder.WithType(ApplicationCommandOptionType.SubCommand);
builder.Options = new List<SlashCommandOptionBuilder>();
foreach (var parameter in Parameters)
{
builder.AddOption(parameter);
}

return builder;
}
}
}

+ 142
- 0
src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs View File

@@ -0,0 +1,142 @@
using Discord.Commands;
using Discord.Commands.Builders;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
public class SlashModuleInfo
{
public const string PathSeperator = "//";
public const string RootModuleName = "TOP";
public const string RootCommandPrefix = RootModuleName + PathSeperator;

public SlashModuleInfo(SlashCommandService service)
{
Service = service;
}

public bool isCommandGroup { get; set; } = false;
public CommandGroup commandGroupInfo { get; set; }

public SlashModuleInfo parent { get; set; }
public List<SlashModuleInfo> commandGroups { get; set; }
public string Path { get; set; } = RootModuleName;

public bool isGlobal { get; set; } = false;
/// <summary>
/// Gets the command service associated with this module.
/// </summary>
public SlashCommandService Service { get; }
/// <summary>
/// Gets a read-only list of commands associated with this module.
/// </summary>
public List<SlashCommandInfo> Commands { get; private set; }

/// <summary>
/// The user command module defined as the interface ISlashCommandModule
/// Used to set context.
/// </summary>
public ISlashCommandModule userCommandModule;
public Type moduleType;

public void SetCommands(List<SlashCommandInfo> commands)
{
if (this.Commands == null)
{
this.Commands = commands;
}
}
public void SetCommandModule(object userCommandModule)
{
if (this.userCommandModule == null)
{
this.userCommandModule = userCommandModule as ISlashCommandModule;
}
}
public void SetType(Type type)
{
moduleType = type;
}

public void MakeCommandGroup(CommandGroup commandGroupInfo, SlashModuleInfo parent)
{
isCommandGroup = true;
this.commandGroupInfo = commandGroupInfo;
this.parent = parent;
}
public void SetSubCommandGroups(List<SlashModuleInfo> subCommandGroups)
{
// this.commandGroups = new List<SlashModuleInfo>(subCommandGroups);
this.commandGroups = subCommandGroups;
}

public void MakePath()
{
Path = parent.Path + SlashModuleInfo.PathSeperator + commandGroupInfo.groupName;
}

public List<SlashCommandCreationProperties> BuildCommands()
{
List<SlashCommandCreationProperties> builtCommands = new List<SlashCommandCreationProperties>();
foreach (var command in Commands)
{
var builtCommand = command.BuildCommand();
if (isGlobal || command.isGlobal)
{
builtCommand.Global = true;
}
builtCommands.Add(builtCommand);
}
foreach(var commandGroup in commandGroups)
{
var builtCommand = commandGroup.BuildTopLevelCommandGroup();
if (isGlobal || commandGroup.isGlobal)
{
builtCommand.Global = true;
}
builtCommands.Add(builtCommand);
}
return builtCommands;
}

public SlashCommandCreationProperties BuildTopLevelCommandGroup()
{
SlashCommandBuilder builder = new SlashCommandBuilder();
builder.WithName(commandGroupInfo.groupName);
builder.WithDescription(commandGroupInfo.description);
foreach (var command in Commands)
{
builder.AddOption(command.BuildSubCommand());
}
foreach (var commandGroup in commandGroups)
{
builder.AddOption(commandGroup.BuildNestedCommandGroup());
}
return builder.Build();
}

private SlashCommandOptionBuilder BuildNestedCommandGroup()
{
SlashCommandOptionBuilder builder = new SlashCommandOptionBuilder();
builder.WithName(commandGroupInfo.groupName);
builder.WithDescription(commandGroupInfo.description);
builder.WithType(ApplicationCommandOptionType.SubCommandGroup);
foreach (var command in Commands)
{
builder.AddOption(command.BuildSubCommand());
}
foreach (var commandGroup in commandGroups)
{
builder.AddOption(commandGroup.BuildNestedCommandGroup());
}

return builder;
}
}
}

+ 45
- 0
src/Discord.Net.Commands/SlashCommands/Info/SlashParameterInfo.cs View File

@@ -0,0 +1,45 @@
using Discord.Commands.Builders;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
public class SlashParameterInfo : SlashCommandOptionBuilder
{
public bool Nullable { get; internal set; }

public object Parse(SocketInteractionDataOption dataOption)
{
switch (Type)
{
case ApplicationCommandOptionType.Boolean:
if (Nullable)
return (bool?)dataOption;
else
return (bool)dataOption;
case ApplicationCommandOptionType.Integer:
if(Nullable)
return (int?)dataOption;
else
return (int)dataOption;
case ApplicationCommandOptionType.String:
return (string)dataOption;
case ApplicationCommandOptionType.Channel:
return (SocketGuildChannel)dataOption;
case ApplicationCommandOptionType.Role:
return (SocketRole)dataOption;
case ApplicationCommandOptionType.User:
return (SocketGuildUser)dataOption;
case ApplicationCommandOptionType.SubCommandGroup:
throw new NotImplementedException();
case ApplicationCommandOptionType.SubCommand:
throw new NotImplementedException();
}
throw new NotImplementedException($"There is no such type of data... unless we missed it. Please report this error on the Discord.Net github page! Type: {Type}");
}
}
}

+ 155
- 7
src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs View File

@@ -1,30 +1,178 @@
using Discord.Commands;
using Discord.Logging;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
public class SlashCommandService
{
private List<SlashCommandModule> _modules;
// This semaphore is used to prevent race conditions.
private readonly SemaphoreSlim _moduleLock;
// This contains a dictionary of all definde SlashCommands, based on it's name
public Dictionary<string, SlashCommandInfo> commandDefs;
// This contains a list of all slash command modules defined by their user in their assembly.
public Dictionary<Type, SlashModuleInfo> moduleDefs;

// This is such a complicated method to log stuff...
public event Func<LogMessage, Task> Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } }
internal readonly AsyncEvent<Func<LogMessage, Task>> _logEvent = new AsyncEvent<Func<LogMessage, Task>>();
internal Logger _logger;
internal LogManager _logManager;

public SlashCommandService() // TODO: possible config?
{
// max one thread
_moduleLock = new SemaphoreSlim(1, 1);
_logManager = new LogManager(LogSeverity.Info);
_logManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false);
_logger = new Logger(_logManager, "SlshCommand");
}

/// <summary>
/// Execute a slash command.
/// </summary>
/// <param name="interaction">Interaction data recieved from discord.</param>
/// <returns></returns>
public async Task<IResult> ExecuteAsync(SocketInteraction interaction)
{
SlashCommandInfo commandInfo;
// Get the name of the actual command - be it a normal slash command or subcommand, and return the options we can give it.
string name = GetSearchName(interaction.Data, out var resultingOptions);
// We still need to make sure it is registerd.
if (commandDefs.TryGetValue(name, out commandInfo))
{
// Then, set the context in which the command will be executed
commandInfo.Module.userCommandModule.SetContext(interaction);
// Then run the command and pass the interaction data over to the CommandInfo class
return await commandInfo.ExecuteAsync(resultingOptions).ConfigureAwait(false);
}
else
{
return SearchResult.FromError(CommandError.UnknownCommand, $"There is no registered slash command with the name {interaction.Data.Name}");
}
}
/// <summary>
/// Get the name of the command we want to search for - be it a normal slash command or a sub command. Returns as out the options to be given to the method.
/// /// </summary>
/// <param name="interactionData"></param>
/// <param name="resultingOptions"></param>
/// <returns></returns>
public string GetSearchName(SocketInteractionData interactionData, out IReadOnlyCollection<SocketInteractionDataOption> resultingOptions)
{
// The names are stored as such:
// TOP//top-level-command-name
// TOP//command-group//command-group//sub-command-name
// What we are looking for is to get from the interaction the specific (sub)command and what we need to pass to the method.
// So we start the search at TOP//{interactionData.name}
// because we are going to go through each sub-option it has. If it is a subcommand/ command group then it's going to be
// inside the dictionary as TOP//{interactionData.name}//{option.name}
// If the option is a parameter we then know that we've reached the end of the call chain - this should be our coomand!
string nameToSearch = SlashModuleInfo.RootCommandPrefix + interactionData.Name;
var options = interactionData.Options;
while(options != null && options.Count == 1)
{
string newName = nameToSearch + SlashModuleInfo.PathSeperator + GetFirstOption(options).Name;
if (AnyKeyContains(commandDefs,newName))
{
nameToSearch = newName;
options = GetFirstOption(options).Options;
}
else
{
break;
}
}
resultingOptions = options;
return nameToSearch;
}
/// <summary>
/// Test to see if any <b>string</b> key contains another string inside it.
/// </summary>
private bool AnyKeyContains(Dictionary<string, SlashCommandInfo> commandDefs, string newName)
{
foreach (var pair in commandDefs)
{
if (pair.Key.Contains(newName))
return true;
}
return false;
}

private SocketInteractionDataOption GetFirstOption(IReadOnlyCollection<SocketInteractionDataOption> options)
{
var it = options.GetEnumerator();
it.MoveNext();
return it.Current;
}

public void AddAssembly()
/// <summary>
/// Registers with discord all previously scanned commands.
/// </summary>
public async Task RegisterCommandsAsync(DiscordSocketClient socketClient, List<ulong> guildIDs, CommandRegistrationOptions registrationOptions)
{
// First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business
await _moduleLock.WaitAsync().ConfigureAwait(false);

try
{
// Build and register all of the commands.
await SlashCommandServiceHelper.RegisterCommands(socketClient, moduleDefs, commandDefs, this, guildIDs, registrationOptions).ConfigureAwait(false);
}
finally
{
_moduleLock.Release();
}
await _logger.InfoAsync("All commands have been registered!").ConfigureAwait(false);
}

public async Task<IResult> ExecuteAsync()
/// <summary>
/// Build all the commands and return them, for manual registration with Discord. This is automatically done in <see cref="RegisterCommandsAsync(DiscordSocketClient, List{ulong}, CommandRegistrationOptions)"/>
/// </summary>
/// <returns>A list of all the valid commands found within this Assembly.</returns>
public async Task<List<SlashCommandCreationProperties>> BuildCommands()
{
// TODO: handle execution
return null;
// First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business
await _moduleLock.WaitAsync().ConfigureAwait(false);
List<SlashCommandCreationProperties> result;
try
{
result = await SlashCommandServiceHelper.BuildCommands(moduleDefs).ConfigureAwait(false);
}
finally
{
_moduleLock.Release();
}
await _logger.InfoAsync("All commands have been built!").ConfigureAwait(false);
return result;
}

/// <summary>
/// Scans the program for Attribute-based SlashCommandModules
/// </summary>
public async Task AddModulesAsync(Assembly assembly, IServiceProvider services)
{
// First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business
await _moduleLock.WaitAsync().ConfigureAwait(false);

try
{
// Get all of the modules that were properly defined by the user.
IReadOnlyList<TypeInfo> types = await SlashCommandServiceHelper.GetValidModuleClasses(assembly, this).ConfigureAwait(false);
// Then, based on that, make an instance out of each of them, and get the resulting SlashModuleInfo s
moduleDefs = await SlashCommandServiceHelper.InstantiateModules(types, this).ConfigureAwait(false);
// After that, internally register all of the commands into SlashCommandInfo
commandDefs = await SlashCommandServiceHelper.CreateCommandInfos(types,moduleDefs,this).ConfigureAwait(false);
}
finally
{
_moduleLock.Release();
}
}
}
}

+ 478
- 0
src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs View File

@@ -0,0 +1,478 @@
using Discord.Commands.Builders;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
internal static class SlashCommandServiceHelper
{
/// <summary>
/// Get all of the valid user-defined slash command modules
/// </summary>
public static async Task<IReadOnlyList<TypeInfo>> GetValidModuleClasses(Assembly assembly, SlashCommandService service)
{
var result = new List<TypeInfo>();

foreach (TypeInfo typeInfo in assembly.DefinedTypes)
{
if (IsValidModuleDefinition(typeInfo))
{
// To simplify our lives, we need the modules to be public.
if (typeInfo.IsPublic || typeInfo.IsNestedPublic)
{
result.Add(typeInfo);
}
else
{
await service._logger.WarningAsync($"Found class {typeInfo.FullName} as a valid SlashCommand Module, but it's not public!");
}
}
}

return result;
}
private static bool IsValidModuleDefinition(TypeInfo typeInfo)
{
// See if the base type (SlashCommandInfo<T>) implements interface ISlashCommandModule
return typeInfo.BaseType.GetInterfaces()
.Any(n => n == typeof(ISlashCommandModule)) &&
typeInfo.GetCustomAttributes(typeof(CommandGroup)).Count() == 0;
}

/// <summary>
/// Create an instance of each user-defined module
/// </summary>
public static async Task<Dictionary<Type, SlashModuleInfo>> InstantiateModules(IReadOnlyList<TypeInfo> types, SlashCommandService slashCommandService)
{
var result = new Dictionary<Type, SlashModuleInfo>();
// Here we get all modules thate are NOT sub command groups and instantiate them.
foreach (Type userModuleType in types)
{
SlashModuleInfo moduleInfo = new SlashModuleInfo(slashCommandService);
moduleInfo.SetType(userModuleType);

// If they want a constructor with different parameters, this is the place to add them.
object instance = userModuleType.GetConstructor(Type.EmptyTypes).Invoke(null);
moduleInfo.SetCommandModule(instance);
moduleInfo.isGlobal = IsCommandModuleGlobal(userModuleType);

moduleInfo.SetSubCommandGroups(InstantiateSubModules(userModuleType, moduleInfo, slashCommandService));
result.Add(userModuleType, moduleInfo);
}
return result;
}
public static List<SlashModuleInfo> InstantiateSubModules(Type rootModule,SlashModuleInfo rootModuleInfo, SlashCommandService slashCommandService)
{
// Instantiate all of the nested modules.
List<SlashModuleInfo> commandGroups = new List<SlashModuleInfo>();
foreach(Type commandGroupType in rootModule.GetNestedTypes())
{
if(TryGetCommandGroupAttribute(commandGroupType, out CommandGroup commandGroup))
{
SlashModuleInfo groupInfo = new SlashModuleInfo(slashCommandService);
groupInfo.SetType(commandGroupType);

object instance = commandGroupType.GetConstructor(Type.EmptyTypes).Invoke(null);
groupInfo.SetCommandModule(instance);

groupInfo.MakeCommandGroup(commandGroup,rootModuleInfo);
groupInfo.MakePath();
groupInfo.isGlobal = IsCommandModuleGlobal(commandGroupType);

groupInfo.SetSubCommandGroups(InstantiateSubModules(commandGroupType, groupInfo, slashCommandService));
commandGroups.Add(groupInfo);
}
}
return commandGroups;
}
public static bool TryGetCommandGroupAttribute(Type module, out CommandGroup commandGroup)
{
if(!module.IsPublic && !module.IsNestedPublic)
{
commandGroup = null;
return false;
}

var commandGroupAttributes = module.GetCustomAttributes(typeof(CommandGroup));
if( commandGroupAttributes.Count() == 0)
{
commandGroup = null;
return false;
}
else if(commandGroupAttributes.Count() > 1)
{
throw new Exception($"Too many CommandGroup attributes on a single class ({module.FullName}). It can only contain one!");
}
else
{
commandGroup = commandGroupAttributes.First() as CommandGroup;
return true;
}
}
public static bool IsCommandModuleGlobal(Type userModuleType)
{
// Verify that we only have one [Global] attribute
IEnumerable<Attribute> slashCommandAttributes = userModuleType.GetCustomAttributes(typeof(Global));
if (slashCommandAttributes.Count() > 1)
{
throw new Exception("Too many Global attributes on a single method. It can only contain one!");
}
// And at least one
if (slashCommandAttributes.Count() == 0)
{
return false;
}
return true;
}
/// <summary>
/// Prepare all of the commands and register them internally.
/// </summary>
public static async Task<Dictionary<string, SlashCommandInfo>> CreateCommandInfos(IReadOnlyList<TypeInfo> types, Dictionary<Type, SlashModuleInfo> moduleDefs, SlashCommandService slashCommandService)
{
// Create the resulting dictionary ahead of time
var result = new Dictionary<string, SlashCommandInfo>();
// For each user-defined module ...
foreach (var userModule in types)
{
// Get its associated information. If there isn't any it means something went wrong, but it's not a critical error.
SlashModuleInfo moduleInfo;
if (moduleDefs.TryGetValue(userModule, out moduleInfo))
{
// Create the root-level commands
var commandInfos = CreateSameLevelCommands(result, userModule, moduleInfo);
moduleInfo.SetCommands(commandInfos);
// Then create all of the command groups it has.
CreateSubCommandInfos(result, moduleInfo.commandGroups, slashCommandService);
}
}
return result;
}
public static void CreateSubCommandInfos(Dictionary<string, SlashCommandInfo> result, List<SlashModuleInfo> subCommandGroups, SlashCommandService slashCommandService)
{
foreach (var subCommandGroup in subCommandGroups)
{
// Create the commands that is on the same hierarchical level as this ...
var commandInfos = CreateSameLevelCommands(result, subCommandGroup.moduleType.GetTypeInfo(), subCommandGroup);
subCommandGroup.SetCommands(commandInfos);

// ... and continue with the lower sub command groups.
CreateSubCommandInfos(result, subCommandGroup.commandGroups, slashCommandService);
}
}
private static List<SlashCommandInfo> CreateSameLevelCommands(Dictionary<string, SlashCommandInfo> result, TypeInfo userModule, SlashModuleInfo moduleInfo)
{
var commandMethods = userModule.GetMethods();
List<SlashCommandInfo> commandInfos = new List<SlashCommandInfo>();
foreach (var commandMethod in commandMethods)
{
// Get the SlashCommand attribute
SlashCommand slashCommand;
if (IsValidSlashCommand(commandMethod, out slashCommand))
{
// Create the delegate for the method we want to call once the user interacts with the bot.
// We use a delegate because of the unknown number and type of parameters we will have.
Delegate delegateMethod = CreateDelegate(commandMethod, moduleInfo.userCommandModule);

SlashCommandInfo commandInfo = new SlashCommandInfo(
module: moduleInfo,
name: slashCommand.commandName,
description: slashCommand.description,
// Generate the parameters. Due to it's complicated way the algorithm has been moved to its own function.
parameters: ConstructCommandParameters(commandMethod),
userMethod: delegateMethod,
isGlobal: IsCommandGlobal(commandMethod)
);

result.Add(commandInfo.Module.Path + SlashModuleInfo.PathSeperator + commandInfo.Name, commandInfo);
commandInfos.Add(commandInfo);
}
}

return commandInfos;
}

/// <summary>
/// Determines wheater a method can be clasified as a slash command
/// </summary>
private static bool IsValidSlashCommand(MethodInfo method, out SlashCommand slashCommand)
{
// Verify that we only have one [SlashCommand(...)] attribute
IEnumerable<Attribute> slashCommandAttributes = method.GetCustomAttributes(typeof(SlashCommand));
if (slashCommandAttributes.Count() > 1)
{
throw new Exception("Too many SlashCommand attributes on a single method. It can only contain one!");
}
// And at least one
if (slashCommandAttributes.Count() == 0)
{
slashCommand = null;
return false;
}
// And return the first (and only) attribute
slashCommand = slashCommandAttributes.First() as SlashCommand;
return true;
}
/// <summary>
/// Determins if the method has a [Global] Attribute.
/// </summary>
private static bool IsCommandGlobal(MethodInfo method)
{
// Verify that we only have one [Global] attribute
IEnumerable<Attribute> slashCommandAttributes = method.GetCustomAttributes(typeof(Global));
if (slashCommandAttributes.Count() > 1)
{
throw new Exception("Too many Global attributes on a single method. It can only contain one!");
}
// And at least one
if (slashCommandAttributes.Count() == 0)
{
return false;
}
return true;
}
/// <summary>
/// Process the parameters of this method, including all the attributes.
/// </summary>
private static List<SlashParameterInfo> ConstructCommandParameters(MethodInfo method)
{
// Prepare the final list of parameters
List<SlashParameterInfo> finalParameters = new List<SlashParameterInfo>();

// For each mehod parameter ...
// ex: ... MyCommand(string abc, int myInt)
// `abc` and `myInt` are parameters
foreach (var methodParameter in method.GetParameters())
{
SlashParameterInfo newParameter = new SlashParameterInfo();

// Test for the [ParameterName] Attribute. If we have it, then use that as the name,
// if not just use the parameter name as the option name.
var customNameAttributes = methodParameter.GetCustomAttributes(typeof(ParameterName));
if (customNameAttributes.Count() == 0)
newParameter.Name = methodParameter.Name;
else if (customNameAttributes.Count() > 1)
throw new Exception($"Too many ParameterName attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!");
else
newParameter.Name = (customNameAttributes.First() as ParameterName).name;

// Get to see if it has a Description Attribute.
// If it has
// 0 -> then use the default description
// 1 -> Use the value from that attribute
// 2+ -> Throw an error. This shouldn't normaly happen, but we check for sake of sanity
var descriptions = methodParameter.GetCustomAttributes(typeof(Description));
if (descriptions.Count() == 0)
newParameter.Description = Description.DefaultDescription;
else if (descriptions.Count() > 1)
throw new Exception($"Too many Description attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!");
else
newParameter.Description = (descriptions.First() as Description).description;

// Set the Type of the parameter.
// In the case of int and int? it returns the same type - INTEGER.
// Same with bool and bool?.
newParameter.Type = TypeFromMethodParameter(methodParameter);

// If we have a nullble type (int? or bool?) mark it as such.
newParameter.Nullable = GetNullableStatus(methodParameter);

// Test for the [Required] Attribute
var requiredAttributes = methodParameter.GetCustomAttributes(typeof(Required));
if (requiredAttributes.Count() == 1)
newParameter.Required = true;
else if (requiredAttributes.Count() > 1)
throw new Exception($"Too many Required attributes on a single parameter ({method.Name} -> {methodParameter.Name}). It can only contain one!");

// Test for the [Choice] Attribute
// A parameter cna have multiple Choice attributes, and for each we're going to add it's key-value pair.
foreach (Choice choice in methodParameter.GetCustomAttributes(typeof(Choice)))
{
// If the parameter expects a string but the value of the choice is of type int, then throw an error.
if (newParameter.Type == ApplicationCommandOptionType.String)
{
if(String.IsNullOrEmpty(choice.choiceStringValue))
{
throw new Exception($"Parameter ({method.Name} -> {methodParameter.Name}) is of type string, but choice is of type int!");
}
newParameter.AddChoice(choice.choiceName, choice.choiceStringValue);
}
// If the parameter expects a int but the value of the choice is of type string, then throw an error.
if (newParameter.Type == ApplicationCommandOptionType.Integer)
{
if (choice.choiceIntValue == null)
{
throw new Exception($"Parameter ({method.Name} -> {methodParameter.Name}) is of type int, but choice is of type string!");
}
newParameter.AddChoice(choice.choiceName, (int)choice.choiceIntValue);
}
}

finalParameters.Add(newParameter);
}
return finalParameters;
}
/// <summary>
/// Get the type of command option from a method parameter info.
/// </summary>
private static ApplicationCommandOptionType TypeFromMethodParameter(ParameterInfo methodParameter)
{
// Can't do switch -- who knows why?
if (methodParameter.ParameterType == typeof(int) ||
methodParameter.ParameterType == typeof(int?))
return ApplicationCommandOptionType.Integer;
if (methodParameter.ParameterType == typeof(string))
return ApplicationCommandOptionType.String;
if (methodParameter.ParameterType == typeof(bool) ||
methodParameter.ParameterType == typeof(bool?))
return ApplicationCommandOptionType.Boolean;
if (methodParameter.ParameterType == typeof(SocketGuildChannel))
return ApplicationCommandOptionType.Channel;
if (methodParameter.ParameterType == typeof(SocketRole))
return ApplicationCommandOptionType.Role;
if (methodParameter.ParameterType == typeof(SocketGuildUser))
return ApplicationCommandOptionType.User;

throw new Exception($"Got parameter type other than int, string, bool, guild, role, or user. {methodParameter.Name}");
}

/// <summary>
/// Gets whater the parameter can be set as null, in the case that parameter type usually does not allow null.
/// More specifically tests to see if it is a type of 'int?' or 'bool?',
/// </summary>
private static bool GetNullableStatus(ParameterInfo methodParameter)
{
if(methodParameter.ParameterType == typeof(int?) ||
methodParameter.ParameterType == typeof(bool?))
{
return true;
}
return false;
}
/// <summary>
/// Creae a delegate from methodInfo. Taken from
/// https://stackoverflow.com/a/40579063/8455128
/// </summary>
private static Delegate CreateDelegate(MethodInfo methodInfo, object target)
{
Func<Type[], Type> getType;
var isAction = methodInfo.ReturnType.Equals((typeof(void)));
var types = methodInfo.GetParameters().Select(p => p.ParameterType);

if (isAction)
{
getType = Expression.GetActionType;
}
else
{
getType = Expression.GetFuncType;
types = types.Concat(new[] { methodInfo.ReturnType });
}

if (methodInfo.IsStatic)
{
return Delegate.CreateDelegate(getType(types.ToArray()), methodInfo);
}

return Delegate.CreateDelegate(getType(types.ToArray()), target, methodInfo.Name);
}

public static async Task RegisterCommands(DiscordSocketClient socketClient, Dictionary<Type, SlashModuleInfo> rootModuleInfos, Dictionary<string, SlashCommandInfo> commandDefs, SlashCommandService slashCommandService, List<ulong> guildIDs,CommandRegistrationOptions options)
{
// TODO: see how we should handle if user wants to register two commands with the same name, one global and one not.
// Build the commands
List<SlashCommandCreationProperties> builtCommands = await BuildCommands(rootModuleInfos).ConfigureAwait(false);

// Scan for each existing command on discord so we know what is already there.
List<Rest.RestGuildCommand> existingGuildCommands = new List<Rest.RestGuildCommand>();
List<Rest.RestGlobalCommand> existingGlobalCommands = new List<Rest.RestGlobalCommand>();
existingGlobalCommands.AddRange(await socketClient.Rest.GetGlobalApplicationCommands().ConfigureAwait(false));
foreach (ulong guildID in guildIDs)
{
existingGuildCommands.AddRange(await socketClient.Rest.GetGuildApplicationCommands(guildID).ConfigureAwait(false));
}

// If we want to keep the existing commands that are already registered
// remove the commands that share the same name from the builtCommands list as to not overwrite.
if (options.ExistingCommands == ExistingCommandOptions.KEEP_EXISTING)
{
foreach (var existingCommand in existingGuildCommands)
{
builtCommands.RemoveAll(x => (!x.Global && x.Name == existingCommand.Name));
}
foreach (var existingCommand in existingGlobalCommands)
{
builtCommands.RemoveAll(x => (x.Global && x.Name == existingCommand.Name));
}
}

// If we want to delete commands that are not going to be re-implemented in builtCommands
// or if we just want a blank slate
if (options.OldCommands == OldCommandOptions.DELETE_UNUSED ||
options.OldCommands == OldCommandOptions.WIPE)
{
foreach (var existingCommand in existingGuildCommands)
{
// If we want to wipe all GUILD commands
// or if the existing command isn't re-defined and re-built
// remove it from discord.
if (options.OldCommands == OldCommandOptions.WIPE ||
// There are no commands which contain this existing command.
!builtCommands.Any(x => !x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name)))
{
await existingCommand.DeleteAsync();
}
}
foreach (var existingCommand in existingGlobalCommands)
{
// If we want to wipe all GLOBAL commands
// or if the existing command isn't re-defined and re-built
// remove it from discord.
if (options.OldCommands == OldCommandOptions.WIPE ||
// There are no commands which contain this existing command.
!builtCommands.Any(x => x.Global && x.Name.Contains(SlashModuleInfo.PathSeperator + existingCommand.Name)))
{
await existingCommand.DeleteAsync();
}
}
}

// And now register them. Globally if the 'Global' flag is set.
// If not then just register them as guild commands on all of the guilds given to us.
foreach (var builtCommand in builtCommands)
{
if (builtCommand.Global)
{
await socketClient.Rest.CreateGlobalCommand(builtCommand).ConfigureAwait(false);
}
else
{
foreach (ulong guildID in guildIDs)
{
await socketClient.Rest.CreateGuildCommand(builtCommand, guildID).ConfigureAwait(false);
}
}
}

return;
}
/// <summary>
/// Build and return all of the commands this assembly contians.
/// </summary>
public static async Task<List<SlashCommandCreationProperties>> BuildCommands(Dictionary<Type, SlashModuleInfo> rootModuleInfos)
{
List<SlashCommandCreationProperties> builtCommands = new List<SlashCommandCreationProperties>();
foreach (var pair in rootModuleInfos)
{
var rootModuleInfo = pair.Value;
builtCommands.AddRange(rootModuleInfo.BuildCommands());
}

return builtCommands;
}
}
}

+ 21
- 0
src/Discord.Net.Commands/SlashCommands/Types/CommandModule/ISlashCommandModule.cs View File

@@ -0,0 +1,21 @@
using Discord.Commands.Builders;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{

public interface ISlashCommandModule
{
void SetContext(IDiscordInteraction interaction);

//void BeforeExecute(CommandInfo command);

//void AfterExecute(CommandInfo command);

//void OnModuleBuilding(CommandService commandService, ModuleBuilder builder);
}
}

+ 34
- 0
src/Discord.Net.Commands/SlashCommands/Types/CommandModule/SlashCommandModule.cs View File

@@ -0,0 +1,34 @@
using Discord.Commands.SlashCommands.Types;
using Discord.WebSocket;
using System;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
public class SlashCommandModule<T> : ISlashCommandModule where T : class, IDiscordInteraction
{
/// <summary>
/// The underlying interaction of the command.
/// </summary>
/// <seealso cref="T:Discord.IDiscordInteraction"/>
/// <seealso cref="T:Discord.WebSocket.SocketInteraction" />
public T Interaction { get; private set; }

void ISlashCommandModule.SetContext(IDiscordInteraction interaction)
{
var newValue = interaction as T;
Interaction = newValue ?? throw new InvalidOperationException($"Invalid interaction type. Expected {typeof(T).Name}, got {interaction.GetType().Name}.");
}


public async Task<IMessage> Reply(string text = null, bool isTTS = false, Embed embed = null, InteractionResponseType Type = InteractionResponseType.ChannelMessageWithSource,
AllowedMentions allowedMentions = null, RequestOptions options = null)
{
if(Interaction is SocketInteraction)
{
return await (Interaction as SocketInteraction).FollowupAsync(text, isTTS, embed, Type, allowedMentions, options);
}
return null;
}
}
}

+ 4
- 0
src/Discord.Net.Core/Entities/Interactions/SlashCommandCreationProperties.cs View File

@@ -21,6 +21,10 @@ namespace Discord
/// </summary>
public string Description { get; set; }

/// <summary>
/// If the command should be defined as a global command.
/// </summary>
public bool Global { get; set; } = false;

/// <summary>
/// Gets or sets the options for this command.


+ 3
- 1
src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs View File

@@ -67,7 +67,9 @@ namespace Discord.API
? option.Options.Select(x => new ApplicationCommandOption(x)).ToArray()
: Optional<ApplicationCommandOption[]>.Unspecified;

this.Required = option.Required.Value;
this.Required = option.Required.HasValue
? option.Required.Value
: Optional<bool>.Unspecified;
this.Default = option.Default.HasValue
? option.Default.Value
: Optional<bool>.Unspecified;


+ 1
- 1
src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs View File

@@ -60,7 +60,7 @@ namespace Discord.Rest
this.Description = model.Description;

this.Options = model.Options.IsSpecified
? model.Options.Value.Select(x => RestApplicationCommandOption.Create(x)).ToImmutableArray()
? model.Options.Value.Select(x => RestApplicationCommandOption.Create(x)).ToImmutableArray().ToReadOnlyCollection()
: null;
}



+ 2
- 2
src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs View File

@@ -60,11 +60,11 @@ namespace Discord.Rest
this.Required = model.Required.Value;

this.Options = model.Options.IsSpecified
? model.Options.Value.Select(x => Create(x)).ToImmutableArray()
? model.Options.Value.Select(x => Create(x)).ToImmutableArray().ToReadOnlyCollection()
: null;

this.Choices = model.Choices.IsSpecified
? model.Choices.Value.Select(x => new RestApplicationCommandChoice(x)).ToImmutableArray()
? model.Choices.Value.Select(x => new RestApplicationCommandChoice(x)).ToImmutableArray().ToReadOnlyCollection()
: null;
}



+ 2
- 2
src/Discord.Net.Rest/Extensions/EntityExtensions.cs View File

@@ -37,8 +37,8 @@ namespace Discord.Rest
public static RoleTags ToEntity(this API.RoleTags model)
{
return new RoleTags(
model.BotId.IsSpecified ? model.BotId.Value : null,
model.IntegrationId.IsSpecified ? model.IntegrationId.Value : null,
model.BotId.IsSpecified ? model.BotId.Value : (ulong?)null,
model.IntegrationId.IsSpecified ? model.IntegrationId.Value : (ulong?)null,
model.IsPremiumSubscriber.IsSpecified ? true : false);
}
public static API.Embed ToModel(this Embed entity)


+ 6
- 6
src/Discord.Net.WebSocket/Entities/Interaction/SocketApplicationCommandOption.cs View File

@@ -54,19 +54,19 @@ namespace Discord.WebSocket

this.Default = model.Default.IsSpecified
? model.Default.Value
: null;
: (bool?)null;

this.Required = model.Required.IsSpecified
? model.Required.Value
: null;
: (bool?)null;

this.Choices = model.Choices.IsSpecified
? model.Choices.Value.Select(x => SocketApplicationCommandChoice.Create(x)).ToImmutableArray()
: new ImmutableArray<SocketApplicationCommandChoice>();
? model.Choices.Value.Select(x => SocketApplicationCommandChoice.Create(x)).ToImmutableArray().ToReadOnlyCollection()
: null;

this.Options = model.Options.IsSpecified
? model.Options.Value.Select(x => SocketApplicationCommandOption.Create(x)).ToImmutableArray()
: new ImmutableArray<SocketApplicationCommandOption>();
? model.Options.Value.Select(x => SocketApplicationCommandOption.Create(x)).ToImmutableArray().ToReadOnlyCollection()
: null;
}

IReadOnlyCollection<IApplicationCommandOptionChoice> IApplicationCommandOption.Choices => Choices;


+ 1
- 1
src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionData.cs View File

@@ -38,7 +38,7 @@ namespace Discord.WebSocket
this.guildId = guildId;

this.Options = model.Options.IsSpecified
? model.Options.Value.Select(x => new SocketInteractionDataOption(x, this.Discord, guildId)).ToImmutableArray()
? model.Options.Value.Select(x => new SocketInteractionDataOption(x, this.Discord, guildId)).ToImmutableArray().ToReadOnlyCollection()
: null;
}



+ 24
- 6
src/Discord.Net.WebSocket/Entities/Interaction/SocketInteractionDataOption.cs View File

@@ -30,13 +30,13 @@ namespace Discord.WebSocket
internal SocketInteractionDataOption() { }
internal SocketInteractionDataOption(Model model, DiscordSocketClient discord, ulong guild)
{
this.Name = Name;
this.Name = model.Name;
this.Value = model.Value.IsSpecified ? model.Value.Value : null;
this.discord = discord;
this.guild = guild;

this.Options = model.Options.IsSpecified
? model.Options.Value.Select(x => new SocketInteractionDataOption(x, discord, guild)).ToImmutableArray()
? model.Options.Value.Select(x => new SocketInteractionDataOption(x, discord, guild)).ToImmutableArray().ToReadOnlyCollection()
: null;

}
@@ -44,14 +44,32 @@ namespace Discord.WebSocket
// Converters
public static explicit operator bool(SocketInteractionDataOption option)
=> (bool)option.Value;
// The default value is of type long, so an implementaiton of of the long option is trivial
public static explicit operator int(SocketInteractionDataOption option)
=> (int)option.Value;
=> unchecked(
(int)( (long)option.Value )
);
public static explicit operator string(SocketInteractionDataOption option)
=> option.Value.ToString();

public static explicit operator bool?(SocketInteractionDataOption option)
{
if (option.Value == null)
return null;
else
return (bool)option;
}
public static explicit operator int?(SocketInteractionDataOption option)
{
if (option.Value == null)
return null;
else
return (int)option;
}

public static explicit operator SocketGuildChannel(SocketInteractionDataOption option)
{
if (option.Value is ulong id)
if (ulong.TryParse((string)option.Value, out ulong id))
{
var guild = option.discord.GetGuild(option.guild);

@@ -66,7 +84,7 @@ namespace Discord.WebSocket

public static explicit operator SocketRole(SocketInteractionDataOption option)
{
if (option.Value is ulong id)
if (ulong.TryParse((string)option.Value, out ulong id))
{
var guild = option.discord.GetGuild(option.guild);

@@ -81,7 +99,7 @@ namespace Discord.WebSocket

public static explicit operator SocketGuildUser(SocketInteractionDataOption option)
{
if(option.Value is ulong id)
if (ulong.TryParse((string)option.Value, out ulong id))
{
var guild = option.discord.GetGuild(option.guild);



Loading…
Cancel
Save