From 2bd895e99d3d9b11482593f89caf2854e0e815fe Mon Sep 17 00:00:00 2001 From: RaidMax Date: Mon, 11 May 2020 16:10:43 -0500 Subject: [PATCH] implement script plugin command registration - issue #132 --- Application/ApplicationManager.cs | 20 +++- Application/Factories/ScriptCommandFactory.cs | 40 ++++++++ Application/Main.cs | 1 + Application/Misc/ScriptCommand.cs | 41 ++++++++ Application/Misc/ScriptPlugin.cs | 98 ++++++++++++++++++- IW4MAdmin.sln | 1 + .../SampleScriptPluginCommand.js | 54 ++++++++++ .../Exceptions/PluginException.cs | 11 +++ SharedLibraryCore/Interfaces/IManager.cs | 10 ++ .../Interfaces/IScriptCommandFactory.cs | 23 +++++ WebfrontCore/Controllers/HomeController.cs | 8 +- 11 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 Application/Factories/ScriptCommandFactory.cs create mode 100644 Application/Misc/ScriptCommand.cs create mode 100644 Plugins/ScriptPlugins/SampleScriptPluginCommand.js create mode 100644 SharedLibraryCore/Exceptions/PluginException.cs create mode 100644 SharedLibraryCore/Interfaces/IScriptCommandFactory.cs diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs index 6ace023c0..72977a317 100644 --- a/Application/ApplicationManager.cs +++ b/Application/ApplicationManager.cs @@ -62,12 +62,13 @@ namespace IW4MAdmin.Application private readonly IParserRegexFactory _parserRegexFactory; private readonly IEnumerable _customParserEvents; private readonly IEventHandler _eventHandler; + private readonly IScriptCommandFactory _scriptCommandFactory; public ApplicationManager(ILogger logger, IMiddlewareActionHandler actionHandler, IEnumerable commands, ITranslationLookup translationLookup, IConfigurationHandler commandConfiguration, IConfigurationHandler appConfigHandler, IGameServerInstanceFactory serverInstanceFactory, IEnumerable plugins, IParserRegexFactory parserRegexFactory, IEnumerable customParserEvents, - IEventHandler eventHandler) + IEventHandler eventHandler, IScriptCommandFactory scriptCommandFactory) { MiddlewareActionHandler = actionHandler; _servers = new ConcurrentBag(); @@ -92,6 +93,7 @@ namespace IW4MAdmin.Application _parserRegexFactory = parserRegexFactory; _customParserEvents = customParserEvents; _eventHandler = eventHandler; + _scriptCommandFactory = scriptCommandFactory; Plugins = plugins; } @@ -267,12 +269,12 @@ namespace IW4MAdmin.Application { if (plugin is ScriptPlugin scriptPlugin) { - await scriptPlugin.Initialize(this); + await scriptPlugin.Initialize(this, _scriptCommandFactory); scriptPlugin.Watcher.Changed += async (sender, e) => { try { - await scriptPlugin.Initialize(this); + await scriptPlugin.Initialize(this, _scriptCommandFactory); } catch (Exception ex) @@ -817,5 +819,17 @@ namespace IW4MAdmin.Application { _operationLookup.Add(operationName, operation); } + + public void AddAdditionalCommand(IManagerCommand command) + { + if (_commands.Any(_command => _command.Name == command.Name || _command.Alias == command.Alias)) + { + throw new InvalidOperationException($"Duplicate command name or alias ({command.Name}, {command.Alias})"); + } + + _commands.Add(command); + } + + public void RemoveCommandByName(string commandName) => _commands.RemoveAll(_command => _command.Name == commandName); } } diff --git a/Application/Factories/ScriptCommandFactory.cs b/Application/Factories/ScriptCommandFactory.cs new file mode 100644 index 000000000..a3453a906 --- /dev/null +++ b/Application/Factories/ScriptCommandFactory.cs @@ -0,0 +1,40 @@ +using IW4MAdmin.Application.Misc; +using SharedLibraryCore; +using SharedLibraryCore.Commands; +using SharedLibraryCore.Configuration; +using SharedLibraryCore.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq; +using static SharedLibraryCore.Database.Models.EFClient; + +namespace IW4MAdmin.Application.Factories +{ + /// + /// implementation of IScriptCommandFactory + /// + public class ScriptCommandFactory : IScriptCommandFactory + { + private CommandConfiguration _config; + private readonly ITranslationLookup _transLookup; + + public ScriptCommandFactory(CommandConfiguration config, ITranslationLookup transLookup) + { + _config = config; + _transLookup = transLookup; + } + + /// + public IManagerCommand CreateScriptCommand(string name, string alias, string description, string permission, IEnumerable<(string, bool)> args, Action executeAction) + { + var permissionEnum = Enum.Parse(permission); + var argsArray = args.Select(_arg => new CommandArgument + { + Name = _arg.Item1, + Required = _arg.Item2 + }).ToArray(); + + return new ScriptCommand(name, alias, description, permissionEnum, argsArray, executeAction, _config, _transLookup); + } + } +} diff --git a/Application/Main.cs b/Application/Main.cs index a89ffe6ff..0b1a1b7f8 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -286,6 +286,7 @@ namespace IW4MAdmin.Application .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddTransient() .AddSingleton(_serviceProvider => diff --git a/Application/Misc/ScriptCommand.cs b/Application/Misc/ScriptCommand.cs new file mode 100644 index 000000000..fdf4785de --- /dev/null +++ b/Application/Misc/ScriptCommand.cs @@ -0,0 +1,41 @@ +using SharedLibraryCore; +using SharedLibraryCore.Commands; +using SharedLibraryCore.Configuration; +using SharedLibraryCore.Interfaces; +using System; +using System.Threading.Tasks; +using static SharedLibraryCore.Database.Models.EFClient; + +namespace IW4MAdmin.Application.Misc +{ + /// + /// generic script command implementation + /// + public class ScriptCommand : Command + { + private readonly Action _executeAction; + + public ScriptCommand(string name, string alias, string description, Permission permission, + CommandArgument[] args, Action executeAction, CommandConfiguration config, ITranslationLookup layout) + : base(config, layout) + { + + _executeAction = executeAction; + Name = name; + Alias = alias; + Description = description; + Permission = permission; + Arguments = args; + } + + public override Task ExecuteAsync(GameEvent E) + { + if (_executeAction == null) + { + throw new InvalidOperationException($"No execute action defined for command \"{Name}\""); + } + + return Task.Run(() => _executeAction(E)); + } + } +} diff --git a/Application/Misc/ScriptPlugin.cs b/Application/Misc/ScriptPlugin.cs index 472c2351f..170edb44a 100644 --- a/Application/Misc/ScriptPlugin.cs +++ b/Application/Misc/ScriptPlugin.cs @@ -1,8 +1,12 @@ using Jint; +using Jint.Native; +using Jint.Runtime; using Microsoft.CSharp.RuntimeBinder; using SharedLibraryCore; using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Exceptions; using SharedLibraryCore.Interfaces; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -34,6 +38,7 @@ namespace IW4MAdmin.Application.Misc private readonly string _fileName; private readonly SemaphoreSlim _onProcessing; private bool successfullyLoaded; + private readonly List _registeredCommandNames; public ScriptPlugin(string filename, string workingDirectory = null) { @@ -47,6 +52,7 @@ namespace IW4MAdmin.Application.Misc Watcher.EnableRaisingEvents = true; _onProcessing = new SemaphoreSlim(1, 1); + _registeredCommandNames = new List(); } ~ScriptPlugin() @@ -55,7 +61,7 @@ namespace IW4MAdmin.Application.Misc _onProcessing.Dispose(); } - public async Task Initialize(IManager manager) + public async Task Initialize(IManager manager, IScriptCommandFactory scriptCommandFactory) { await _onProcessing.WaitAsync(); @@ -75,6 +81,14 @@ namespace IW4MAdmin.Application.Misc if (!firstRun) { await OnUnloadAsync(); + + foreach (string commandName in _registeredCommandNames) + { + manager.GetLogger(0).WriteDebug($"Removing plugin registered command \"{commandName}\""); + manager.RemoveCommandByName(commandName); + } + + _registeredCommandNames.Clear(); } successfullyLoaded = false; @@ -106,6 +120,26 @@ namespace IW4MAdmin.Application.Misc Name = pluginObject.name; Version = (float)pluginObject.version; + var commands = _scriptEngine.GetValue("commands"); + + if (commands != JsValue.Undefined) + { + try + { + foreach (var command in GenerateScriptCommands(commands, scriptCommandFactory)) + { + manager.GetLogger(0).WriteDebug($"Adding plugin registered command \"{command.Name}\""); + manager.AddAdditionalCommand(command); + _registeredCommandNames.Add(command.Name); + } + } + + catch (RuntimeBinderException e) + { + throw new PluginException($"Not all required fields were found: {e.Message}") { PluginFile = _fileName }; + } + } + await OnLoadAsync(manager); try @@ -193,5 +227,67 @@ namespace IW4MAdmin.Application.Misc await Task.FromResult(_scriptEngine.Execute("plugin.onUnloadAsync()").GetCompletionValue()); } } + + /// + /// finds declared script commands in the script plugin + /// + /// commands value from jint parser + /// factory to create the command from + /// + public IEnumerable GenerateScriptCommands(JsValue commands, IScriptCommandFactory scriptCommandFactory) + { + List commandList = new List(); + + // go through each defined command + foreach (var command in commands.AsArray()) + { + dynamic dynamicCommand = command.ToObject(); + string name = dynamicCommand.name; + string alias = dynamicCommand.alias; + string description = dynamicCommand.description; + string permission = dynamicCommand.permission; + + List<(string, bool)> args = new List<(string, bool)>(); + dynamic arguments = null; + + try + { + arguments = dynamicCommand.arguments; + } + + catch (RuntimeBinderException) + { + // arguments are optional + } + + if (arguments != null) + { + foreach (var arg in dynamicCommand.arguments) + { + args.Add((arg.name, (bool)arg.required)); + } + } + + void execute(GameEvent e) + { + _scriptEngine.SetValue("_event", e); + var jsEventObject = _scriptEngine.GetValue("_event"); + + try + { + dynamicCommand.execute.Target.Invoke(jsEventObject); + } + + catch (JavaScriptException ex) + { + throw new PluginException($"An error occured while executing action for script plugin: {ex.Error} (Line: {ex.Location.Start.Line}, Character: {ex.Location.Start.Column})") { PluginFile = _fileName }; + } + } + + commandList.Add(scriptCommandFactory.CreateScriptCommand(name, alias, description, permission, args, execute)); + } + + return commandList; + } } } diff --git a/IW4MAdmin.sln b/IW4MAdmin.sln index be8e6badb..b872c4ca3 100644 --- a/IW4MAdmin.sln +++ b/IW4MAdmin.sln @@ -45,6 +45,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ScriptPlugins", "ScriptPlug Plugins\ScriptPlugins\ParserRektT5M.js = Plugins\ScriptPlugins\ParserRektT5M.js Plugins\ScriptPlugins\ParserT7.js = Plugins\ScriptPlugins\ParserT7.js Plugins\ScriptPlugins\ParserTeknoMW3.js = Plugins\ScriptPlugins\ParserTeknoMW3.js + Plugins\ScriptPlugins\SampleScriptPluginCommand.js = Plugins\ScriptPlugins\SampleScriptPluginCommand.js Plugins\ScriptPlugins\SharedGUIDKick.js = Plugins\ScriptPlugins\SharedGUIDKick.js Plugins\ScriptPlugins\VPNDetection.js = Plugins\ScriptPlugins\VPNDetection.js EndProjectSection diff --git a/Plugins/ScriptPlugins/SampleScriptPluginCommand.js b/Plugins/ScriptPlugins/SampleScriptPluginCommand.js new file mode 100644 index 000000000..0b8ee4f05 --- /dev/null +++ b/Plugins/ScriptPlugins/SampleScriptPluginCommand.js @@ -0,0 +1,54 @@ +let commands = [{ + // required + name: "pingpong", + // required + description: "pongs a ping", + // required + alias: "pp", + // required + permission: "User", + // optional + arguments: [{ + name: "times to ping", + required: true + }], + // required + execute: (gameEvent) => { + // parse the first argument (number of times) + let times = parseInt(gameEvent.Data); + + // we only want to allow ping pong up to 5 times + if (times > 5 || times <= 0) { + gameEvent.Origin.Tell("You can only ping pong between 1 and 5 times"); + return; + } + + // we want to print out a pong message for the number of times they requested + for (var i = 0; i < times; i++) { + gameEvent.Origin.Tell(`^${i}pong #${i + 1}^7`); + + // don't want to wait if it's the last pong + if (i < times - 1) { + System.Threading.Tasks.Task.Delay(1000).Wait(); + } + } + } +}]; + +let plugin = { + author: 'RaidMax', + version: 1.0, + name: 'Ping Pong Sample Command Plugin', + + onEventAsync: function (gameEvent, server) { + }, + + onLoadAsync: function (manager) { + }, + + onUnloadAsync: function () { + }, + + onTickAsync: function (server) { + } +}; \ No newline at end of file diff --git a/SharedLibraryCore/Exceptions/PluginException.cs b/SharedLibraryCore/Exceptions/PluginException.cs new file mode 100644 index 000000000..b5a1afa72 --- /dev/null +++ b/SharedLibraryCore/Exceptions/PluginException.cs @@ -0,0 +1,11 @@ +using System; + +namespace SharedLibraryCore.Exceptions +{ + public class PluginException : Exception + { + public PluginException(string message) : base(message) { } + + public string PluginFile { get; set; } + } +} diff --git a/SharedLibraryCore/Interfaces/IManager.cs b/SharedLibraryCore/Interfaces/IManager.cs index 337a3a1b9..efce6464f 100644 --- a/SharedLibraryCore/Interfaces/IManager.cs +++ b/SharedLibraryCore/Interfaces/IManager.cs @@ -70,5 +70,15 @@ namespace SharedLibraryCore.Interfaces /// /// event to be processed void AddEvent(GameEvent gameEvent); + /// + /// adds an additional (script) command to the command list + /// + /// + void AddAdditionalCommand(IManagerCommand command); + /// + /// removes a command by its name + /// + /// name of command + void RemoveCommandByName(string name); } } diff --git a/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs b/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs new file mode 100644 index 000000000..b59d0876d --- /dev/null +++ b/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace SharedLibraryCore.Interfaces +{ + /// + /// defines capabilities of script command factory + /// + public interface IScriptCommandFactory + { + /// + /// generate a new script command from parsed source + /// + /// name of command + /// alias of command + /// description of command + /// minimum required permission + /// command arguments (name, is required) + /// action to peform when commmand is executed + /// + IManagerCommand CreateScriptCommand(string name, string alias, string description, string permission, IEnumerable<(string, bool)> args, Action executeAction); + } +} diff --git a/WebfrontCore/Controllers/HomeController.cs b/WebfrontCore/Controllers/HomeController.cs index 1b9bf43ca..d26cbdfff 100644 --- a/WebfrontCore/Controllers/HomeController.cs +++ b/WebfrontCore/Controllers/HomeController.cs @@ -12,8 +12,11 @@ namespace WebfrontCore.Controllers { public class HomeController : BaseController { - public HomeController(IManager manager) : base(manager) + private readonly ITranslationLookup _translationLookup; + + public HomeController(IManager manager, ITranslationLookup translationLookup) : base(manager) { + _translationLookup = translationLookup; } public async Task Index(Game? game = null) @@ -69,7 +72,8 @@ namespace WebfrontCore.Controllers // we need the plugin type the command is defined in var pluginType = _cmd.GetType().Assembly.GetTypes().FirstOrDefault(_type => _type.Assembly != excludedAssembly && typeof(IPlugin).IsAssignableFrom(_type)); return pluginType == null ? - Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_HELP_COMMAND_NATIVE"] : + _translationLookup["WEBFRONT_HELP_COMMAND_NATIVE"] : + pluginType.Name == "ScriptPlugin" ? _translationLookup["WEBFRONT_HELP_SCRIPT_PLUGIN"] : Manager.Plugins.First(_plugin => _plugin.GetType() == pluginType).Name; // for now we're just returning the name of the plugin, maybe later we'll include more info }) .Select(_grp => (_grp.Key, _grp.AsEnumerable()));