Core: progress on scripting

This commit is contained in:
genteure 2022-05-11 00:20:57 +08:00
parent 43d1c6f2ef
commit cc27045fc4
12 changed files with 458 additions and 1 deletions

View File

@ -37,7 +37,8 @@ namespace BililiveRecorder.Cli.Configure
TimingWatchdogTimeout,
RecordDanmakuFlushInterval,
NetworkTransportUseSystemProxy,
NetworkTransportAllowedAddressFamily
NetworkTransportAllowedAddressFamily,
UserScript
}
public enum RoomConfigProperties
{
@ -86,6 +87,7 @@ namespace BililiveRecorder.Cli.Configure
GlobalConfig.Add(GlobalConfigProperties.RecordDanmakuFlushInterval, new ConfigInstruction<GlobalConfig, uint>(config => config.HasRecordDanmakuFlushInterval = false, (config, value) => config.RecordDanmakuFlushInterval = value) { Name = "RecordDanmakuFlushInterval", CanBeOptional = true });
GlobalConfig.Add(GlobalConfigProperties.NetworkTransportUseSystemProxy, new ConfigInstruction<GlobalConfig, bool>(config => config.HasNetworkTransportUseSystemProxy = false, (config, value) => config.NetworkTransportUseSystemProxy = value) { Name = "NetworkTransportUseSystemProxy", CanBeOptional = true });
GlobalConfig.Add(GlobalConfigProperties.NetworkTransportAllowedAddressFamily, new ConfigInstruction<GlobalConfig, AllowedAddressFamily>(config => config.HasNetworkTransportAllowedAddressFamily = false, (config, value) => config.NetworkTransportAllowedAddressFamily = value) { Name = "NetworkTransportAllowedAddressFamily", CanBeOptional = true });
GlobalConfig.Add(GlobalConfigProperties.UserScript, new ConfigInstruction<GlobalConfig, string>(config => config.HasUserScript = false, (config, value) => config.UserScript = value) { Name = "UserScript", CanBeOptional = true });
RoomConfig.Add(RoomConfigProperties.RoomId, new ConfigInstruction<RoomConfig, int>(config => config.HasRoomId = false, (config, value) => config.RoomId = value) { Name = "RoomId", CanBeOptional = false });
RoomConfig.Add(RoomConfigProperties.AutoRecord, new ConfigInstruction<RoomConfig, bool>(config => config.HasAutoRecord = false, (config, value) => config.AutoRecord = value) { Name = "AutoRecord", CanBeOptional = false });

View File

@ -5,10 +5,15 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<RestoreAdditionalProjectSources>
https://api.nuget.org/v3/index.json;
https://www.myget.org/F/jint/api/v3/index.json
</RestoreAdditionalProjectSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.2.8" />
<PackageReference Include="Jint" Version="3.0.0-preview-275" />
<PackageReference Include="JsonSubTypes" Version="1.8.0" />
<PackageReference Include="HierarchicalPropertyDefault" Version="0.1.4-beta-g75fdf624b1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />

View File

@ -176,6 +176,11 @@ namespace BililiveRecorder.Core.Config.V3
/// </summary>
public AllowedAddressFamily NetworkTransportAllowedAddressFamily => this.GetPropertyValue<AllowedAddressFamily>();
/// <summary>
/// 自定义脚本
/// </summary>
public string UserScript => this.GetPropertyValue<string>();
}
[JsonObject(MemberSerialization.OptIn)]
@ -373,6 +378,14 @@ namespace BililiveRecorder.Core.Config.V3
[JsonProperty(nameof(NetworkTransportAllowedAddressFamily)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<AllowedAddressFamily> OptionalNetworkTransportAllowedAddressFamily { get => this.GetPropertyValueOptional<AllowedAddressFamily>(nameof(this.NetworkTransportAllowedAddressFamily)); set => this.SetPropertyValueOptional(value, nameof(this.NetworkTransportAllowedAddressFamily)); }
/// <summary>
/// 自定义脚本
/// </summary>
public string UserScript { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasUserScript { get => this.GetPropertyHasValue(nameof(this.UserScript)); set => this.SetPropertyHasValue<string>(value, nameof(this.UserScript)); }
[JsonProperty(nameof(UserScript)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string> OptionalUserScript { get => this.GetPropertyValueOptional<string>(nameof(this.UserScript)); set => this.SetPropertyValueOptional(value, nameof(this.UserScript)); }
}
public sealed partial class DefaultConfig
@ -428,6 +441,8 @@ namespace BililiveRecorder.Core.Config.V3
public AllowedAddressFamily NetworkTransportAllowedAddressFamily => AllowedAddressFamily.Any;
public string UserScript => string.Empty;
}
}

View File

@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Jint;
using Jint.Native;
using Jint.Native.Json;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Interop;
using Serilog;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintConsole : ObjectInstance
{
private readonly ILogger logger;
private readonly Dictionary<string, int> counters = new();
private readonly Dictionary<string, Stopwatch> timers = new();
public JintConsole(Engine engine, ILogger logger) : base(engine)
{
this.logger = logger?.ForContext<JintConsole>() ?? throw new ArgumentNullException(nameof(logger));
}
protected override void Initialize()
{
Add("assert", this.Assert);
Add("clear", this.Clear);
Add("count", this.Count);
Add("countReset", this.CountReset);
Add("debug", this.Debug);
Add("error", this.Error);
Add("info", this.Info);
Add("log", this.Log);
Add("time", this.Time);
Add("timeEnd", this.TimeEnd);
Add("timeLog", this.TimeLog);
Add("trace", this.Trace);
Add("warn", this.Warn);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
void Add(string name, Func<JsValue, JsValue[], JsValue> func)
{
this.FastAddProperty(name, new ClrFunctionInstance(this._engine, name, func), false, false, false);
}
}
private string[] FormatToString(ReadOnlySpan<JsValue> values)
{
var result = new string[values.Length];
var jsonSerializer = new Lazy<JsonSerializer>(() => new JsonSerializer(this._engine));
for (var i = 0; i < values.Length; i++)
{
var value = values[i];
var text = value switch
{
JsString jsString => jsString.ToString(),
JsBoolean or JsNumber or JsBigInt or JsNull or JsUndefined => value.ToString(),
_ => jsonSerializer.Value.Serialize(values[i], Undefined, Undefined).ToString()
};
result[i] = text;
}
return result;
}
// TODO: Add call stack support
// Workaround: use `new Error().stack` in js side
// ref: https://github.com/sebastienros/jint/discussions/1115
private JsValue Assert(JsValue thisObject, JsValue[] arguments)
{
if (arguments.At(0).IsLooselyEqual(0))
{
string[] messages;
if (arguments.Length < 2)
{
messages = Array.Empty<string>();
}
else
{
messages = this.FormatToString(arguments.AsSpan(1));
}
this.logger.Error("[Script] Assertion failed: {Messages}", messages);
}
return Undefined;
}
private JsValue Clear(JsValue thisObject, JsValue[] arguments) => Undefined; // noop
private JsValue Count(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.counters.TryGetValue(name, out var count))
{
count++;
}
else
{
count = 1;
}
this.counters[name] = count;
this.logger.Information("[Script] {CounterName}: {Count}", name, count);
return Undefined;
}
private JsValue CountReset(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
this.counters.Remove(name);
this.logger.Information("[Script] {CounterName}: {Count}", name, 0);
return Undefined;
}
private JsValue Debug(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Debug("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Error(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Error("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Info(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Information("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Log(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Information("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Time(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.timers.ContainsKey(name))
{
this.logger.Warning("[Script] Timer {TimerName} already exists", name);
}
else
{
this.timers[name] = Stopwatch.StartNew();
}
return Undefined;
}
private JsValue TimeEnd(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.timers.TryGetValue(name, out var timer))
{
timer.Stop();
this.timers.Remove(name);
this.logger.Information("[Script] {TimerName}: {ElapsedMilliseconds} ms", name, timer.ElapsedMilliseconds);
}
else
{
this.logger.Warning("[Script] Timer {TimerName} does not exist", name);
}
return Undefined;
}
private JsValue TimeLog(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.timers.TryGetValue(name, out var timer))
{
this.logger.Information("[Script] {TimerName}: {ElapsedMilliseconds} ms", name, timer.ElapsedMilliseconds);
}
else
{
this.logger.Warning("[Script] Timer {TimerName} does not exist", name);
}
return Undefined;
}
private JsValue Trace(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Information("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Warn(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Warning("[Script] {Messages}", messages);
return Undefined;
}
}
}

View File

@ -0,0 +1,12 @@
using Jint;
using Jint.Native.Object;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintDns : ObjectInstance
{
public JintDns(Engine engine) : base(engine)
{
}
}
}

View File

@ -0,0 +1,12 @@
using Jint;
using Jint.Native.Object;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintDotnet : ObjectInstance
{
public JintDotnet(Engine engine) : base(engine)
{
}
}
}

