Browse Source

Implemented the most basic form of the command service. Does not yet parse arguments, nor does it register commands. Also added to the aforementioned dev-test project.

pull/1733/head^2
Cosma George 4 years ago
parent
commit
f4b321429a
13 changed files with 524 additions and 21 deletions
  1. +72
    -0
      SlashCommandsExample/DiscordClient.cs
  2. +20
    -0
      SlashCommandsExample/Modules/InvalidModule.cs
  3. +33
    -0
      SlashCommandsExample/Modules/PingCommand.cs
  4. +12
    -3
      SlashCommandsExample/Program.cs
  5. +14
    -0
      SlashCommandsExample/SlashCommandsExample.csproj
  6. +1
    -0
      src/Discord.Net.Commands/Discord.Net.Commands.csproj
  7. +10
    -4
      src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs
  8. +64
    -0
      src/Discord.Net.Commands/SlashCommands/Info/SlashCommandInfo.cs
  9. +47
    -0
      src/Discord.Net.Commands/SlashCommands/Info/SlashModuleInfo.cs
  10. +65
    -7
      src/Discord.Net.Commands/SlashCommands/SlashCommandService.cs
  11. +151
    -0
      src/Discord.Net.Commands/SlashCommands/SlashCommandServiceHelper.cs
  12. +21
    -0
      src/Discord.Net.Commands/SlashCommands/Types/ISlashCommandModule.cs
  13. +14
    -7
      src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs

+ 72
- 0
SlashCommandsExample/DiscordClient.cs View File

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

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

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

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

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

await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);

await Task.Delay(-1);
}

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

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

+ 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
}
}

+ 33
- 0
SlashCommandsExample/Modules/PingCommand.cs View File

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

