From b1a1aae6c0446b280974abc27df37f0910454c5e Mon Sep 17 00:00:00 2001 From: RaidMax Date: Mon, 7 Feb 2022 18:43:36 -0600 Subject: [PATCH] initial framework for gsc + iw4madmin integration improvements to script plugin capabilities and error feedback --- Application/Application.csproj | 2 +- Application/Factories/ScriptCommandFactory.cs | 5 +- Application/Main.cs | 6 +- Application/Misc/PluginImporter.cs | 33 +- Application/Misc/ScriptCommand.cs | 13 +- Application/Misc/ScriptPlugin.cs | 313 ++++++++++----- Application/Misc/ScriptPluginTimerHelper.cs | 157 ++++++++ IW4MAdmin.sln | 1 + Plugins/ScriptPlugins/GameInterface.js | 359 ++++++++++++++++++ SharedLibraryCore/Interfaces/IPlugin.cs | 2 +- .../Interfaces/IPluginImporter.cs | 4 +- .../Interfaces/IScriptCommandFactory.cs | 7 +- .../Interfaces/IScriptPluginTimerHelper.cs | 15 + SharedLibraryCore/Server.cs | 20 +- SharedLibraryCore/SharedLibraryCore.csproj | 42 +- WebfrontCore/Controllers/HomeController.cs | 4 +- 16 files changed, 820 insertions(+), 163 deletions(-) create mode 100644 Application/Misc/ScriptPluginTimerHelper.cs create mode 100644 Plugins/ScriptPlugins/GameInterface.js create mode 100644 SharedLibraryCore/Interfaces/IScriptPluginTimerHelper.cs diff --git a/Application/Application.csproj b/Application/Application.csproj index b42f32614..58966aa01 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -24,7 +24,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Application/Factories/ScriptCommandFactory.cs b/Application/Factories/ScriptCommandFactory.cs index ce6ea26e2..7d7cdf806 100644 --- a/Application/Factories/ScriptCommandFactory.cs +++ b/Application/Factories/ScriptCommandFactory.cs @@ -6,6 +6,7 @@ using SharedLibraryCore.Interfaces; using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Data.Models.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -30,7 +31,7 @@ namespace IW4MAdmin.Application.Factories /// public IManagerCommand CreateScriptCommand(string name, string alias, string description, string permission, - bool isTargetRequired, IEnumerable<(string, bool)> args, Action executeAction) + bool isTargetRequired, IEnumerable<(string, bool)> args, Func executeAction, Server.Game[] supportedGames) { var permissionEnum = Enum.Parse(permission); var argsArray = args.Select(_arg => new CommandArgument @@ -40,7 +41,7 @@ namespace IW4MAdmin.Application.Factories }).ToArray(); return new ScriptCommand(name, alias, description, isTargetRequired, permissionEnum, argsArray, executeAction, - _config, _transLookup, _serviceProvider.GetRequiredService>()); + _config, _transLookup, _serviceProvider.GetRequiredService>(), supportedGames); } } } diff --git a/Application/Main.cs b/Application/Main.cs index b778dd472..9735172d3 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -311,10 +311,7 @@ namespace IW4MAdmin.Application } // register any script plugins - foreach (var scriptPlugin in pluginImporter.DiscoverScriptPlugins()) - { - serviceCollection.AddSingleton(scriptPlugin); - } + serviceCollection.AddSingleton(sp => pluginImporter.DiscoverScriptPlugins(sp)); // register any eventable types foreach (var assemblyType in typeof(Program).Assembly.GetTypes() @@ -435,6 +432,7 @@ namespace IW4MAdmin.Application .AddSingleton() .AddSingleton() .AddSingleton() + .AddTransient() .AddSingleton(translationLookup) .AddDatabaseContextOptions(appConfig); diff --git a/Application/Misc/PluginImporter.cs b/Application/Misc/PluginImporter.cs index c4fb091a0..65f9cf868 100644 --- a/Application/Misc/PluginImporter.cs +++ b/Application/Misc/PluginImporter.cs @@ -6,6 +6,7 @@ using SharedLibraryCore.Interfaces; using System.Linq; using SharedLibraryCore; using IW4MAdmin.Application.API.Master; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharedLibraryCore.Configuration; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -37,26 +38,26 @@ namespace IW4MAdmin.Application.Misc /// discovers all the script plugins in the plugins dir /// /// - public IEnumerable DiscoverScriptPlugins() + public IEnumerable DiscoverScriptPlugins(IServiceProvider serviceProvider) { - string pluginDir = $"{Utilities.OperatingDirectory}{PLUGIN_DIR}{Path.DirectorySeparatorChar}"; + var pluginDir = $"{Utilities.OperatingDirectory}{PLUGIN_DIR}{Path.DirectorySeparatorChar}"; - if (Directory.Exists(pluginDir)) + if (!Directory.Exists(pluginDir)) { - var scriptPluginFiles = Directory.GetFiles(pluginDir, "*.js").AsEnumerable().Union(GetRemoteScripts()); - - _logger.LogDebug("Discovered {count} potential script plugins", scriptPluginFiles.Count()); - - if (scriptPluginFiles.Count() > 0) - { - foreach (string fileName in scriptPluginFiles) - { - _logger.LogDebug("Discovered script plugin {fileName}", fileName); - var plugin = new ScriptPlugin(_logger, fileName); - yield return plugin; - } - } + return Enumerable.Empty(); } + + var scriptPluginFiles = + Directory.GetFiles(pluginDir, "*.js").AsEnumerable().Union(GetRemoteScripts()).ToList(); + + _logger.LogDebug("Discovered {count} potential script plugins", scriptPluginFiles.Count); + + return scriptPluginFiles.Select(fileName => + { + _logger.LogDebug("Discovered script plugin {fileName}", fileName); + return new ScriptPlugin(_logger, + serviceProvider.GetRequiredService(), fileName); + }).ToList(); } /// diff --git a/Application/Misc/ScriptCommand.cs b/Application/Misc/ScriptCommand.cs index 26d756e01..4db84987a 100644 --- a/Application/Misc/ScriptCommand.cs +++ b/Application/Misc/ScriptCommand.cs @@ -6,7 +6,6 @@ using System; using System.Threading.Tasks; using Data.Models.Client; using Microsoft.Extensions.Logging; -using static SharedLibraryCore.Database.Models.EFClient; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace IW4MAdmin.Application.Misc @@ -16,14 +15,15 @@ namespace IW4MAdmin.Application.Misc /// public class ScriptCommand : Command { - private readonly Action _executeAction; + private readonly Func _executeAction; private readonly ILogger _logger; - public ScriptCommand(string name, string alias, string description, bool isTargetRequired, EFClient.Permission permission, - CommandArgument[] args, Action executeAction, CommandConfiguration config, ITranslationLookup layout, ILogger logger) + public ScriptCommand(string name, string alias, string description, bool isTargetRequired, + EFClient.Permission permission, + CommandArgument[] args, Func executeAction, CommandConfiguration config, + ITranslationLookup layout, ILogger logger, Server.Game[] supportedGames) : base(config, layout) { - _executeAction = executeAction; _logger = logger; Name = name; @@ -32,6 +32,7 @@ namespace IW4MAdmin.Application.Misc RequiresTarget = isTargetRequired; Permission = permission; Arguments = args; + SupportedGames = supportedGames; } public override async Task ExecuteAsync(GameEvent e) @@ -43,7 +44,7 @@ namespace IW4MAdmin.Application.Misc try { - await Task.Run(() => _executeAction(e)); + await _executeAction(e); } catch (Exception ex) { diff --git a/Application/Misc/ScriptPlugin.cs b/Application/Misc/ScriptPlugin.cs index f46bd2b0c..84b82719b 100644 --- a/Application/Misc/ScriptPlugin.cs +++ b/Application/Misc/ScriptPlugin.cs @@ -13,6 +13,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Jint.Runtime.Interop; using Microsoft.Extensions.Logging; using Serilog.Context; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -36,29 +37,31 @@ namespace IW4MAdmin.Application.Misc /// public bool IsParser { get; private set; } - public FileSystemWatcher Watcher { get; private set; } + public FileSystemWatcher Watcher { get; } private Engine _scriptEngine; private readonly string _fileName; - private readonly SemaphoreSlim _onProcessing; - private bool successfullyLoaded; + private readonly SemaphoreSlim _onProcessing = new(1, 1); + private bool _successfullyLoaded; private readonly List _registeredCommandNames; private readonly ILogger _logger; + private readonly IScriptPluginTimerHelper _timerHelper; - public ScriptPlugin(ILogger logger, string filename, string workingDirectory = null) + public ScriptPlugin(ILogger logger, IScriptPluginTimerHelper timerHelper, string filename, string workingDirectory = null) { _logger = logger; _fileName = filename; - Watcher = new FileSystemWatcher() + Watcher = new FileSystemWatcher { - Path = workingDirectory == null ? $"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}" : workingDirectory, + Path = workingDirectory ?? $"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", NotifyFilter = NotifyFilters.Size, Filter = _fileName.Split(Path.DirectorySeparatorChar).Last() }; Watcher.EnableRaisingEvents = true; - _onProcessing = new SemaphoreSlim(1, 1); _registeredCommandNames = new List(); + _timerHelper = timerHelper; + _timerHelper.SetDependency(_onProcessing); } ~ScriptPlugin() @@ -67,12 +70,13 @@ namespace IW4MAdmin.Application.Misc _onProcessing.Dispose(); } - public async Task Initialize(IManager manager, IScriptCommandFactory scriptCommandFactory, IScriptPluginServiceResolver serviceResolver) + public async Task Initialize(IManager manager, IScriptCommandFactory scriptCommandFactory, + IScriptPluginServiceResolver serviceResolver) { - await _onProcessing.WaitAsync(); - try { + await _onProcessing.WaitAsync(); + // for some reason we get an event trigger when the file is not finished being modified. // this must have been a change in .NET CORE 3.x // so if the new file is empty we can't process it yet @@ -81,26 +85,27 @@ namespace IW4MAdmin.Application.Misc return; } - bool firstRun = _scriptEngine == null; + var firstRun = _scriptEngine == null; // it's been loaded before so we need to call the unload event if (!firstRun) { await OnUnloadAsync(); - foreach (string commandName in _registeredCommandNames) + foreach (var commandName in _registeredCommandNames) { - _logger.LogDebug("Removing plugin registered command {command}", commandName); + _logger.LogDebug("Removing plugin registered command {Command}", commandName); manager.RemoveCommandByName(commandName); } _registeredCommandNames.Clear(); } - successfullyLoaded = false; + _successfullyLoaded = false; string script; - using (var stream = new FileStream(_fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + await using (var stream = + new FileStream(_fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { using (var reader = new StreamReader(stream, Encoding.Default)) { @@ -110,45 +115,33 @@ namespace IW4MAdmin.Application.Misc _scriptEngine = new Engine(cfg => cfg.AllowClr(new[] - { - typeof(System.Net.Http.HttpClient).Assembly, - typeof(EFClient).Assembly, - typeof(Utilities).Assembly, - typeof(Encoding).Assembly - }) - .CatchClrExceptions()); - - try - { - _scriptEngine.Execute(script); - } - catch (JavaScriptException ex) - { - - _logger.LogError(ex, - "Encountered JavaScript runtime error while executing {methodName} for script plugin {plugin} at {@locationInfo}", - nameof(Initialize), _fileName, ex.Location); - throw new PluginException($"A JavaScript parsing error occured while initializing script plugin"); - } - - catch (Exception e) - { - - _logger.LogError(e, - "Encountered unexpected error while running {methodName} for script plugin {plugin}", - nameof(Initialize), _fileName); - throw new PluginException($"An unexpected error occured while initialization script plugin"); - } + { + typeof(System.Net.Http.HttpClient).Assembly, + typeof(EFClient).Assembly, + typeof(Utilities).Assembly, + typeof(Encoding).Assembly + }) + .CatchClrExceptions() + .AddObjectConverter(new PermissionLevelToStringConverter())); + _scriptEngine.Execute(script); _scriptEngine.SetValue("_localization", Utilities.CurrentLocalization); _scriptEngine.SetValue("_serviceResolver", serviceResolver); - dynamic pluginObject = _scriptEngine.GetValue("plugin").ToObject(); + dynamic pluginObject = _scriptEngine.Evaluate("plugin").ToObject(); Author = pluginObject.author; Name = pluginObject.name; Version = (float)pluginObject.version; - var commands = _scriptEngine.GetValue("commands"); + var commands = JsValue.Undefined; + try + { + commands = _scriptEngine.Evaluate("commands"); + } + catch (JavaScriptException) + { + // ignore because commands aren't defined; + } if (commands != JsValue.Undefined) { @@ -156,7 +149,7 @@ namespace IW4MAdmin.Application.Misc { foreach (var command in GenerateScriptCommands(commands, scriptCommandFactory)) { - _logger.LogDebug("Adding plugin registered command {commandName}", command.Name); + _logger.LogDebug("Adding plugin registered command {CommandName}", command.Name); manager.AddAdditionalCommand(command); _registeredCommandNames.Add(command.Name); } @@ -164,7 +157,8 @@ namespace IW4MAdmin.Application.Misc catch (RuntimeBinderException e) { - throw new PluginException($"Not all required fields were found: {e.Message}") { PluginFile = _fileName }; + throw new PluginException($"Not all required fields were found: {e.Message}") + { PluginFile = _fileName }; } } @@ -174,8 +168,8 @@ namespace IW4MAdmin.Application.Misc { await OnLoadAsync(manager); IsParser = true; - var eventParser = (IEventParser)_scriptEngine.GetValue("eventParser").ToObject(); - var rconParser = (IRConParser)_scriptEngine.GetValue("rconParser").ToObject(); + var eventParser = (IEventParser)_scriptEngine.Evaluate("eventParser").ToObject(); + var rconParser = (IRConParser)_scriptEngine.Evaluate("rconParser").ToObject(); manager.AdditionalEventParsers.Add(eventParser); manager.AdditionalRConParsers.Add(rconParser); } @@ -194,27 +188,32 @@ namespace IW4MAdmin.Application.Misc await OnLoadAsync(manager); } - successfullyLoaded = true; + _successfullyLoaded = true; } - catch (JavaScriptException ex) { _logger.LogError(ex, - "Encountered JavaScript runtime error while executing {methodName} for script plugin {plugin} initialization {@locationInfo}", - nameof(OnLoadAsync), _fileName, ex.Location); - + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo}", + nameof(Initialize), Path.GetFileName(_fileName), ex.Location); + + throw new PluginException("An error occured while initializing script plugin"); + } + catch (Exception ex) when (ex.InnerException is JavaScriptException jsEx) + { + _logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} initialization {@LocationInfo}", + nameof(Initialize), _fileName, jsEx.Location); + throw new PluginException("An error occured while initializing script plugin"); } - catch (Exception ex) { _logger.LogError(ex, - "Encountered unexpected error while running {methodName} for script plugin {plugin}", - nameof(OnLoadAsync), _fileName); - - throw new PluginException("An unexpected error occured while initializing script plugin"); - } + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin}", + nameof(OnLoadAsync), Path.GetFileName(_fileName)); + throw new PluginException("An error occured while executing action for script plugin"); + } finally { if (_onProcessing.CurrentCount == 0) @@ -224,42 +223,41 @@ namespace IW4MAdmin.Application.Misc } } - public async Task OnEventAsync(GameEvent E, Server S) + public async Task OnEventAsync(GameEvent gameEvent, Server server) { - if (successfullyLoaded) + if (_successfullyLoaded) { - await _onProcessing.WaitAsync(); - try { - _scriptEngine.SetValue("_gameEvent", E); - _scriptEngine.SetValue("_server", S); - _scriptEngine.SetValue("_IW4MAdminClient", Utilities.IW4MAdminClient(S)); - _scriptEngine.Execute("plugin.onEventAsync(_gameEvent, _server)").GetCompletionValue(); + await _onProcessing.WaitAsync(); + _scriptEngine.SetValue("_gameEvent", gameEvent); + _scriptEngine.SetValue("_server", server); + _scriptEngine.SetValue("_IW4MAdminClient", Utilities.IW4MAdminClient(server)); + _scriptEngine.Evaluate("plugin.onEventAsync(_gameEvent, _server)"); } catch (JavaScriptException ex) { - using (LogContext.PushProperty("Server", S.ToString())) + using (LogContext.PushProperty("Server", server.ToString())) { _logger.LogError(ex, - "Encountered JavaScript runtime error while executing {methodName} for script plugin {plugin} with event type {eventType} {@locationInfo}", - nameof(OnEventAsync), _fileName, E.Type, ex.Location); + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} with event type {EventType} {@LocationInfo}", + nameof(OnEventAsync), Path.GetFileName(_fileName), gameEvent.Type, ex.Location); } - throw new PluginException($"An error occured while executing action for script plugin"); + throw new PluginException("An error occured while executing action for script plugin"); } - catch (Exception e) + catch (Exception ex) { - using (LogContext.PushProperty("Server", S.ToString())) + using (LogContext.PushProperty("Server", server.ToString())) { - _logger.LogError(e, - "Encountered unexpected error while running {methodName} for script plugin {plugin} with event type {eventType}", - nameof(OnEventAsync), _fileName, E.Type); + _logger.LogError(ex, + "Encountered error while running {MethodName} for script plugin {Plugin} with event type {EventType}", + nameof(OnEventAsync), _fileName, gameEvent.Type); } - throw new PluginException($"An error occured while executing action for script plugin"); + throw new PluginException("An error occured while executing action for script plugin"); } finally @@ -272,25 +270,70 @@ namespace IW4MAdmin.Application.Misc } } - public async Task OnLoadAsync(IManager manager) + public Task OnLoadAsync(IManager manager) { - _logger.LogDebug("OnLoad executing for {name}", Name); - _scriptEngine.SetValue("_manager", manager); - await Task.FromResult(_scriptEngine.Execute("plugin.onLoadAsync(_manager)").GetCompletionValue()); - } - - public async Task OnTickAsync(Server S) - { - _scriptEngine.SetValue("_server", S); - await Task.FromResult(_scriptEngine.Execute("plugin.onTickAsync(_server)").GetCompletionValue()); - } - - public async Task OnUnloadAsync() - { - if (successfullyLoaded) + try { - await Task.FromResult(_scriptEngine.Execute("plugin.onUnloadAsync()").GetCompletionValue()); + _logger.LogDebug("OnLoad executing for {Name}", Name); + _scriptEngine.SetValue("_manager", manager); + _scriptEngine.SetValue("_timerHelper", _timerHelper); + _scriptEngine.Evaluate("plugin.onLoadAsync(_manager)"); + + return Task.CompletedTask; } + catch (JavaScriptException ex) + { + _logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo}", + nameof(OnLoadAsync), Path.GetFileName(_fileName), ex.Location); + + throw new PluginException("A runtime error occured while executing action for script plugin"); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin}", + nameof(OnLoadAsync), Path.GetFileName(_fileName)); + + throw new PluginException("An error occured while executing action for script plugin"); + } + } + + public async Task OnTickAsync(Server server) + { + _scriptEngine.SetValue("_server", server); + await Task.FromResult(_scriptEngine.Evaluate("plugin.onTickAsync(_server)")); + } + + public Task OnUnloadAsync() + { + if (!_successfullyLoaded) + { + return Task.CompletedTask; + } + + try + { + _scriptEngine.Evaluate("plugin.onUnloadAsync()"); + } + catch (JavaScriptException ex) + { + _logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo}", + nameof(OnUnloadAsync), Path.GetFileName(_fileName), ex.Location); + + throw new PluginException("A runtime error occured while executing action for script plugin"); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin}", + nameof(OnUnloadAsync), Path.GetFileName(_fileName)); + + throw new PluginException("An error occured while executing action for script plugin"); + } + + return Task.CompletedTask; } /// @@ -299,9 +342,9 @@ namespace IW4MAdmin.Application.Misc /// commands value from jint parser /// factory to create the command from /// - public IEnumerable GenerateScriptCommands(JsValue commands, IScriptCommandFactory scriptCommandFactory) + private IEnumerable GenerateScriptCommands(JsValue commands, IScriptCommandFactory scriptCommandFactory) { - List commandList = new List(); + var commandList = new List(); // go through each defined command foreach (var command in commands.AsArray()) @@ -311,9 +354,10 @@ namespace IW4MAdmin.Application.Misc string alias = dynamicCommand.alias; string description = dynamicCommand.description; string permission = dynamicCommand.permission; - bool targetRequired = false; + List supportedGames = null; + var targetRequired = false; - List<(string, bool)> args = new List<(string, bool)>(); + var args = new List<(string, bool)>(); dynamic arguments = null; try @@ -344,26 +388,85 @@ namespace IW4MAdmin.Application.Misc } } - void execute(GameEvent e) + try { - _scriptEngine.SetValue("_event", e); - var jsEventObject = _scriptEngine.GetValue("_event"); + foreach (var game in dynamicCommand.supportedGames) + { + supportedGames ??= new List(); + supportedGames.Add(Enum.Parse(typeof(Server.Game), game.ToString())); + } + } + catch (RuntimeBinderException) + { + // supported games is optional + } + async Task Execute(GameEvent gameEvent) + { try { - dynamicCommand.execute.Target.Invoke(jsEventObject); + await _onProcessing.WaitAsync(); + + _scriptEngine.SetValue("_event", gameEvent); + var jsEventObject = _scriptEngine.Evaluate("_event"); + + dynamicCommand.execute.Target.Invoke(_scriptEngine, 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 }; + using (LogContext.PushProperty("Server", gameEvent.Owner?.ToString())) + { + _logger.LogError(ex, "Could not execute command action for {Filename} {@Location}", + Path.GetFileName(_fileName), ex.Location); + } + + throw new PluginException("A runtime error occured while executing action for script plugin"); + } + + catch (Exception ex) + { + using (LogContext.PushProperty("Server", gameEvent.Owner?.ToString())) + { + _logger.LogError(ex, + "Could not execute command action for script plugin {FileName}", + Path.GetFileName(_fileName)); + } + + throw new PluginException("An error occured while executing action for script plugin"); + } + + + finally + { + if (_onProcessing.CurrentCount == 0) + { + _onProcessing.Release(1); + } } } - commandList.Add(scriptCommandFactory.CreateScriptCommand(name, alias, description, permission, targetRequired, args, execute)); + commandList.Add(scriptCommandFactory.CreateScriptCommand(name, alias, description, permission, + targetRequired, args, Execute, supportedGames?.ToArray())); } return commandList; } } + + public class PermissionLevelToStringConverter : IObjectConverter + { + public bool TryConvert(Engine engine, object value, out JsValue result) + { + if (value is Data.Models.Client.EFClient.Permission) + { + result = value.ToString(); + return true; + } + + + result = JsValue.Null; + return false; + } + } } diff --git a/Application/Misc/ScriptPluginTimerHelper.cs b/Application/Misc/ScriptPluginTimerHelper.cs new file mode 100644 index 000000000..ac33b8d55 --- /dev/null +++ b/Application/Misc/ScriptPluginTimerHelper.cs @@ -0,0 +1,157 @@ +using System; +using System.Threading; +using Jint.Native; +using Jint.Runtime; +using Microsoft.Extensions.Logging; +using SharedLibraryCore.Interfaces; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace IW4MAdmin.Application.Misc; + +public class ScriptPluginTimerHelper : IScriptPluginTimerHelper +{ + private Timer _timer; + private Action _actions; + private Delegate _jsAction; + private string _actionName; + private const int DefaultDelay = 0; + private const int DefaultInterval = 1000; + private readonly ILogger _logger; + private readonly ManualResetEventSlim _onRunningTick = new(); + private SemaphoreSlim _onDependentAction; + + public ScriptPluginTimerHelper(ILogger logger) + { + _logger = logger; + } + + ~ScriptPluginTimerHelper() + { + if (_timer != null) + { + Stop(); + } + _onRunningTick.Dispose(); + } + + public void Start(int delay, int interval) + { + if (_actions is null) + { + throw new InvalidOperationException("Timer action must be defined before starting"); + } + + if (delay < 0) + { + throw new ArgumentException("Timer delay must be >= 0"); + } + + if (interval < 20) + { + throw new ArgumentException("Timer interval must be at least 20ms"); + } + + Stop(); + + _logger.LogDebug("Starting script timer..."); + + _onRunningTick.Set(); + _timer ??= new Timer(callback => _actions(), null, delay, interval); + IsRunning = true; + } + + public void Start(int interval) + { + Start(DefaultDelay, interval); + } + + public void Start() + { + Start(DefaultDelay, DefaultInterval); + } + + public void Stop() + { + if (_timer == null) + { + return; + } + + _logger.LogDebug("Stopping script timer..."); + _timer.Change(Timeout.Infinite, Timeout.Infinite); + _timer.Dispose(); + _timer = null; + IsRunning = false; + } + + public void OnTick(Delegate action, string actionName) + { + if (string.IsNullOrEmpty(actionName)) + { + throw new ArgumentException("actionName must be provided", nameof(actionName)); + } + + if (action is null) + { + throw new ArgumentException("action must be provided", nameof(action)); + } + + _logger.LogDebug("Adding new action with name {ActionName}", actionName); + + _jsAction = action; + _actionName = actionName; + _actions = OnTick; + } + + private void ReleaseThreads() + { + _onRunningTick.Set(); + + if (_onDependentAction?.CurrentCount != 0) + { + return; + } + + _onDependentAction?.Release(1); + } + private void OnTick() + { + try + { + if (!_onRunningTick.IsSet) + { + _logger.LogWarning("Previous {OnTick} is still running, so we are skipping this one", + nameof(OnTick)); + return; + } + + _onRunningTick.Reset(); + + // the js engine is not thread safe so we need to ensure we're not executing OnTick and OnEventAsync simultaneously + _onDependentAction?.WaitAsync().Wait(); + _jsAction.DynamicInvoke(JsValue.Undefined, new[] { JsValue.Undefined }); + ReleaseThreads(); + } + + catch (Exception ex) when (ex.InnerException is JavaScriptException jsex) + { + _logger.LogError(jsex, + "Could not execute timer tick for script action {ActionName} [@{LocationInfo}]", _actionName, + jsex.Location); + ReleaseThreads(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not execute timer tick for script action {ActionName}", _actionName); + _onRunningTick.Set(); + ReleaseThreads(); + } + } + + public void SetDependency(SemaphoreSlim dependentSemaphore) + { + _onDependentAction = dependentSemaphore; + } + + public bool IsRunning { get; private set; } +} diff --git a/IW4MAdmin.sln b/IW4MAdmin.sln index 68c9e99ec..e5705a7dd 100644 --- a/IW4MAdmin.sln +++ b/IW4MAdmin.sln @@ -52,6 +52,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ScriptPlugins", "ScriptPlug Plugins\ScriptPlugins\ParserCSGO.js = Plugins\ScriptPlugins\ParserCSGO.js Plugins\ScriptPlugins\ParserCSGOSM.js = Plugins\ScriptPlugins\ParserCSGOSM.js Plugins\ScriptPlugins\ParserPlutoniumT4COZM.js = Plugins\ScriptPlugins\ParserPlutoniumT4COZM.js + Plugins\ScriptPlugins\GameInterface.js = Plugins\ScriptPlugins\GameInterface.js EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AutomessageFeed", "Plugins\AutomessageFeed\AutomessageFeed.csproj", "{F5815359-CFC7-44B4-9A3B-C04BACAD5836}" diff --git a/Plugins/ScriptPlugins/GameInterface.js b/Plugins/ScriptPlugins/GameInterface.js new file mode 100644 index 000000000..8e60a8146 --- /dev/null +++ b/Plugins/ScriptPlugins/GameInterface.js @@ -0,0 +1,359 @@ +const eventTypes = { + 1: 'start', // a server started being monitored + 6: 'disconnect', // a client detected a leaving the game + 9: 'preconnect', // client detected as joining via log or status + 101: 'warn' // client was warned +}; + +const servers = {}; +const inDvar = 'sv_iw4madmin_in'; +const outDvar = 'sv_iw4madmin_out'; +const pollRate = 5000; +let logger = {}; + +let plugin = { + author: 'RaidMax', + version: 1.0, + name: 'Game Interface', + enabled: true, // indicates if the plugin is enabled + + onEventAsync: (gameEvent, server) => { + const eventType = eventTypes[gameEvent.Type]; + + switch (eventType) { + case 'start': + this.enabled = initialize(server); + break; + case 'preconnect': + // when the plugin is reloaded after the servers are started + if (servers[server.EndPoint] == null) { + this.enabled = initialize(server); + + if (!this.enabled) { + return; + } + } + const timer = servers[server.EndPoint].timer; + if (!timer.IsRunning) { + timer.Start(0, pollRate); + } + break; + case 'disconnect': + if (server.ClientNum === 0 && servers[server.EndPoint] != null) { + servers[server.EndPoint].timer.Stop(); + } + break; + case 'warn': + const warningTitle = _localization.LocalizationIndex['GLOBAL_WARNING']; + sendScriptCommand(server, 'Alert', gameEvent.Target, { + alertType: warningTitle + '!', + message: gameEvent.Data + }); + break; + } + }, + + onLoadAsync: manager => { + logger = _serviceResolver.ResolveService('ILogger'); + logger.WriteInfo('Game Interface Startup'); + }, + + onUnloadAsync: () => { + for (let i = 0; i < servers.length; i++) { + servers[i].timer.Stop(); + } + }, + + onTickAsync: server => { + } +}; + +let commands = [{ + // required + name: 'giveweapon', + // required + description: 'gives specified weapon', + // required + alias: 'gw', + // required + permission: 'SeniorAdmin', + // optional (defaults to false) + targetRequired: false, + // optional + arguments: [{ + name: 'weapon name', + required: true + }], + supportedGames: ['IW4'], + // required + execute: (gameEvent) => { + sendScriptCommand(gameEvent.Owner, 'GiveWeapon', gameEvent.Origin, {weaponName: gameEvent.Data}); + } +}, + { + // required + name: 'takeweapons', + // required + description: 'take all weapons from specifies player', + // required + alias: 'tw', + // required + permission: 'SeniorAdmin', + // optional (defaults to false) + targetRequired: true, + // optional + arguments: [], + supportedGames: ['IW4'], + // required + execute: (gameEvent) => { + sendScriptCommand(gameEvent.Owner, 'TakeWeapons', gameEvent.Target, undefined); + } + }, + { + // required + name: 'switchteam', + // required + description: 'switches specified player to the opposite team', + // required + alias: 'st', + // required + permission: 'Administrator', + // optional (defaults to false) + targetRequired: true, + // optional + arguments: [{ + name: 'player', + required: true + }], + supportedGames: ['IW4'], + // required + execute: (gameEvent) => { + sendScriptCommand(gameEvent.Owner, 'SwitchTeams', gameEvent.Target, undefined); + } + }, + { + // required + name: 'hide', + // required + description: 'hide yourself', + // required + alias: 'hi', + // required + permission: 'SeniorAdmin', + // optional (defaults to false) + targetRequired: false, + // optional + arguments: [], + supportedGames: ['IW4'], + // required + execute: (gameEvent) => { + sendScriptCommand(gameEvent.Owner, 'Hide', gameEvent.Origin, undefined); + } + }, + { + // required + name: 'unhide', + // required + description: 'unhide yourself', + // required + alias: 'unh', + // required + permission: 'SeniorAdmin', + // optional (defaults to false) + targetRequired: false, + // optional + arguments: [], + supportedGames: ['IW4'], + // required + execute: (gameEvent) => { + sendScriptCommand(gameEvent.Owner, 'Unhide', gameEvent.Origin, undefined); + } + }, + { + // required + name: 'alert', + // required + description: 'alert a player', + // required + alias: 'alr', + // required + permission: 'SeniorAdmin', + // optional (defaults to false) + targetRequired: true, + // optional + arguments: [{ + name: 'alert message', + required: true + }], + supportedGames: ['IW4'], + // required + execute: (gameEvent) => { + sendScriptCommand(gameEvent.Owner, 'Alert', gameEvent.Target, { + alertType: 'Alert', + message: gameEvent.Data + }); + } + }]; + +const sendScriptCommand = (server, command, target, data) => { + if (plugin.enabled) { + sendEvent(server, false, 'ExecuteCommandRequested', command, target, data); + } +} + +const sendEvent = (server, responseExpected, event, subtype, client, data) => { + const logger = _serviceResolver.ResolveService('ILogger'); + + let pendingOut = true; + let pendingCheckCount = 0; + while (pendingOut === true && pendingCheckCount <= 10) { + try { + pendingOut = server.GetServerDvar(outDvar) !== null; + } catch (error) { + logger.WriteError(`Could not check server output dvar for IO status ${error}`); + } + + if (pendingOut) { + logger.WriteDebug('Waiting for event bus to be cleared'); + System.Threading.Tasks.Task.Delay(1000).Wait(); + } + pendingCheckCount++; + } + + if (pendingCheckCount === true) { + logger.WriteWarning(`Reached maximum attempts waiting for output to be available for ${server.EndPoint}`) + } + + let clientNumber = ''; + if (client !== undefined) { + clientNumber = client.ClientNumber; + } + if (responseExpected === undefined) { + responseExpected = 0; + } + const output = `${responseExpected ? '1' : '0'};${event};${subtype};${clientNumber};${buildDataString(data)}`; + logger.WriteDebug(`Sending output to server ${output}`); + + try { + server.SetServerDvar(outDvar, output); + } catch (error) { + logger.WriteError(`Could not set server output dvar ${error}`); + } +}; + +const parseEvent = (input) => { + if (input === undefined) { + return {}; + } + + const eventInfo = input.split(';'); + + return { + eventType: eventInfo[1], + subType: eventInfo[2], + clientNumber: eventInfo[3], + data: eventInfo.length > 4 ? eventInfo [4] : undefined + } +} + +const initialize = (server) => { + const logger = _serviceResolver.ResolveService('ILogger'); + let enabled = false; + try { + enabled = server.GetServerDvar('sv_iw4madmin_integration_enabled') === '1'; + } catch (error) { + logger.WriteError(`Could not get integration status of ${server.EndPoint} - ${error}`); + } + + logger.WriteDebug(`GSC Integration enabled = ${enabled}`); + + if (!enabled) { + return false; + } + + logger.WriteDebug(`Setting up bus timer for ${server.EndPoint}`); + + let timer = _timerHelper; + timer.OnTick(() => pollForEvents(server), `GameEventPoller ${server.ToString()}`) + + servers[server.EndPoint] = { + timer: timer + } + + try { + server.SetServerDvar(inDvar, ''); + server.SetServerDvar(outDvar, ''); + } catch (error) { + logger.WriteError(`Could set default values bus dvars for ${server.EndPoint} - ${error}`); + } + + return true; +}; + +const pollForEvents = server => { + const logger = _serviceResolver.ResolveService('ILogger'); + + let input; + try { + input = server.GetServerDvar(inDvar); + } catch (error) { + logger.WriteError(`Could not get input bus value for ${server.EndPoint} - ${error}`); + return; + } + + if (input === undefined || input === null || input === 'null') { + input = ''; + } + + if (input.length > 0) { + + const event = parseEvent(input) + + logger.WriteInfo(`Processing input... ${event.eventType}`); + + if (event.eventType === 'ClientDataRequested') { + const client = server.GetClientByNumber(event.clientNumber); + + if (client != null) { + logger.WriteInfo(`Found client ${client.Name}`); + + let data = []; + + if (event.subType === 'Meta') { + const metaService = _serviceResolver.ResolveService('IMetaService'); + const meta = metaService.GetPersistentMeta(event.data, client).Result; + data[event.data] = meta === null ? '' : meta.Value; + } else { + data = { + level: client.Level, + lastConnection: client.LastConnection + }; + } + + sendEvent(server, false, 'ClientDataReceived', event.subType, client, data); + } + + logger.WriteWarning(`Could not find client slot ${event.clientNumber} when processing ${event.eventType}`); + } + + try { + server.SetServerDvar(inDvar, ''); + } catch (error) { + logger.WriteError(`Could not reset in bus value for ${server.EndPoint} - ${error}`); + } + } +} + +const buildDataString = data => { + if (data === undefined) { + return ''; + } + + let formattedData = ''; + + for (const prop in data) { + formattedData += `${prop}=${data[prop]}|`; + } + + return formattedData.substring(0, Math.max(0, formattedData.length - 1)); +} diff --git a/SharedLibraryCore/Interfaces/IPlugin.cs b/SharedLibraryCore/Interfaces/IPlugin.cs index 7d573f8ef..a506d972c 100644 --- a/SharedLibraryCore/Interfaces/IPlugin.cs +++ b/SharedLibraryCore/Interfaces/IPlugin.cs @@ -10,7 +10,7 @@ namespace SharedLibraryCore.Interfaces bool IsParser => false; Task OnLoadAsync(IManager manager); Task OnUnloadAsync(); - Task OnEventAsync(GameEvent E, Server S); + Task OnEventAsync(GameEvent gameEvent, Server server); Task OnTickAsync(Server S); } } \ No newline at end of file diff --git a/SharedLibraryCore/Interfaces/IPluginImporter.cs b/SharedLibraryCore/Interfaces/IPluginImporter.cs index 9086fc74a..1002f0f37 100644 --- a/SharedLibraryCore/Interfaces/IPluginImporter.cs +++ b/SharedLibraryCore/Interfaces/IPluginImporter.cs @@ -18,6 +18,6 @@ namespace SharedLibraryCore.Interfaces /// discovers the script plugins /// /// initialized script plugin collection - IEnumerable DiscoverScriptPlugins(); + IEnumerable DiscoverScriptPlugins(IServiceProvider serviceProvider); } -} \ No newline at end of file +} diff --git a/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs b/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs index f8ce95655..704d5572c 100644 --- a/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs +++ b/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace SharedLibraryCore.Interfaces { @@ -18,8 +19,10 @@ namespace SharedLibraryCore.Interfaces /// target required or not /// command arguments (name, is required) /// action to peform when commmand is executed + /// /// IManagerCommand CreateScriptCommand(string name, string alias, string description, string permission, - bool isTargetRequired, IEnumerable<(string, bool)> args, Action executeAction); + bool isTargetRequired, IEnumerable<(string, bool)> args, Func executeAction, + Server.Game[] supportedGames); } -} \ No newline at end of file +} diff --git a/SharedLibraryCore/Interfaces/IScriptPluginTimerHelper.cs b/SharedLibraryCore/Interfaces/IScriptPluginTimerHelper.cs new file mode 100644 index 000000000..fa089a349 --- /dev/null +++ b/SharedLibraryCore/Interfaces/IScriptPluginTimerHelper.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading; + +namespace SharedLibraryCore.Interfaces; + +public interface IScriptPluginTimerHelper +{ + void Start(int delay, int interval); + void Start(int interval); + void Start(); + void Stop(); + void OnTick(Delegate action, string actionName); + bool IsRunning { get; } + void SetDependency(SemaphoreSlim dependentSemaphore); +} diff --git a/SharedLibraryCore/Server.cs b/SharedLibraryCore/Server.cs index 27f8bb9b4..36b2214da 100644 --- a/SharedLibraryCore/Server.cs +++ b/SharedLibraryCore/Server.cs @@ -387,5 +387,23 @@ namespace SharedLibraryCore } public abstract Task GetIdForServer(Server server = null); + + public string[] ExecuteServerCommand(string command) + { + return this.ExecuteCommandAsync(command).GetAwaiter().GetResult(); + } + + public string GetServerDvar(string dvarName) + { + return this.GetDvarAsync(dvarName).GetAwaiter().GetResult()?.Value; + } + + public void SetServerDvar(string dvarName, string dvarValue) + { + this.SetDvarAsync(dvarName, dvarValue).GetAwaiter().GetResult(); + } + + public EFClient GetClientByNumber(int clientNumber) => + GetClientsAsList().FirstOrDefault(client => client.ClientNumber == clientNumber); } -} \ No newline at end of file +} diff --git a/SharedLibraryCore/SharedLibraryCore.csproj b/SharedLibraryCore/SharedLibraryCore.csproj index 421f3c8fc..b55f03a4e 100644 --- a/SharedLibraryCore/SharedLibraryCore.csproj +++ b/SharedLibraryCore/SharedLibraryCore.csproj @@ -28,32 +28,32 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + - + @@ -61,7 +61,7 @@ - + diff --git a/WebfrontCore/Controllers/HomeController.cs b/WebfrontCore/Controllers/HomeController.cs index 73fd4695f..979f069ad 100644 --- a/WebfrontCore/Controllers/HomeController.cs +++ b/WebfrontCore/Controllers/HomeController.cs @@ -85,7 +85,7 @@ namespace WebfrontCore.Controllers _type.Assembly != excludedAssembly && typeof(IPlugin).IsAssignableFrom(_type)); return pluginType == null ? _translationLookup["WEBFRONT_HELP_COMMAND_NATIVE"] : pluginType.Name == "ScriptPlugin" ? _translationLookup["WEBFRONT_HELP_SCRIPT_PLUGIN"] : - Manager.Plugins.First(_plugin => _plugin.GetType().FullName == pluginType.FullName) + Manager.Plugins.FirstOrDefault(_plugin => _plugin.GetType().FullName == pluginType.FullName)? .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())); @@ -93,4 +93,4 @@ namespace WebfrontCore.Controllers return View(commands); } } -} \ No newline at end of file +}