View File

@ -0,0 +1,20 @@
using Jint;
using Jint.Native;
using Jint.Native.Function;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintFetchSync : FunctionInstance
{
private static readonly JsString functionName = new JsString("fetch");
public JintFetchSync(Engine engine) : base(engine, engine.Realm, functionName)
{
}
protected override JsValue Call(JsValue thisObject, JsValue[] arguments)
{
return Undefined;
}
}
}

View File

@ -0,0 +1,143 @@
using System;
using System.Threading;
using BililiveRecorder.Core.Config.V3;
using BililiveRecorder.Core.Scripting.Runtime;
using Esprima;
using Esprima.Ast;
using Jint;
using Jint.Native.Function;
using Jint.Native.Object;
using Serilog;
namespace BililiveRecorder.Core.Scripting
{
public class UserScriptRunner
{
private static int ExecutionId = 0;
private readonly GlobalConfig config;
private readonly Options jintOptions;
private static readonly Script setupScript;
private string? cachedScriptSource;
private Script? cachedScript;
static UserScriptRunner()
{
setupScript = new JavaScriptParser(@"
globalThis.recorderEvents = {};
").ParseScript();
}
public UserScriptRunner(GlobalConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
this.jintOptions = new Options()
.CatchClrExceptions()
.LimitRecursion(100)
.RegexTimeoutInterval(TimeSpan.FromSeconds(2))
.Configure(engine =>
{
engine.Realm.GlobalObject.FastAddProperty("dns", new JintDns(engine), writable: false, enumerable: false, configurable: false);
engine.Realm.GlobalObject.FastAddProperty("dotnet", new JintDotnet(engine), writable: false, enumerable: false, configurable: false);
engine.Realm.GlobalObject.FastAddProperty("fetchSync", new JintFetchSync(engine), writable: false, enumerable: false, configurable: false);
});
}
private Script? GetParsedScript()
{
var source = this.config.UserScript;
if (this.cachedScript is not null)
{
if (string.IsNullOrWhiteSpace(source))
{
this.cachedScript = null;
this.cachedScriptSource = null;
return null;
}
else if (this.cachedScriptSource == source)
{
return this.cachedScript;
}
}
if (string.IsNullOrWhiteSpace(source))
{
return null;
}
var parser = new JavaScriptParser(source);
var script = parser.ParseScript();
this.cachedScript = script;
this.cachedScriptSource = source;
return script;
}
private Engine CreateJintEngine(ILogger logger)
{
var engine = new Engine(this.jintOptions);
engine.Realm.GlobalObject.FastAddProperty("console", new JintConsole(engine, logger), writable: false, enumerable: false, configurable: false);
engine.Execute(setupScript);
return engine;
}
public string CallOnStreamUrl(ILogger logger, string originalUrl)
{
var log = BuildLogger(logger);
var script = this.GetParsedScript();
if (script is null)
{
return originalUrl;
}
var engine = this.CreateJintEngine(log);
engine.Execute(script);
if (engine.Realm.GlobalObject.Get("recorderEvents") is not ObjectInstance events)
{
log.Warning("脚本: recorderEvents 被修改为非 object");
return originalUrl;
}
if (events.Get("onStreamUrl") is not FunctionInstance func)
{
return originalUrl;
}
var result = engine.Call(func, originalUrl);
if (result.Type == Jint.Runtime.Types.String)
{
}
else if (result.Type == Jint.Runtime.Types.Object)
{
}
else
{
}
throw new NotImplementedException();
}
private static ILogger BuildLogger(ILogger logger)
{
var id = Interlocked.Increment(ref ExecutionId);
return logger.ForContext<UserScriptRunner>().ForContext(nameof(ExecutionId), id);
}
}
}

View File

@ -12,6 +12,7 @@ using System.Threading.Tasks;
using System.Windows.Threading;
using BililiveRecorder.ToolBox;
using Sentry;
using Sentry.Extensibility;
using Serilog;
using Serilog.Core;
using Serilog.Exceptions;
@ -238,6 +239,8 @@ namespace BililiveRecorder.WPF
o.DisableAppDomainUnhandledExceptionCapture();
o.DisableTaskUnobservedTaskExceptionCapture();
o.AddExceptionFilterForType<System.Net.Http.HttpRequestException>();
o.AddExceptionFilterForType<OutOfMemoryException>();
o.AddEventProcessor(new SentryEventProcessor());
o.TextFormatter = new MessageTemplateTextFormatter("[{RoomId}] {Message}{NewLine}{Exception}{@ExceptionDetail:j}");
@ -269,5 +272,11 @@ namespace BililiveRecorder.WPF
[HandleProcessCorruptedStateExceptions, SecurityCritical]
private static void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) =>
logger.Fatal(e.Exception, "Unhandled exception from Application.DispatcherUnhandledException");
private class SentryEventProcessor : ISentryEventProcessor
{
private static readonly string NameOfJintConsole = typeof(Core.Scripting.Runtime.JintConsole).FullName;
public SentryEvent? Process(SentryEvent e) => e?.Logger == NameOfJintConsole ? null : e;
}
}
}

