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