implement PluginV2 for script plugins

This commit is contained in:
RaidMax
2023-04-04 18:24:13 -05:00
parent ad20572879
commit fab3cf95d6
33 changed files with 1659 additions and 1026 deletions

View File

@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Data.Models;
using Data.Models.Client;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using SharedLibraryCore.Commands;
using SharedLibraryCore.Configuration;
using SharedLibraryCore.Interfaces;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace IW4MAdmin.Application.Plugin.Script
{
/// <summary>
/// generic script command implementation
/// </summary>
public class ScriptCommand : Command
{
private readonly Func<GameEvent, Task> _executeAction;
private readonly ILogger _logger;
public ScriptCommand(string name, string alias, string description, bool isTargetRequired,
EFClient.Permission permission,
IEnumerable<CommandArgument> args, Func<GameEvent, Task> executeAction, CommandConfiguration config,
ITranslationLookup layout, ILogger<ScriptCommand> logger, IEnumerable<Reference.Game> supportedGames)
: base(config, layout)
{
_executeAction = executeAction;
_logger = logger;
Name = name;
Alias = alias;
Description = description;
RequiresTarget = isTargetRequired;
Permission = permission;
Arguments = args.ToArray();
SupportedGames = supportedGames?.Select(game => (Server.Game)game).ToArray();
}
public override async Task ExecuteAsync(GameEvent e)
{
if (_executeAction == null)
{
throw new InvalidOperationException($"No execute action defined for command \"{Name}\"");
}
try
{
await _executeAction(e);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to execute ScriptCommand action for command {Command} {@Event}", Name, e);
}
}
}
}

View File

