From 535f9fb9e8bab1fe3d1fe63741b9b8674a3ed146 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sun, 27 Nov 2022 20:43:21 +0000 Subject: [PATCH] Add Commands extension --- .github/workflows/nightly.yml | 1 + .github/workflows/prerelease.yml | 1 + .github/workflows/release.yml | 1 + .../Attributes/AliasesAttribute.cs | 52 ++++ .../Attributes/CommandAttribute.cs | 31 ++ .../Attributes/RemainderAttribute.cs | 9 + VpSharp.Commands/Command.cs | 36 +++ VpSharp.Commands/CommandContext.cs | 69 +++++ VpSharp.Commands/CommandModule.cs | 8 + VpSharp.Commands/CommandsExtension.cs | 279 ++++++++++++++++++ .../CommandsExtensionConfiguration.cs | 13 + .../VirtualParadiseClientExtensions.cs | 18 ++ VpSharp.Commands/VpSharp.Commands.csproj | 35 +++ .../VpSharp.IntegrationTests.csproj | 15 +- .../src/CommandModules/TestCommands.cs | 21 ++ VpSharp.IntegrationTests/src/Program.cs | 6 +- VpSharp.sln | 6 + 17 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 VpSharp.Commands/Attributes/AliasesAttribute.cs create mode 100644 VpSharp.Commands/Attributes/CommandAttribute.cs create mode 100644 VpSharp.Commands/Attributes/RemainderAttribute.cs create mode 100644 VpSharp.Commands/Command.cs create mode 100644 VpSharp.Commands/CommandContext.cs create mode 100644 VpSharp.Commands/CommandModule.cs create mode 100644 VpSharp.Commands/CommandsExtension.cs create mode 100644 VpSharp.Commands/CommandsExtensionConfiguration.cs create mode 100644 VpSharp.Commands/VirtualParadiseClientExtensions.cs create mode 100644 VpSharp.Commands/VpSharp.Commands.csproj create mode 100644 VpSharp.IntegrationTests/src/CommandModules/TestCommands.cs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c599b5c..72345fa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -32,6 +32,7 @@ jobs: run: | mkdir build dotnet pack VpSharp -p:SymbolPackageFormat=snupkg --include-symbols --include-source -o build -p:VersionSuffix='nightly' -p:BuildNumber=${{ github.run_number }} + dotnet pack VpSharp.Commands -p:SymbolPackageFormat=snupkg --include-symbols --include-source -o build -p:VersionSuffix='nightly' -p:BuildNumber=${{ github.run_number }} - name: Push NuGet Package to GitHub run: dotnet nuget push "build/*" --source "github" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 41828ad..e33d22e 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -32,6 +32,7 @@ jobs: run: | mkdir build dotnet pack VpSharp -p:SymbolPackageFormat=snupkg --include-symbols --include-source -o build -p:VersionSuffix='prerelease' -p:BuildNumber=${{ github.run_number }} + dotnet pack VpSharp.Commands -p:SymbolPackageFormat=snupkg --include-symbols --include-source -o build -p:VersionSuffix='prerelease' -p:BuildNumber=${{ github.run_number }} - name: Push NuGet Package to GitHub run: dotnet nuget push "build/*" --source "github" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02edf6c..defbcf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,7 @@ jobs: run: | mkdir build dotnet pack VpSharp -p:SymbolPackageFormat=snupkg --include-symbols --include-source -o build + dotnet pack VpSharp.Commands -p:SymbolPackageFormat=snupkg --include-symbols --include-source -o build - name: Push NuGet Package to GitHub run: dotnet nuget push "build/*" --source "github" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/VpSharp.Commands/Attributes/AliasesAttribute.cs b/VpSharp.Commands/Attributes/AliasesAttribute.cs new file mode 100644 index 0000000..9322285 --- /dev/null +++ b/VpSharp.Commands/Attributes/AliasesAttribute.cs @@ -0,0 +1,52 @@ +namespace VpSharp.Commands.Attributes; + +/// +/// Defines the aliases of a command. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class AliasesAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The first alias. + /// Additional aliases. + /// + /// is . + /// -or- + /// is . + /// + /// + /// is empty, or consists of only whitespace. + /// -or- + /// An element in is null, empty, or consists of only whitespace. + /// + public AliasesAttribute(string alias, params string[] aliases) + { + ArgumentNullException.ThrowIfNull(alias); + ArgumentNullException.ThrowIfNull(aliases); + + if (string.IsNullOrWhiteSpace(alias)) + { + throw new ArgumentException("Alias cannot be empty"); + } + + foreach (string a in aliases) + { + if (string.IsNullOrWhiteSpace(a)) + { + throw new ArgumentException("Cannot have a null alias"); + } + } + + Aliases = new string[aliases.Length + 1]; + Aliases[0] = alias; + Array.Copy(aliases, 0, Aliases, 1, aliases.Length); + } + + /// + /// Gets the command aliases. + /// + /// The command aliases. + public string[] Aliases { get; } +} diff --git a/VpSharp.Commands/Attributes/CommandAttribute.cs b/VpSharp.Commands/Attributes/CommandAttribute.cs new file mode 100644 index 0000000..d73a8b7 --- /dev/null +++ b/VpSharp.Commands/Attributes/CommandAttribute.cs @@ -0,0 +1,31 @@ +namespace VpSharp.Commands.Attributes; + +/// +/// Defines the name of a command. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CommandAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The command name. + /// is . + /// is empty, or consists of only whitespace. + public CommandAttribute(string name) + { + ArgumentNullException.ThrowIfNull(name); + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Name cannot be empty"); + } + + Name = name; + } + + /// + /// Gets the command name. + /// + /// The command name. + public string Name { get; } +} diff --git a/VpSharp.Commands/Attributes/RemainderAttribute.cs b/VpSharp.Commands/Attributes/RemainderAttribute.cs new file mode 100644 index 0000000..27a03bf --- /dev/null +++ b/VpSharp.Commands/Attributes/RemainderAttribute.cs @@ -0,0 +1,9 @@ +namespace VpSharp.Commands.Attributes; + +/// +/// Indicates that a string parameter should consume the remainder of the arguments as one string. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class RemainderAttribute : Attribute +{ +} diff --git a/VpSharp.Commands/Command.cs b/VpSharp.Commands/Command.cs new file mode 100644 index 0000000..09600f3 --- /dev/null +++ b/VpSharp.Commands/Command.cs @@ -0,0 +1,36 @@ +using System.Reflection; + +namespace VpSharp.Commands; + +/// +/// Represents a registered command. +/// +public sealed class Command +{ + internal Command(string name, string[] aliases, MethodInfo method, CommandModule module) + { + Name = name; + Aliases = aliases[..]; + Method = method; + Module = module; + Parameters = method.GetParameters()[1..]; + } + + /// + /// Gets the aliases for this command. + /// + /// The aliases. + public string[] Aliases { get; } + + /// + /// Gets the name of this command. + /// + /// The name. + public string Name { get; } + + internal MethodInfo Method { get; } + + internal CommandModule Module { get; } + + internal ParameterInfo[] Parameters { get; } +} diff --git a/VpSharp.Commands/CommandContext.cs b/VpSharp.Commands/CommandContext.cs new file mode 100644 index 0000000..4a83e13 --- /dev/null +++ b/VpSharp.Commands/CommandContext.cs @@ -0,0 +1,69 @@ +using System.Drawing; +using VpSharp.Entities; + +namespace VpSharp.Commands; + +/// +/// Provides metadata about a command invocation. +/// +public sealed class CommandContext +{ + private readonly VirtualParadiseClient _client; + + internal CommandContext(VirtualParadiseClient client, VirtualParadiseAvatar avatar, string commandName, string alias, + string rawArguments) + { + _client = client; + Avatar = avatar; + CommandName = commandName; + Alias = alias; + RawArguments = rawArguments; + Arguments = rawArguments.Split(); + } + + /// + /// Gets the alias that was used to invoke the command. + /// + /// The alias used. + public string Alias { get; } + + /// + /// Gets the arguments of the command. + /// + /// The arguments passed by the avatar. + public string[] Arguments { get; } + + /// + /// Gets the avatar who executed the command. + /// + /// The executing avatar. + public VirtualParadiseAvatar Avatar { get; } + + /// + /// Gets the command name. + /// + /// The name of the command being executed. + public string CommandName { get; } + + /// + /// Gets the raw argument string as sent by the avatar. + /// + /// The raw argument string. + public string RawArguments { get; } + + /// + /// Sends a response message to the command. + /// + /// The message to send. + /// + /// to respond only to the avatar which sent the command; to send a + /// regular chat message. + /// + /// The message which was sent. + public Task RespondAsync(string message, bool ephemeral = false) + { + return ephemeral + ? Avatar.SendMessageAsync(_client.CurrentAvatar?.Name, message, FontStyle.Regular, Color.Black) + : _client.SendMessageAsync(message); + } +} diff --git a/VpSharp.Commands/CommandModule.cs b/VpSharp.Commands/CommandModule.cs new file mode 100644 index 0000000..23664c6 --- /dev/null +++ b/VpSharp.Commands/CommandModule.cs @@ -0,0 +1,8 @@ +namespace VpSharp.Commands; + +/// +/// Represents the base class for command modules. +/// +public abstract class CommandModule +{ +} diff --git a/VpSharp.Commands/CommandsExtension.cs b/VpSharp.Commands/CommandsExtension.cs new file mode 100644 index 0000000..e59597d --- /dev/null +++ b/VpSharp.Commands/CommandsExtension.cs @@ -0,0 +1,279 @@ +using System.Reflection; +using VpSharp.ClientExtensions; +using VpSharp.Commands.Attributes; +using VpSharp.EventData; + +namespace VpSharp.Commands; + +/// +/// Implements the Commands extension for . +/// +public sealed class CommandsExtension : VirtualParadiseClientExtension +{ + private const BindingFlags BindingFlags = System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance; + + private readonly CommandsExtensionConfiguration _configuration; + private readonly Dictionary _commandMap = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The owning client. + /// The configuration to use. + /// is . + public CommandsExtension(VirtualParadiseClient client, CommandsExtensionConfiguration configuration) + : base(client) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + /// + /// Registers all commands from all modules in the specified assembly. + /// + /// The assembly whose command modules to register. + /// + /// + /// + /// A command module could not be instantiated. + /// + /// A command in the specified assembly does not have a as its first parameter. + /// -or- + /// A command in the specified assembly contains a duplicate name or alias for a command. + /// -or- + /// + /// A command in the specified assembly has on a parameter that is not the last in + /// the parameter list. + /// + /// + public void RegisterCommands(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + + foreach (Type type in assembly.GetTypes()) + { + if (!type.IsAbstract && type.IsSubclassOf(typeof(CommandModule))) + { + RegisterCommands(type); + } + } + } + + /// + /// Registers the command + /// + /// + /// refers to a type that does not inherit . + /// + /// could not be instantiated. + /// + /// A command in the specified module does not have a as its first parameter. + /// -or- + /// A command in the specified module contains a duplicate name or alias for a command. + /// -or- + /// + /// A command in the specified module has on a parameter that is not the last in the + /// parameter list. + /// + /// + public void RegisterCommands() where T : CommandModule + { + RegisterCommands(typeof(T)); + } + + /// + /// Registers the command + /// + /// + /// is . + /// + /// refers to an abstract type. + /// -or- + /// refers to a type that does not inherit . + /// + /// could not be instantiated. + /// + /// A command in the specified module does not have a as its first parameter. + /// -or- + /// A command in the specified module contains a duplicate name or alias for a command. + /// -or- + /// + /// A command in the specified module has on a parameter that is not the last in the + /// parameter list. + /// + /// + public void RegisterCommands(Type moduleType) + { + ArgumentNullException.ThrowIfNull(moduleType); + + if (moduleType.IsAbstract) + { + throw new ArgumentException("Module type cannot be abstract"); + } + + if (!moduleType.IsSubclassOf(typeof(CommandModule))) + { + throw new ArgumentException($"Module type is not a subclass of {typeof(CommandModule)}"); + } + + if (Activator.CreateInstance(moduleType) is not CommandModule module) + { + var innerException = new Exception($"Could not instantiate {moduleType.FullName}"); + throw new TypeInitializationException(moduleType.FullName, innerException); + } + + foreach (MethodInfo method in moduleType.GetMethods(BindingFlags)) + { + RegisterCommandMethod(module, method); + } + } + + /// + protected override Task OnMessageReceived(MessageReceivedEventArgs args) + { + var message = args.Message; + + if (message.Type != MessageType.ChatMessage) + { + return base.OnMessageReceived(args); + } + + foreach (ReadOnlySpan prefix in _configuration.Prefixes) + { + ReadOnlySpan content = message.Content; + if (!content.StartsWith(prefix)) + { + continue; + } + + ReadOnlySpan commandName, rawArguments; + int spaceIndex = content.IndexOf(' '); + + if (spaceIndex == -1) + { + commandName = content[prefix.Length..]; + rawArguments = ReadOnlySpan.Empty; + } + else + { + commandName = content[prefix.Length..spaceIndex]; + rawArguments = content[(spaceIndex + 1)..]; + } + + var commandNameString = commandName.ToString(); + if (!_commandMap.TryGetValue(commandNameString, out Command? command)) + { + return base.OnMessageReceived(args); + } + + var context = new CommandContext(Client, message.Author, command.Name, commandNameString, rawArguments.ToString()); + object?[] arguments = {context}; + + if (rawArguments.Length > 0) + { + spaceIndex = rawArguments.IndexOf(' '); + if (spaceIndex == -1) + { + Array.Resize(ref arguments, 2); + arguments[1] = rawArguments.ToString(); + } + else + { + var appendLast = true; + + for (var argumentIndex = 1; spaceIndex > -1; argumentIndex++) + { + Array.Resize(ref arguments, argumentIndex + 1); + + if (argumentIndex == command.Parameters.Length) + { + if (command.Parameters[argumentIndex - 1].GetCustomAttribute() is not null) + { + appendLast = false; + arguments[argumentIndex] = rawArguments.ToString(); + break; + } + } + + arguments[argumentIndex] = rawArguments[..spaceIndex].ToString(); + rawArguments = rawArguments[(spaceIndex + 1)..]; + spaceIndex = rawArguments.IndexOf(' '); + argumentIndex++; + } + + if (appendLast) + { + Array.Resize(ref arguments, arguments.Length + 1); + arguments[^1] = rawArguments.ToString(); + } + } + } + + Console.WriteLine(string.Join(';', arguments)); + object? returnValue = command.Method.Invoke(command.Module, arguments); + if (returnValue is Task task) + { + return task; + } + + return base.OnMessageReceived(args); + } + + return base.OnMessageReceived(args); + } + + private void RegisterCommandMethod(CommandModule module, MethodInfo methodInfo) + { + var commandAttribute = methodInfo.GetCustomAttribute(); + if (commandAttribute is null) + { + return; + } + + var aliasesAttribute = methodInfo.GetCustomAttribute(); + ParameterInfo[] parameters = methodInfo.GetParameters(); + if (parameters.Length == 0 || parameters[0].ParameterType != typeof(CommandContext)) + { + throw new InvalidOperationException($"Command method must have a {typeof(CommandContext)} parameter at index 0."); + } + + for (var index = 0; index < parameters.Length - 1; index++) + { + if (parameters[index].GetCustomAttribute() is not null) + { + throw new InvalidOperationException($"{typeof(RemainderAttribute)} can only be placed on the last parameter."); + } + } + + var types = new Type[parameters.Length - 1]; + for (var index = 1; index < parameters.Length; index++) + { + types[index - 1] = parameters[index].ParameterType; + } + + var command = new Command( + commandAttribute.Name, + aliasesAttribute?.Aliases ?? Array.Empty(), + methodInfo, + module + ); + + if (_commandMap.ContainsKey(command.Name)) + { + throw new InvalidOperationException($"Duplicate command name registered ({command.Name})"); + } + + _commandMap[command.Name] = command; + + foreach (string alias in command.Aliases) + { + if (_commandMap.ContainsKey(alias)) + { + throw new InvalidOperationException($"Duplicate command name registered ({alias})"); + } + + _commandMap[alias] = command; + } + } +} diff --git a/VpSharp.Commands/CommandsExtensionConfiguration.cs b/VpSharp.Commands/CommandsExtensionConfiguration.cs new file mode 100644 index 0000000..f9e75cf --- /dev/null +++ b/VpSharp.Commands/CommandsExtensionConfiguration.cs @@ -0,0 +1,13 @@ +namespace VpSharp.Commands; + +/// +/// Defines configuration for . +/// +public sealed class CommandsExtensionConfiguration +{ + /// + /// Gets or sets the prefixes to be use for commands. + /// + /// The command prefixes, as an array of values. + public string[] Prefixes { get; set; } = Array.Empty(); +} diff --git a/VpSharp.Commands/VirtualParadiseClientExtensions.cs b/VpSharp.Commands/VirtualParadiseClientExtensions.cs new file mode 100644 index 0000000..d8e2e06 --- /dev/null +++ b/VpSharp.Commands/VirtualParadiseClientExtensions.cs @@ -0,0 +1,18 @@ +namespace VpSharp.Commands; + +/// +/// Extension methods for . +/// +public static class VirtualParadiseClientExtensions +{ + /// + /// Registers to be used with the current client. + /// + /// The . + /// The configuration required for the extensions. + /// The commands extension instance. + public static CommandsExtension UseCommands(this VirtualParadiseClient client, CommandsExtensionConfiguration configuration) + { + return client.AddExtension(configuration); + } +} diff --git a/VpSharp.Commands/VpSharp.Commands.csproj b/VpSharp.Commands/VpSharp.Commands.csproj new file mode 100644 index 0000000..c6cd049 --- /dev/null +++ b/VpSharp.Commands/VpSharp.Commands.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + enable + enable + 0.1.0 + + + + $(VersionPrefix)-$(VersionSuffix) + $(VersionPrefix).0 + $(VersionPrefix).0 + $(VersionPrefix)-$(VersionSuffix) + + + + $(VersionPrefix)-$(VersionSuffix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix)-$(VersionSuffix).$(BuildNumber) + + + + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + $(VersionPrefix) + + + + + + + diff --git a/VpSharp.IntegrationTests/VpSharp.IntegrationTests.csproj b/VpSharp.IntegrationTests/VpSharp.IntegrationTests.csproj index 3b50734..51ec100 100644 --- a/VpSharp.IntegrationTests/VpSharp.IntegrationTests.csproj +++ b/VpSharp.IntegrationTests/VpSharp.IntegrationTests.csproj @@ -8,16 +8,17 @@ - + + - - Always - - - Always - + + Always + + + Always + diff --git a/VpSharp.IntegrationTests/src/CommandModules/TestCommands.cs b/VpSharp.IntegrationTests/src/CommandModules/TestCommands.cs new file mode 100644 index 0000000..743a75f --- /dev/null +++ b/VpSharp.IntegrationTests/src/CommandModules/TestCommands.cs @@ -0,0 +1,21 @@ +using VpSharp.Commands; +using VpSharp.Commands.Attributes; + +namespace VpSharp.IntegrationTests.CommandModules; + +internal sealed class TestCommands : CommandModule +{ + [Command("echo")] + [Aliases("say")] + public async Task EchoCommand(CommandContext context, [Remainder] string message) + { + await context.RespondAsync(message); + } + + [Command("ping")] + [Aliases("pong", "pingpong")] + public async Task PingAsync(CommandContext context) + { + await context.RespondAsync("Pong!"); + } +} diff --git a/VpSharp.IntegrationTests/src/Program.cs b/VpSharp.IntegrationTests/src/Program.cs index ff1bdaf..c28e85e 100644 --- a/VpSharp.IntegrationTests/src/Program.cs +++ b/VpSharp.IntegrationTests/src/Program.cs @@ -1,5 +1,7 @@ using VpSharp; +using VpSharp.Commands; using VpSharp.Entities; +using VpSharp.IntegrationTests.CommandModules; string? username = Environment.GetEnvironmentVariable("username"); string? password = Environment.GetEnvironmentVariable("password"); @@ -16,7 +18,7 @@ if (string.IsNullOrWhiteSpace(password)) return; } -var configuration = new VirtualParadiseConfiguration() +var configuration = new VirtualParadiseConfiguration { Username = username, Password = password, @@ -26,6 +28,8 @@ var configuration = new VirtualParadiseConfiguration() }; using var client = new VirtualParadiseClient(configuration); +CommandsExtension commands = client.UseCommands(new CommandsExtensionConfiguration {Prefixes = new[] {"!"}}); +commands.RegisterCommands(); Console.WriteLine(@"Connecting to universe"); await client.ConnectAsync().ConfigureAwait(false); diff --git a/VpSharp.sln b/VpSharp.sln index f212b22..8665e1a 100644 --- a/VpSharp.sln +++ b/VpSharp.sln @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VpSharp.Commands", "VpSharp.Commands\VpSharp.Commands.csproj", "{8EE96C20-57AA-48E1-95A2-04580C4F7E05}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,5 +34,9 @@ Global {87C0D19A-27C9-4041-9DD5-191B8D0FDEF8}.Debug|Any CPU.Build.0 = Debug|Any CPU {87C0D19A-27C9-4041-9DD5-191B8D0FDEF8}.Release|Any CPU.ActiveCfg = Release|Any CPU {87C0D19A-27C9-4041-9DD5-191B8D0FDEF8}.Release|Any CPU.Build.0 = Release|Any CPU + {8EE96C20-57AA-48E1-95A2-04580C4F7E05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EE96C20-57AA-48E1-95A2-04580C4F7E05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EE96C20-57AA-48E1-95A2-04580C4F7E05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EE96C20-57AA-48E1-95A2-04580C4F7E05}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal