diff --git a/Directory.Build.props b/Directory.Build.props index faba107..0bfa760 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,5 @@ - - enable - - Mara Contributors LuzFaltex @@ -20,5 +16,47 @@ ..\output\$(Configuration)\ + + enable + true + + + CS8600; + CS8601; + CS8602; + CS8603; + CS8604; + CS8608; + CS8609; + CS8610; + CS8611; + CS8612; + CS8613; + CS8614; + CS8615; + CS8616; + CS8617; + CS8618; + CS8619; + CS8620; + CS8621; + CS8622; + CS8625; + CS8626; + CS8629; + CS8631; + CS8633; + CS8634; + CS8638; + CS8639; + CS8643; + CS8644; + CS8645; + + + + + + \ No newline at end of file diff --git a/Mara.Common/Constants.cs b/Mara.Common/Constants.cs index 0d2ffd8..ecc67c3 100644 --- a/Mara.Common/Constants.cs +++ b/Mara.Common/Constants.cs @@ -1,13 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Mara.Common +namespace Mara.Common { - public class Constants + /// + /// Provides a set of constant values + /// + public static class Constants { - + } } diff --git a/Mara.Common/Discord/EmbedBuilder.cs b/Mara.Common/Discord/EmbedBuilder.cs index b439af6..12888a9 100644 --- a/Mara.Common/Discord/EmbedBuilder.cs +++ b/Mara.Common/Discord/EmbedBuilder.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.Drawing; using System.Linq; -using System.Security.Cryptography; using Mara.Common.Extensions; using Mara.Common.Results; using Remora.Discord.API; @@ -21,18 +20,29 @@ public class EmbedBuilder public string Title { get; set; } public EmbedType Type { get; set; } + [MaxLength(EmbedConstants.MaxDescriptionLength)] public string Description { get; set; } + [Url] + public string Url { get; set; } + public DateTimeOffset Timestamp { get; set; } + public Color Color { get; set; } + public IEmbedFooter Footer { get; set; } - public IEmbedImage Image { get; set; } - public IEmbedThumbnail Thumbnail { get; set; } - public IEmbedAuthor Author { get; set; } + + public IEmbedImage? Image { get; set; } + + public IEmbedThumbnail? Thumbnail { get; set; } + + public IEmbedAuthor? Author { get; set; } + public IReadOnlyList Fields => new ReadOnlyCollection(_fields); + private IList _fields; public EmbedBuilder() : this(new List(EmbedConstants.MaxFieldCount)) @@ -55,6 +65,7 @@ private EmbedBuilder(List fields) Footer = EmbedConstants.DefaultFooter; Image = null; Thumbnail = null; + Author = null; _fields = fields; } @@ -68,18 +79,19 @@ public static EmbedBuilder FromEmbed(Embed embed) Timestamp = embed.Timestamp.GetValueOrDefault(DateTimeOffset.UtcNow), Color = embed.Colour.GetValueOrDefault(EmbedConstants.DefaultColor), Footer = embed.Footer.GetValueOrDefault(EmbedConstants.DefaultFooter), - Image = embed.Image.GetValueOrDefault(default), - Thumbnail = embed.Thumbnail.GetValueOrDefault(default) + Image = embed.Image.HasValue ? embed.Image.Value : null, + Thumbnail = embed.Thumbnail.HasValue ? embed.Thumbnail.Value : null }; + /// + /// Returns the overall length of the embed. + /// public int Length { get { int titleLength = Title.Length; - int authorLength = Author.Name.HasValue - ? Author.Name.Value!.Length - : 0; + int authorLength = Author?.Name.Length ?? 0; int descriptionLength = Description.Length; int footerLength = Footer?.Text.Length ?? 0; int fieldSum = _fields.Sum(field => field.Name.Length + field.Value.Length); @@ -182,9 +194,9 @@ public EmbedBuilder WithTimestamp(DateTimeOffset dateTimeOffset) /// /// The current builder. /// - public EmbedBuilder WithColor(Color colour) + public EmbedBuilder WithColor(Color color) { - Color = colour; + Color = color; return this; } @@ -194,7 +206,7 @@ public EmbedBuilder WithColor(Color colour) /// /// The current builder. /// - public EmbedBuilder WithAuthor([MaxLength(EmbedConstants.MaxAuthorNameLength)] string name, [Url] string url = default, [Url] string iconUrl = default) + public EmbedBuilder WithAuthor([MaxLength(EmbedConstants.MaxAuthorNameLength)] string name, [Url] string url = "", [Url] string iconUrl = "") { Author = new EmbedAuthor(name, url, iconUrl); return this; @@ -220,7 +232,11 @@ public EmbedBuilder WithUserAsAuthor(IUser user) /// /// The current builder. /// - public EmbedBuilder WithFooter(string text, [Url] string iconUrl = default) + public EmbedBuilder WithFooter + ( + [MaxLength(EmbedConstants.MaxFooterTextLength)] string text, + [Url] string iconUrl = "" + ) { if (text.Length > EmbedConstants.MaxFooterTextLength) throw new ArgumentException( @@ -254,7 +270,7 @@ public EmbedBuilder AddField(string name, string value, bool inline = false) /// . /// /// The field builder class containing the field properties. - /// Field count exceeds . + /// Field count exceeds . /// /// The current builder. /// @@ -286,23 +302,23 @@ public EmbedBuilder SetFields(IList fields) /// /// The built embed object. /// - /// Total embed length exceeds . + /// Total embed length exceeds . public Embed Build() => Length > EmbedConstants.MaxEmbedLength ? throw new InvalidOperationException( $"Total embed length must be less than or equal to {EmbedConstants.MaxEmbedLength}.") : new Embed() { - Title = new Optional(Title), - Type = new Optional(Type), - Description = new Optional(Description), - Url = new Optional(Url), - Timestamp = new Optional(Timestamp), - Colour = new Optional(Color), + Title = Title, + Type = Type, + Description = Description, + Url = Url, + Timestamp = Timestamp, + Colour = Color, Footer = new Optional(Footer), - Image = new Optional(Image), - Thumbnail = new Optional(Thumbnail), - Author = new Optional(Author), + Image = Image is null ? default : new Optional(Image), + Thumbnail = Thumbnail is null ? default : new Optional(Thumbnail), + Author = Author is null ? default : new Optional(Author), Fields = new Optional>(Fields) }; } diff --git a/Mara.Common/Discord/EmbedConstants.cs b/Mara.Common/Discord/EmbedConstants.cs index c83d3a1..2af5db4 100644 --- a/Mara.Common/Discord/EmbedConstants.cs +++ b/Mara.Common/Discord/EmbedConstants.cs @@ -3,6 +3,9 @@ namespace Mara.Common.Discord { + /// + /// A set of constants which represent restraits on Embeds. + /// public static class EmbedConstants { /// @@ -38,6 +41,14 @@ public static class EmbedConstants /// /// Default embed footer. "React with ❌ to remove this embed." /// - public static readonly EmbedFooter DefaultFooter = new("React with ❌ to remove this embed."); + public static readonly EmbedFooter DefaultFooter = new("React with ❌ to remove this embed"); + + /// + /// A set of image assets in url form for use in embeds. + /// + public static class ImageUrls + { + + } } } diff --git a/Mara.Common/Discord/Feedback/IdentityInformationConfiguration.cs b/Mara.Common/Discord/Feedback/IdentityInformationConfiguration.cs index a7fbe4a..0e712b4 100644 --- a/Mara.Common/Discord/Feedback/IdentityInformationConfiguration.cs +++ b/Mara.Common/Discord/Feedback/IdentityInformationConfiguration.cs @@ -1,7 +1,12 @@ -using Remora.Discord.Core; +using System; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.Core; namespace Mara.Common.Discord.Feedback { + /// + /// Provides information about the identity of the current application. + /// public sealed class IdentityInformationConfiguration { /// @@ -10,13 +15,8 @@ public sealed class IdentityInformationConfiguration public Snowflake Id { get; set; } /// - /// Gets the application id if the bot. + /// Gets the application behind this bot. /// - public Snowflake ApplicationId { get; set; } - - /// - /// Gets the id of the bot's owner. - /// - public Snowflake OwnerId { get; set; } + public IApplication Application { get; set; } } } diff --git a/Mara.Common/Discord/FormatUtilities.cs b/Mara.Common/Discord/FormatUtilities.cs index aec2a20..3e92c6f 100644 --- a/Mara.Common/Discord/FormatUtilities.cs +++ b/Mara.Common/Discord/FormatUtilities.cs @@ -1,14 +1,25 @@ -using System.Linq; +using System; +using System.Linq; using System.Text.RegularExpressions; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Objects; namespace Mara.Common.Discord { - public static class FormatUtilities + public static class StringUtilities { + /// + /// Represents a zero width space character. + /// public const char ZeroWidthSpace = '\x200b'; + /// + /// Represents an empty string. This field is constant. + /// + public const string EmptyString = ""; + } + public static class FormatUtilities + { // Characters which need escaping private static readonly string[] SensitiveCharacters = { "\\", "*", "_", "~", "`", "|", ">" }; @@ -66,6 +77,8 @@ public static string Mention(IChannel channel) /// Returns a string with spoiler formatting. public static string Spoiler(string text) => $"||{text}||"; /// Returns a markdown-formatted URL. Only works in descriptions and fields. + public static string Url(string url) => $"[{url}]({url})"; + /// Returns a markdown-formatted URL. Only works in descriptions and fields. public static string Url(string text, string url) => $"[{text}]({url})"; /// Escapes a URL so that a preview is not generated. public static string EscapeUrl(string url) => $"<{url}>"; @@ -76,9 +89,71 @@ public static string Mention(IChannel channel) /// The text to wrap in a code block. /// The code language. Ignored for single-line code blocks. /// - public static string Code(string text, string language = null) + public static string Code(string text, string? language = null) => language is not null || text.Contains("\n") ? $"```{language ?? ""}\n{text}\n```" : $"`{text}`"; + + /// + /// Returns a markdown timestamp which renders the specified date and time in the user's timezone and locale. + /// + /// The date and time to display. + /// What format to display it in. + /// Discord Message Formatting Timestamp Styles + public static string DynamicTimeStamp(DateTime timeStamp, TimeStampStyle style) + => DynamicTimeStamp(new DateTimeOffset(timeStamp), style); + + /// + public static string DynamicTimeStamp(DateTimeOffset timeStamp, TimeStampStyle style = TimeStampStyle.ShortDateTime) + { + char format = style switch + { + TimeStampStyle.ShortTime => 't', + TimeStampStyle.LongTime => 'T', + TimeStampStyle.ShortDate => 'd', + TimeStampStyle.LongDate => 'D', + TimeStampStyle.ShortDateTime => 'f', + TimeStampStyle.LongDateTime => 'F', + TimeStampStyle.RelativeTime => 'R', + _ => 'f' + }; + + return $""; + } + + /// + /// Determines the output style for the Dynamic Time Stamp + /// + public enum TimeStampStyle + { + /// + /// 16:20 + /// + ShortTime, + /// + /// 16:20:30 + /// + LongTime, + /// + /// 20/04/2021 + /// + ShortDate, + /// + /// 20 April 2021 + /// + LongDate, + /// + /// 20 April 2021 16:20 + /// + ShortDateTime, + /// + /// Tuesday, 20 April 2021 16:20 + /// + LongDateTime, + /// + /// 2 months ago + /// + RelativeTime + } } } diff --git a/Mara.Common/Extensions/JsonExtensions.cs b/Mara.Common/Extensions/JsonExtensions.cs deleted file mode 100644 index 5a70384..0000000 --- a/Mara.Common/Extensions/JsonExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; - -namespace Mara.Common.Extensions -{ - public static class JsonExtensions - { - public static TResult? ToObject(this JsonElement element, JsonSerializerOptions jsonSerializerOptions) - => ToObject(element.GetRawText(), jsonSerializerOptions); - public static TResult? ToObject(this JsonElement element, ILogger logger, JsonSerializerOptions jsonSerializerOptions) - { - var json = element.GetRawText(); - logger.LogTrace(json); - return ToObject(json, jsonSerializerOptions); - } - - public static TResult? ToObject(this JsonDocument document, JsonSerializerOptions jsonSerializerOptions) - => ToObject(document.RootElement.GetRawText(), jsonSerializerOptions); - - public static TResult? ToObject(this JsonDocument document, ILogger logger, JsonSerializerOptions jsonSerializerOptions) - { - var json = document.RootElement.GetRawText(); - logger.LogTrace(json); - return ToObject(json, jsonSerializerOptions); - } - - private static TResult? ToObject(string rawText, JsonSerializerOptions jsonSerializerOptions) - => JsonSerializer.Deserialize(rawText, jsonSerializerOptions); - } -} diff --git a/Mara.Common/Extensions/LoggerExtensions.cs b/Mara.Common/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000..1c3b29a --- /dev/null +++ b/Mara.Common/Extensions/LoggerExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; +using Remora.Results; + +namespace Mara.Common.Extensions +{ + public static class LoggerExtensions + { + /// + /// Formats and writes an error log message + /// + /// The object this logger writes for. + /// The logger. + /// An representing the problem that occurred. + public static void LogError(this ILogger logger, IResultError error) + { + if (error is ExceptionError exceptionError) + { + logger.LogError(exceptionError.Exception, exceptionError.Message); + } + + logger.LogError(error.Message); + } + } +} diff --git a/Mara.Common/ISkippedPlugin.cs b/Mara.Common/ISkippedPlugin.cs index 99855b7..9bf169e 100644 --- a/Mara.Common/ISkippedPlugin.cs +++ b/Mara.Common/ISkippedPlugin.cs @@ -1,6 +1,4 @@ -using System; - -namespace Mara.Common +namespace Mara.Common { /// /// Marks a plugin that should be skipped and ignored during loading. diff --git a/Mara.Common/Mara.Common.csproj b/Mara.Common/Mara.Common.csproj index 35d6c8c..f34e1f6 100644 --- a/Mara.Common/Mara.Common.csproj +++ b/Mara.Common/Mara.Common.csproj @@ -1,17 +1,22 @@  - net5.0 + net6.0 1.0.0 $(Version) $(Version) - - - - + + + + + + + + + diff --git a/Mara.Common/Models/MaraConfig.cs b/Mara.Common/Models/MaraConfig.cs index f40c6a3..30bba9c 100644 --- a/Mara.Common/Models/MaraConfig.cs +++ b/Mara.Common/Models/MaraConfig.cs @@ -2,7 +2,7 @@ namespace Mara.Common.Models { - public class MaraConfig + public sealed class MaraConfig { public static readonly MaraConfig Default = new() { @@ -11,8 +11,8 @@ public class MaraConfig PrivacyPolicyUrl = "" }; - public string DiscordToken { get; init; } - public Dictionary ConnectionStrings { get; init; } - public string PrivacyPolicyUrl { get; init; } + public string DiscordToken { get; init; } = ""; + public Dictionary ConnectionStrings { get; init; } = new(); + public string PrivacyPolicyUrl { get; init; } = ""; } } diff --git a/Mara.Common/Results/DatabaseValueNotFoundError.cs b/Mara.Common/Results/DatabaseValueNotFoundError.cs new file mode 100644 index 0000000..141eb24 --- /dev/null +++ b/Mara.Common/Results/DatabaseValueNotFoundError.cs @@ -0,0 +1,9 @@ +using Remora.Results; + +namespace Mara.Common.Results +{ + /// + /// Represents an error where no database models could be found with the provided query. + /// + public sealed record DatabaseValueNotFoundError() : ResultError("The requested database model(s) could not be found with the provided query."); +} diff --git a/Mara.Common/Results/EmbedError.cs b/Mara.Common/Results/EmbedError.cs index 2d614e3..13a222b 100644 --- a/Mara.Common/Results/EmbedError.cs +++ b/Mara.Common/Results/EmbedError.cs @@ -5,5 +5,5 @@ namespace Mara.Common.Results /// /// Represents an error which occurs when building an Embed. /// - public record EmbedError(string Reason) : ResultError($"Failed to create an embed. {Reason}"); + public record EmbedError(string Reason) : ResultError($"Failed to create an embed: {Reason}"); } diff --git a/Mara.Common/ValueConverters/SnowflakeToNumberConverter.cs b/Mara.Common/ValueConverters/SnowflakeToNumberConverter.cs new file mode 100644 index 0000000..d8d5f78 --- /dev/null +++ b/Mara.Common/ValueConverters/SnowflakeToNumberConverter.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using DiscordConstants = Remora.Discord.API.Constants; +using Remora.Rest.Core; + +namespace Mara.Common.ValueConverters +{ + /// + /// Converts a Snowflake unto a ulong and back. + /// + public sealed class SnowflakeToNumberConverter : ValueConverter + { + private static readonly ConverterMappingHints _defaultHints = new(precision: 20, scale: 0); + + /// + /// Creates a new instance of the type. + /// + public SnowflakeToNumberConverter() : base(sf => sf.Value, value => new(value, DiscordConstants.DiscordEpoch), _defaultHints) + { + } + } +} diff --git a/Mara.Runtime/Extensions/ServiceCollectionExtensions.cs b/Mara.Runtime/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index fa52196..0000000 --- a/Mara.Runtime/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -namespace Mara.Runtime.Extensions -{ - public static class ServiceCollectionExtensions - { - public static IServiceCollection AddMara(this IServiceCollection services) - { - services.AddHttpClient(); - - // services.AddCommandHelp(); - - services.AddMemoryCache(); - - services.AddHostedService(); - - return services; - } - } -} diff --git a/Mara.Runtime/Mara.Runtime.csproj b/Mara.Runtime/Mara.Runtime.csproj index bf32754..57fb839 100644 --- a/Mara.Runtime/Mara.Runtime.csproj +++ b/Mara.Runtime/Mara.Runtime.csproj @@ -2,7 +2,7 @@ Exe - net5.0 + net6.0 add0e956-c141-46d8-b042-67f19259e4f3 0.1.0 $(Version) @@ -10,22 +10,22 @@ - - - - - - - - - - - + + + + + + + + + + + - - + + - + diff --git a/Mara.Runtime/MaraBot.cs b/Mara.Runtime/MaraBot.cs index 75d743b..c062add 100644 --- a/Mara.Runtime/MaraBot.cs +++ b/Mara.Runtime/MaraBot.cs @@ -2,16 +2,13 @@ using System.Globalization; using System.Threading; using System.Threading.Tasks; -using Mara.Common; -using Mara.Common.Models; +using Mara.Common.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Remora.Discord.Commands.Services; using Remora.Discord.Gateway; -using Remora.Plugins.Abstractions; using Remora.Plugins.Services; +using Remora.Results; namespace Mara.Runtime { @@ -19,21 +16,17 @@ public sealed class MaraBot : BackgroundService { private readonly DiscordGatewayClient _discordClient; private readonly IServiceProvider _services; - private readonly MaraConfig _config; private readonly IHostApplicationLifetime _applicationLifetime; - private readonly IHostEnvironment _environment; private readonly ILogger _logger; private readonly PluginService _pluginService; - private IServiceScope _scope; + private IServiceScope? _scope = null; public MaraBot ( DiscordGatewayClient discordClient, IServiceProvider services, - IOptions config, IHostApplicationLifetime applicationLifetime, - IHostEnvironment environment, ILogger logger, PluginService pluginService ) @@ -41,49 +34,61 @@ PluginService pluginService _discordClient = discordClient; _services = services; _applicationLifetime = applicationLifetime; - _environment = environment; _logger = logger; _pluginService = pluginService; - _config = config.Value; } + + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { Thread.CurrentThread.CurrentCulture = new CultureInfo("en-us"); Thread.CurrentThread.CurrentUICulture = Thread.CurrentThread.CurrentCulture; - _logger.LogInformation("Starting bot service...");; + var initResult = await InitializeAsync(stoppingToken); + if (!initResult.IsSuccess) + { + _logger.LogError(initResult.Error); + return; + } + + _logger.LogInformation("Logging into Discord and starting the client."); + + var runResult = await _discordClient.RunAsync(stoppingToken); + + if (!runResult.IsSuccess) + { + _logger.LogCritical("A critical error has occurred: {Error}", runResult.Error!.Message); + } + } - IServiceScope? scope = null; + private async Task InitializeAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Initializing bot service..."); + try { // Create new scope for this session - scope = _services.CreateScope(); + _scope = _services.CreateScope(); // Register the OnStopping method with the cancellation token stoppingToken.Register(OnStopping); // Load plugins - var plugins = _pluginService.LoadAvailablePlugins(); + var pluginTree = _pluginService.LoadPluginTree(); - foreach (var plugin in plugins) + var initResult = await pluginTree.InitializeAsync(_scope.ServiceProvider, stoppingToken); + if (!initResult.IsSuccess) { - if (plugin is ISkippedPlugin) - continue; - - var serviceScope = _services.CreateScope(); - - if (plugin is IMigratablePlugin migratablePlugin) - { - if (await migratablePlugin.HasCreatedPersistentStoreAsync(serviceScope.ServiceProvider)) - { - await migratablePlugin.MigratePluginAsync(serviceScope.ServiceProvider); - } - } + return initResult; + } - await plugin.InitializeAsync(serviceScope.ServiceProvider); + var migrateResult = await pluginTree.MigrateAsync(_scope.ServiceProvider, stoppingToken); + if (migrateResult.IsSuccess) + { + return migrateResult; } - _scope = scope; + return Result.FromSuccess(); } catch (Exception ex) { @@ -91,33 +96,24 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) OnStopping(); - throw; + return ex; } + } - _logger.LogInformation("Logging into Discord and starting the client."); - - var runResult = await _discordClient.RunAsync(stoppingToken); - - if (!runResult.IsSuccess) + private void OnStopping() + { + _logger.LogInformation("Stopping background service."); + try { - _logger.LogCritical("A critical error has occurred: {Error}", runResult.Error!.Message); + _applicationLifetime.StopApplication(); } - - void OnStopping() + finally { - _logger.LogInformation("Stopping background service."); - try - { - _applicationLifetime.StopApplication(); - } - finally - { - scope?.Dispose(); - _scope = null; - } + _scope = null; } } + /// public override void Dispose() { try diff --git a/Mara.Runtime/Program.cs b/Mara.Runtime/Program.cs index aeaa852..837e215 100644 --- a/Mara.Runtime/Program.cs +++ b/Mara.Runtime/Program.cs @@ -1,25 +1,22 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Reflection; using System.Text.Json; using System.Threading.Tasks; -using Mara.Common; +using Mara.Common.Discord.Feedback; using Mara.Common.Models; -using Mara.Runtime.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; -using Remora.Commands.Extensions; using Remora.Discord.API.Abstractions.Gateway.Commands; -using Remora.Discord.API.Extensions; using Remora.Discord.Commands.Extensions; using Remora.Discord.Gateway; using Remora.Discord.Gateway.Extensions; -using Remora.Discord.Rest.Extensions; using Remora.Plugins.Services; +using Remora.Results; using Serilog; using Serilog.Events; @@ -62,43 +59,46 @@ public static async Task Main(string[] args) .UseDefaultServiceProvider(x => x.ValidateScopes = true) .ConfigureServices((context, services) => { - var pluginService = new PluginService(); - var plugins = pluginService.LoadAvailablePlugins().ToList(); + var pluginServiceOptions = new PluginServiceOptions(new List() { "./Plugins" }); + var pluginService = new PluginService(Options.Create(pluginServiceOptions)); - Console.WriteLine($"Discovered {plugins.Count} plugins."); + var plugins = pluginService.LoadPluginTree(); + var configurePluginsResult = plugins.ConfigureServices(services); + if (!configurePluginsResult.IsSuccess) + { + Console.WriteLine($"Failed to load plugins! {configurePluginsResult.Error.Message}"); + if (configurePluginsResult.Error is ExceptionError exe) + { + Console.WriteLine(exe.Exception.ToString()); + } + } services.Configure(context.Configuration); services.AddSingleton(pluginService); + services.AddSingleton(); Debug.Assert(!string.IsNullOrEmpty(context.Configuration[nameof(MaraConfig.DiscordToken)])); - services.AddDiscordApi(); services.AddDiscordGateway(x => context.Configuration[nameof(MaraConfig.DiscordToken)]); - services.AddDiscordRest(x => context.Configuration[nameof(MaraConfig.DiscordToken)]); - services.AddCommands(); services.AddDiscordCommands(enableSlash: true); - services.AddMara(); + + services.AddMemoryCache(); + services.AddHostedService(); services.Configure(x => x.Intents |= GatewayIntents.DirectMessages | GatewayIntents.GuildBans | + GatewayIntents.GuildEmojisAndStickers | GatewayIntents.GuildIntegrations | GatewayIntents.GuildInvites | GatewayIntents.GuildMembers | - GatewayIntents.GuildMessageReactions); - - foreach (var plugin in plugins) - { - if (plugin is ISkippedPlugin) - continue; - - Console.Write($"Configuring {plugin.Name} version {plugin.Version.ToString(3)}..."); - plugin.ConfigureServices(services); - Console.WriteLine("Done"); - } + GatewayIntents.GuildMessageReactions | + GatewayIntents.GuildMessages | + GatewayIntents.Guilds | + GatewayIntents.GuildWebhooks); }) - .ConfigureLogging((context, builder) => + .ConfigureLogging((_, builder) => { Serilog.Core.Logger seriLogger = new LoggerConfiguration() .MinimumLevel.Verbose() @@ -110,8 +110,11 @@ public static async Task Main(string[] args) builder.AddSerilog(seriLogger); Log.Logger = seriLogger; - }) - .UseConsoleLifetime(); + }); + + hostBuilder = (Debugger.IsAttached && Environment.UserInteractive) + ? hostBuilder.UseConsoleLifetime() + : hostBuilder.UseWindowsService(); using var host = hostBuilder.Build(); diff --git a/Mara.Tests/Mara.Tests.csproj b/Mara.Tests/Mara.Tests.csproj new file mode 100644 index 0000000..d2478b5 --- /dev/null +++ b/Mara.Tests/Mara.Tests.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Mara.Tests/SnowflakeValueConverterTests.cs b/Mara.Tests/SnowflakeValueConverterTests.cs new file mode 100644 index 0000000..eed40e5 --- /dev/null +++ b/Mara.Tests/SnowflakeValueConverterTests.cs @@ -0,0 +1,67 @@ +using Mara.Common.ValueConverters; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Remora.Rest.Core; +using Xunit; + +using DiscordConstants = Remora.Discord.API.Constants; + +namespace Mara.Tests +{ + public class SnowflakeValueConverterTests + { + private static readonly ValueConverter _snowflakeToNumber = new SnowflakeToNumberConverter(); + + private static readonly ulong foxtrekId = 197291773133979648; + private static readonly ulong maraId = 801312393069199370; + + [Fact] + public void CanConvertSnowflakesToNumbers() + { + var converter = _snowflakeToNumber.ConvertToProviderExpression.Compile(); + + var foxtrek = new Snowflake(foxtrekId, DiscordConstants.DiscordEpoch); + var mara = new Snowflake(maraId, DiscordConstants.DiscordEpoch); + + Assert.Equal(foxtrekId, converter(foxtrek)); + Assert.Equal(maraId, converter(mara)); + } + + [Fact] + public void CanConvertSnowflakesToNumbersObject() + { + var converter = _snowflakeToNumber.ConvertToProvider; + + var foxtrek = new Snowflake(foxtrekId, DiscordConstants.DiscordEpoch); + var mara = new Snowflake(maraId, DiscordConstants.DiscordEpoch); + + Assert.Equal(foxtrekId, converter(foxtrek)); + Assert.Equal(maraId, converter(mara)); + Assert.Null(converter(null)); + } + + [Fact] + public void CanConvertNumbersToSnowflakes() + { + var converter = _snowflakeToNumber.ConvertFromProviderExpression.Compile(); + + var foxtrek = new Snowflake(foxtrekId, DiscordConstants.DiscordEpoch); + var mara = new Snowflake(maraId, DiscordConstants.DiscordEpoch); + + Assert.Equal(foxtrek, converter(foxtrekId)); + Assert.Equal(mara, converter(maraId)); + } + + [Fact] + public void CanConvertNumbersToSnowflakesObject() + { + var converter = _snowflakeToNumber.ConvertFromProvider; + + var foxtrek = new Snowflake(foxtrekId, DiscordConstants.DiscordEpoch); + var mara = new Snowflake(maraId, DiscordConstants.DiscordEpoch); + + Assert.Equal(foxtrek, converter(foxtrekId)); + Assert.Equal(mara, converter(maraId)); + Assert.Null(converter(null)); + } + } +} diff --git a/Mara.sln b/Mara.sln index aacbfe7..1688f5c 100644 --- a/Mara.sln +++ b/Mara.sln @@ -1,25 +1,39 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31321.278 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{5095E260-6923-41C6-9DF8-A395F2EE275C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mara.Runtime", "Mara.Runtime\Mara.Runtime.csproj", "{CBD53B34-977B-4CD0-BD6E-6D03B98A191F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Runtime", "Mara.Runtime\Mara.Runtime.csproj", "{CBD53B34-977B-4CD0-BD6E-6D03B98A191F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mara.Plugins.Core", "Plugins\Mara.Plugins.Core\Mara.Plugins.Core.csproj", "{9B5BAB08-4F92-420B-B45E-149B566664A7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Core", "Plugins\Mara.Plugins.Core\Mara.Plugins.Core.csproj", "{9B5BAB08-4F92-420B-B45E-149B566664A7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mara.Common", "Mara.Common\Mara.Common.csproj", "{F0E906C4-5187-48A6-864E-B526AA2FDB9D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Common", "Mara.Common\Mara.Common.csproj", "{F0E906C4-5187-48A6-864E-B526AA2FDB9D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mara.Plugins.Mediator", "Plugins\Mara.Plugins.MediatR\Mara.Plugins.Mediator.csproj", "{189647A0-1828-4640-9504-C70AB7971370}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Mediator", "Plugins\Mara.Plugins.MediatR\Mara.Plugins.Mediator.csproj", "{189647A0-1828-4640-9504-C70AB7971370}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mara.Plugins.BetterEmbeds", "Plugins\Mara.Plugins.BetterEmbed\Mara.Plugins.BetterEmbeds.csproj", "{AC75C1EC-34E5-4BBE-8060-78D174F4D6D3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.BetterEmbeds", "Plugins\Mara.Plugins.BetterEmbed\Mara.Plugins.BetterEmbeds.csproj", "{AC75C1EC-34E5-4BBE-8060-78D174F4D6D3}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{39EA8E1A-E1D9-419D-AF42-860058C74536}" ProjectSection(SolutionItems) = preProject Directory.Build.props = Directory.Build.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Consent", "Plugins\Mara.Plugins.Consent\Mara.Plugins.Consent.csproj", "{EE0456BD-8336-4C69-9E03-143B5B37BC51}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Moderation", "Plugins\Mara.Plugins.Moderation\Mara.Plugins.Moderation.csproj", "{D7B323BE-10DD-4C87-99C3-D1E56A9A95C4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.BetterEmbeds.Tests", "Plugins\Mara.Plugins.BetterEmbeds.Tests\Mara.Plugins.BetterEmbeds.Tests.csproj", "{164CD06A-7F1D-4B35-B52F-7D9751DA3719}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Core.Tests", "Plugins\Mara.Plugins.Core.Tests\Mara.Plugins.Core.Tests.csproj", "{667C812B-DC53-4AF1-A759-CEB3E186DE25}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Mediator.Tests", "Plugins\Mara.Plugins.Mediator.Tests\Mara.Plugins.Mediator.Tests.csproj", "{708A306D-15C9-4872-A894-2419BF271689}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Plugins.Moderation.Tests", "Plugins\Mara.Plugins.Moderation.Tests\Mara.Plugins.Moderation.Tests.csproj", "{BA0B9865-7049-4963-8462-BC517F23F594}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mara.Tests", "Mara.Tests\Mara.Tests.csproj", "{BA3B9272-B100-463A-8C67-16CBFDDA68BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +60,34 @@ Global {AC75C1EC-34E5-4BBE-8060-78D174F4D6D3}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC75C1EC-34E5-4BBE-8060-78D174F4D6D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC75C1EC-34E5-4BBE-8060-78D174F4D6D3}.Release|Any CPU.Build.0 = Release|Any CPU + {EE0456BD-8336-4C69-9E03-143B5B37BC51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE0456BD-8336-4C69-9E03-143B5B37BC51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE0456BD-8336-4C69-9E03-143B5B37BC51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE0456BD-8336-4C69-9E03-143B5B37BC51}.Release|Any CPU.Build.0 = Release|Any CPU + {D7B323BE-10DD-4C87-99C3-D1E56A9A95C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7B323BE-10DD-4C87-99C3-D1E56A9A95C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7B323BE-10DD-4C87-99C3-D1E56A9A95C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7B323BE-10DD-4C87-99C3-D1E56A9A95C4}.Release|Any CPU.Build.0 = Release|Any CPU + {164CD06A-7F1D-4B35-B52F-7D9751DA3719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {164CD06A-7F1D-4B35-B52F-7D9751DA3719}.Debug|Any CPU.Build.0 = Debug|Any CPU + {164CD06A-7F1D-4B35-B52F-7D9751DA3719}.Release|Any CPU.ActiveCfg = Release|Any CPU + {164CD06A-7F1D-4B35-B52F-7D9751DA3719}.Release|Any CPU.Build.0 = Release|Any CPU + {667C812B-DC53-4AF1-A759-CEB3E186DE25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {667C812B-DC53-4AF1-A759-CEB3E186DE25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {667C812B-DC53-4AF1-A759-CEB3E186DE25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {667C812B-DC53-4AF1-A759-CEB3E186DE25}.Release|Any CPU.Build.0 = Release|Any CPU + {708A306D-15C9-4872-A894-2419BF271689}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {708A306D-15C9-4872-A894-2419BF271689}.Debug|Any CPU.Build.0 = Debug|Any CPU + {708A306D-15C9-4872-A894-2419BF271689}.Release|Any CPU.ActiveCfg = Release|Any CPU + {708A306D-15C9-4872-A894-2419BF271689}.Release|Any CPU.Build.0 = Release|Any CPU + {BA0B9865-7049-4963-8462-BC517F23F594}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA0B9865-7049-4963-8462-BC517F23F594}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA0B9865-7049-4963-8462-BC517F23F594}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA0B9865-7049-4963-8462-BC517F23F594}.Release|Any CPU.Build.0 = Release|Any CPU + {BA3B9272-B100-463A-8C67-16CBFDDA68BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA3B9272-B100-463A-8C67-16CBFDDA68BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA3B9272-B100-463A-8C67-16CBFDDA68BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA3B9272-B100-463A-8C67-16CBFDDA68BB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -54,6 +96,12 @@ Global {9B5BAB08-4F92-420B-B45E-149B566664A7} = {5095E260-6923-41C6-9DF8-A395F2EE275C} {189647A0-1828-4640-9504-C70AB7971370} = {5095E260-6923-41C6-9DF8-A395F2EE275C} {AC75C1EC-34E5-4BBE-8060-78D174F4D6D3} = {5095E260-6923-41C6-9DF8-A395F2EE275C} + {EE0456BD-8336-4C69-9E03-143B5B37BC51} = {5095E260-6923-41C6-9DF8-A395F2EE275C} + {D7B323BE-10DD-4C87-99C3-D1E56A9A95C4} = {5095E260-6923-41C6-9DF8-A395F2EE275C} + {164CD06A-7F1D-4B35-B52F-7D9751DA3719} = {5095E260-6923-41C6-9DF8-A395F2EE275C} + {667C812B-DC53-4AF1-A759-CEB3E186DE25} = {5095E260-6923-41C6-9DF8-A395F2EE275C} + {708A306D-15C9-4872-A894-2419BF271689} = {5095E260-6923-41C6-9DF8-A395F2EE275C} + {BA0B9865-7049-4963-8462-BC517F23F594} = {5095E260-6923-41C6-9DF8-A395F2EE275C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7F5007AB-7A59-4CAF-B60F-5FA522B11A6C} diff --git a/Plugins/Mara.Plugins.BetterEmbed/API/RedditRestAPI.cs b/Plugins/Mara.Plugins.BetterEmbed/API/RedditRestAPI.cs index 956b94e..7fd9a50 100644 --- a/Plugins/Mara.Plugins.BetterEmbed/API/RedditRestAPI.cs +++ b/Plugins/Mara.Plugins.BetterEmbed/API/RedditRestAPI.cs @@ -1,13 +1,10 @@ -using System; -using System.Net.Http; -using System.Text.Json; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Mara.Common.Extensions; using Mara.Plugins.BetterEmbeds.Models.Reddit; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Remora.Discord.Rest.Results; +using Remora.Rest; using Remora.Results; namespace Mara.Plugins.BetterEmbeds.API @@ -17,15 +14,15 @@ public sealed class RedditRestAPI public const string PostUrl = "https://www.reddit.com/r/{0}/comments/{1}/.json"; public const string ProfileUrl = "https://www.reddit.com/user/{0}/about.json"; - private readonly HttpClient _client; private readonly JsonSerializerOptions _serializerOptions; private readonly ILogger _logger; - - public RedditRestAPI(IHttpClientFactory factory, IOptions serializerOptions, ILogger logger) + private readonly IRestHttpClient _restClient; + + public RedditRestAPI(IRestHttpClient restClient, IOptions serializerOptions, ILogger logger) { - _logger = logger; + _restClient = restClient; _serializerOptions = serializerOptions.Value; - _client = factory.CreateClient(); + _logger = logger; } /// @@ -33,11 +30,12 @@ public RedditRestAPI(IHttpClientFactory factory, IOptions /// /// The subreddit this post belongs to. /// The unique id of this post. + /// Whether or not to allow an empty return value. /// The cancellation token for this operation. /// A retrieval result which may or may not have succeeded. public async Task> GetRedditPostAsync ( - string subredditName, + string subredditName, string postId, bool allowNullReturn = false, CancellationToken cancellationToken = default @@ -45,9 +43,14 @@ public async Task> GetRedditPostAsync { var redditUrl = string.Format(PostUrl, subredditName, postId); - var response = await _client.GetAsync(redditUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - - return await UnpackResponseAsync(response, "$[0].data.children[0].data", allowNullReturn, cancellationToken); + return await _restClient.GetAsync + ( + redditUrl, + "$[0].data.children[0].data", + x => x.Build(), + allowNullReturn, + cancellationToken + ); } public async Task> GetRedditUserAsync @@ -59,75 +62,14 @@ public async Task> GetRedditUserAsync { var redditUrl = string.Format(ProfileUrl, username); - var response = await _client.GetAsync(redditUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - - return await UnpackResponseAsync(response, "$.data.subreddit", allowNullReturn, cancellationToken); - } - - private async Task> UnpackResponseAsync - ( - HttpResponseMessage response, - string path = "", - bool allowNullReturn = false, - CancellationToken cancellationToken = default - ) - { - if (!response.IsSuccessStatusCode) - { - return new HttpResultError(response.StatusCode, response.ReasonPhrase); - } - - if (response.Content.Headers.ContentLength == 0) - { - return allowNullReturn - ? Result.FromSuccess(default!) - : throw new InvalidOperationException("Response content null, but null returns not allowed."); - } - - TEntity? entity; - - if (string.IsNullOrEmpty(path)) - { - entity = await JsonSerializer.DeserializeAsync - ( - await response.Content.ReadAsStreamAsync(cancellationToken), - new JsonSerializerOptions(JsonSerializerDefaults.Web), - cancellationToken - ); - } - else - { - var doc = await JsonSerializer.DeserializeAsync - ( - await response.Content.ReadAsStreamAsync(cancellationToken), - new JsonSerializerOptions(JsonSerializerDefaults.Web), - cancellationToken - ); - - var element = doc.SelectElement(path); - - if (!element.HasValue) - { - return allowNullReturn - ? Result.FromSuccess(default!) - : throw new InvalidOperationException("Response content null, but null returns not allowed."); - } - - // _logger.LogTrace(element.Value.GetRawText()); - - entity = element.Value.ToObject(_serializerOptions); - } - - if (entity is not null) - { - _logger.LogTrace(JsonSerializer.Serialize(entity)); - return Result.FromSuccess(entity); - } - - return allowNullReturn - ? Result.FromSuccess(default!) // Might be TEntity? - : throw new InvalidOperationException("Response content null, but null returns not allowed."); - + return await _restClient.GetAsync + ( + redditUrl, + "$.data.subreddit", + x => x.Build(), + allowNullReturn, + cancellationToken + ); } } } diff --git a/Plugins/Mara.Plugins.BetterEmbed/BetterEmbedPlugin.cs b/Plugins/Mara.Plugins.BetterEmbed/BetterEmbedPlugin.cs index d5c6611..aab613e 100644 --- a/Plugins/Mara.Plugins.BetterEmbed/BetterEmbedPlugin.cs +++ b/Plugins/Mara.Plugins.BetterEmbed/BetterEmbedPlugin.cs @@ -3,17 +3,22 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Mara.Plugins.BetterEmbeds; -using Mara.Plugins.BetterEmbeds.API; using Mara.Plugins.BetterEmbeds.MessageHandlers; using Mara.Plugins.BetterEmbeds.Models.OEmbed; using Mara.Plugins.BetterEmbeds.Models.Reddit; using Mara.Plugins.BetterEmbeds.Models.Reddit.Converters; using Mara.Plugins.BetterEmbeds.Services; -using Remora.Discord.API.Extensions; -using Remora.Discord.API.Json; using Remora.Discord.Gateway.Extensions; using Remora.Plugins.Abstractions; using Remora.Plugins.Abstractions.Attributes; +using Remora.Rest.Results; +using Remora.Rest.Extensions; +using Polly; +using System.Net.Http; +using Polly.Contrib.WaitAndRetry; +using Remora.Results; +using System.Threading.Tasks; +using Polly.Retry; [assembly:RemoraPlugin(typeof(BetterEmbedPlugin))] @@ -32,10 +37,52 @@ public class BetterEmbedPlugin : PluginDescriptor public override string Description => "Provides improved embed functionality for links Discord handles poorly."; /// - public override void ConfigureServices(IServiceCollection serviceCollection) + public override Result ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddScoped(); - serviceCollection.AddScoped(); + // serviceCollection.AddRestHttpClient>>(); + + var retryDelay = Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 5); + + var clientBuilder = serviceCollection + .AddRestHttpClient>("Reddit") + .ConfigureHttpClient((services, client) => + { + var assemblyName = Assembly.GetExecutingAssembly().GetName(); + var name = assemblyName.Name ?? "LuzFaltex.Mara"; + var version = assemblyName.Version ?? new Version(1, 0, 0); + + client.BaseAddress = new("https://www.reddit.com/"); + client.DefaultRequestHeaders.UserAgent.Add + ( + new System.Net.Http.Headers.ProductInfoHeaderValue(name, version.ToString()) + ); + }) + .AddTransientHttpErrorPolicy + ( + b => b.WaitAndRetryAsync(retryDelay) + ) + .AddPolicyHandler + ( + Policy + .HandleResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync + ( + 1, + (iteration, response, context) => + { + if (response.Result == default) + { + return TimeSpan.FromSeconds(1); + } + + return (TimeSpan)(response.Result.Headers.RetryAfter is null or { Delta: null } + ? TimeSpan.FromSeconds(1) + : response.Result.Headers.RetryAfter.Delta); + }, + (_, _, _, _) => Task.CompletedTask + ) + ); serviceCollection.AddResponder(); serviceCollection.AddResponder(); @@ -75,6 +122,8 @@ public override void ConfigureServices(IServiceCollection serviceCollection) options.AddDataObjectConverter(); options.AddDataObjectConverter(); }); - } + + return Result.FromSuccess(); + } } } diff --git a/Plugins/Mara.Plugins.BetterEmbed/Mara.Plugins.BetterEmbeds.csproj b/Plugins/Mara.Plugins.BetterEmbed/Mara.Plugins.BetterEmbeds.csproj index e1d2d3f..0c2803e 100644 --- a/Plugins/Mara.Plugins.BetterEmbed/Mara.Plugins.BetterEmbeds.csproj +++ b/Plugins/Mara.Plugins.BetterEmbed/Mara.Plugins.BetterEmbeds.csproj @@ -1,7 +1,7 @@  - net5.0 + net6.0 1.0.0 $(Version) $(Version) @@ -9,9 +9,10 @@ - - - + + + + diff --git a/Plugins/Mara.Plugins.BetterEmbed/MessageHandlers/RedditEmbedBuilder.cs b/Plugins/Mara.Plugins.BetterEmbed/MessageHandlers/RedditEmbedBuilder.cs index fd2a69e..b5f17d2 100644 --- a/Plugins/Mara.Plugins.BetterEmbed/MessageHandlers/RedditEmbedBuilder.cs +++ b/Plugins/Mara.Plugins.BetterEmbed/MessageHandlers/RedditEmbedBuilder.cs @@ -96,7 +96,8 @@ public override async ValueTask> BuildEmbedAsync(Match match, IMe { var user = userResult.Entity; var url = new Uri(user.IconImage); - userIconUrl = url.GetLeftPart(UriPartial.Path); + userIconUrl = url.GetLeftPart(UriPartial.Path) + ; } /* @@ -195,35 +196,28 @@ public override async ValueTask> BuildEmbedAsync(Match match, IMe */ - var embed = new Embed() - { - Title = redditPost.Title, - Url = string.Format(RedditRestAPI.PostUrl, subreddit, postId).Replace(".json", ""), - Author = new EmbedAuthor(redditPost.Author, - userUrl, userIconUrl), - Footer = new EmbedFooter($"Posted on {redditPost.Subreddit}", - "https://www.redditstatic.com/desktop2x/img/favicon/android-icon-192x192.png"), - Timestamp = (DateTimeOffset) redditPost.PostDate, - Colour = Color.DimGray, - Type = EmbedType.Rich - }; - - List fields = new(); - - fields.Add(new EmbedField("Score", $"{redditPost.Score} ({redditPost.UpvoteRatio * 100}%)", true)); + var embedBuilder = new EmbedBuilder() + .WithTitle(redditPost.Title) + .WithUrl(string.Format(RedditRestAPI.PostUrl, subreddit, postId).Replace(".json", "")) + .WithAuthor(redditPost.Author, userUrl, userIconUrl) + .WithFooter($"Posted on {redditPost.Subreddit}", + "https://www.redditstatic.com/desktop2x/img/favicon/android-icon-192x192.png") + .WithTimestamp((DateTimeOffset) redditPost.PostDate); + + embedBuilder.Color = Color.DimGray; + + embedBuilder.AddField("Score", $"{redditPost.Score} ({redditPost.UpvoteRatio * 100}%)", inline: true); if (redditPost.PostFlair.HasValue) { if (redditPost.PostFlair.Value.Contains(":")) { - var parts = redditPost.PostFlair.Value.Split(":"); - // embedBuilder.AddField($"{parts[0]}:", parts[1], true); - fields.Add(new EmbedField($"{parts[0]}:", parts[1], true)); + var parts = redditPost.PostFlair.Value.Split(":", StringSplitOptions.TrimEntries); + embedBuilder.AddField($"{parts[0]}:", parts[1], true); } else { - // embedBuilder.AddField("Post Flair:", redditPost.PostFlair.Value, true); - fields.Add(new EmbedField("Post Flair:", redditPost.PostFlair.Value, true)); + embedBuilder.AddField("Post Flair:", redditPost.PostFlair.Value, true); } } @@ -250,11 +244,13 @@ public override async ValueTask> BuildEmbedAsync(Match match, IMe case EmbedType.Image: case EmbedType.GIFV: { - embed = embed with {Image = new EmbedImage(redditPost.Url)}; + embedBuilder.WithImageUrl(redditPost.Url); break; } case EmbedType.Video when redditPost.Media.HasValue: { + // TODO: Post new embed containing video + /* var media = redditPost.Media.Value; if (media.RedditVideo.HasValue) { @@ -265,34 +261,34 @@ public override async ValueTask> BuildEmbedAsync(Match match, IMe embed = embed with {Image = new EmbedImage(redditVideo.Url)}; } } - + */ break; + } case EmbedType.Link: case EmbedType.Article: { - embed = embed with - { - Thumbnail = new EmbedThumbnail(redditPost.Thumbnail), - Description = FormatUtilities.Url(redditPost.Url, redditPost.Url) - }; - + embedBuilder + .WithThumbnailUrl(redditPost.Thumbnail) + .WithDescription(FormatUtilities.Url(redditPost.Url)); + break; } case EmbedType.Rich: default: { - embed = embed with - { - Description = redditPost.Text.Value.Truncate(EmbedConstants.MaxDescriptionLength, - $"…\n{FormatUtilities.Url("Read More", embed.Url.Value)}") - }; + embedBuilder.WithDescription(redditPost.Text.Value.Truncate(EmbedConstants.MaxDescriptionLength, + $"…\n{FormatUtilities.Url("Read More", embedBuilder.Url)}")); + break; } } - embed = embed with {Fields = fields}; - return embed; + var verifyResult = embedBuilder.Ensure(); + + return verifyResult.IsSuccess + ? embedBuilder.Build() + : Result.FromError(verifyResult); } private async Task IsGuildNsfw(Snowflake guildId, CancellationToken cancellationToken = default) diff --git a/Plugins/Mara.Plugins.BetterEmbed/Results/JsonError.cs b/Plugins/Mara.Plugins.BetterEmbed/Results/JsonError.cs new file mode 100644 index 0000000..a037d9e --- /dev/null +++ b/Plugins/Mara.Plugins.BetterEmbed/Results/JsonError.cs @@ -0,0 +1,10 @@ +using Remora.Results; + +namespace Mara.Plugins.BetterEmbeds.Results +{ + /// + /// Represents an error that occurred while parsing JSON. + /// + /// The error messge. + public sealed record JsonError(string Message) : ResultError(Message); +} diff --git a/Plugins/Mara.Plugins.BetterEmbeds.Tests/Mara.Plugins.BetterEmbeds.Tests.csproj b/Plugins/Mara.Plugins.BetterEmbeds.Tests/Mara.Plugins.BetterEmbeds.Tests.csproj new file mode 100644 index 0000000..617180a --- /dev/null +++ b/Plugins/Mara.Plugins.BetterEmbeds.Tests/Mara.Plugins.BetterEmbeds.Tests.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/Plugins/Mara.Plugins.BetterEmbeds.Tests/UnitTest1.cs b/Plugins/Mara.Plugins.BetterEmbeds.Tests/UnitTest1.cs new file mode 100644 index 0000000..f709b51 --- /dev/null +++ b/Plugins/Mara.Plugins.BetterEmbeds.Tests/UnitTest1.cs @@ -0,0 +1,14 @@ +using System; +using Xunit; + +namespace Mara.Plugins.BetterEmbeds.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} diff --git a/Plugins/Mara.Plugins.Consent/ConsentPlugin.cs b/Plugins/Mara.Plugins.Consent/ConsentPlugin.cs new file mode 100644 index 0000000..0449078 --- /dev/null +++ b/Plugins/Mara.Plugins.Consent/ConsentPlugin.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Remora.Plugins.Abstractions; +using Remora.Results; + +namespace Mara.Plugins.Consent +{ + /// + /// A plugin which keeps track of user consents on a global basis. + /// + public sealed class ConsentPlugin : PluginDescriptor, IMigratablePlugin + { + public override string Name => "Consent"; + + /// + public override Version Version => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0); + + public override string Description => "Retrieves and stores user consent on a global basis."; + + public override Result ConfigureServices(IServiceCollection serviceCollection) + { + return base.ConfigureServices(serviceCollection); + } + + public Task MigrateAsync(IServiceProvider serviceProvider, CancellationToken ct = default) + { + return Task.FromResult(Result.FromSuccess()); + } + } +} diff --git a/Plugins/Mara.Plugins.Consent/Mara.Plugins.Consent.csproj b/Plugins/Mara.Plugins.Consent/Mara.Plugins.Consent.csproj new file mode 100644 index 0000000..4d3baa4 --- /dev/null +++ b/Plugins/Mara.Plugins.Consent/Mara.Plugins.Consent.csproj @@ -0,0 +1,12 @@ + + + + net6.0 + + + + + + + + diff --git a/Plugins/Mara.Plugins.Core.Tests/Mara.Plugins.Core.Tests.csproj b/Plugins/Mara.Plugins.Core.Tests/Mara.Plugins.Core.Tests.csproj new file mode 100644 index 0000000..617180a --- /dev/null +++ b/Plugins/Mara.Plugins.Core.Tests/Mara.Plugins.Core.Tests.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/Plugins/Mara.Plugins.Core.Tests/UnitTest1.cs b/Plugins/Mara.Plugins.Core.Tests/UnitTest1.cs new file mode 100644 index 0000000..14062de --- /dev/null +++ b/Plugins/Mara.Plugins.Core.Tests/UnitTest1.cs @@ -0,0 +1,14 @@ +using System; +using Xunit; + +namespace Mara.Plugins.Core.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} diff --git a/Plugins/Mara.Plugins.Core/Attributes.cs b/Plugins/Mara.Plugins.Core/Attributes.cs new file mode 100644 index 0000000..4b659e2 --- /dev/null +++ b/Plugins/Mara.Plugins.Core/Attributes.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; +using Mara.Plugins.Core; +using Remora.Plugins.Abstractions.Attributes; + +[assembly: RemoraPlugin(typeof(CorePlugin))] +[assembly: InternalsVisibleTo("Mara.Plugins.Core.Tests")] diff --git a/Plugins/Mara.Plugins.Core/Commands/AboutCommand.cs b/Plugins/Mara.Plugins.Core/Commands/AboutCommand.cs index a1d55ed..669212b 100644 --- a/Plugins/Mara.Plugins.Core/Commands/AboutCommand.cs +++ b/Plugins/Mara.Plugins.Core/Commands/AboutCommand.cs @@ -1,23 +1,13 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; +using System.ComponentModel; using System.Drawing; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Mara.Common.Discord; using Mara.Common.Discord.Feedback; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; using Remora.Commands.Attributes; using Remora.Commands.Groups; -using Remora.Commands.Services; +using Remora.Discord.API; using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Commands.Responders; -using Remora.Discord.Commands.Services; -using Remora.Discord.Core; using Remora.Results; namespace Mara.Plugins.Core.Commands @@ -25,13 +15,13 @@ namespace Mara.Plugins.Core.Commands public sealed class AboutCommand : CommandGroup { private readonly FeedbackService _feedbackService; - private readonly IMemoryCache _cache; + private readonly IdentityInformationConfiguration _identityInformation; private readonly IDiscordRestUserAPI _userApi; - public AboutCommand(FeedbackService feedbackService, IMemoryCache cache, IDiscordRestUserAPI userApi) + public AboutCommand(FeedbackService feedbackService, IdentityInformationConfiguration identityInformation, IDiscordRestUserAPI userApi) { _feedbackService = feedbackService; - _cache = cache; + _identityInformation = identityInformation; _userApi = userApi; } @@ -39,23 +29,43 @@ public AboutCommand(FeedbackService feedbackService, IMemoryCache cache, IDiscor [Description("Provides information about the bot.")] public async Task ShowBotInfoAsync() { - var identity = _cache.Get(nameof(IdentityInformationConfiguration)); - var user = await _userApi.GetUserAsync(identity.OwnerId); - var embedBuilder = new EmbedBuilder() .WithTitle("Mara") .WithUrl("https://mara.luzfaltex.com") .WithColor(Color.Pink) .WithDescription("A custom-tailored Discord moderation bot by LuzFaltex."); - if (user.IsSuccess) + if (_identityInformation.Application.Team is { } team) + { + var avatarUrlResult = CDN.GetTeamIconUrl(team, imageSize: 256); + + if (avatarUrlResult.IsSuccess) + { + var avatarUrl = avatarUrlResult.Entity; + embedBuilder = embedBuilder.WithAuthor(team.Name, iconUrl: avatarUrl!.AbsoluteUri); + } + else + { + var teamOwner = await _userApi.GetUserAsync(team.OwnerUserID, CancellationToken); + if (teamOwner.IsSuccess) + { + embedBuilder = embedBuilder.WithUserAsAuthor(teamOwner.Entity); + } + } + } + else { - embedBuilder = embedBuilder.WithUserAsAuthor(user.Entity); + var ownerId = _identityInformation.Application.Owner!.ID.Value; + var user = await _userApi.GetUserAsync(ownerId, CancellationToken); + if (user.IsSuccess) + { + embedBuilder = embedBuilder.WithUserAsAuthor(user.Entity); + } } embedBuilder.AddField("Version", typeof(CorePlugin).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"); - return await _feedbackService.SendContextualEmbedAsync(embedBuilder.Build(), this.CancellationToken); + return await _feedbackService.SendContextualEmbedAsync(embedBuilder.Build(), ct: CancellationToken); } } } diff --git a/Plugins/Mara.Plugins.Core/CorePlugin.cs b/Plugins/Mara.Plugins.Core/CorePlugin.cs index d9d9315..934e0bb 100644 --- a/Plugins/Mara.Plugins.Core/CorePlugin.cs +++ b/Plugins/Mara.Plugins.Core/CorePlugin.cs @@ -1,20 +1,20 @@ using System; using System.Reflection; +using System.Threading; using System.Threading.Tasks; -using Mara.Plugins.Core; using Mara.Plugins.Core.Commands; using Mara.Plugins.Core.Responders; using Microsoft.Extensions.DependencyInjection; using Remora.Commands.Extensions; using Remora.Discord.Gateway.Extensions; using Remora.Plugins.Abstractions; -using Remora.Plugins.Abstractions.Attributes; using Remora.Results; -[assembly:RemoraPlugin(typeof(CorePlugin))] - namespace Mara.Plugins.Core { + /// + /// Represents core functionality. + /// public class CorePlugin : PluginDescriptor { /// @@ -25,20 +25,22 @@ public class CorePlugin : PluginDescriptor public override string Description => "Provides core functionality for the bot."; /// - public override void ConfigureServices(IServiceCollection serviceCollection) + public override Result ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddResponder(); serviceCollection.AddResponder(); serviceCollection.AddResponder(); - serviceCollection.AddResponder(); + serviceCollection.AddResponder(); serviceCollection.AddCommandGroup(); + + return Result.FromSuccess(); } /// - public override ValueTask InitializeAsync(IServiceProvider serviceProvider) + public override ValueTask InitializeAsync(IServiceProvider serviceProvider, CancellationToken ct = default) { - return base.InitializeAsync(serviceProvider); + return base.InitializeAsync(serviceProvider, ct); } } } diff --git a/Plugins/Mara.Plugins.Core/Mara.Plugins.Core.csproj b/Plugins/Mara.Plugins.Core/Mara.Plugins.Core.csproj index c94d703..d309b2a 100644 --- a/Plugins/Mara.Plugins.Core/Mara.Plugins.Core.csproj +++ b/Plugins/Mara.Plugins.Core/Mara.Plugins.Core.csproj @@ -1,16 +1,16 @@  - net5.0 + net6.0 1.0.0 $(Version) $(Version) - - - + + + diff --git a/Plugins/Mara.Plugins.Core/Responders/MessageDeleteResponder.cs b/Plugins/Mara.Plugins.Core/Responders/DeleteRequestResponder.cs similarity index 88% rename from Plugins/Mara.Plugins.Core/Responders/MessageDeleteResponder.cs rename to Plugins/Mara.Plugins.Core/Responders/DeleteRequestResponder.cs index 9cc24a1..8d85e32 100644 --- a/Plugins/Mara.Plugins.Core/Responders/MessageDeleteResponder.cs +++ b/Plugins/Mara.Plugins.Core/Responders/DeleteRequestResponder.cs @@ -12,16 +12,16 @@ namespace Mara.Plugins.Core.Responders { - public sealed class MessageDeleteResponder : IResponder + public sealed class DeleteRequestResponder : IResponder { private readonly DiscordRestChannelAPI _channelApi; - public MessageDeleteResponder(DiscordRestChannelAPI channelApi) + public DeleteRequestResponder(DiscordRestChannelAPI channelApi) { _channelApi = channelApi; } - public async Task RespondAsync(MessageReactionAdd gatewayEvent, CancellationToken cancellationToken = new CancellationToken()) + public async Task RespondAsync(MessageReactionAdd gatewayEvent, CancellationToken cancellationToken = default) { // If the reaction wasn't ❌, skip. if (!gatewayEvent.Emoji.Name.Equals("❌")) diff --git a/Plugins/Mara.Plugins.Core/Responders/ReadyResponder.cs b/Plugins/Mara.Plugins.Core/Responders/ReadyResponder.cs index b51e45d..17327a0 100644 --- a/Plugins/Mara.Plugins.Core/Responders/ReadyResponder.cs +++ b/Plugins/Mara.Plugins.Core/Responders/ReadyResponder.cs @@ -1,22 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Mara.Common.Discord.Feedback; using Mara.Common.Events; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Gateway.Commands; using Remora.Discord.API.Objects; using Remora.Discord.Commands.Services; -using Remora.Discord.Core; using Remora.Discord.Gateway; using Remora.Results; @@ -25,7 +17,7 @@ namespace Mara.Plugins.Core.Responders public sealed class ReadyResponder : LoggingEventResponderBase { private readonly IDiscordRestOAuth2API _oauthApi; - private readonly IMemoryCache _cache; + private readonly IdentityInformationConfiguration _identityInfo; private readonly DiscordGatewayClient _gatewayClient; private readonly SlashService _slashService; private readonly ILogger _logger; @@ -34,24 +26,21 @@ public ReadyResponder ( ILogger logger, IDiscordRestOAuth2API oauth, - IMemoryCache cache, + IdentityInformationConfiguration identityInfo, DiscordGatewayClient gatewayClient, SlashService slashService ) : base(logger) { _logger = logger; _oauthApi = oauth; - _cache = cache; + _identityInfo = identityInfo; _gatewayClient = gatewayClient; _slashService = slashService; } public override async Task HandleAsync(IReady gatewayEvent, CancellationToken cancellationToken = default) { - var identityInfo = new IdentityInformationConfiguration - { - Id = gatewayEvent.User.ID - }; + _identityInfo.Id = gatewayEvent.User.ID; var getApplication = await _oauthApi.GetCurrentBotApplicationInformationAsync(cancellationToken); if (!getApplication.IsSuccess) @@ -59,37 +48,14 @@ public override async Task HandleAsync(IReady gatewayEvent, Cancellation return Result.FromError(getApplication); } - var application = getApplication.Entity; - - identityInfo.ApplicationId = application.ID; - identityInfo.OwnerId = application.Owner.ID.Value; + _identityInfo.Application = getApplication.Entity; - // Update memory cache - _cache.Set(nameof(IdentityInformationConfiguration), identityInfo); + var application = getApplication.Entity; // Set status var updatePresence = new UpdatePresence(ClientStatus.Online, false, null, new[] {new Activity("anime", ActivityType.Watching)}); - _gatewayClient.SubmitCommandAsync(updatePresence); - - // Load slash commands - /* - var checkSlashService = _slashService.SupportsSlashCommands(); - - if (checkSlashService.IsSuccess) - { - var updateSlash = await _slashService.UpdateSlashCommandsAsync(ct: cancellationToken); - if (!updateSlash.IsSuccess) - { - _logger.LogWarning("Failed to update slash commands: {Reason}", - updateSlash.Error.Message); - } - } - else - { - _logger.LogWarning("The registered commands of the bot don't support slash commands: {Reason}", checkSlashService.Error.Message); - } - */ + _gatewayClient.SubmitCommand(updatePresence); return Result.FromSuccess(); } diff --git a/Plugins/Mara.Plugins.Core/Responders/SlashCommandRegistrationResponder.cs b/Plugins/Mara.Plugins.Core/Responders/SlashCommandRegistrationResponder.cs index da8f9a2..b96ab3e 100644 --- a/Plugins/Mara.Plugins.Core/Responders/SlashCommandRegistrationResponder.cs +++ b/Plugins/Mara.Plugins.Core/Responders/SlashCommandRegistrationResponder.cs @@ -3,10 +3,12 @@ using Microsoft.Extensions.Logging; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.Commands.Services; -using Remora.Discord.Core; using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; using Remora.Results; +using DiscordConstants = Remora.Discord.API.Constants; + namespace Mara.Plugins.Core.Responders { public sealed class SlashCommandRegistrationResponder : IResponder @@ -24,7 +26,7 @@ public SlashCommandRegistrationResponder(SlashService slashService, ILogger RespondAsync(IGuildCreate gatewayEvent, CancellationToken cancellationToken = new CancellationToken()) { // For debug only - var guildId = new Snowflake(861515006067998731); + var guildId = new Snowflake(861515006067998731, DiscordConstants.DiscordEpoch); if (gatewayEvent.ID != guildId) { diff --git a/Plugins/Mara.Plugins.MediatR/Mara.Plugins.Mediator.csproj b/Plugins/Mara.Plugins.MediatR/Mara.Plugins.Mediator.csproj index c693899..b14ae30 100644 --- a/Plugins/Mara.Plugins.MediatR/Mara.Plugins.Mediator.csproj +++ b/Plugins/Mara.Plugins.MediatR/Mara.Plugins.Mediator.csproj @@ -2,18 +2,18 @@ Library - net5.0 + net6.0 1.0.0 $(Version) $(Version) - + - - + + diff --git a/Plugins/Mara.Plugins.Mediator.Tests/Mara.Plugins.Mediator.Tests.csproj b/Plugins/Mara.Plugins.Mediator.Tests/Mara.Plugins.Mediator.Tests.csproj new file mode 100644 index 0000000..617180a --- /dev/null +++ b/Plugins/Mara.Plugins.Mediator.Tests/Mara.Plugins.Mediator.Tests.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/Plugins/Mara.Plugins.Mediator.Tests/UnitTest1.cs b/Plugins/Mara.Plugins.Mediator.Tests/UnitTest1.cs new file mode 100644 index 0000000..f9dc234 --- /dev/null +++ b/Plugins/Mara.Plugins.Mediator.Tests/UnitTest1.cs @@ -0,0 +1,14 @@ +using System; +using Xunit; + +namespace Mara.Plugins.Mediator.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation.Tests/Mara.Plugins.Moderation.Tests.csproj b/Plugins/Mara.Plugins.Moderation.Tests/Mara.Plugins.Moderation.Tests.csproj new file mode 100644 index 0000000..617180a --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation.Tests/Mara.Plugins.Moderation.Tests.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/Plugins/Mara.Plugins.Moderation.Tests/UnitTest1.cs b/Plugins/Mara.Plugins.Moderation.Tests/UnitTest1.cs new file mode 100644 index 0000000..7c8e98a --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation.Tests/UnitTest1.cs @@ -0,0 +1,14 @@ +using System; +using Xunit; + +namespace Mara.Plugins.Moderation.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Commands/InfoCommand.cs b/Plugins/Mara.Plugins.Moderation/Commands/InfoCommand.cs new file mode 100644 index 0000000..d79315f --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Commands/InfoCommand.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection.Metadata.Ecma335; +using System.Text; +using System.Threading.Tasks; +using Mara.Common.Discord; +using Mara.Plugins.Moderation.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Core; +using Remora.Results; + +namespace Mara.Plugins.Moderation.Commands +{ + public sealed class InfoCommand : CommandGroup + { + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestUserAPI _userApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly UserService _userService; + private readonly ICommandContext _context; + + public InfoCommand + ( + FeedbackService feedbackService, + IDiscordRestUserAPI userApi, + IDiscordRestGuildAPI guildApi, + UserService userService, + ICommandContext context + ) + { + _feedbackService = feedbackService; + _userApi = userApi; + _guildApi = guildApi; + _userService = userService; + _context = context; + } + + [Command("info")] + [Description("Returns information about a user.")] + [CommandType(ApplicationCommandType.ChatInput)] + public async Task ShowUserInfoChatAsync(IUser user, Snowflake? guildId = null) + { + var buildEmbedResult = await BuildUserInfoEmbed(user, guildId); + + if (!buildEmbedResult.IsSuccess) + { + return buildEmbedResult; + } + + return await _feedbackService.SendContextualEmbedAsync(buildEmbedResult.Entity, ct: base.CancellationToken); + } + + [Command("User Info")] + [Description("Returns information about a user.")] + [CommandType(ApplicationCommandType.User)] + public async Task ShowUserInfoMenuAsync() + { + var user = _context.User; + + var buildEmbedResult = _context.GuildID.HasValue + ? await BuildUserInfoEmbed(user, _context.GuildID.Value) + : await BuildUserInfoEmbed(user, null); + + if (!buildEmbedResult.IsSuccess) + { + return buildEmbedResult; + } + + return await _feedbackService.SendContextualEmbedAsync(buildEmbedResult.Entity, ct: base.CancellationToken); + } + + private async Task> BuildUserInfoEmbed(IUser user, Snowflake? guildId) + { + var userInfo = await _userService.GetUserInformation(user); + + if (!userInfo.IsSuccess) + { + return Result.FromError(userInfo); + } + + var embedBuilder = new EmbedBuilder() + .WithUserAsAuthor(user); + + embedBuilder.WithThumbnailUrl(embedBuilder.Author?.IconUrl.Value ?? ""); + + var userInformation = new StringBuilder() + .AppendLine($"ID: {user.ID.Value}") + .AppendLine($"Profile: {FormatUtilities.Mention(user)}") + .AppendLine($"First Seen: {userInfo.Entity.FirstSeen}") + .AppendLine($"Last Seen: {userInfo.Entity.LastSeen}"); + embedBuilder.AddField("❯ User Information", userInformation.ToString()); + + var memberInfo = new StringBuilder() + .AppendLine($"Created: {user.ID.Timestamp}"); + + Result joinDate = default; + + if (guildId is not null) + { + joinDate = await GetJoinDate(user, guildId.Value); + } + + if (_context.GuildID.HasValue) + { + joinDate = await GetJoinDate(user, _context.GuildID.Value); + } + + if (joinDate.IsSuccess) + { + memberInfo.AppendLine($"Joined: {FormatUtilities.DynamicTimeStamp(joinDate.Entity, FormatUtilities.TimeStampStyle.RelativeTime)}"); + FormatUtilities.DynamicTimeStamp(joinDate.Entity, FormatUtilities.TimeStampStyle.ShortTime); + } + + embedBuilder.WithFooter(EmbedConstants.DefaultFooter); + embedBuilder.WithCurrentTimestamp(); + + var ensure = embedBuilder.Ensure(); + + if (!ensure.IsSuccess) + { + return Result.FromError(ensure); + } + + return embedBuilder.Build(); + } + + private async Task> GetJoinDate(IUser user, Snowflake guildId) + { + var guildMember = await _guildApi.GetGuildMemberAsync(guildId, user.ID, base.CancellationToken); + + return guildMember.IsSuccess + ? guildMember.Entity.JoinedAt + : Result.FromError(guildMember); + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Mara.Plugins.Moderation.csproj b/Plugins/Mara.Plugins.Moderation/Mara.Plugins.Moderation.csproj new file mode 100644 index 0000000..e35ec8d --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Mara.Plugins.Moderation.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + + + + + + + + + + + + + diff --git a/Plugins/Mara.Plugins.Moderation/Models/Audit.cs b/Plugins/Mara.Plugins.Moderation/Models/Audit.cs new file mode 100644 index 0000000..b3845a4 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/Audit.cs @@ -0,0 +1,51 @@ +using Remora.Rest.Core; +using System.Collections.Generic; + +namespace Mara.Plugins.Moderation.Models +{ + /// + /// Represents an action that takes place on a server. + /// + public sealed class Audit + { + /// + /// The audit's unique id + /// + public int Id { get; set; } + + /// + /// The id of the Guild where this event took place. + /// + public Snowflake GuildId { get; set; } + + /// + /// The user or bot responsible for the action. + /// + public Snowflake Source { get; set; } + + /// + /// The kind of event that took place + /// + public EventType EventType { get; set; } + + /// + /// The target of the action + /// + public Snowflake Target { get; set; } + + /// + /// A collection of actions taken during this change. + /// + public List AuditActions { get; set; } = new(); + + /// + /// If the action was performed as part of a change, its change number goes here. + /// + public int? ChangeNumber { get; set; } + + /// + /// User-provided information regarding the audit entry. + /// + public string? Comment { get; set; } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/AuditAction.cs b/Plugins/Mara.Plugins.Moderation/Models/AuditAction.cs new file mode 100644 index 0000000..7787dc3 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/AuditAction.cs @@ -0,0 +1,18 @@ +namespace Mara.Plugins.Moderation.Models +{ + /// + /// Represents an action taken during an audit, such as a channel name change or the user that was banned. + /// + public sealed class AuditAction + { + /// + /// The unique identifier for this item. + /// + public int Id { get; set; } + + /// + /// A reference back to the audit this action belongs to. + /// + public Audit Audit { get; set; } = new(); + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/AuditConfiguration.cs b/Plugins/Mara.Plugins.Moderation/Models/AuditConfiguration.cs new file mode 100644 index 0000000..07d40f6 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/AuditConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Mara.Common.ValueConverters; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Mara.Plugins.Moderation.Models +{ + public sealed class AuditConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(a => a.Source).HasConversion(); + builder.Property(a => a.Target).HasConversion(); + + builder.Property(a => a.EventType).HasConversion>(); + + builder.HasMany(a => a.AuditActions).WithOne(aa => aa.Audit); + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/EventType.cs b/Plugins/Mara.Plugins.Moderation/Models/EventType.cs new file mode 100644 index 0000000..83b62db --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/EventType.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mara.Plugins.Moderation.Models +{ + public enum EventType + { + /// + /// Placeholder. Used for when there is no valid event type. + /// + None = 0, + + ChannelCreate = 1, + ChannelDelete = 2, + ChannelUpdate = 3, + ChannelPinsUpdate = 4, + StageInstanceCreate = 5, + StageInstanceDelete = 6, + StageInstanceUpdate = 7, + ThreadCreate = 8, + ThreadDelete = 9, + ThreadUpdate = 10, + + GuildBanAdd = 11, + GuildBanRemove = 12, + GuildCreate = 13, + GuildDelete = 14, + GuildEmojisUpdate = 15, + GuildIntegrationsUpdate = 16, + GuildMemberAdd = 17, + GuildMemberRemove = 18, + GuildMemberUpdate = 19, + GuildRoleCreate = 20, + GuildRoleDelete = 21, + GuildRoleUpdate = 22, + GuildStickersUpdate = 23, + GuildUpdate = 24, + GuildInviteCreate = 25, + GuildInviteDelete = 26, + + MessageCreate = 27, + MessageDelete = 28, + MessageUpdate = 29, + MessageReactionAdd = 30, + MessageReactionRemove = 31, + + WebhookUpdate = 32 + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/Infraction.cs b/Plugins/Mara.Plugins.Moderation/Models/Infraction.cs new file mode 100644 index 0000000..e6a6a75 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/Infraction.cs @@ -0,0 +1,52 @@ +using Remora.Rest.Core; +using System; + +namespace Mara.Plugins.Moderation.Models +{ + public sealed class Infraction + { + /// + /// The unique identifier of this infraction. + /// + public int Id { get; set; } + + /// + /// A reference back to the user that has this infraction. + /// + public UserInformation User { get; set; } = new(); + + /// + /// The date and time the infraction took place. + /// + public DateTime TimeStamp { get; set; } + + /// + /// What kind of action was taken by the responsible moderator. + /// + public InfractionKind InfractionKind { get; set; } + + /// + /// The user responsible for resolving the issue. + /// + public Snowflake ResponsibleModerator { get; set; } + + /// + /// Information about this infraction. + /// + public string Reason { get; set; } = ""; + + /// + /// True if this infraction was rescinded; otherwise, False. + /// + public bool Rescinded { get; set; } + } + + public enum InfractionKind + { + Warn = 1, + Mute = 2, + Kick = 4, + SoftBan = 8, + Ban = 16 + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/InfractionConfiguration.cs b/Plugins/Mara.Plugins.Moderation/Models/InfractionConfiguration.cs new file mode 100644 index 0000000..7b0037a --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/InfractionConfiguration.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mara.Plugins.Moderation.Models +{ + public sealed class InfractionConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedOnAdd(); + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/UserInformation.cs b/Plugins/Mara.Plugins.Moderation/Models/UserInformation.cs new file mode 100644 index 0000000..f893e29 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/UserInformation.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Remora.Rest.Core; + +namespace Mara.Plugins.Moderation.Models +{ + + public sealed class UserInformation + { + public Snowflake Id { get; init; } + + public DateTime FirstSeen { get; init; } + + public DateTime LastSeen { get; set; } + + public List Infractions { get; set; } = new(); + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Models/UserInformationConfiguration.cs b/Plugins/Mara.Plugins.Moderation/Models/UserInformationConfiguration.cs new file mode 100644 index 0000000..e993f9d --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Models/UserInformationConfiguration.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mara.Plugins.Moderation.Models +{ + public sealed class UserInformationConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.HasMany(x => x.Infractions).WithOne(x => x.User); + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/ModerationContext.cs b/Plugins/Mara.Plugins.Moderation/ModerationContext.cs new file mode 100644 index 0000000..9b07a63 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/ModerationContext.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using Mara.Plugins.Moderation.Models; +using Microsoft.EntityFrameworkCore; + +namespace Mara.Plugins.Moderation +{ + public sealed class ModerationContext : DbContext + { + public DbSet UserInfo { get; } + + public ModerationContext(DbContextOptions options) : base(options) + { + + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + base.OnModelCreating(builder); + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/ModerationPlugin.cs b/Plugins/Mara.Plugins.Moderation/ModerationPlugin.cs new file mode 100644 index 0000000..2b398ae --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/ModerationPlugin.cs @@ -0,0 +1,41 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Mara.Plugins.Moderation; +using Microsoft.Extensions.DependencyInjection; +using Remora.Plugins.Abstractions; +using Remora.Plugins.Abstractions.Attributes; +using Remora.Results; + +[assembly:RemoraPlugin(typeof(ModerationPlugin))] + +namespace Mara.Plugins.Moderation +{ + public sealed class ModerationPlugin : PluginDescriptor, IMigratablePlugin + { + public override string Name => "Moderation"; + + public override Version Version => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0); + public override string Description => "Provides extended moderation features for staff members."; + + public override void ConfigureServices(IServiceCollection serviceCollection) + { + base.ConfigureServices(serviceCollection); + } + + public override ValueTask InitializeAsync(IServiceProvider serviceProvider) + { + return base.InitializeAsync(serviceProvider); + } + + public async Task MigratePluginAsync(IServiceProvider serviceProvider) + { + throw new NotImplementedException(); + } + + public async Task HasCreatedPersistentStoreAsync(IServiceProvider serviceProvider) + { + throw new NotImplementedException(); + } + } +} diff --git a/Plugins/Mara.Plugins.Moderation/Services/UserService.cs b/Plugins/Mara.Plugins.Moderation/Services/UserService.cs new file mode 100644 index 0000000..9ca5e46 --- /dev/null +++ b/Plugins/Mara.Plugins.Moderation/Services/UserService.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Mara.Plugins.Moderation.Models; +using Microsoft.EntityFrameworkCore; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.Core; +using Remora.Results; + +namespace Mara.Plugins.Moderation.Services +{ + public sealed class UserService + { + private readonly ModerationContext _dbContext; + + public UserService(ModerationContext moderationContext) + { + _dbContext = moderationContext; + } + + public Task> GetUserInformation(IUser user) + => GetUserInformation(user.ID); + + public async Task> GetUserInformation(Snowflake snowflake) + { + var result = await _dbContext.UserInfo.FirstOrDefaultAsync(x => x.Id == snowflake); + + if (result == default) + { + return new DatabaseValueNotFoundError(); + } + + return Result.FromSuccess(result!); + } + } +}