@ -0,0 +1,567 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Data.Models;
using IW4MAdmin.Application.Configuration;
using IW4MAdmin.Application.Extensions;
using IW4MAdmin.Application.Misc;
using Jint;
using Jint.Native;
using Jint.Runtime;
using Jint.Runtime.Interop;
using Microsoft.CSharp.RuntimeBinder;
using Microsoft.Extensions.Logging;
using Serilog.Context;
using SharedLibraryCore;
using SharedLibraryCore.Commands;
using SharedLibraryCore.Database.Models;
using SharedLibraryCore.Exceptions;
using SharedLibraryCore.Interfaces;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace IW4MAdmin.Application.Plugin.Script
{
/// <summary>
/// implementation of IPlugin
/// used to proxy script plugin requests
/// </summary>
public class ScriptPlugin : IPlugin
{
public string Name { get; set; }
public float Version { get; set; }
public string Author { get; set; }
/// <summary>
/// indicates if the plugin is a parser
/// </summary>
public bool IsParser { get; private set; }
public FileSystemWatcher Watcher { get; }
private Engine _scriptEngine;
private readonly string _fileName;
private readonly SemaphoreSlim _onProcessing = new(1, 1);
private bool _successfullyLoaded;
private readonly List<string> _registeredCommandNames;
private readonly ILogger _logger;
public ScriptPlugin(ILogger logger, string filename, string workingDirectory = null)
{
_logger = logger;
_fileName = filename;
Watcher = new FileSystemWatcher
{
Path = workingDirectory ?? $"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}",
NotifyFilter = NotifyFilters.LastWrite,
Filter = _fileName.Split(Path.DirectorySeparatorChar).Last()
};
Watcher.EnableRaisingEvents = true;
_registeredCommandNames = new List<string>();
}
~ScriptPlugin()
{
Watcher.Dispose();
_onProcessing.Dispose();
}
public async Task Initialize(IManager manager, IScriptCommandFactory scriptCommandFactory,
IScriptPluginServiceResolver serviceResolver, IConfigurationHandlerV2<ScriptPluginConfiguration> configHandler)
{
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
if (new FileInfo(_fileName).Length == 0L)
{
return;
}
var firstRun = _scriptEngine == null;
// it's been loaded before so we need to call the unload event
if (!firstRun)
{
await OnUnloadAsync();
foreach (var commandName in _registeredCommandNames)
{
_logger.LogDebug("Removing plugin registered command {Command}", commandName);
manager.RemoveCommandByName(commandName);
}
_registeredCommandNames.Clear();
}
_successfullyLoaded = false;
string script;
await using (var stream =
new FileStream(_fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
using (var reader = new StreamReader(stream, Encoding.Default))
{
script = await reader.ReadToEndAsync();
}
}
_scriptEngine?.Dispose();
_scriptEngine = new Engine(cfg =>
cfg.AddExtensionMethods(typeof(Utilities), typeof(Enumerable), typeof(Queryable),
typeof(ScriptPluginExtensions))
.AllowClr(new[]
{
typeof(System.Net.Http.HttpClient).Assembly,
typeof(EFClient).Assembly,
typeof(Utilities).Assembly,
typeof(Encoding).Assembly,
typeof(CancellationTokenSource).Assembly,
typeof(Data.Models.Client.EFClient).Assembly,
typeof(IW4MAdmin.Plugins.Stats.Plugin).Assembly
})
.CatchClrExceptions()
.AddObjectConverter(new PermissionLevelToStringConverter()));
_scriptEngine.Execute(script);
if (!_scriptEngine.GetValue("init").IsUndefined())
{
// this is a v2 plugin and we don't want to try to load it
Watcher.EnableRaisingEvents = false;
Watcher.Dispose();
return;
}
_scriptEngine.SetValue("_localization", Utilities.CurrentLocalization);
_scriptEngine.SetValue("_serviceResolver", serviceResolver);
dynamic pluginObject = _scriptEngine.Evaluate("plugin").ToObject();
Author = pluginObject.author;
Name = pluginObject.name;
Version = (float)pluginObject.version;
var commands = JsValue.Undefined;
try
{
commands = _scriptEngine.Evaluate("commands");
}
catch (JavaScriptException)
{
// ignore because commands aren't defined;
}
if (commands != JsValue.Undefined)
{
try
{
foreach (var command in GenerateScriptCommands(commands, scriptCommandFactory))
{
_logger.LogDebug("Adding plugin registered command {CommandName}", 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 };
}
}
async Task<bool> OnLoadTask()
{
await OnLoadAsync(manager);
return true;
}
var loadComplete = false;
try
{
if (pluginObject.isParser)
{
loadComplete = await OnLoadTask();
IsParser = true;
var eventParser = (IEventParser)_scriptEngine.Evaluate("eventParser").ToObject();
var rconParser = (IRConParser)_scriptEngine.Evaluate("rconParser").ToObject();
manager.AdditionalEventParsers.Add(eventParser);
manager.AdditionalRConParsers.Add(rconParser);
}
}
catch (RuntimeBinderException)
{
var configWrapper = new ScriptPluginConfigurationWrapper(Name, _scriptEngine, configHandler);
if (!loadComplete)
{
_scriptEngine.SetValue("_configHandler", configWrapper);
loadComplete = await OnLoadTask();
}
}
if (!firstRun && !loadComplete)
{
loadComplete = await OnLoadTask();
}
_successfullyLoaded = loadComplete;
}
catch (JavaScriptException ex)
{
_logger.LogError(ex,
"Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo} StackTrace={StackTrace}",
nameof(Initialize), Path.GetFileName(_fileName), ex.Location, ex.JavaScriptStackTrace);
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} StackTrace={StackTrace}",
nameof(Initialize), _fileName, jsEx.Location, jsEx.JavaScriptStackTrace);
throw new PluginException("An error occured while initializing 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");
}
finally
{
if (_onProcessing.CurrentCount == 0)
{
_onProcessing.Release(1);
}
}
}
public async Task OnEventAsync(GameEvent gameEvent, Server server)
{
if (!_successfullyLoaded)
{
return;
}
var shouldRelease = false;
try
{
await _onProcessing.WaitAsync(Utilities.DefaultCommandTimeout / 2);
shouldRelease = true;
WrapJavaScriptErrorHandling(() =>
{
_scriptEngine.SetValue("_gameEvent", gameEvent);
_scriptEngine.SetValue("_server", server);
_scriptEngine.SetValue("_IW4MAdminClient", Utilities.IW4MAdminClient(server));
return _scriptEngine.Evaluate("plugin.onEventAsync(_gameEvent, _server)");
}, new { EventType = gameEvent.Type }, server);
}
finally
{
if (_onProcessing.CurrentCount == 0 && shouldRelease)
{
_onProcessing.Release(1);
}
}
}
public Task OnLoadAsync(IManager manager)
{
_logger.LogDebug("OnLoad executing for {Name}", Name);
WrapJavaScriptErrorHandling(() =>
{
_scriptEngine.SetValue("_manager", manager);
return _scriptEngine.Evaluate("plugin.onLoadAsync(_manager)");
});
return Task.CompletedTask;
}
public Task OnTickAsync(Server server)
{
return Task.CompletedTask;
}
public async Task OnUnloadAsync()
{
if (!_successfullyLoaded)
{
return;
}
try
{
await _onProcessing.WaitAsync();
_logger.LogDebug("OnUnload executing for {Name}", Name);
WrapJavaScriptErrorHandling(() => _scriptEngine.Evaluate("plugin.onUnloadAsync()"));
}
finally
{
if (_onProcessing.CurrentCount == 0)
{
_onProcessing.Release(1);
}
}
}
public T ExecuteAction<T>(Delegate action, CancellationToken token, params object[] param)
{
var shouldRelease = false;
try
{
using var forceTimeout = new CancellationTokenSource(5000);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(forceTimeout.Token, token);
_onProcessing.Wait(combined.Token);
shouldRelease = true;
_logger.LogDebug("Executing action for {Name}", Name);
return WrapJavaScriptErrorHandling(T() =>
{
var args = param.Select(p => JsValue.FromObject(_scriptEngine, p)).ToArray();
var result = action.DynamicInvoke(JsValue.Undefined, args);
return (T)(result as JsValue)?.ToObject();
},
new
{
Params = string.Join(", ",
param?.Select(eachParam => $"Type={eachParam?.GetType().Name} Value={eachParam}") ??
Enumerable.Empty<string>())
});
}
finally
{
if (_onProcessing.CurrentCount == 0 && shouldRelease)
{
_onProcessing.Release(1);
}
}
}
public T WrapDelegate<T>(Delegate act, CancellationToken token, params object[] args)
{
var shouldRelease = false;
try
{
using var forceTimeout = new CancellationTokenSource(5000);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(forceTimeout.Token, token);
_onProcessing.Wait(combined.Token);
shouldRelease = true;
_logger.LogDebug("Wrapping delegate action for {Name}", Name);
return WrapJavaScriptErrorHandling(
T() => (T)(act.DynamicInvoke(JsValue.Null,
args.Select(arg => JsValue.FromObject(_scriptEngine, arg)).ToArray()) as ObjectWrapper)
?.ToObject(),
new
{
Params = string.Join(", ",
args?.Select(eachParam => $"Type={eachParam?.GetType().Name} Value={eachParam}") ??
Enumerable.Empty<string>())
});
}
finally
{
if (_onProcessing.CurrentCount == 0 && shouldRelease)
{
_onProcessing.Release(1);
}
}
}
/// <summary>
/// finds declared script commands in the script plugin
/// </summary>
/// <param name="commands">commands value from jint parser</param>
/// <param name="scriptCommandFactory">factory to create the command from</param>
/// <returns></returns>
private IEnumerable<IManagerCommand> GenerateScriptCommands(JsValue commands,
IScriptCommandFactory scriptCommandFactory)
{
var commandList = new List<IManagerCommand>();
// 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;
if (dynamicCommand.permission is Data.Models.Client.EFClient.Permission perm)
{
dynamicCommand.permission = perm.ToString();
}
string permission = dynamicCommand.permission;
List<Reference.Game> supportedGames = null;
var targetRequired = false;
var args = new List<CommandArgument>();
dynamic arguments = null;
try
{
arguments = dynamicCommand.arguments;
}
catch (RuntimeBinderException)
{
// arguments are optional
}
try
{
targetRequired = dynamicCommand.targetRequired;
}
catch (RuntimeBinderException)
{
// arguments are optional
}
if (arguments != null)
{
foreach (var arg in dynamicCommand.arguments)
{
args.Add(new CommandArgument { Name = arg.name, Required = (bool)arg.required });
}
}
try
{
foreach (var game in dynamicCommand.supportedGames)
{
supportedGames ??= new List<Reference.Game>();
supportedGames.Add(Enum.Parse(typeof(Reference.Game), game.ToString()));
}
}
catch (RuntimeBinderException)
{
// supported games is optional
}
async Task Execute(GameEvent gameEvent)
{
try
{
await _onProcessing.WaitAsync();
_scriptEngine.SetValue("_event", gameEvent);
var jsEventObject = _scriptEngine.Evaluate("_event");
dynamicCommand.execute.Target.Invoke(_scriptEngine, jsEventObject);
}
catch (JavaScriptException ex)
{
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, supportedGames));
}
return commandList;
}
private T WrapJavaScriptErrorHandling<T>(Func<T> work, object additionalData = null, Server server = null,
[CallerMemberName] string methodName = "")
{
using (LogContext.PushProperty("Server", server?.ToString()))
{
try
{
return work();
}
catch (JavaScriptException ex)
{
_logger.LogError(ex,
"Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo} StackTrace={StackTrace} {@AdditionalData}",
methodName, Path.GetFileName(_fileName), ex.Location, ex.StackTrace, additionalData);
throw new PluginException("A runtime error occured while executing action for 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} StackTrace={StackTrace} {@AdditionalData}",
methodName, _fileName, jsEx.Location, jsEx.JavaScriptStackTrace, additionalData);
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}",
methodName, Path.GetFileName(_fileName));
throw new PluginException("An error occured while executing action for script plugin");
}
}
}
}
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;
}
}
}