View File

@ -64,6 +64,7 @@ namespace BililiveRecorder.Web.Models
public Optional<uint>? OptionalRecordDanmakuFlushInterval { get; set; }
public Optional<bool>? OptionalNetworkTransportUseSystemProxy { get; set; }
public Optional<AllowedAddressFamily>? OptionalNetworkTransportAllowedAddressFamily { get; set; }
public Optional<string>? OptionalUserScript { get; set; }
public void ApplyTo(GlobalConfig config)
{
@ -91,6 +92,7 @@ namespace BililiveRecorder.Web.Models
if (this.OptionalRecordDanmakuFlushInterval.HasValue) config.OptionalRecordDanmakuFlushInterval = this.OptionalRecordDanmakuFlushInterval.Value;
if (this.OptionalNetworkTransportUseSystemProxy.HasValue) config.OptionalNetworkTransportUseSystemProxy = this.OptionalNetworkTransportUseSystemProxy.Value;
if (this.OptionalNetworkTransportAllowedAddressFamily.HasValue) config.OptionalNetworkTransportAllowedAddressFamily = this.OptionalNetworkTransportAllowedAddressFamily.Value;
if (this.OptionalUserScript.HasValue) config.OptionalUserScript = this.OptionalUserScript.Value;
}
}
@ -138,6 +140,7 @@ namespace BililiveRecorder.Web.Models.Rest
public Optional<uint> OptionalRecordDanmakuFlushInterval { get; set; }
public Optional<bool> OptionalNetworkTransportUseSystemProxy { get; set; }
public Optional<AllowedAddressFamily> OptionalNetworkTransportAllowedAddressFamily { get; set; }
public Optional<string> OptionalUserScript { get; set; }
}
}
@ -190,6 +193,7 @@ namespace BililiveRecorder.Web.Models.Graphql
this.Field(x => x.OptionalRecordDanmakuFlushInterval, type: typeof(HierarchicalOptionalType<uint>));
this.Field(x => x.OptionalNetworkTransportUseSystemProxy, type: typeof(HierarchicalOptionalType<bool>));
this.Field(x => x.OptionalNetworkTransportAllowedAddressFamily, type: typeof(HierarchicalOptionalType<AllowedAddressFamily>));
this.Field(x => x.OptionalUserScript, type: typeof(HierarchicalOptionalType<string>));
}
}
@ -221,6 +225,7 @@ namespace BililiveRecorder.Web.Models.Graphql
this.Field(x => x.RecordDanmakuFlushInterval);
this.Field(x => x.NetworkTransportUseSystemProxy);
this.Field(x => x.NetworkTransportAllowedAddressFamily);
this.Field(x => x.UserScript);
}
}
@ -269,6 +274,7 @@ namespace BililiveRecorder.Web.Models.Graphql
this.Field(x => x.OptionalRecordDanmakuFlushInterval, nullable: true, type: typeof(HierarchicalOptionalInputType<uint>));
this.Field(x => x.OptionalNetworkTransportUseSystemProxy, nullable: true, type: typeof(HierarchicalOptionalInputType<bool>));
this.Field(x => x.OptionalNetworkTransportAllowedAddressFamily, nullable: true, type: typeof(HierarchicalOptionalInputType<AllowedAddressFamily>));
this.Field(x => x.OptionalUserScript, nullable: true, type: typeof(HierarchicalOptionalInputType<string>));
}
}

View File

@ -261,6 +261,22 @@
}
}
},
"UserScript": {
"description": "自定义脚本\n默认: ",
"markdownDescription": "自定义脚本 \n默认: ` `\n\n",
"type": "object",
"additionalProperties": false,
"properties": {
"HasValue": {
"type": "boolean",
"default": true
},
"Value": {
"type": "string",
"default": ""
}
}
},
"RecordMode": {
"description": "录制模式\n默认: RecordMode.Standard",
"markdownDescription": "录制模式 \n默认: `RecordMode.Standard `\n\n本设置项是一个 enum键值对应如下\n\n| 键 | 值 |\n|:--:|:--:|\n| RecordMode.Standard | 0 |\n| RecordMode.RawData | 1 |\n\n关于录制模式的说明见 [录制模式](/docs/basic/record_mode/)",

View File

@ -234,4 +234,13 @@ export const data: Array<ConfigEntry> = [
advancedConfig: true,
markdown: ""
},
{
name: "UserScript",
description: "自定义脚本",
type: "string",
defaultValue: "string.Empty",
configType: "globalOnly",
advancedConfig: true,
markdown: ""
},
];