From c742913987830e87404e27b12f5a5bdb5cda078e Mon Sep 17 00:00:00 2001 From: sabihoshi Date: Sat, 1 Jan 2022 17:51:42 +0800 Subject: [PATCH] feat: Add evaluation commands --- Zhongli.Bot/Bot.cs | 2 + Zhongli.Bot/Modules/GeneralModule.cs | 19 +++ .../Modules/InteractiveGeneralModule.cs | 27 ++++ .../Commands/RequireTeamMemberAttribute.cs | 29 +++++ .../RequireTeamMemberAttribute.cs | 29 +++++ .../Evaluation/ConsoleLikeStringWriter.cs | 65 ++++++++++ .../Evaluation/EvaluationResult.cs | 90 +++++++++++++ .../Evaluation/EvaluationService.cs | 122 ++++++++++++++++++ .../Evaluation/ScriptExecutionContext.cs | 101 +++++++++++++++ Zhongli.Services/Evaluation/TypeExtensions.cs | 53 ++++++++ Zhongli.Services/Zhongli.Services.csproj | 1 + 11 files changed, 538 insertions(+) create mode 100644 Zhongli.Bot/Modules/InteractiveGeneralModule.cs create mode 100644 Zhongli.Services/Core/Preconditions/Commands/RequireTeamMemberAttribute.cs create mode 100644 Zhongli.Services/Core/Preconditions/Interactions/RequireTeamMemberAttribute.cs create mode 100644 Zhongli.Services/Evaluation/ConsoleLikeStringWriter.cs create mode 100644 Zhongli.Services/Evaluation/EvaluationResult.cs create mode 100644 Zhongli.Services/Evaluation/EvaluationService.cs create mode 100644 Zhongli.Services/Evaluation/ScriptExecutionContext.cs create mode 100644 Zhongli.Services/Evaluation/TypeExtensions.cs diff --git a/Zhongli.Bot/Bot.cs b/Zhongli.Bot/Bot.cs index 13854de..3a037ef 100644 --- a/Zhongli.Bot/Bot.cs +++ b/Zhongli.Bot/Bot.cs @@ -21,6 +21,7 @@ using Zhongli.Services.CommandHelp; using Zhongli.Services.Core; using Zhongli.Services.Core.Listeners; +using Zhongli.Services.Evaluation; using Zhongli.Services.Expirable; using Zhongli.Services.Image; using Zhongli.Services.Logging; @@ -59,6 +60,7 @@ private static ServiceProvider ConfigureServices() => .AddSingleton(new InteractiveConfig { DefaultTimeout = TimeSpan.FromMinutes(10) }) .AddSingleton() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Zhongli.Bot/Modules/GeneralModule.cs b/Zhongli.Bot/Modules/GeneralModule.cs index 45e3b7d..6498924 100644 --- a/Zhongli.Bot/Modules/GeneralModule.cs +++ b/Zhongli.Bot/Modules/GeneralModule.cs @@ -2,13 +2,32 @@ using System.Threading.Tasks; using Discord; using Discord.Commands; +using Discord.WebSocket; using Humanizer; +using Zhongli.Services.Core.Preconditions.Commands; +using Zhongli.Services.Evaluation; using Zhongli.Services.Utilities; +using CommandContext = Zhongli.Data.Models.Discord.CommandContext; namespace Zhongli.Bot.Modules; public class GeneralModule : ModuleBase { + private readonly EvaluationService _evaluation; + + public GeneralModule(EvaluationService evaluation) { _evaluation = evaluation; } + + [Command("eval")] + [RequireTeamMember] + public async Task EvalAsync([Remainder] string code) + { + var context = new CommandContext(Context); + var result = await _evaluation.EvaluateAsync(context, code); + + var embed = EvaluationService.BuildEmbed(context, result); + await ReplyAsync(embed: embed.Build()); + } + [Command("ping")] [Summary("Shows the ping latency of the bot.")] public async Task PingAsync() diff --git a/Zhongli.Bot/Modules/InteractiveGeneralModule.cs b/Zhongli.Bot/Modules/InteractiveGeneralModule.cs new file mode 100644 index 0000000..04facff --- /dev/null +++ b/Zhongli.Bot/Modules/InteractiveGeneralModule.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Discord.Commands; +using Discord.Interactions; +using Zhongli.Services.Core.Preconditions.Interactions; +using Zhongli.Services.Evaluation; +using InteractionContext = Zhongli.Data.Models.Discord.InteractionContext; + +namespace Zhongli.Bot.Modules; + +public class InteractiveGeneralModule : InteractionModuleBase +{ + private readonly EvaluationService _evaluation; + + public InteractiveGeneralModule(EvaluationService evaluation) { _evaluation = evaluation; } + + [SlashCommand("eval", "Evaluate C# code")] + [RequireTeamMember] + public async Task EvalAsync([Remainder] string code) + { + await DeferAsync(true); + var context = new InteractionContext(Context); + var result = await _evaluation.EvaluateAsync(context, code); + + var embed = EvaluationService.BuildEmbed(context, result); + await FollowupAsync(embed: embed.Build(), ephemeral: true); + } +} \ No newline at end of file diff --git a/Zhongli.Services/Core/Preconditions/Commands/RequireTeamMemberAttribute.cs b/Zhongli.Services/Core/Preconditions/Commands/RequireTeamMemberAttribute.cs new file mode 100644 index 0000000..de28589 --- /dev/null +++ b/Zhongli.Services/Core/Preconditions/Commands/RequireTeamMemberAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace Zhongli.Services.Core.Preconditions.Commands; + +public class RequireTeamMemberAttribute : PreconditionAttribute +{ + public override async Task CheckPermissionsAsync( + ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (context.Client.TokenType is not TokenType.Bot) + { + return PreconditionResult.FromError( + $"{nameof(RequireTeamMemberAttribute)} is not supported by this TokenType."); + } + + var application = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + + if (context.User.Id == application.Owner.Id + || application.Team.OwnerUserId == application.Owner.Id + || application.Team.TeamMembers.Any(t => context.User.Id == t.User.Id)) + return PreconditionResult.FromSuccess(); + + return PreconditionResult.FromError("Command can only be run by team members of the bot."); + } +} \ No newline at end of file diff --git a/Zhongli.Services/Core/Preconditions/Interactions/RequireTeamMemberAttribute.cs b/Zhongli.Services/Core/Preconditions/Interactions/RequireTeamMemberAttribute.cs new file mode 100644 index 0000000..1222302 --- /dev/null +++ b/Zhongli.Services/Core/Preconditions/Interactions/RequireTeamMemberAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; + +namespace Zhongli.Services.Core.Preconditions.Interactions; + +public class RequireTeamMemberAttribute : PreconditionAttribute +{ + public override async Task CheckRequirementsAsync( + IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + if (context.Client.TokenType is not TokenType.Bot) + { + return PreconditionResult.FromError( + $"{nameof(RequireTeamMemberAttribute)} is not supported by this TokenType."); + } + + var application = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + + if (context.User.Id == application.Owner.Id + || application.Team.OwnerUserId == application.Owner.Id + || application.Team.TeamMembers.Any(t => context.User.Id == t.User.Id)) + return PreconditionResult.FromSuccess(); + + return PreconditionResult.FromError("Command can only be run by team members of the bot."); + } +} \ No newline at end of file diff --git a/Zhongli.Services/Evaluation/ConsoleLikeStringWriter.cs b/Zhongli.Services/Evaluation/ConsoleLikeStringWriter.cs new file mode 100644 index 0000000..eb982e8 --- /dev/null +++ b/Zhongli.Services/Evaluation/ConsoleLikeStringWriter.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace Zhongli.Services.Evaluation; + +[SuppressMessage("ReSharper", "UnusedMember.Global")] +[SuppressMessage("ReSharper", "UnusedParameter.Global")] +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +[SuppressMessage("Performance", "CA1822:Mark members as static")] +public class ConsoleLikeStringWriter : StringWriter +{ + public ConsoleLikeStringWriter(StringBuilder builder) : base(builder) { } + + public ConsoleKeyInfo ReadKey() => new('z', ConsoleKey.Z, false, false, false); + + public ConsoleKeyInfo ReadKey(bool z) + { + if (z) Write("z"); + return ReadKey(); + } + + public int Read() => 0; + + public Stream OpenStandardError() => new MemoryStream(); + + public Stream OpenStandardError(int a) => new MemoryStream(a); + + public Stream OpenStandardInput() => new MemoryStream(); + + public Stream OpenStandardInput(int a) => new MemoryStream(a); + + public Stream OpenStandardOutput() => new MemoryStream(); + + public Stream OpenStandardOutput(int a) => new MemoryStream(a); + + public string ReadLine() => $"{nameof(Zhongli)}{Environment.NewLine}"; + + public void Beep() { } + + public void Beep(int a, int b) { } + + public void Clear() { } + + public void MoveBufferArea(int a, int b, int c, int d, int e) { } + + public void MoveBufferArea(int a, int b, int c, int d, int e, char f, ConsoleColor g, ConsoleColor h) { } + + public void ResetColor() { } + + public void SetBufferSize(int a, int b) { } + + public void SetCursorPosition(int a, int b) { } + + public void SetError(TextWriter wr) { } + + public void SetIn(TextWriter wr) { } + + public void SetOut(TextWriter wr) { } + + public void SetWindowPosition(int a, int b) { } + + public void SetWindowSize(int a, int b) { } +} \ No newline at end of file diff --git a/Zhongli.Services/Evaluation/EvaluationResult.cs b/Zhongli.Services/Evaluation/EvaluationResult.cs new file mode 100644 index 0000000..c82e86a --- /dev/null +++ b/Zhongli.Services/Evaluation/EvaluationResult.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Scripting; + +namespace Zhongli.Services.Evaluation; + +public class EvaluationResult +{ + public static readonly JsonSerializerOptions SerializerOptions = new() + { + MaxDepth = 10240, + IncludeFields = true, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + ReferenceHandler = ReferenceHandler.IgnoreCycles + }; + + public EvaluationResult() { } + + public EvaluationResult(string code, ScriptState state, string consoleOut, TimeSpan executionTime, + TimeSpan compileTime) + { + state = state ?? throw new ArgumentNullException(nameof(state)); + + ReturnValue = state.ReturnValue; + var type = state.ReturnValue?.GetType(); + + if (type?.GetTypeInfo().ImplementedInterfaces.Contains(typeof(IEnumerator)) ?? false) + { + var genericParams = type.GetGenericArguments(); + + if (genericParams.Length == 2) + { + type = typeof(List<>).MakeGenericType(genericParams[1]); + + ReturnValue = Activator.CreateInstance(type, ReturnValue); + } + } + + ReturnTypeName = type?.ParseGenericArgs(); + ExecutionTime = executionTime; + CompileTime = compileTime; + ConsoleOut = consoleOut; + Code = code; + Exception = state.Exception?.Message; + ExceptionType = state.Exception?.GetType().Name; + } + + public object? ReturnValue { get; set; } + + public string Code { get; set; } = null!; + + public string ConsoleOut { get; set; } = null!; + + public string? Exception { get; set; } + + public string? ExceptionType { get; set; } + + public string? ReturnTypeName { get; set; } + + public TimeSpan CompileTime { get; set; } + + public TimeSpan ExecutionTime { get; set; } + + public static EvaluationResult CreateErrorResult(string code, string consoleOut, TimeSpan compileTime, + ImmutableArray compileErrors) + { + var ex = new CompilationErrorException(string.Join("\n", compileErrors.Select(a => a.GetMessage())), + compileErrors); + var errorResult = new EvaluationResult + { + Code = code, + CompileTime = compileTime, + ConsoleOut = consoleOut, + Exception = ex.Message, + ExceptionType = ex.GetType().Name, + ExecutionTime = TimeSpan.FromMilliseconds(0), + ReturnValue = null, + ReturnTypeName = null + }; + return errorResult; + } +} \ No newline at end of file diff --git a/Zhongli.Services/Evaluation/EvaluationService.cs b/Zhongli.Services/Evaluation/EvaluationService.cs new file mode 100644 index 0000000..10c7300 --- /dev/null +++ b/Zhongli.Services/Evaluation/EvaluationService.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Discord; +using Humanizer; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Zhongli.Data.Models.Discord; +using Zhongli.Services.Utilities; +using static Discord.EmbedFieldBuilder; + +namespace Zhongli.Services.Evaluation; + +public class EvaluationService +{ + private readonly IServiceProvider _services; + + public EvaluationService(IServiceProvider services) { _services = services; } + + public static EmbedBuilder BuildEmbed(Context context, EvaluationResult result) + { + var returnValue = JsonSerializer.Serialize(result.ReturnValue, EvaluationResult.SerializerOptions); + var consoleOut = result.ConsoleOut; + var status = string.IsNullOrEmpty(result.Exception) ? "Success" : "Failure"; + + var compile = result.CompileTime.TotalMilliseconds; + var execution = result.ExecutionTime.TotalMilliseconds; + var embed = new EmbedBuilder() + .WithUserAsAuthor(context.User) + .WithTitle($"Eval Result: {status}") + .WithDescription(Format.Code(result.Code, "cs")) + .WithColor(string.IsNullOrEmpty(result.Exception) ? Color.Green : Color.Red) + .WithFooter($"Compile: {compile:F}ms | Execution: {execution:F}ms"); + + if (result.ReturnValue != null) + { + embed.AddField(a => a + .WithName($"Result: {result.ReturnTypeName ?? "null"}") + .WithValue(Format.Code(returnValue.Truncate(MaxFieldValueLength), "json"))); + } + + if (!string.IsNullOrWhiteSpace(consoleOut)) + { + embed.AddField(a => a + .WithName("Console Output") + .WithValue(Format.Code(consoleOut.Truncate(MaxFieldValueLength), "txt"))); + } + + if (!string.IsNullOrWhiteSpace(result.Exception)) + { + var formatted = Regex.Replace(result.Exception, "^", "- ", RegexOptions.Multiline); + embed.AddField(a => a + .WithName($"Exception: {result.ExceptionType}") + .WithValue(Format.Code(formatted.Truncate(MaxFieldValueLength), "diff"))); + } + + return embed; + } + + public async Task EvaluateAsync(Context context, string code) + { + var sw = new Stopwatch(); + + var console = new StringBuilder(); + await using var writer = new ConsoleLikeStringWriter(console); + + var globals = new Globals(writer, context, _services); + var execution = new ScriptExecutionContext(code); + + sw.Start(); + var script = CSharpScript.Create(execution.Code, execution.Options, typeof(Globals)); + + var compilation = script.GetCompilation(); + var compileTime = sw.Elapsed; + + var compileResult = compilation.GetDiagnostics(); + var compileErrors = compileResult + .Where(a => a.Severity is DiagnosticSeverity.Error) + .ToImmutableArray(); + + if (!compileErrors.IsEmpty) + return EvaluationResult.CreateErrorResult(code, console.ToString(), sw.Elapsed, compileErrors); + + try + { + var state = await script.RunAsync(globals, _ => true).ConfigureAwait(false); + var result = new EvaluationResult(code, state, console.ToString(), sw.Elapsed, compileTime); + sw.Stop(); + + try + { + // Check if the result is serializable, if not return the exception as a result + _ = JsonSerializer.Serialize(result, EvaluationResult.SerializerOptions); + } + catch (Exception ex) + { + var exception = $"An exception occurred when serializing: {ex.GetType().Name}: {ex.Message}"; + result = new EvaluationResult + { + Code = code, + CompileTime = compileTime, + ConsoleOut = console.ToString(), + ExecutionTime = sw.Elapsed, + Exception = exception, + ExceptionType = ex.GetType().Name + }; + } + + return result; + } + catch (CompilationErrorException ex) + { + return EvaluationResult.CreateErrorResult(code, console.ToString(), sw.Elapsed, ex.Diagnostics); + } + } +} \ No newline at end of file diff --git a/Zhongli.Services/Evaluation/ScriptExecutionContext.cs b/Zhongli.Services/Evaluation/ScriptExecutionContext.cs new file mode 100644 index 0000000..b82928f --- /dev/null +++ b/Zhongli.Services/Evaluation/ScriptExecutionContext.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text.RegularExpressions; +using Humanizer; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Zhongli.Data.Models.Discord; + +namespace Zhongli.Services.Evaluation; + +public record Globals(ConsoleLikeStringWriter Console, Context Context, IServiceProvider Services); + +public class ScriptExecutionContext +{ + private static readonly List DefaultImports = + new() + { + "Discord", + "Discord.Commands", + "Discord.Interactions", + "Discord.WebSocket", + "Humanizer", + "Humanizer.Localisation", + "Newtonsoft.Json", + "Newtonsoft.Json.Linq", + "System", + "System.Collections", + "System.Collections.Concurrent", + "System.Collections.Immutable", + "System.Collections.Generic", + "System.Linq", + "System.Linq.Expressions", + "System.Net", + "System.Net.Http", + "System.Numerics", + "System.Text", + "System.Text.RegularExpressions", + "System.Threading", + "System.Threading.Tasks", + "System.Text.Json" + }; + + private static readonly List DefaultReferences = + new() + { + typeof(Enumerable).GetTypeInfo().Assembly, + typeof(HttpClient).GetTypeInfo().Assembly, + typeof(List<>).GetTypeInfo().Assembly, + typeof(string).GetTypeInfo().Assembly, + typeof(ValueTuple).GetTypeInfo().Assembly, + typeof(Globals).GetTypeInfo().Assembly, + typeof(CollectionHumanizeExtensions).GetTypeInfo().Assembly + }; + + private static readonly IEnumerable AssemblyImports = Assembly + .GetEntryAssembly()!.GetTypes() + .Select(x => x.Namespace) + .OfType(); + + private static readonly IEnumerable AssemblyReferences = AppDomain + .CurrentDomain.GetAssemblies() + .Where(x => !x.IsDynamic && !string.IsNullOrWhiteSpace(x.Location)); + + public ScriptExecutionContext(string code) { Code = Regex.Replace(code, @"```\w*", string.Empty); } + + public ScriptOptions Options => + ScriptOptions.Default + .WithLanguageVersion(LanguageVersion.Preview) + .WithOptimizationLevel(OptimizationLevel.Release) + .WithImports(Imports) + .WithReferences(References); + + public string Code { get; set; } + + private HashSet References { get; } = new(DefaultReferences.Concat(AssemblyReferences)); + + private HashSet Imports { get; } = new(DefaultImports.Concat(AssemblyImports)); + + public bool TryAddReferenceAssembly(Assembly? assembly) + { + if (assembly is null) return false; + + if (References.Contains(assembly)) return false; + + References.Add(assembly); + return true; + } + + public void AddImport(string import) + { + if (string.IsNullOrEmpty(import)) return; + if (Imports.Contains(import)) return; + + Imports.Add(import); + } +} \ No newline at end of file diff --git a/Zhongli.Services/Evaluation/TypeExtensions.cs b/Zhongli.Services/Evaluation/TypeExtensions.cs new file mode 100644 index 0000000..ee4e44f --- /dev/null +++ b/Zhongli.Services/Evaluation/TypeExtensions.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Zhongli.Services.Evaluation; + +public static class TypeExtensions +{ + private const string ArrayBrackets = "[]"; + + private static readonly Dictionary PrimitiveTypeNames = new() + { + ["Boolean"] = "bool", + ["Byte"] = "byte", + ["Char"] = "char", + ["Decimal"] = "decimal", + ["Double"] = "double", + ["Int16"] = "short", + ["Int32"] = "int", + ["Int64"] = "long", + ["SByte"] = "sbyte", + ["Single"] = "float", + ["String"] = "string", + ["UInt16"] = "ushort", + ["UInt32"] = "uint", + ["UInt64"] = "ulong", + ["Object"] = "object" + }; + + public static string ParseGenericArgs(this Type type) + { + var generic = type.GetGenericArguments(); + + if (generic.Length == 0) return GetPrimitiveTypeName(type); + + var name = type.Name; + var args = generic.Select(a => a.ParseGenericArgs()); + return name.Replace($"`{generic.Length}", $"<{string.Join(", ", args)}>"); + } + + private static string GetPrimitiveTypeName(Type type) + { + var name = type.Name; + if (type.IsArray) name = name.Replace(ArrayBrackets, string.Empty); + + if (!PrimitiveTypeNames.TryGetValue(name, out var primitive) || string.IsNullOrEmpty(primitive)) + return name; + + return type.IsArray + ? string.Join(string.Empty, primitive, ArrayBrackets) + : primitive; + } +} \ No newline at end of file diff --git a/Zhongli.Services/Zhongli.Services.csproj b/Zhongli.Services/Zhongli.Services.csproj index 8af6223..d6afd75 100644 --- a/Zhongli.Services/Zhongli.Services.csproj +++ b/Zhongli.Services/Zhongli.Services.csproj @@ -18,6 +18,7 @@ +