View File

@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using IW4MAdmin.Application.Configuration;
using Jint;
using Jint.Native;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Plugin.Script;
public class ScriptPluginConfigurationWrapper
{
private readonly ScriptPluginConfiguration _config;
private readonly IConfigurationHandlerV2<ScriptPluginConfiguration> _configHandler;
private readonly Engine _scriptEngine;
private string _pluginName;
public ScriptPluginConfigurationWrapper(string pluginName, Engine scriptEngine, IConfigurationHandlerV2<ScriptPluginConfiguration> configHandler)
{
_pluginName = pluginName;
_scriptEngine = scriptEngine;
_configHandler = configHandler;
_config = configHandler.Get("ScriptPluginSettings", new ScriptPluginConfiguration()).GetAwaiter().GetResult();
}
public void SetName(string name)
{
_pluginName = name;
}
public async Task SetValue(string key, object value)
{
var castValue = value;
if (value is double doubleValue)
{
castValue = AsInteger(doubleValue) ?? value;
}
if (value is object[] array && array.All(item => item is double d && AsInteger(d) != null))
{
castValue = array.Select(item => AsInteger((double)item)).ToArray();
}
if (!_config.ContainsKey(_pluginName))
{
_config.Add(_pluginName, new Dictionary<string, object>());
}
var plugin = _config[_pluginName];
if (plugin.ContainsKey(key))
{
plugin[key] = castValue;
}
else
{
plugin.Add(key, castValue);
}
await _configHandler.Set(_config);
}
public JsValue GetValue(string key)
{
if (!_config.ContainsKey(_pluginName))
{
return JsValue.Undefined;
}
if (!_config[_pluginName].ContainsKey(key))
{
return JsValue.Undefined;
}
var item = _config[_pluginName][key];
if (item is JsonElement { ValueKind: JsonValueKind.Array } jElem)
{
item = jElem.Deserialize<List<dynamic>>();
}
return JsValue.FromObject(_scriptEngine, item);
}
private static int? AsInteger(double value)
{
return int.TryParse(value.ToString(CultureInfo.InvariantCulture), out var parsed) ? parsed : null;
}
}

View File

