implement PluginV2 for script plugins
This commit is contained in:
58
Application/Plugin/Script/ScriptCommand.cs
Normal file
58
Application/Plugin/Script/ScriptCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
567
Application/Plugin/Script/ScriptPlugin.cs
Normal file
567
Application/Plugin/Script/ScriptPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
32
Application/Plugin/Script/ScriptPluginFactory.cs
Normal file
32
Application/Plugin/Script/ScriptPluginFactory.cs
Normal 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>());
|
||||
}
|
||||
}
|
136
Application/Plugin/Script/ScriptPluginHelper.cs
Normal file
136
Application/Plugin/Script/ScriptPluginHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
48
Application/Plugin/Script/ScriptPluginServiceResolver.cs
Normal file
48
Application/Plugin/Script/ScriptPluginServiceResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
201
Application/Plugin/Script/ScriptPluginTimerHelper.cs
Normal file
201
Application/Plugin/Script/ScriptPluginTimerHelper.cs
Normal 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; }
|
||||
}
|
568
Application/Plugin/Script/ScriptPluginV2.cs
Normal file
568
Application/Plugin/Script/ScriptPluginV2.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
6
Application/Plugin/Script/ScriptPluginWebRequest.cs
Normal file
6
Application/Plugin/Script/ScriptPluginWebRequest.cs
Normal 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);
|
Reference in New Issue
Block a user