mirror of
https://github.com/RaidMax/IW4M-Admin.git
synced 2025-06-13 00:28:10 -05:00
implement PluginV2 for script plugins
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user