@ -0,0 +1,32 @@
using System;
using IW4MAdmin.Application.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Plugin.Script;
public class ScriptPluginFactory : IScriptPluginFactory
{
private readonly IServiceProvider _serviceProvider;
public ScriptPluginFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public object CreateScriptPlugin(Type type, string fileName)
{
if (type == typeof(IPlugin))
{
return new ScriptPlugin(_serviceProvider.GetRequiredService<ILogger<ScriptPlugin>>(),
fileName);
}
return new ScriptPluginV2(fileName, _serviceProvider.GetRequiredService<ILogger<ScriptPluginV2>>(),
_serviceProvider.GetRequiredService<IScriptPluginServiceResolver>(),
_serviceProvider.GetRequiredService<IScriptCommandFactory>(),
_serviceProvider.GetRequiredService<IConfigurationHandlerV2<ScriptPluginConfiguration>>(),
_serviceProvider.GetRequiredService<IInteractionRegistration>());
}
}

View File

@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jint.Native;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Plugin.Script;
public class ScriptPluginHelper
{
private readonly IManager _manager;
private readonly ScriptPluginV2 _scriptPlugin;
private readonly SemaphoreSlim _onRequestRunning = new(1, 5);
private const int RequestTimeout = 500;
public ScriptPluginHelper(IManager manager, ScriptPluginV2 scriptPlugin)
{
_manager = manager;
_scriptPlugin = scriptPlugin;
}
public void GetUrl(string url, Delegate callback)
{
RequestUrl(new ScriptPluginWebRequest(url), callback);
}
public void GetUrl(string url, Dictionary<string, string> headers, Delegate callback)
{
RequestUrl(new ScriptPluginWebRequest(url, Headers: headers), callback);
}
public void PostUrl(string url, Dictionary<string, string> headers, Delegate callback)
{
RequestUrl(new ScriptPluginWebRequest(url, null, "POST", Headers: headers), callback);
}
public void RequestUrl(ScriptPluginWebRequest request, Delegate callback)
{
Task.Run(() =>
{
try
{
var response = RequestInternal(request);
_scriptPlugin.ExecuteWithErrorHandling(scriptEngine =>
{
callback.DynamicInvoke(JsValue.Undefined, new[] { JsValue.FromObject(scriptEngine, response) });
});
}
catch
{
// ignored
}
});
}
public void RequestNotify(int delayMs, Delegate callback)
{
Task.Run(async () =>
{
try
{
await Task.Delay(delayMs, _manager.CancellationToken);
_scriptPlugin.ExecuteWithErrorHandling(_ => callback.DynamicInvoke(JsValue.Undefined));
}
catch
{
// ignored
}
});
}
private object RequestInternal(ScriptPluginWebRequest request)
{
var entered = false;
using var tokenSource = new CancellationTokenSource(RequestTimeout);
using var client = new HttpClient();
try
{
_onRequestRunning.Wait(tokenSource.Token);
entered = true;
var requestMessage = new HttpRequestMessage(new HttpMethod(request.Method), request.Url);
if (request.Body is not null)
{
requestMessage.Content = new StringContent(request.Body.ToString() ?? string.Empty, Encoding.Default,
request.ContentType ?? "text/plain");
}
if (request.Headers is not null)
{
foreach (var (key, value) in request.Headers)
{
if (!string.IsNullOrWhiteSpace(key))
{
requestMessage.Headers.Add(key, value);
}
}
}
var response = client.Send(requestMessage, tokenSource.Token);
using var reader = new StreamReader(response.Content.ReadAsStream());
return reader.ReadToEnd();
}
catch (HttpRequestException ex)
{
return new
{
ex.StatusCode,
ex.Message,
IsError = true
};
}
catch (Exception ex)
{
return new
{
ex.Message,
IsError = true
};
}
finally
{
if (entered)
{
_onRequestRunning.Release(1);
}
}
}
}

View File