namespace SlashCommandsExample.Modules
{
public class PingCommand : SlashCommandModule<SocketInteraction>
{
[SlashCommand("johnny-test", "Ping the bot to see if it is alive!")]
public async Task PingAsync()
{
await Interaction.FollowupAsync(":white_check_mark: **Bot Online**");
}
}
}
/*
The base way of defining a command using the regular command service:

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

+ 12
- 3
SlashCommandsExample/Program.cs View File

@@ -4,14 +4,23 @@
* this project should be re-made into one that could be used as an example usage of the new Slash Command Service. * 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;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using Discord.SlashCommands;
using Discord.WebSocket;


namespace SlashCommandsExample namespace SlashCommandsExample
{ {
class Program class Program
{ {
static void Main(string[] args)
static void Main(string[] args)
{ {
Console.WriteLine("Hello World!"); 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();
}
}
} }

+ 14
- 0
SlashCommandsExample/SlashCommandsExample.csproj View File

@@ -5,4 +5,18 @@
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup> </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> </Project>

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

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


</Project> </Project>

+ 10
- 4
src/Discord.Net.Commands/SlashCommands/Attributes/SlashCommand.cs View File

@@ -15,15 +15,21 @@ namespace Discord.SlashCommands
/// <summary> /// <summary>
/// The name of this slash command. /// The name of this slash command.
/// </summary> /// </summary>
public string CommandName;
public string commandName;

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


/// <summary> /// <summary>
/// Tells the <see cref="SlashCommandService"/> that this class/function is a slash command. /// Tells the <see cref="SlashCommandService"/> that this class/function is a slash command.
/// </summary> /// </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;
this.description = description;
} }
} }
} }

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

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

namespace Discord.SlashCommands
{
public class SlashCommandInfo
{
/// <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 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, Delegate userMethod)
{
Module = module;
Name = name;
Description = description;
this.userMethod = userMethod;
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();

});
}

public async Task<IResult> ExecuteAsync(object[] args)
{
return await callback.Invoke(args).ConfigureAwait(false);
}
}
}

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

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

namespace Discord.SlashCommands
{
public class SlashModuleInfo
{
public SlashModuleInfo(SlashCommandService service)
{
Service = service;
}

/// <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 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;
}
}
}
}

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

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


namespace Discord.SlashCommands namespace Discord.SlashCommands
{ {
public class SlashCommandService 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? public SlashCommandService() // TODO: possible config?
{ {

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


public void AddAssembly() public void AddAssembly()
@@ -21,10 +39,50 @@ namespace Discord.SlashCommands


} }


public async Task<IResult> ExecuteAsync()
/// <summary>
/// Execute a slash command.
/// </summary>
/// <param name="interaction">Interaction data recieved from discord.</param>
/// <returns></returns>
public async Task<IResult> ExecuteAsync(SocketInteraction interaction)
{
// First, get the info about this command, if it exists
SlashCommandInfo commandInfo;
if (commandDefs.TryGetValue(interaction.Data.Name, out commandInfo))
{
// TODO: implement everything that has to do with parameters :)

// Then, set the context in which the command will be executed
commandInfo.Module.userCommandModule.SetContext(interaction);
// Then run the command (with no parameters)
return await commandInfo.ExecuteAsync(new object[] { }).ConfigureAwait(false);
}
else
{
return SearchResult.FromError(CommandError.UnknownCommand, $"There is no registered slash command with the name {interaction.Data.Name}");
}
}
public async Task AddModulesAsync(Assembly assembly, IServiceProvider services)
{ {
// TODO: handle execution
return null;
// First take a hold of the module lock, as to make sure we aren't editing stuff while we do our business
await _moduleLock.WaitAsync().ConfigureAwait(false);

try
{
// Get all of the modules that were properly defined by the user.
IReadOnlyList<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.PrepareAsync(types,moduleDefs,this).ConfigureAwait(false);
// TODO: And finally, register the commands with discord.
await SlashCommandServiceHelper.RegisterCommands(commandDefs, this, services).ConfigureAwait(false);
}
finally
{
_moduleLock.Release();
}
} }
} }
} }

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

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading.Tasks;

namespace Discord.SlashCommands
{
internal static class SlashCommandServiceHelper
{
/// <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));
}

/// <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>();
foreach (Type userModuleType in types)
{
SlashModuleInfo moduleInfo = new SlashModuleInfo(slashCommandService);

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

result.Add(userModuleType, moduleInfo);
}
return result;
}

/// <summary>
/// Prepare all of the commands and register them internally.
/// </summary>
public static async Task<Dictionary<string, SlashCommandInfo>> PrepareAsync(IReadOnlyList<TypeInfo> types, Dictionary<Type, SlashModuleInfo> moduleDefs, SlashCommandService slashCommandService)
{
var result = new Dictionary<string, SlashCommandInfo>();
// fore each user-defined module
foreach (var userModule in types)
{
// Get its associated information
SlashModuleInfo moduleInfo;
if (moduleDefs.TryGetValue(userModule, out moduleInfo))
{
// and get all of its method, and check if they are valid, and if so create a new SlashCommandInfo for them.
var commandMethods = userModule.GetMethods();
List<SlashCommandInfo> commandInfos = new List<SlashCommandInfo>();
foreach (var commandMethod in commandMethods)
{
SlashCommand slashCommand;
if (IsValidSlashCommand(commandMethod, out slashCommand))
{
Delegate delegateMethod = CreateDelegate(commandMethod, moduleInfo.userCommandModule);
SlashCommandInfo commandInfo = new SlashCommandInfo(
module: moduleInfo,
name: slashCommand.commandName,
description: slashCommand.description,
userMethod: delegateMethod
);
result.Add(slashCommand.commandName, commandInfo);
commandInfos.Add(commandInfo);
}
}
moduleInfo.SetCommands(commandInfos);
}
}
return result;
}
private static bool IsValidSlashCommand(MethodInfo method, out SlashCommand slashCommand)
{
// Verify that we only have one [SlashCommand(...)] attribute
IEnumerable<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>
/// Creae a delegate from methodInfo. Taken from
/// https://stackoverflow.com/a/40579063/8455128
/// </summary>
public 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(Dictionary<string, SlashCommandInfo> commandDefs, SlashCommandService slashCommandService, IServiceProvider services)
{
return;
}
}
}

+ 21
- 0
src/Discord.Net.Commands/SlashCommands/Types/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);
}
}

+ 14
- 7
src/Discord.Net.Commands/SlashCommands/Types/SlashCommandModule.cs View File

@@ -1,14 +1,21 @@
using Discord.Commands.SlashCommands.Types;
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;


namespace Discord.SlashCommands namespace Discord.SlashCommands
{ {
internal class SlashCommandModule
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}.");
}
} }
} }

Loading…
Cancel
Save