@ -0,0 +1,48 @@
using System;
using System.Linq;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Plugin.Script
{
/// <summary>
/// implementation of IScriptPluginServiceResolver
/// </summary>
public class ScriptPluginServiceResolver : IScriptPluginServiceResolver
{
private readonly IServiceProvider _serviceProvider;
public ScriptPluginServiceResolver(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public object ResolveService(string serviceName)
{
var serviceType = DetermineRootType(serviceName);
return _serviceProvider.GetService(serviceType);
}
public object ResolveService(string serviceName, string[] genericParameters)
{
var serviceType = DetermineRootType(serviceName, genericParameters.Length);
var genericTypes = genericParameters.Select(genericTypeParam => DetermineRootType(genericTypeParam));
var resolvedServiceType = serviceType.MakeGenericType(genericTypes.ToArray());
return _serviceProvider.GetService(resolvedServiceType);
}
private Type DetermineRootType(string serviceName, int genericParamCount = 0)
{
var typeCollection = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(t => t.GetTypes());
var generatedName = $"{serviceName}{(genericParamCount == 0 ? "" : $"`{genericParamCount}")}".ToLower();
var serviceType = typeCollection.FirstOrDefault(type => type.Name.ToLower() == generatedName);
if (serviceType == null)
{
throw new InvalidOperationException($"No object type '{serviceName}' defined in loaded assemblies");
}
return serviceType;
}
}
}

View File

@ -0,0 +1,201 @@
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.Plugin.Script;
[Obsolete("This architecture is superseded by the request notify delay architecture")]
public class ScriptPluginTimerHelper : IScriptPluginTimerHelper
{
private Timer _timer;
private Action _actions;
private Delegate _jsAction;
private string _actionName;
private int _interval = DefaultInterval;
private long _waitingCount;
private const int DefaultDelay = 0;
private const int DefaultInterval = 1000;
private const int MaxWaiting = 10;
private readonly ILogger _logger;
private readonly SemaphoreSlim _onRunningTick = new(1, 1);
private SemaphoreSlim _onDependentAction;
public ScriptPluginTimerHelper(ILogger<ScriptPluginTimerHelper> 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...");
_timer ??= new Timer(callback => _actions(), null, delay, interval);
_interval = 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 = OnTickInternal;
}
private void ReleaseThreads(bool releaseOnRunning, bool releaseOnDependent)
{
if (releaseOnRunning && _onRunningTick.CurrentCount == 0)
{
_logger.LogDebug("-Releasing OnRunning for timer");
_onRunningTick.Release(1);
}
if (releaseOnDependent && _onDependentAction?.CurrentCount == 0)
{
_onDependentAction?.Release(1);
}
}
private async void OnTickInternal()
{
var releaseOnRunning = false;
var releaseOnDependent = false;
try
{
try
{
if (Interlocked.Read(ref _waitingCount) > MaxWaiting)
{
_logger.LogWarning("Reached max number of waiting count ({WaitingCount}) for {OnTick}",
_waitingCount, nameof(OnTickInternal));
return;
}
Interlocked.Increment(ref _waitingCount);
using var tokenSource1 = new CancellationTokenSource();
tokenSource1.CancelAfter(TimeSpan.FromMilliseconds(_interval));
await _onRunningTick.WaitAsync(tokenSource1.Token);
releaseOnRunning = true;
}
catch (OperationCanceledException)
{
_logger.LogDebug("Previous {OnTick} is still running, so we are skipping this one",
nameof(OnTickInternal));
return;
}
using var tokenSource = new CancellationTokenSource();
tokenSource.CancelAfter(TimeSpan.FromSeconds(5));
try
{
// the js engine is not thread safe so we need to ensure we're not executing OnTick and OnEventAsync simultaneously
if (_onDependentAction is not null)
{
await _onDependentAction.WaitAsync(tokenSource.Token);
releaseOnDependent = true;
}
}
catch (OperationCanceledException)
{
_logger.LogWarning("Dependent action did not release in allotted time so we are cancelling this tick");
return;
}
_logger.LogDebug("+Running OnTick for timer");
var start = DateTime.Now;
_jsAction.DynamicInvoke(JsValue.Undefined, new[] { JsValue.Undefined });
_logger.LogDebug("OnTick took {Time}ms", (DateTime.Now - start).TotalMilliseconds);
}
catch (Exception ex) when (ex.InnerException is JavaScriptException jsx)
{
_logger.LogError(jsx,
"Could not execute timer tick for script action {ActionName} [{@LocationInfo}] [{@StackTrace}]",
_actionName,
jsx.Location, jsx.JavaScriptStackTrace);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not execute timer tick for script action {ActionName}", _actionName);
}
finally
{
ReleaseThreads(releaseOnRunning, releaseOnDependent);
Interlocked.Decrement(ref _waitingCount);
}
}
public void SetDependency(SemaphoreSlim dependentSemaphore)
{
_onDependentAction = dependentSemaphore;
}
public bool IsRunning { get; private set; }
}

View File

@ -0,0 +1,568 @@
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Data.Models;
using IW4MAdmin.Application.Configuration;
using IW4MAdmin.Application.Extensions;
using Jint;
using Jint.Native;
using Jint.Runtime;
using Jint.Runtime.Interop;
using Microsoft.Extensions.Logging;
using Serilog.Context;
using SharedLibraryCore;
using SharedLibraryCore.Commands;
using SharedLibraryCore.Database.Models;
using SharedLibraryCore.Events.Server;
using SharedLibraryCore.Exceptions;
using SharedLibraryCore.Interfaces;
using SharedLibraryCore.Interfaces.Events;
using ILogger = Microsoft.Extensions.Logging.ILogger;
using JavascriptEngine = Jint.Engine;
namespace IW4MAdmin.Application.Plugin.Script;
public class ScriptPluginV2 : IPluginV2
{
public string Name { get; private set; } = string.Empty;
public string Author { get; private set; } = string.Empty;
public string Version { get; private set; }
private readonly string _fileName;
private readonly ILogger<ScriptPluginV2> _logger;
private readonly IScriptPluginServiceResolver _pluginServiceResolver;
private readonly IScriptCommandFactory _scriptCommandFactory;
private readonly IConfigurationHandlerV2<ScriptPluginConfiguration> _configHandler;
private readonly IInteractionRegistration _interactionRegistration;
private readonly SemaphoreSlim _onProcessingScript = new(1, 1);
private readonly SemaphoreSlim _onLoadingFile = new(1, 1);
private readonly FileSystemWatcher _scriptWatcher;
private readonly List<string> _registeredCommandNames = new();
private readonly List<string> _registeredInteractions = new();
private readonly Dictionary<MethodInfo, List<object>> _registeredEvents = new();
private bool _firstInitialization = true;
private record ScriptPluginDetails(string Name, string Author, string Version,
ScriptPluginCommandDetails[] Commands, ScriptPluginInteractionDetails[] Interactions);
private record ScriptPluginCommandDetails(string Name, string Description, string Alias, string Permission,
bool TargetRequired, CommandArgument[] Arguments, IEnumerable<Reference.Game> SupportedGames, Delegate Execute);
private JavascriptEngine ScriptEngine
{
get
{
lock (ActiveEngines)
{
return ActiveEngines[$"{GetHashCode()}-{_nextEngineId}"];
}
}
}
private record ScriptPluginInteractionDetails(string Name, Delegate Action);
private ScriptPluginConfigurationWrapper _scriptPluginConfigurationWrapper;
private int _nextEngineId;
private static readonly Dictionary<string, JavascriptEngine> ActiveEngines = new();
public ScriptPluginV2(string fileName, ILogger<ScriptPluginV2> logger,
IScriptPluginServiceResolver pluginServiceResolver, IScriptCommandFactory scriptCommandFactory,
IConfigurationHandlerV2<ScriptPluginConfiguration> configHandler,
IInteractionRegistration interactionRegistration)
{
_fileName = fileName;
_logger = logger;
_pluginServiceResolver = pluginServiceResolver;
_scriptCommandFactory = scriptCommandFactory;
_configHandler = configHandler;
_interactionRegistration = interactionRegistration;
_scriptWatcher = new FileSystemWatcher
{
Path = Path.Join(Utilities.OperatingDirectory, "Plugins"),
NotifyFilter = NotifyFilters.LastWrite,
Filter = _fileName.Split(Path.DirectorySeparatorChar).Last()
};
IManagementEventSubscriptions.Load += OnLoad;
}
public void ExecuteWithErrorHandling(Action<Engine> work)
{
WrapJavaScriptErrorHandling(() =>
{
work(ScriptEngine);
return true;
}, _logger, _fileName, _onProcessingScript);
}
public object QueryWithErrorHandling(Delegate action, params object[] args)
{
return WrapJavaScriptErrorHandling(() =>
{
var jsArgs = args?.Select(param => JsValue.FromObject(ScriptEngine, param)).ToArray();
var result = action.DynamicInvoke(JsValue.Undefined, jsArgs);
return result;
}, _logger, _fileName, _onProcessingScript);
}
private async Task OnLoad(IManager manager, CancellationToken token)
{
var entered = false;
try
{
await _onLoadingFile.WaitAsync(token);
entered = true;
_logger.LogDebug("{Method} executing for {Plugin}", nameof(OnLoad), _fileName);
if (new FileInfo(_fileName).Length == 0L)
{
return;
}
_scriptWatcher.EnableRaisingEvents = false;
UnregisterScriptEntities(manager);
ResetEngineState();
if (_firstInitialization)
{
_scriptWatcher.Changed += async (_, _) => await OnLoad(manager, token);
_firstInitialization = false;
}
await using var stream =
new FileStream(_fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.Default);
var pluginScript = await reader.ReadToEndAsync();
var pluginDetails = WrapJavaScriptErrorHandling(() =>
{
if (IsEngineDisposed(GetHashCode(), _nextEngineId))
{
return null;
}
ScriptEngine.Execute(pluginScript);
var initResult = ScriptEngine.Call("init", JsValue.FromObject(ScriptEngine, EventCallbackWrapper),
JsValue.FromObject(ScriptEngine, _pluginServiceResolver),
JsValue.FromObject(ScriptEngine, _scriptPluginConfigurationWrapper),
JsValue.FromObject(ScriptEngine, new ScriptPluginHelper(manager, this)));
if (initResult.IsNull() || initResult.IsUndefined())
{
return null;
}
return AsScriptPluginInstance(initResult.ToObject());
}, _logger, _fileName, _onProcessingScript);
if (pluginDetails is null)
{
_logger.LogInformation("No valid script plugin signature found for {FilePath}", _fileName);
return;
}
foreach (var command in pluginDetails.Commands)
{
RegisterCommand(manager, command);
_logger.LogDebug("Registered script plugin command {Command} for {Plugin}", command.Name,
pluginDetails.Name);
}
foreach (var interaction in pluginDetails.Interactions)
{
RegisterInteraction(interaction);
_logger.LogDebug("Registered script plugin interaction {Interaction} for {Plugin}", interaction.Name,
pluginDetails.Name);
}
Name = pluginDetails.Name;
Author = pluginDetails.Author;
Version = pluginDetails.Version;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error encountered loading script plugin {Name}", _fileName);
}
finally
{
if (entered)
{
_onLoadingFile.Release(1);
_scriptWatcher.EnableRaisingEvents = true;
}
_logger.LogDebug("{Method} completed for {Plugin}", nameof(OnLoad), _fileName);
}
}
private void RegisterInteraction(ScriptPluginInteractionDetails interaction)
{
Task<IInteractionData> Action(int? targetId, Reference.Game? game, CancellationToken token) =>
WrapJavaScriptErrorHandling(() =>
{
if (IsEngineDisposed(GetHashCode(), _nextEngineId))
{
return null;
}
var args = new object[] { targetId, game, token }.Select(arg => JsValue.FromObject(ScriptEngine, arg))
.ToArray();
if (interaction.Action.DynamicInvoke(JsValue.Undefined, args) is not ObjectWrapper result)
{
throw new PluginException("Invalid interaction object returned");
}
return Task.FromResult((IInteractionData)result.ToObject());
}, _logger, _fileName, _onProcessingScript);
_interactionRegistration.RegisterInteraction(interaction.Name, Action);
_registeredInteractions.Add(interaction.Name);
}
private void RegisterCommand(IManager manager, ScriptPluginCommandDetails command)
{
Task Execute(GameEvent gameEvent) =>
WrapJavaScriptErrorHandling(() =>
{
if (IsEngineDisposed(GetHashCode(), _nextEngineId))
{
return null;
}
command.Execute.DynamicInvoke(JsValue.Undefined,
new[] { JsValue.FromObject(ScriptEngine, gameEvent) });
return Task.CompletedTask;
}, _logger, _fileName, _onProcessingScript);
var scriptCommand = _scriptCommandFactory.CreateScriptCommand(command.Name, command.Alias,
command.Description,
command.Permission, command.TargetRequired,
command.Arguments, Execute, command.SupportedGames);
manager.AddAdditionalCommand(scriptCommand);
_registeredCommandNames.Add(scriptCommand.Name);
}
private void ResetEngineState()
{
JavascriptEngine oldEngine = null;
lock (ActiveEngines)
{
if (ActiveEngines.ContainsKey($"{GetHashCode()}-{_nextEngineId}"))
{
oldEngine = ActiveEngines[$"{GetHashCode()}-{_nextEngineId}"];
_logger.LogDebug("Removing script engine from active list {HashCode}", _nextEngineId);
ActiveEngines.Remove($"{GetHashCode()}-{_nextEngineId}");
}
}
Interlocked.Increment(ref _nextEngineId);
oldEngine?.Dispose();
var newEngine = new JavascriptEngine(cfg =>
cfg.AddExtensionMethods(typeof(Utilities), typeof(Enumerable), typeof(Queryable),
typeof(ScriptPluginExtensions), typeof(LoggerExtensions))
.AllowClr(typeof(System.Net.Http.HttpClient).Assembly, typeof(EFClient).Assembly,
typeof(Utilities).Assembly, typeof(Encoding).Assembly, typeof(CancellationTokenSource).Assembly,
typeof(Data.Models.Client.EFClient).Assembly, typeof(IW4MAdmin.Plugins.Stats.Plugin).Assembly)
.CatchClrExceptions()
.AddObjectConverter(new EnumsToStringConverter()));
lock (ActiveEngines)
{
_logger.LogDebug("Adding script engine to active list {HashCode}", _nextEngineId);
ActiveEngines.Add($"{GetHashCode()}-{_nextEngineId}", newEngine);
}
_scriptPluginConfigurationWrapper =
new ScriptPluginConfigurationWrapper(_fileName.Split(Path.DirectorySeparatorChar).Last(), ScriptEngine,
_configHandler);
}
private void UnregisterScriptEntities(IManager manager)
{
foreach (var commandName in _registeredCommandNames)
{
manager.RemoveCommandByName(commandName);
_logger.LogDebug("Unregistered script plugin command {Command} for {Plugin}", commandName, Name);
}
_registeredCommandNames.Clear();
foreach (var interactionName in _registeredInteractions)
{
_interactionRegistration.UnregisterInteraction(interactionName);
}
_registeredInteractions.Clear();
foreach (var (removeMethod, subscriptions) in _registeredEvents)
{
foreach (var subscription in subscriptions)
{
removeMethod.Invoke(null, new[] { subscription });
}
subscriptions.Clear();
}
_registeredEvents.Clear();
}
private void EventCallbackWrapper(string eventCallbackName, Delegate javascriptAction)
{
var eventCategory = eventCallbackName.Split(".")[0];
var eventCategoryType = eventCategory switch
{
nameof(IManagementEventSubscriptions) => typeof(IManagementEventSubscriptions),
nameof(IGameEventSubscriptions) => typeof(IGameEventSubscriptions),
nameof(IGameServerEventSubscriptions) => typeof(IGameServerEventSubscriptions),
_ => null
};
if (eventCategoryType is null)
{
_logger.LogWarning("{EventCategory} is not a valid subscription category", eventCategory);
return;
}
var eventName = eventCallbackName.Split(".")[1];
var eventAddMethod = eventCategoryType.GetMethods()
.FirstOrDefault(method => method.Name.StartsWith($"add_{eventName}"));
var eventRemoveMethod = eventCategoryType.GetMethods()
.FirstOrDefault(method => method.Name.StartsWith($"remove_{eventName}"));
if (eventAddMethod is null || eventRemoveMethod is null)
{
_logger.LogWarning("{EventName} is not a valid subscription event", eventName);
return;
}
var genericType = eventAddMethod.GetParameters()[0].ParameterType.GetGenericArguments()[0];
var eventWrapper =
typeof(ScriptPluginV2).GetMethod(nameof(BuildEventWrapper), BindingFlags.Static | BindingFlags.NonPublic)!
.MakeGenericMethod(genericType)
.Invoke(null,
new object[]
{ _logger, _fileName, javascriptAction, GetHashCode(), _nextEngineId, _onProcessingScript });
eventAddMethod.Invoke(null, new[] { eventWrapper });
if (!_registeredEvents.ContainsKey(eventRemoveMethod))
{
_registeredEvents.Add(eventRemoveMethod, new List<object> { eventWrapper });
}
else
{
_registeredEvents[eventRemoveMethod].Add(eventWrapper);
}
}
private static Func<TEventType, CancellationToken, Task> BuildEventWrapper<TEventType>(ILogger logger,
string fileName, Delegate javascriptAction, int hashCode, int engineId, SemaphoreSlim onProcessingScript)
{
return (coreEvent, token) =>
{
return WrapJavaScriptErrorHandling(() =>
{
if (IsEngineDisposed(hashCode, engineId))
{
return Task.CompletedTask;
}
JavascriptEngine engine;
lock (ActiveEngines)
{
engine = ActiveEngines[$"{hashCode}-{engineId}"];
}
var args = new object[] { coreEvent, token }
.Select(param => JsValue.FromObject(engine, param))
.ToArray();
javascriptAction.DynamicInvoke(JsValue.Undefined, args);
return Task.CompletedTask;
}, logger, fileName, onProcessingScript, (coreEvent as GameServerEvent)?.Server,
additionalData: coreEvent.GetType().Name);
};
}
private static bool IsEngineDisposed(int hashCode, int engineId)
{
lock (ActiveEngines)
{
return !ActiveEngines.ContainsKey($"{hashCode}-{engineId}");
}
}
private static TResultType WrapJavaScriptErrorHandling<TResultType>(Func<TResultType> work, ILogger logger,
string fileName, SemaphoreSlim onProcessingScript, IGameServer server = null, object additionalData = null,
bool throwException = false,
[CallerMemberName] string methodName = "")
{
using (LogContext.PushProperty("Server", server?.Id))
{
var waitCompleted = false;
try
{
onProcessingScript.Wait();
waitCompleted = true;
return work();
}
catch (JavaScriptException ex)
{
logger.LogError(ex,
"Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo} StackTrace={StackTrace} {@AdditionalData}",
methodName, Path.GetFileName(fileName), ex.Location, ex.StackTrace, additionalData);
if (throwException)
{
throw new PluginException("A runtime error occured while executing action for 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} StackTrace={StackTrace} {@AdditionalData}",
methodName, fileName, jsEx.Location, jsEx.JavaScriptStackTrace, additionalData);
if (throwException)
{
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}",
methodName, Path.GetFileName(fileName));
if (throwException)
{
throw new PluginException("An error occured while executing action for script plugin");
}
}
finally
{
if (waitCompleted)
{
onProcessingScript.Release(1);
}
}
}
return default;
}
private static ScriptPluginDetails AsScriptPluginInstance(dynamic source)
{
var commandDetails = Array.Empty<ScriptPluginCommandDetails>();
if (HasProperty(source, "commands") && source.commands is dynamic[])
{
commandDetails = ((dynamic[])source.commands).Select(command =>
{
var commandArgs = Array.Empty<CommandArgument>();
if (HasProperty(command, "arguments") && command.arguments is dynamic[])
{
commandArgs = ((dynamic[])command.arguments).Select(argument => new CommandArgument
{
Name = HasProperty(argument, "name") ? argument.name : string.Empty,
Required = HasProperty(argument, "required") && argument.required is bool &&
(bool)argument.required
}).ToArray();
}
var name = HasProperty(command, "name") && command.name is string
? (string)command.name
: string.Empty;
var description = HasProperty(command, "description") && command.description is string
? (string)command.description
: string.Empty;
var alias = HasProperty(command, "alias") && command.alias is string
? (string)command.alias
: string.Empty;
var permission = HasProperty(command, "permission") && command.permission is string
? (string)command.permission
: string.Empty;
var isTargetRequired = HasProperty(command, "targetRequired") && command.targetRequired is bool &&
(bool)command.targetRequired;
var supportedGames =
HasProperty(command, "supportedGames") && command.supportedGames is IEnumerable<object>
? ((IEnumerable<object>)command.supportedGames).Where(game => game?.ToString() is not null)
.Select(game =>
Enum.Parse<Reference.Game>(game.ToString()!))
: Array.Empty<Reference.Game>();
var execute = HasProperty(command, "execute") && command.execute is Delegate
? (Delegate)command.execute
: (GameEvent _) => Task.CompletedTask;
return new ScriptPluginCommandDetails(name, description, alias, permission, isTargetRequired,
commandArgs, supportedGames, execute);
}).ToArray();
}
var interactionDetails = Array.Empty<ScriptPluginInteractionDetails>();
if (HasProperty(source, "interactions") && source.interactions is dynamic[])
{
interactionDetails = ((dynamic[])source.interactions).Select(interaction =>
{
var name = HasProperty(interaction, "name") && interaction.name is string
? (string)interaction.name
: string.Empty;
var action = HasProperty(interaction, "action") && interaction.action is Delegate
? (Delegate)interaction.action
: null;
return new ScriptPluginInteractionDetails(name, action);
}).ToArray();
}
var name = HasProperty(source, "name") && source.name is string ? (string)source.name : string.Empty;
var author = HasProperty(source, "author") && source.author is string ? (string)source.author : string.Empty;
var version = HasProperty(source, "version") && source.version is string ? (string)source.author : string.Empty;
return new ScriptPluginDetails(name, author, version, commandDetails, interactionDetails);
}
private static bool HasProperty(dynamic source, string name)
{
Type objType = source.GetType();
if (objType == typeof(ExpandoObject))
{
return ((IDictionary<string, object>)source).ContainsKey(name);
}
return objType.GetProperty(name) != null;
}
public class EnumsToStringConverter : IObjectConverter
{
public bool TryConvert(Engine engine, object value, out JsValue result)
{
if (value is Enum)
{
result = value.ToString();
return true;
}
result = JsValue.Null;
return false;
}
}
}

View File

@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace IW4MAdmin.Application.Plugin.Script;
public record ScriptPluginWebRequest(string Url, object Body = null, string Method = "GET", string ContentType = "text/plain",
Dictionary<string, string> Headers = null);