diff --git a/.gitmodules b/.gitmodules index 7ca35a9..28319d5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "src/Mmcc.Bot.Polychat/Protos"] path = src/Mmcc.Bot.Polychat/Protos url = https://github.com/ModdedMinecraftClub/protos +[submodule "src/porbeagle"] + path = src/porbeagle + url = https://github.com/TraceLD/porbeagle diff --git a/src/Mmcc.Bot.Caching/ButtonHandlerRepository.cs b/src/Mmcc.Bot.Caching/ButtonHandlerRepository.cs deleted file mode 100644 index 3a3ba4c..0000000 --- a/src/Mmcc.Bot.Caching/ButtonHandlerRepository.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using Microsoft.Extensions.Caching.Memory; -using Mmcc.Bot.Caching.Entities; - -namespace Mmcc.Bot.Caching; - -/// -/// Represents a button handler repository. -/// -public interface IButtonHandlerRepository -{ - /// - /// Registers a new button handler from a object with the repository. - /// - /// object containing the handler. - void Register(HandleableButton handleableButton); - - /// - /// Registers a button handler with the repository. - /// - /// The ID the button handler corresponds to. - /// The . - void Register(ulong buttonId, ButtonHandler handler); - - /// - /// De-registers a button handler from the repository. - /// - /// The ID of the button to deregister. - void Deregister(ulong buttonId); - - /// - /// Gets a button handler for a button with a given ID or default value of if not found. - /// - /// The ID of the button for which to get a button handler. - /// The button handler corresponding to a button with a given ID or default value of if not found. - ButtonHandler? GetOrDefault(ulong buttonId); -} - -/// -public class ButtonHandlerRepository : IButtonHandlerRepository -{ - private readonly IMemoryCache _cache; - - /// - /// Sliding button handler cache expiration in minutes. - /// - private const int SlidingExpirationInMinutes = 5; - - /// - /// Absolute button handler cache expiration in minutes. - /// - /// - /// 15 minutes is how an interaction lasts by default in the Discord API. - private const int AbsoluteExpirationInMinutes = 15; - - /// - /// Instantiates a new instance of . - /// - /// The memory cache. - public ButtonHandlerRepository(IMemoryCache cache) - { - _cache = cache; - } - - /// - public void Register(HandleableButton handleableButton) => - Register(handleableButton.Id.Value, handleableButton.Handler); - - /// - public void Register(ulong buttonId, ButtonHandler handler) => - _cache.Set(buttonId, handler, new MemoryCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(SlidingExpirationInMinutes), - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(AbsoluteExpirationInMinutes) - }); - - /// - public void Deregister(ulong buttonId) => - _cache.Remove(buttonId); - - /// - public ButtonHandler? GetOrDefault(ulong buttonId) => - _cache.Get(buttonId); -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Caching/CachingSetup.cs b/src/Mmcc.Bot.Caching/CachingSetup.cs deleted file mode 100644 index 99292c0..0000000 --- a/src/Mmcc.Bot.Caching/CachingSetup.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Mmcc.Bot.Caching; - -/// -/// Extension methods that register Mmcc.Bot.Caching with the service collection. -/// -public static class CachingSetup -{ - /// - /// Registers Mmcc.Bot.Caching classes with the service collection. - /// - /// The . - /// The . - public static IServiceCollection AddMmccCaching(this IServiceCollection services) => - services.AddSingleton(); -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Caching/Entities/ButtonHandler.cs b/src/Mmcc.Bot.Caching/Entities/ButtonHandler.cs deleted file mode 100644 index c115a8d..0000000 --- a/src/Mmcc.Bot.Caching/Entities/ButtonHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Rest.Core; - -namespace Mmcc.Bot.Caching.Entities; - -public record ButtonHandler( - Type HandlerCommandType, - Type ContextType, - object Context, - Optional RequiredPermission = new() -); \ No newline at end of file diff --git a/src/Mmcc.Bot.Caching/Entities/HandleableButton.cs b/src/Mmcc.Bot.Caching/Entities/HandleableButton.cs deleted file mode 100644 index 150d7c2..0000000 --- a/src/Mmcc.Bot.Caching/Entities/HandleableButton.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MediatR; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.Caching.Entities; - -public record HandleableButton -{ - public Snowflake Id { get; init; } - public IButtonComponent Component { get; init; } - public ButtonHandler Handler { get; init; } - - private HandleableButton(Snowflake id, IButtonComponent component, ButtonHandler handler) - { - Id = id; - Component = component; - Handler = handler; - } - - public static HandleableButton Create( - Snowflake id, - IButtonComponent component, - TContext context, - Optional requiredPermission = new() - ) - where THandlerCommand : IRequest - where TContext : class - => new(id, component, new(typeof(THandlerCommand), typeof(TContext), context, requiredPermission)); - - public void Deconstruct(out Snowflake id, out IButtonComponent component, out ButtonHandler handler) - { - id = Id; - component = Component; - handler = Handler; - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.Extensions/Caching/ButtonExtensions.cs b/src/Mmcc.Bot.Common.Extensions/Caching/ButtonExtensions.cs deleted file mode 100644 index 869120d..0000000 --- a/src/Mmcc.Bot.Common.Extensions/Caching/ButtonExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Mmcc.Bot.Caching; -using Mmcc.Bot.Caching.Entities; - -namespace Mmcc.Bot.Common.Extensions.Caching -{ - public static class ButtonExtensions - { - public static HandleableButton RegisterWith(this HandleableButton handleableButton, IButtonHandlerRepository repository) - { - repository.Register(handleableButton); - return handleableButton; - } - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.Extensions/Database/Entities/MemberApplicationExtensions.cs b/src/Mmcc.Bot.Common.Extensions/Database/Entities/MemberApplicationExtensions.cs deleted file mode 100644 index 62977bd..0000000 --- a/src/Mmcc.Bot.Common.Extensions/Database/Entities/MemberApplicationExtensions.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.Database.Entities; -using Mmcc.Bot.RemoraAbstractions.Timestamps; -using Remora.Discord.API.Objects; - -namespace Mmcc.Bot.Common.Extensions.Database.Entities -{ - /// - /// Extensions for . - /// - public static class MemberApplicationExtensions - { - /// - /// Get an embed representation of a member application. - /// - /// Member application. - /// Colour palette for the embed to use. - /// Embed representing the member application. - public static Embed GetEmbed(this MemberApplication memberApplication, IColourPalette colourPalette) - { - var statusStr = memberApplication.AppStatus.ToString(); - var embedConditionalAttributes = memberApplication.AppStatus switch - { - ApplicationStatus.Pending => new - { - Colour = colourPalette.Blue, - StatusFieldValue = $":clock1: {statusStr}" - }, - ApplicationStatus.Approved => new - { - Colour = colourPalette.Green, - StatusFieldValue = $":white_check_mark: {statusStr}" - }, - ApplicationStatus.Rejected => new - { - Colour = colourPalette.Red, - StatusFieldValue = $":no_entry: {statusStr}" - }, - _ => throw new ArgumentOutOfRangeException(nameof(memberApplication)) - }; - return new Embed - { - Title = $"Member Application #{memberApplication.MemberApplicationId}", - Description = - $"Submitted at {new DiscordTimestamp(memberApplication.AppTime).AsStyled(DiscordTimestampStyle.ShortDateTime)}.", - Fields = new List - { - new("Author", $"{memberApplication.AuthorDiscordName} (ID: `{memberApplication.AuthorDiscordId}`)", - false), - new("Status", embedConditionalAttributes.StatusFieldValue, false), - new( - "Provided details", - $"{memberApplication.MessageContent}\n" + - $"**[Original message (click here)](https://discord.com/channels/{memberApplication.GuildId}/{memberApplication.ChannelId}/{memberApplication.MessageId})**", - false - ) - }, - Colour = embedConditionalAttributes.Colour, - Thumbnail = new EmbedThumbnail(memberApplication.ImageUrl) - }; - } - - /// - /// Gets an enumerable of formatted embed fields that represent the member applications. - /// - /// Enumerable of member applications. - /// Enumerable of formatted embed fields that represent the member applications. - public static IEnumerable GetEmbedFields(this IEnumerable memberApplications) => - memberApplications.Select(app => new EmbedField - ( - $"[{app.MemberApplicationId}] {app.AuthorDiscordName}", - $"*Submitted at:* {new DiscordTimestamp(app.AppTime).AsStyled(DiscordTimestampStyle.ShortDateTime)}.", - false - )); - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.Extensions/Database/Entities/ModerationActionExtensions.cs b/src/Mmcc.Bot.Common.Extensions/Database/Entities/ModerationActionExtensions.cs index c26e845..d3c16dc 100644 --- a/src/Mmcc.Bot.Common.Extensions/Database/Entities/ModerationActionExtensions.cs +++ b/src/Mmcc.Bot.Common.Extensions/Database/Entities/ModerationActionExtensions.cs @@ -29,6 +29,22 @@ public static IEnumerable GetEmbedFields(this IEnumerable ma switch + { + { UserDiscordId: { } dId, UserIgn: { } ign } => + $$""" + Discord user: <@{{dId}}> + IGN: `{{ign}}` + """, + + { UserDiscordId: { } dId } => $"Discord user: <@{dId}>", + + { UserIgn: { } ign } => $"IGN: `{ign}`", + + _ => "No Discord ID/IGN data." + }; + private static EmbedField GetEmbedFieldForActionsOfType(this IEnumerable moderationActions, ModerationActionType type, bool showAssociatedDiscord, bool showAssociatedIgn) { var list = moderationActions.Where(ma => ma.ModerationActionType == type).ToList(); diff --git a/src/Mmcc.Bot.Common.Extensions/FluentValidation/Results/ValidationFailuresExtensions.cs b/src/Mmcc.Bot.Common.Extensions/FluentValidation/Results/ValidationFailuresExtensions.cs deleted file mode 100644 index 73851f6..0000000 --- a/src/Mmcc.Bot.Common.Extensions/FluentValidation/Results/ValidationFailuresExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation.Results; -using Remora.Discord.API.Objects; - -namespace Mmcc.Bot.Common.Extensions.FluentValidation.Results -{ - /// - /// Extensions for . - /// - public static class ValidationFailuresExtensions - { - /// - /// Gets containing details of failures. - /// - /// Validation failures. - /// Whether the should be inline. Defaults to false. - /// containing details of failures. - public static EmbedField ToEmbedField(this IEnumerable validationFailures, bool inline = false) - { - var validationFailuresList = validationFailures.ToList(); - - if (!validationFailuresList.Any()) - { - return new("Failures", "No description."); - } - - var descriptionSb = string.Join("\n", - validationFailuresList - .Select((vf, i) => $"{i + 1}) {vf.ToString().Replace('\'', '`')}")); - return new("Reason(s)", descriptionSb, inline); - } - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/Mmcc.Bot.Common.Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index 7c2afcb..9806af0 100644 --- a/src/Mmcc.Bot.Common.Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Mmcc.Bot.Common.Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using FluentValidation; +using System; +using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -33,6 +34,11 @@ IConfigurationSection configurationSection where TValidator : AbstractValidator, new() { var config = configurationSection.Get(); + if (config is null) + { + throw new Exception($"Could not match a configuration section to {typeof(TConfig)}"); + } + var validator = new TValidator(); validator.ValidateAndThrow(config); diff --git a/src/Mmcc.Bot.Common.Extensions/Mmcc.Bot.Common.Extensions.csproj b/src/Mmcc.Bot.Common.Extensions/Mmcc.Bot.Common.Extensions.csproj index 98bcc18..662737d 100644 --- a/src/Mmcc.Bot.Common.Extensions/Mmcc.Bot.Common.Extensions.csproj +++ b/src/Mmcc.Bot.Common.Extensions/Mmcc.Bot.Common.Extensions.csproj @@ -1,22 +1,20 @@ - net6.0 + net7.0 enable - 10 + 11 - - - - + + diff --git a/src/Mmcc.Bot.Common.Extensions/Remora/Discord/API/Abstractions/Rest/DiscordRestGuildApiExtensions.cs b/src/Mmcc.Bot.Common.Extensions/Remora/Discord/API/Abstractions/Rest/DiscordRestGuildApiExtensions.cs index c348bf3..e400161 100644 --- a/src/Mmcc.Bot.Common.Extensions/Remora/Discord/API/Abstractions/Rest/DiscordRestGuildApiExtensions.cs +++ b/src/Mmcc.Bot.Common.Extensions/Remora/Discord/API/Abstractions/Rest/DiscordRestGuildApiExtensions.cs @@ -33,7 +33,7 @@ public static async Task> FindGuildChannelByName(this IDiscordR var guildChannels = getGuildChannelsResult.Entity; var channel = guildChannels .Where(c => c.Name.HasValue) - .FirstOrDefault(c => c.Name.Value.Equals(channelName)); + .FirstOrDefault(c => c.Name.Value!.Equals(channelName)); if (channel is null) { return new NotFoundError( diff --git a/src/Mmcc.Bot.Common.Extensions/System/KeyValuePairDiscordExtensions.cs b/src/Mmcc.Bot.Common.Extensions/System/KeyValuePairDiscordExtensions.cs new file mode 100644 index 0000000..1533c4b --- /dev/null +++ b/src/Mmcc.Bot.Common.Extensions/System/KeyValuePairDiscordExtensions.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Linq; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Common.Extensions.System; + +public static class KeyValuePairDiscordExtensions +{ + public static IEnumerable ToEmbedFields(this IEnumerable> kvEnumerable) + => kvEnumerable.Select(x => new EmbedField(x.Key, x.Value, false)); + + public static EmbedField ToEmbedField(this KeyValuePair kv) + => new(kv.Key, kv.Value, false); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.Extensions/System/String.cs b/src/Mmcc.Bot.Common.Extensions/System/StringExtensions.cs similarity index 69% rename from src/Mmcc.Bot.Common.Extensions/System/String.cs rename to src/Mmcc.Bot.Common.Extensions/System/StringExtensions.cs index cbc0b17..3e0ea4a 100644 --- a/src/Mmcc.Bot.Common.Extensions/System/String.cs +++ b/src/Mmcc.Bot.Common.Extensions/System/StringExtensions.cs @@ -2,12 +2,14 @@ namespace Mmcc.Bot.Common.Extensions.System { - public static class String + public static class StringExtensions { public static string[] SplitByNewLine(this string s) => s.Split( new[] {"\r\n", "\r", "\n"}, StringSplitOptions.None ); + + public static string DoubleQuotes(this string s) => $"\"{s}\""; } } \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Buttons/DonateButton.cs b/src/Mmcc.Bot.Common.UI/Buttons/DonateButton.cs new file mode 100644 index 0000000..7e2087e --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Buttons/DonateButton.cs @@ -0,0 +1,12 @@ +using Mmcc.Bot.Common.Statics; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Common.UI.Buttons; + +public record DonateButton() : ButtonComponent( + Style: ButtonComponentStyle.Link, + Label: "Donate", + Emoji: new PartialEmoji(Name: "❤️"), + URL: MmccUrls.Donations +); \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Buttons/ForumButton.cs b/src/Mmcc.Bot.Common.UI/Buttons/ForumButton.cs new file mode 100644 index 0000000..f1ea7cb --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Buttons/ForumButton.cs @@ -0,0 +1,12 @@ +using Mmcc.Bot.Common.Statics; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Common.UI.Buttons; + +public record ForumButton() : ButtonComponent( + ButtonComponentStyle.Link, + "Forum", + new PartialEmoji(Name: "🗣️"), + URL: MmccUrls.Forum +); \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Buttons/MmccGithubOrgButton.cs b/src/Mmcc.Bot.Common.UI/Buttons/MmccGithubOrgButton.cs new file mode 100644 index 0000000..597d929 --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Buttons/MmccGithubOrgButton.cs @@ -0,0 +1,13 @@ +using Mmcc.Bot.Common.Statics; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Common.UI.Buttons; + +public record MmccGithubOrgButton() : ButtonComponent( + ButtonComponentStyle.Link, + "GitHub", + new PartialEmoji(new Snowflake(453413238638641163)), + URL: MmccUrls.GitHub +); \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Buttons/MmccWebsiteButton.cs b/src/Mmcc.Bot.Common.UI/Buttons/MmccWebsiteButton.cs new file mode 100644 index 0000000..af159f8 --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Buttons/MmccWebsiteButton.cs @@ -0,0 +1,13 @@ +using Mmcc.Bot.Common.Statics; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Common.UI.Buttons; + +public record MmccWebsiteButton() : ButtonComponent( + ButtonComponentStyle.Link, + "Website", + new PartialEmoji(new Snowflake(863798570602856469)), + URL: MmccUrls.Website +); \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Buttons/WikiButton.cs b/src/Mmcc.Bot.Common.UI/Buttons/WikiButton.cs new file mode 100644 index 0000000..77cdcc5 --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Buttons/WikiButton.cs @@ -0,0 +1,12 @@ +using Mmcc.Bot.Common.Statics; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Common.UI.Buttons; + +public record WikiButton() : ButtonComponent( + ButtonComponentStyle.Link, + "Wiki", + new PartialEmoji(Name: "📖"), + URL: MmccUrls.Wiki +); \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Embeds/NotificationEmbed.cs b/src/Mmcc.Bot.Common.UI/Embeds/NotificationEmbed.cs new file mode 100644 index 0000000..7f5a65e --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Embeds/NotificationEmbed.cs @@ -0,0 +1,19 @@ +using Mmcc.Bot.Common.Extensions.System; +using Mmcc.Bot.Common.Models; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Common.UI.Embeds; + +public record NotificationEmbed : Embed +{ + public NotificationEmbed(IMmccNotification context) : base( + Title: context.Title, + Description: context.Description ?? new Optional(), + Timestamp: context.Timestamp ?? new Optional(), + Fields: context.CustomProperties?.ToEmbedFields().ToList() ?? new Optional>() + ) + { + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common.UI/Mmcc.Bot.Common.UI.csproj b/src/Mmcc.Bot.Common.UI/Mmcc.Bot.Common.UI.csproj new file mode 100644 index 0000000..363e860 --- /dev/null +++ b/src/Mmcc.Bot.Common.UI/Mmcc.Bot.Common.UI.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + + + + + + + + diff --git a/src/Mmcc.Bot.Common/Errors/InteractionExpiredError.cs b/src/Mmcc.Bot.Common/Errors/InteractionExpiredError.cs new file mode 100644 index 0000000..553cb23 --- /dev/null +++ b/src/Mmcc.Bot.Common/Errors/InteractionExpiredError.cs @@ -0,0 +1,9 @@ +using Remora.Commands.Trees.Nodes; +using Remora.Results; + +namespace Mmcc.Bot.Common.Errors; + +/// +/// Represents a failure resulting from an interaction having expired in the corresponding store. +/// +public record InteractionExpiredError(string Message, IChildNode? Node = default) : ResultError(Message); \ No newline at end of file diff --git a/src/Mmcc.Bot.Common/ExcludeFromMediatrAssemblyScanAttribute.cs b/src/Mmcc.Bot.Common/ExcludeFromMediatrAssemblyScanAttribute.cs new file mode 100644 index 0000000..89a3b44 --- /dev/null +++ b/src/Mmcc.Bot.Common/ExcludeFromMediatrAssemblyScanAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace Mmcc.Bot.Common; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] +public class ExcludeFromMediatrAssemblyScanAttribute : Attribute +{ +} diff --git a/src/Mmcc.Bot.Common/Mmcc.Bot.Common.csproj b/src/Mmcc.Bot.Common/Mmcc.Bot.Common.csproj index c8d753c..2d0da02 100644 --- a/src/Mmcc.Bot.Common/Mmcc.Bot.Common.csproj +++ b/src/Mmcc.Bot.Common/Mmcc.Bot.Common.csproj @@ -1,15 +1,20 @@ - net6.0 + net7.0 enable - 10 + 11 - - - + + + + + + + + diff --git a/src/Mmcc.Bot.Common/Models/Colours/ColourPalette.cs b/src/Mmcc.Bot.Common/Models/Colours/ColourPalette.cs new file mode 100644 index 0000000..448a4fb --- /dev/null +++ b/src/Mmcc.Bot.Common/Models/Colours/ColourPalette.cs @@ -0,0 +1,17 @@ +using System.Drawing; + +namespace Mmcc.Bot.Common.Models.Colours; + +// TODO: Remove all usages of IColourPalette; +public static class ColourPalette +{ + public static Color Black => ColorTranslator.FromHtml("#262626"); + public static Color Gray => ColorTranslator.FromHtml("#6B7280"); + public static Color Red => ColorTranslator.FromHtml("#EF4444"); + public static Color Yellow => ColorTranslator.FromHtml("#F59E0B"); + public static Color Green => ColorTranslator.FromHtml("#10B981"); + public static Color Blue => ColorTranslator.FromHtml("#3B82F6"); + public static Color Indigo => ColorTranslator.FromHtml("#6366F1"); + public static Color Purple => ColorTranslator.FromHtml("#8B5CF6"); + public static Color Pink => ColorTranslator.FromHtml("#EC4899"); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common/Models/Colours/IColourPalette.cs b/src/Mmcc.Bot.Common/Models/Colours/IColourPalette.cs index 11e5427..fa94cee 100644 --- a/src/Mmcc.Bot.Common/Models/Colours/IColourPalette.cs +++ b/src/Mmcc.Bot.Common/Models/Colours/IColourPalette.cs @@ -2,6 +2,8 @@ namespace Mmcc.Bot.Common.Models.Colours { + // TODO: REMOVE THIS CLASS; + /// /// Colour palette for embeds; /// @@ -17,4 +19,17 @@ public interface IColourPalette public Color Purple { get; } public Color Pink { get; } } + + public class TailwindColourPalette : IColourPalette + { + public Color Black => ColorTranslator.FromHtml("#262626"); + public Color Gray => ColorTranslator.FromHtml("#6B7280"); + public Color Red => ColorTranslator.FromHtml("#EF4444"); + public Color Yellow => ColorTranslator.FromHtml("#F59E0B"); + public Color Green => ColorTranslator.FromHtml("#10B981"); + public Color Blue => ColorTranslator.FromHtml("#3B82F6"); + public Color Indigo => ColorTranslator.FromHtml("#6366F1"); + public Color Purple => ColorTranslator.FromHtml("#8B5CF6"); + public Color Pink => ColorTranslator.FromHtml("#EC4899"); + } } \ No newline at end of file diff --git a/src/Mmcc.Bot.Common/Models/Colours/TailwindColourPalette.cs b/src/Mmcc.Bot.Common/Models/Colours/TailwindColourPalette.cs deleted file mode 100644 index baf2a5c..0000000 --- a/src/Mmcc.Bot.Common/Models/Colours/TailwindColourPalette.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Drawing; - -namespace Mmcc.Bot.Common.Models.Colours -{ - /// - public class TailwindColourPalette : IColourPalette - { - public Color Black => ColorTranslator.FromHtml("#262626"); - public Color Gray => ColorTranslator.FromHtml("#6B7280"); - public Color Red => ColorTranslator.FromHtml("#EF4444"); - public Color Yellow => ColorTranslator.FromHtml("#F59E0B"); - public Color Green => ColorTranslator.FromHtml("#10B981"); - public Color Blue => ColorTranslator.FromHtml("#3B82F6"); - public Color Indigo => ColorTranslator.FromHtml("#6366F1"); - public Color Purple => ColorTranslator.FromHtml("#8B5CF6"); - public Color Pink => ColorTranslator.FromHtml("#EC4899"); - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common/Models/IDiscordNotifiable.cs b/src/Mmcc.Bot.Common/Models/IDiscordNotifiable.cs new file mode 100644 index 0000000..d72cccf --- /dev/null +++ b/src/Mmcc.Bot.Common/Models/IDiscordNotifiable.cs @@ -0,0 +1,8 @@ +using Remora.Rest.Core; + +namespace Mmcc.Bot.Common.Models; + +public interface IDiscordNotifiable +{ + public Snowflake TargetGuildId { get; } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.Common/Models/IMmccNotification.cs b/src/Mmcc.Bot.Common/Models/IMmccNotification.cs new file mode 100644 index 0000000..aca2049 --- /dev/null +++ b/src/Mmcc.Bot.Common/Models/IMmccNotification.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using MediatR; + +namespace Mmcc.Bot.Common.Models; + +public interface IMmccNotification : INotification +{ + public string Title { get; } + public string? Description { get; } + public DateTimeOffset? Timestamp { get; } + IReadOnlyList>? CustomProperties { get; } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.Database/DesignTimeBotContextFactory.cs b/src/Mmcc.Bot.Database/DesignTimeBotContextFactory.cs index ef7fab6..3013698 100644 --- a/src/Mmcc.Bot.Database/DesignTimeBotContextFactory.cs +++ b/src/Mmcc.Bot.Database/DesignTimeBotContextFactory.cs @@ -21,7 +21,7 @@ public BotContext CreateDbContext(string[] args) var optionsBuilder = new DbContextOptionsBuilder(); var boundConfig = config.GetSection("MySql").Get(); var connString = - $"Server={boundConfig.ServerIp};Port={boundConfig.Port};Database={boundConfig.DatabaseName};Uid={boundConfig.Username};Pwd={boundConfig.Password};Allow User Variables=True"; + $"Server={boundConfig!.ServerIp};Port={boundConfig.Port};Database={boundConfig.DatabaseName};Uid={boundConfig.Username};Pwd={boundConfig.Password};Allow User Variables=True"; optionsBuilder.UseMySql( connString, ServerVersion.Parse("10.4.11-mariadb"), diff --git a/src/Mmcc.Bot.Database/Mmcc.Bot.Database.csproj b/src/Mmcc.Bot.Database/Mmcc.Bot.Database.csproj index 0bcd71e..eb2a63e 100644 --- a/src/Mmcc.Bot.Database/Mmcc.Bot.Database.csproj +++ b/src/Mmcc.Bot.Database/Mmcc.Bot.Database.csproj @@ -1,22 +1,21 @@ - net6.0 + net7.0 enable - 10 - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + diff --git a/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/Mmcc.Bot.Generators.PolychatRequestResolverGenerator.csproj b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/Mmcc.Bot.Generators.PolychatRequestResolverGenerator.csproj new file mode 100644 index 0000000..e4fac13 --- /dev/null +++ b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/Mmcc.Bot.Generators.PolychatRequestResolverGenerator.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + enable + enable + 11 + + + + + + diff --git a/src/Mmcc.Bot.Generators/RequestResolverGenerator.cs b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/RequestResolverGenerator.cs similarity index 82% rename from src/Mmcc.Bot.Generators/RequestResolverGenerator.cs rename to src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/RequestResolverGenerator.cs index 98e70e3..d6c8f4a 100644 --- a/src/Mmcc.Bot.Generators/RequestResolverGenerator.cs +++ b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/RequestResolverGenerator.cs @@ -1,10 +1,8 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Mmcc.Bot.Generators +namespace Mmcc.Bot.Generators.PolychatRequestResolverGenerator { [Generator] public class RequestResolverGenerator : ISourceGenerator @@ -79,20 +77,21 @@ List polychatMessageClasses private string AnnotateMsgType(string type) => $"global::{MSG_NAMESPACE}.{type}"; - protected override string FillInStub(string generatedFiller) - => $@"// auto-generated -namespace {GENERATED_NAMESPACE}; - -#pragma warning disable CS0612 // type is obsolete -public partial class {GENERATED_CLASS} -{{ - public {AnnotateTypeWithGlobal(_polychatRequestInterfaceSymbol)}? Resolve() - {{ -{string.Join("\n", generatedFiller.Split('\n').Select(s => Indent(s, 2)))} - }} -}} -#pragma warning restore CS0612 -"; + protected override string FillInStub(string generatedFiller) => + $$""" + // auto-generated + namespace {{GENERATED_NAMESPACE}}; + + #pragma warning disable CS0612 // type is obsolete + public partial class {{GENERATED_CLASS}} + { + public {{AnnotateTypeWithGlobal(_polychatRequestInterfaceSymbol)}}? Resolve() + { + {{string.Join("\n", generatedFiller.Split('\n').Select(s => Indent(s, 2)))}} + } + } + #pragma warning restore CS0612 + """; protected override string GenerateFiller() { @@ -115,13 +114,13 @@ protected override string GenerateFiller() return sb.ToString(); } - private static string GenerateNoClassesStub() - => new StringBuilder() - .AppendLine("// no Polychat classes found;") - .AppendLine("// once classes are added to the Polychat project generated code will go here;") - .AppendLine() - .AppendLine("return null;") - .ToString(); + private static string GenerateNoClassesStub() => + """ + // no Polychat classes found; + // once classes are added to the Polychat project generated code will go here; + + return null; + """; private string GenerateIfForMsgClass(string messageType) => new StringBuilder() diff --git a/src/Mmcc.Bot.Generators/TemplateGeneratorBase.cs b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/TemplateGeneratorBase.cs similarity index 95% rename from src/Mmcc.Bot.Generators/TemplateGeneratorBase.cs rename to src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/TemplateGeneratorBase.cs index 4761ff5..ab65194 100644 --- a/src/Mmcc.Bot.Generators/TemplateGeneratorBase.cs +++ b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.PolychatRequestResolverGenerator/TemplateGeneratorBase.cs @@ -1,7 +1,6 @@ -using System.Linq; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; -namespace Mmcc.Bot.Generators +namespace Mmcc.Bot.Generators.PolychatRequestResolverGenerator { /// /// Represents a base class to be inherited by template generators. diff --git a/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.csproj b/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.csproj deleted file mode 100644 index 2449501..0000000 --- a/src/Mmcc.Bot.Generators/Mmcc.Bot.Generators.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - netstandard2.0 - 9.0 - enable - - - - - - diff --git a/src/Mmcc.Bot.Caching/Mmcc.Bot.Caching.csproj b/src/Mmcc.Bot.InMemoryStore/Mmcc.Bot.InMemoryStore.csproj similarity index 58% rename from src/Mmcc.Bot.Caching/Mmcc.Bot.Caching.csproj rename to src/Mmcc.Bot.InMemoryStore/Mmcc.Bot.InMemoryStore.csproj index 4227cc0..9702445 100644 --- a/src/Mmcc.Bot.Caching/Mmcc.Bot.Caching.csproj +++ b/src/Mmcc.Bot.InMemoryStore/Mmcc.Bot.InMemoryStore.csproj @@ -2,14 +2,13 @@ net6.0 + enable enable - 10 + 11 - - - + diff --git a/src/Mmcc.Bot.InMemoryStore/Stores/MessageMemberAppContextStore.cs b/src/Mmcc.Bot.InMemoryStore/Stores/MessageMemberAppContextStore.cs new file mode 100644 index 0000000..a9c42df --- /dev/null +++ b/src/Mmcc.Bot.InMemoryStore/Stores/MessageMemberAppContextStore.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Mmcc.Bot.InMemoryStore.Stores; + +public interface IMessageMemberAppContextStore +{ + void Add(ulong messageId, int memberAppId); + void Remove(ulong messageId); + int? GetOrDefault(ulong key); +} + +public class MessageMemberAppContextStore : IMessageMemberAppContextStore +{ + private readonly IMemoryCache _memCache; + + private readonly TimeSpan _slidingExpiration = TimeSpan.FromMinutes(15); + public readonly TimeSpan _absoluteExpiration = TimeSpan.FromHours(1); + + public MessageMemberAppContextStore(IMemoryCache memCache) + => _memCache = memCache; + + public void Add(ulong messageId, int memberAppId) + => _memCache.Set(messageId, memberAppId, new MemoryCacheEntryOptions + { + SlidingExpiration = _slidingExpiration, + AbsoluteExpirationRelativeToNow = _absoluteExpiration + }); + + public void Remove(ulong messageId) + => _memCache.Remove(messageId); + + public int? GetOrDefault(ulong key) + => _memCache.Get(key); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.InMemoryStore/Stores/StoresSetup.cs b/src/Mmcc.Bot.InMemoryStore/Stores/StoresSetup.cs new file mode 100644 index 0000000..908265c --- /dev/null +++ b/src/Mmcc.Bot.InMemoryStore/Stores/StoresSetup.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Mmcc.Bot.InMemoryStore.Stores; + +/// +/// Extension methods that register stores in Mmcc.Bot.InMemoryStore.Stores with the service collection. +/// +public static class StoresSetup +{ + /// + /// Registers stores in Mmcc.Bot.InMemoryStore.Store classes with the service collection. + /// + /// The . + /// The . + public static IServiceCollection AddInMemoryStores(this IServiceCollection services) => + services.AddSingleton(); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.Mojang/Mmcc.Bot.Mojang.csproj b/src/Mmcc.Bot.Mojang/Mmcc.Bot.Mojang.csproj index 587c0bb..658e8c5 100644 --- a/src/Mmcc.Bot.Mojang/Mmcc.Bot.Mojang.csproj +++ b/src/Mmcc.Bot.Mojang/Mmcc.Bot.Mojang.csproj @@ -1,14 +1,13 @@ - net6.0 + net7.0 enable - 10 - - + + diff --git a/src/Mmcc.Bot.Polychat/Mmcc.Bot.Polychat.csproj b/src/Mmcc.Bot.Polychat/Mmcc.Bot.Polychat.csproj index fa074f4..63a278a 100644 --- a/src/Mmcc.Bot.Polychat/Mmcc.Bot.Polychat.csproj +++ b/src/Mmcc.Bot.Polychat/Mmcc.Bot.Polychat.csproj @@ -1,26 +1,25 @@ - net6.0 + net7.0 enable - 10 - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + @@ -30,7 +29,10 @@ - + diff --git a/src/Mmcc.Bot.Polychat/Services/DiscordSanitiserService.cs b/src/Mmcc.Bot.Polychat/Services/DiscordSanitiserService.cs index 754f5bd..c11c3d7 100644 --- a/src/Mmcc.Bot.Polychat/Services/DiscordSanitiserService.cs +++ b/src/Mmcc.Bot.Polychat/Services/DiscordSanitiserService.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Mmcc.Bot.Polychat.Abstractions; +using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Rest.Core; @@ -64,10 +65,14 @@ public DiscordSanitiserService(IDiscordRestGuildAPI guildApi) public async Task SanitiseMessageContent(IMessage message) { var s = message.Content; - - s = SanitiseUsernameAndNicknameMentions(s, message.Mentions); - s = await SanitiseChannelMentions(s, message.GuildID); - s = await SanitiseRoleMentions(s, message.GuildID); + + if (message is IMessageCreate msgCreateEv) + { + s = SanitiseUsernameAndNicknameMentions(s, msgCreateEv.Mentions); + s = await SanitiseChannelMentions(s, msgCreateEv.GuildID); + s = await SanitiseRoleMentions(s, msgCreateEv.GuildID); + } + s = SanitiseStandardEmoji(s); s = SanitiseCustomEmoji(s); s = s.Replace("️", " "); diff --git a/src/Mmcc.Bot.RemoraAbstractions/AbstractionsSetup.cs b/src/Mmcc.Bot.RemoraAbstractions/AbstractionsSetup.cs index 7c99c2a..2b2fbd1 100644 --- a/src/Mmcc.Bot.RemoraAbstractions/AbstractionsSetup.cs +++ b/src/Mmcc.Bot.RemoraAbstractions/AbstractionsSetup.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; -using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Conditions.InteractionSpecific; using Mmcc.Bot.RemoraAbstractions.Parsers; using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Extensions; namespace Mmcc.Bot.RemoraAbstractions; @@ -21,12 +23,19 @@ public static IServiceCollection AddRemoraAbstractions(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddCondition(); services.AddCondition(); + services.AddCondition(); + services.AddCondition(); + services.AddParser(); services.AddParser(); diff --git a/src/Mmcc.Bot.RemoraAbstractions/CommandTreeWalker.cs b/src/Mmcc.Bot.RemoraAbstractions/CommandTreeWalker.cs new file mode 100644 index 0000000..802f163 --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/CommandTreeWalker.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Remora.Commands.Trees; +using Remora.Commands.Trees.Nodes; + +namespace Mmcc.Bot.RemoraAbstractions; + +public class CommandTreeWalker +{ + private readonly CommandTree _commandTree; + + public CommandTreeWalker(CommandTree commandTree) + => _commandTree = commandTree; + + public void PreOrderTraverseParentNodes(Action onParentNode) + => PreOrderTraverseParentNodes(_commandTree.Root, onParentNode); + + public GroupNode? GetGroupNodeByPath(List path) + { + var root = _commandTree.Root; + + var currPathIndex = 0; + var children = root.Children.OfType().ToList(); + while (true) + { + var matchedChild = children.FirstOrDefault(c => c.Key.Equals(path[currPathIndex])); + + if (matchedChild is null) + return null; + + if (currPathIndex == path.Count - 1) + return matchedChild; + + currPathIndex++; + children = matchedChild.Children.OfType().ToList(); + } + } + + public List CollectPath(GroupNode node) + { + IEnumerable res = new List { node.Key }; + var parent = node.Parent; + while (parent is GroupNode groupNode) + { + res = res.Prepend(groupNode.Key); + parent = groupNode.Parent; + } + + return res.ToList(); + } + + private void PreOrderTraverseParentNodes(IParentNode parentNode, Action onNode) + { + onNode(parentNode); + + foreach (var childNode in parentNode.Children.OfType()) + { + PreOrderTraverseParentNodes(childNode, onNode); + } + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Conditions/Attributes/RequireGuildAttribute.cs b/src/Mmcc.Bot.RemoraAbstractions/Conditions/Attributes/RequireGuildAttribute.cs deleted file mode 100644 index 6dd643e..0000000 --- a/src/Mmcc.Bot.RemoraAbstractions/Conditions/Attributes/RequireGuildAttribute.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Remora.Commands.Conditions; - -namespace Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; - -/// -/// Marks a command as requiring to be executed within a guild. -/// -public class RequireGuildAttribute : ConditionAttribute -{ -} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Conditions/Attributes/RequireUserGuildPermissionAttribute.cs b/src/Mmcc.Bot.RemoraAbstractions/Conditions/Attributes/RequireUserGuildPermissionAttribute.cs deleted file mode 100644 index 6ae8828..0000000 --- a/src/Mmcc.Bot.RemoraAbstractions/Conditions/Attributes/RequireUserGuildPermissionAttribute.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Remora.Commands.Conditions; -using Remora.Discord.API.Abstractions.Objects; - -namespace Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; - -/// -/// Marks a command as requiring the requesting user to have a particular permission within the guild. -/// -public class RequireUserGuildPermissionAttribute : ConditionAttribute -{ - /// - /// Gets the permission. - /// - public DiscordPermission Permission { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The permission. - public RequireUserGuildPermissionAttribute(DiscordPermission permission) - { - Permission = permission; - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Conditions/RequireGuildCondition.cs b/src/Mmcc.Bot.RemoraAbstractions/Conditions/CommandSpecific/RequireGuildCondition.cs similarity index 66% rename from src/Mmcc.Bot.RemoraAbstractions/Conditions/RequireGuildCondition.cs rename to src/Mmcc.Bot.RemoraAbstractions/Conditions/CommandSpecific/RequireGuildCondition.cs index 65c4a55..32d317d 100644 --- a/src/Mmcc.Bot.RemoraAbstractions/Conditions/RequireGuildCondition.cs +++ b/src/Mmcc.Bot.RemoraAbstractions/Conditions/CommandSpecific/RequireGuildCondition.cs @@ -1,12 +1,17 @@ using System.Threading; using System.Threading.Tasks; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; using Remora.Commands.Conditions; -using Remora.Commands.Results; using Remora.Discord.Commands.Contexts; using Remora.Results; -namespace Mmcc.Bot.RemoraAbstractions.Conditions; +namespace Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; + +/// +/// Marks a command as requiring to be executed within a guild. +/// +public class RequireGuildAttribute : ConditionAttribute +{ +} /// /// Checks if the command was executed within a guild before allowing execution. @@ -14,23 +19,20 @@ namespace Mmcc.Bot.RemoraAbstractions.Conditions; public class RequireGuildCondition : ICondition { private readonly MessageContext _context; - + /// /// Instantiates a new instance of the class. /// /// The message context. public RequireGuildCondition(MessageContext context) - { - _context = context; - } - + => _context = context; + /// public ValueTask CheckAsync(RequireGuildAttribute attribute, CancellationToken ct) { - var guild = _context.Message.GuildID; + var guild = _context.GuildID; return new(!guild.HasValue - ? new ConditionNotSatisfiedError( - "Command that requires to be executed within a guild was executed outside of one") + ? new InvalidOperationError("Command that requires to be executed within a guild was executed outside of one") : Result.FromSuccess()); } } \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Conditions/RequireUserGuildPermissionCondition.cs b/src/Mmcc.Bot.RemoraAbstractions/Conditions/CommandSpecific/RequireUserGuildPermissionCondition.cs similarity index 59% rename from src/Mmcc.Bot.RemoraAbstractions/Conditions/RequireUserGuildPermissionCondition.cs rename to src/Mmcc.Bot.RemoraAbstractions/Conditions/CommandSpecific/RequireUserGuildPermissionCondition.cs index bf6cff4..cdc5858 100644 --- a/src/Mmcc.Bot.RemoraAbstractions/Conditions/RequireUserGuildPermissionCondition.cs +++ b/src/Mmcc.Bot.RemoraAbstractions/Conditions/CommandSpecific/RequireUserGuildPermissionCondition.cs @@ -1,12 +1,32 @@ using System.Threading; using System.Threading.Tasks; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; using Mmcc.Bot.RemoraAbstractions.Services; using Remora.Commands.Conditions; +using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Commands.Contexts; using Remora.Results; -namespace Mmcc.Bot.RemoraAbstractions.Conditions; +namespace Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; + +/// +/// Marks a command as requiring the requesting user to have a particular permission within the guild. +/// +public class RequireUserGuildPermissionAttribute : ConditionAttribute +{ + /// + /// Gets the permission. + /// + public DiscordPermission Permission { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The permission. + public RequireUserGuildPermissionAttribute(DiscordPermission permission) + { + Permission = permission; + } +} /// /// Checks required Guild permissions before allowing execution. @@ -30,8 +50,8 @@ public RequireUserGuildPermissionCondition(MessageContext context, IDiscordPermi } /// - public async ValueTask CheckAsync(RequireUserGuildPermissionAttribute attribute, CancellationToken ct) => - await _permissionsService.CheckHasRequiredPermission( + public async ValueTask CheckAsync(RequireUserGuildPermissionAttribute attribute, CancellationToken ct) + => await _permissionsService.CheckHasRequiredPermission( attribute.Permission, _context.ChannelID, _context.User, ct diff --git a/src/Mmcc.Bot.RemoraAbstractions/Conditions/InteractionSpecific/InteractionRequireGuildCondition.cs b/src/Mmcc.Bot.RemoraAbstractions/Conditions/InteractionSpecific/InteractionRequireGuildCondition.cs new file mode 100644 index 0000000..d98993d --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Conditions/InteractionSpecific/InteractionRequireGuildCondition.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using Remora.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Conditions.InteractionSpecific; + +public class InteractionRequireGuildAttribute : ConditionAttribute +{ +} + +public class InteractionRequireGuildCondition : ICondition +{ + private readonly InteractionContext _context; + + public InteractionRequireGuildCondition(InteractionContext context) + => _context = context; + + public ValueTask CheckAsync(InteractionRequireGuildAttribute attribute, CancellationToken ct = new CancellationToken()) + { + var guild = _context.GuildID; + return new(!guild.HasValue + ? new InvalidOperationError("Command that requires to be executed within a guild was executed outside of one") + : Result.FromSuccess()); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Conditions/InteractionSpecific/InteractionRequireUserGuildPermission.cs b/src/Mmcc.Bot.RemoraAbstractions/Conditions/InteractionSpecific/InteractionRequireUserGuildPermission.cs new file mode 100644 index 0000000..3947bb4 --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Conditions/InteractionSpecific/InteractionRequireUserGuildPermission.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services; +using Remora.Commands.Conditions; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.Commands.Contexts; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Conditions.InteractionSpecific; + +public class InteractionRequireUserGuildPermissionAttribute : ConditionAttribute +{ + /// + /// Gets the permission. + /// + public DiscordPermission Permission { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The permission. + public InteractionRequireUserGuildPermissionAttribute(DiscordPermission permission) + => Permission = permission; +} + +public class InteractionRequireUserGuildPermissionCondition : ICondition +{ + private readonly InteractionContext _context; + private readonly IDiscordPermissionsService _permissionsService; + + + public InteractionRequireUserGuildPermissionCondition(InteractionContext context, IDiscordPermissionsService permissionsService) + { + _context = context; + _permissionsService = permissionsService; + } + + public async ValueTask CheckAsync(InteractionRequireUserGuildPermissionAttribute attribute, CancellationToken ct) + => await _permissionsService.CheckHasRequiredPermission( + attribute.Permission, + _context.ChannelID, + _context.User, ct + ); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Mmcc.Bot.RemoraAbstractions.csproj b/src/Mmcc.Bot.RemoraAbstractions/Mmcc.Bot.RemoraAbstractions.csproj index a06779b..13c2ac8 100644 --- a/src/Mmcc.Bot.RemoraAbstractions/Mmcc.Bot.RemoraAbstractions.csproj +++ b/src/Mmcc.Bot.RemoraAbstractions/Mmcc.Bot.RemoraAbstractions.csproj @@ -1,17 +1,17 @@ - net6.0 + net7.0 enable - 10 - + + - + diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/CommandResponder.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/CommandResponder.cs deleted file mode 100644 index dc282a9..0000000 --- a/src/Mmcc.Bot.RemoraAbstractions/Services/CommandResponder.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; -using Remora.Discord.Commands.Contexts; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.RemoraAbstractions.Services; - -/// -/// Responds to the message the command was invoked by. -/// -public interface ICommandResponder -{ - /// - /// Responds to the message the command was invoked by with a message with content. - /// - /// Content of the response. - /// Result of the asynchronous operation. - Task Respond(string message); - - /// - /// Responds to the message the command was invoked by with a message with s content. - /// - /// Content of the response. - /// Result of the asynchronous operation. - Task Respond(params Embed[] embeds); - - /// - /// Responds to the message the command was invoked by with a message with s content. - /// - /// Content of the response. - /// Result of the asynchronous operation. - Task Respond(List embeds); - - /// - /// Responds to the message the command was invoked by with a message with components. - /// - /// Components content of the response. - /// String content of the response. - /// Embeds content of the response. - /// - Task RespondWithComponents(IReadOnlyList components, Optional content = new(), params Embed[] embeds); -} - -/// -public class CommandResponder : ICommandResponder -{ - private readonly MessageContext _context; - private readonly IDiscordRestChannelAPI _channelApi; - - /// - /// Instantiates a new instance of the . - /// - /// The message context. - /// The channel API. - public CommandResponder(MessageContext context, IDiscordRestChannelAPI channelApi) - { - _context = context; - _channelApi = channelApi; - } - - /// - public async Task Respond(string message) => - await _channelApi.CreateMessageAsync( - channelID: _context.ChannelID, - content: message, - messageReference: new MessageReference(_context.MessageID, FailIfNotExists: false) - ); - - /// - public async Task Respond(params Embed[] embeds) => - await Respond(embeds.ToList()); - - /// - public async Task Respond(List embeds) => - await _channelApi.CreateMessageAsync( - channelID: _context.ChannelID, - embeds: embeds, - messageReference: new MessageReference(_context.MessageID, FailIfNotExists: false) - ); - - /// - public async Task RespondWithComponents(IReadOnlyList components, Optional content = new(), params Embed[] embeds) => - await _channelApi.CreateMessageAsync( - channelID: _context.ChannelID, - content: content, - embeds: embeds, - components: new(components), - messageReference: new MessageReference(_context.MessageID, FailIfNotExists: false) - ); -} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/DiscordPermissionsService.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/DiscordPermissionsService.cs index 3f81974..d5a4bf8 100644 --- a/src/Mmcc.Bot.RemoraAbstractions/Services/DiscordPermissionsService.cs +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/DiscordPermissionsService.cs @@ -1,7 +1,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Remora.Commands.Results; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; @@ -65,8 +64,7 @@ public async ValueTask CheckHasRequiredPermission( var channel = getChannel.Entity; if (!channel.GuildID.HasValue) { - return new ConditionNotSatisfiedError( - "Command requires a guild permission but was executed outside of a guild."); + return new InvalidOperationError("Command requires a guild permission but was executed outside of a guild."); } var guildId = channel.GuildID.Value; @@ -135,8 +133,7 @@ public async ValueTask CheckHasRequiredPermission( var hasPermission = computedPermissions.HasPermission(permission); return !hasPermission - ? new ConditionNotSatisfiedError( - $"Guild User requesting the command does not have the required {permission.ToString()} permission") + ? new InvalidOperationError($"Guild User requesting the command does not have the required {permission.ToString()} permission") : Result.FromSuccess(); } } \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/ErrorProcessingService.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/ErrorProcessingService.cs new file mode 100644 index 0000000..f0df52b --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/ErrorProcessingService.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using Mmcc.Bot.Common.Errors; +using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.Common.Models.Settings; +using Mmcc.Bot.Common.Statics; +using Remora.Commands.Results; +using Remora.Discord.API.Objects; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Services; + +public interface IErrorProcessingService +{ + public Embed GetErrorEmbed(IResultError err); +} + +public class ErrorProcessingService : IErrorProcessingService +{ + private readonly IColourPalette _colourPalette; + private readonly DiscordSettings _discordSettings; + + public ErrorProcessingService(IColourPalette colourPalette, DiscordSettings discordSettings) + { + _colourPalette = colourPalette; + _discordSettings = discordSettings; + } + + public Embed GetErrorEmbed(IResultError err) + { + var errorEmbed = new Embed + { + Thumbnail = EmbedProperties.MmccLogoThumbnail, + Colour = _colourPalette.Red, + Timestamp = DateTimeOffset.UtcNow + }; + errorEmbed = err switch + { + CommandNotFoundError cnfe => errorEmbed with + { + Title = ":exclamation: Command not found", + Description = $"Could not find a matching command for `{_discordSettings.Prefix}{cnfe.OriginalInput}`." + }, + ValidationError(var message, var validationFailures, _) => errorEmbed with + { + Title = ":exclamation: Validation error.", + Description = message.Replace('\'', '`'), + Fields = new List {ValidationFailuresToEmbedField(validationFailures)} + }, + NotFoundError => errorEmbed with + { + Title = ":x: Resource not found.", + Description = err.Message + }, + null => errorEmbed with + { + Title = ":exclamation: Error.", + Description = "Unknown error." + }, + _ => errorEmbed with + { + Title = $":x: {err.GetType()}.", + Description = err.Message + } + }; + + return errorEmbed; + } + + /// + /// Gets containing details of failures. + /// + /// Validation failures. + /// Whether the should be inline. Defaults to false. + /// containing details of failures. + private static EmbedField ValidationFailuresToEmbedField(IEnumerable validationFailures, bool inline = false) + { + var validationFailuresList = validationFailures.ToList(); + + if (!validationFailuresList.Any()) + { + return new("Failures", "No description."); + } + + var descriptionSb = string.Join("\n", + validationFailuresList + .Select((vf, i) => $"{i + 1}) {vf.ToString().Replace('\'', '`')}")); + return new("Reason(s)", descriptionSb, inline); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/HelpService.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/HelpService.cs index 8414465..36a0fa0 100644 --- a/src/Mmcc.Bot.RemoraAbstractions/Services/HelpService.cs +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/HelpService.cs @@ -1,118 +1,141 @@ using System.Collections.Generic; using System.Linq; -using System.Text; +using System.Reflection; +using Mmcc.Bot.Common.Extensions.System; using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.Common.Models.Settings; using Mmcc.Bot.Common.Statics; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; using Remora.Commands.Trees.Nodes; using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; namespace Mmcc.Bot.RemoraAbstractions.Services; -/// -/// Service for obtaining help embeds. -/// public interface IHelpService { - /// - /// Traverses a command tree and produces help embeds. - /// - /// The nodes to traverse. - /// The embeds output. - void TraverseAndGetHelpEmbeds(IList nodes, IList embeds); + Embed GetHelpForAll(); + Result GetHelpForCategory(List pathToCategory); } - -/// + public class HelpService : IHelpService { + private const string CategoryIcon = ":file_folder:"; + private const string CommandIcon = "❯"; + private readonly IColourPalette _colourPalette; + private readonly DiscordSettings _discordSettings; + private readonly CommandTreeWalker _cmdTreeWalker; - /// - /// Instantiates a new instance of . - /// - /// The colour palette. - public HelpService(IColourPalette colourPalette) + public HelpService( + IColourPalette colourPalette, + CommandTreeWalker cmdTreeWalker, + DiscordSettings discordSettings + ) { _colourPalette = colourPalette; + _cmdTreeWalker = cmdTreeWalker; + _discordSettings = discordSettings; } - /// - public void TraverseAndGetHelpEmbeds(IList nodes, IList embeds) + public Embed GetHelpForAll() { - var orphans = nodes - .OfType() - .ToList(); - var normals = nodes - .OfType() - .ToList(); - var fields = new List(); - - foreach (var orphan in orphans) + var categoryEmbedFields = new List(); + _cmdTreeWalker.PreOrderTraverseParentNodes(node => { - var nameString = new StringBuilder(); - var orphanParams = orphan.Shape.Parameters; - - if (orphanParams.Any()) - { - var paramsString = new StringBuilder(); - - for (var i = 0; i < orphanParams.Count; i++) - { - paramsString.Append(i != orphanParams.Count - 1 - ? $"<{orphanParams[i].HintName}> " - : $"<{orphanParams[i].HintName}>"); - } - - nameString.AppendLine($"❯ {orphan.Key} {paramsString}"); - } - else - { - nameString.AppendLine($"❯ {orphan.Key}"); - } - - var fieldValueSb = new StringBuilder(); - if (orphan.Aliases.Any()) - { - fieldValueSb.Append("**Aliases:** "); - for (var i = 0; i < orphan.Aliases.Count; i++) - { - fieldValueSb.Append(i != orphan.Aliases.Count - 1 - ? $"\"{orphan.Aliases[i]}\", " - : $"\"{orphan.Aliases[i]}\""); - } - } - fieldValueSb.Append("\n" + orphan.Shape.Description); - - fields.Add(new EmbedField(nameString.ToString(), $"{fieldValueSb}", false)); - } - - var parent = orphans.FirstOrDefault()?.Parent; - var embed = parent switch + if (node is not GroupNode groupNode) + return; + + var embedFieldForCategory = GetEmbedFieldForCategory(groupNode); + categoryEmbedFields.Add(embedFieldForCategory); + }); + + var helpEmbed = new Embed { - GroupNode g => new Embed - { - // what the fuck?? - Title = $":arrow_right: {g.Description} " + - $"[`!{g.Key}`{(g.Aliases.Any() ? "/" + string.Join("/", g.Aliases.Select(a => $"`!{a}`")) : "")}]", - Description = $"Usage: `!{g.Key} `." - }, - _ => new Embed - { - Title = ":arrow_right: General commands [`!`]", - Description = "Usage: `! `." - } + Title = ":information_source: Help", + Description = "Shows available categories. To see commands for a given category use `!help `.", + Fields = categoryEmbedFields, + Colour = _colourPalette.Blue, + Thumbnail = EmbedProperties.MmccLogoThumbnail }; - embed = embed with + + return helpEmbed; + } + + public Result GetHelpForCategory(List pathToCategory) + { + var category = _cmdTreeWalker.GetGroupNodeByPath(pathToCategory); + if (category is null) + return Result.FromError( + new NotFoundError($"No category matches {Markdown.InlineCode(string.Join(" ", pathToCategory))}") + ); + + var formattedPath = GetFormattedPathForCategory(category); + + var embedTitle = $"{CategoryIcon} {category.Description} [{formattedPath}]"; + var embedDescription = $"Usage: {formattedPath[..^1]} `"; + var embedFields = GetCommandsEmbedFieldsForCategory(category); + + var embed = new Embed { - Fields = fields, + Title = embedTitle, + Description = embedDescription, + Fields = embedFields, Colour = _colourPalette.Blue, Thumbnail = EmbedProperties.MmccLogoThumbnail }; - - embeds.Add(embed); + + return embed; + } - foreach (var normal in normals) - { - TraverseAndGetHelpEmbeds(normal.Children.ToList(), embeds); - } + private List GetCommandsEmbedFieldsForCategory(GroupNode category) + => category.Children + .OfType() + .Select(GetEmbedFieldForCommand) + .ToList(); + + private EmbedField GetEmbedFieldForCommand(CommandNode cmd) + { + var cmdDescription = cmd.Shape.Description; + var cmdArgs = cmd.Shape.Parameters; + var cmdArgsFormatted = string.Join(" ", cmdArgs.Select(x => $"<{x.HintName}>")); + + var fieldName = $"{CommandIcon} {cmd.Key} {cmdArgsFormatted}"; + + var fieldDescAliasesLine = cmd.Aliases.Any() + ? $"{Markdown.Underline("Aliases:")} {string.Join(", ", cmd.Aliases.Select(x => x.DoubleQuotes()))}\n" + : ""; + + var requiredPermission = cmd.CommandMethod.GetCustomAttribute(typeof(RequireUserGuildPermissionAttribute)); + var requiredPermissionLine = requiredPermission is RequireUserGuildPermissionAttribute r + ? $"{Markdown.Underline("Required user permission:")} {r.Permission}\n" + : ""; + + var fieldDescription = $"{fieldDescAliasesLine}{requiredPermissionLine}{cmdDescription}"; + + return new EmbedField(fieldName, fieldDescription, false); + } + + private EmbedField GetEmbedFieldForCategory(GroupNode category) + { + var formattedPath = GetFormattedPathForCategory(category); + var fullHelpCmd = $"!help {formattedPath}"; + + var fieldName = $"{CategoryIcon} {category.Description} [{formattedPath}]"; + var fieldDesc = $"Full help: {Markdown.InlineCode(fullHelpCmd)}"; + + return new EmbedField(fieldName, fieldDesc, false); + } + + private string GetFormattedPathForCategory(GroupNode category) + { + var prefix = _discordSettings.Prefix; + var path = category.Parent is GroupNode + ? string.Join(" ", _cmdTreeWalker.CollectPath(category)) + : category.Key; + var formattedPath = Markdown.InlineCode($"{prefix}{path}"); + + return formattedPath; } } \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/InteractionHelperService.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/InteractionHelperService.cs new file mode 100644 index 0000000..a734103 --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/InteractionHelperService.cs @@ -0,0 +1,72 @@ +using System.Linq; +using System.Threading.Tasks; +using Mmcc.Bot.Common.Models.Settings; +using Mmcc.Bot.RemoraAbstractions.UI.Extensions; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Contexts; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Services; + +public interface IInteractionHelperService +{ + Task NotifyDeferredMessageIsComing(); + Task RespondWithModal(InteractionModalCallbackData modalCallbackData); + Task SendFollowup(params Embed[] embeds); + Task SendFollowup(string msg); +} + +public class InteractionHelperService : IInteractionHelperService +{ + private readonly InteractionContext _context; + private readonly IDiscordRestInteractionAPI _interactionApi; + private readonly DiscordSettings _discordSettings; + private readonly IErrorProcessingService _errorProcessingService; + + public InteractionHelperService( + InteractionContext context, + IDiscordRestInteractionAPI interactionApi, + DiscordSettings discordSettings, + IErrorProcessingService errorProcessingService + ) + { + _context = context; + _interactionApi = interactionApi; + _discordSettings = discordSettings; + _errorProcessingService = errorProcessingService; + } + + public async Task NotifyDeferredMessageIsComing() + => await _interactionApi.CreateInteractionResponseAsync(_context.ID, _context.Token, + new InteractionResponse(InteractionCallbackType.DeferredChannelMessageWithSource)); + + public async Task RespondWithModal(InteractionModalCallbackData modalCallbackData) + => await _interactionApi.CreateInteractionResponseAsync(_context.ID, _context.Token, + modalCallbackData.GetInteractionResponse()); + + public async Task SendFollowup(params Embed[] embeds) + { + var res = await _interactionApi.CreateFollowupMessageAsync( + new(_discordSettings.ApplicationId), + _context.Token, + embeds: embeds.ToList() + ); + return res.IsSuccess + ? Result.FromSuccess() + : Result.FromError(res); + } + + public async Task SendFollowup(string msg) + { + var res = await _interactionApi.CreateFollowupMessageAsync( + new(_discordSettings.ApplicationId), + _context.Token, + msg + ); + return res.IsSuccess + ? Result.FromSuccess() + : Result.FromError(res); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/InteractionResponder.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/InteractionResponder.cs deleted file mode 100644 index ca75f22..0000000 --- a/src/Mmcc.Bot.RemoraAbstractions/Services/InteractionResponder.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Mmcc.Bot.Common.Models.Settings; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.RemoraAbstractions.Services; - -public interface IInteractionResponder -{ - Task NotifyDeferredMessageIsComing( - Snowflake interactionId, - string interactionToken, - CancellationToken ct = default - ); - - Task SendFollowup(string interactionToken, params Embed[] embeds); - - Task SendFollowup(string interactionToken, string msg); -} - -public class InteractionResponder : IInteractionResponder -{ - private readonly DiscordSettings _discordSettings; - private readonly IDiscordRestInteractionAPI _interactionApi; - - public InteractionResponder(DiscordSettings discordSettings, IDiscordRestInteractionAPI interactionApi) - { - _discordSettings = discordSettings; - _interactionApi = interactionApi; - } - - public async Task NotifyDeferredMessageIsComing( - Snowflake interactionId, - string interactionToken, - CancellationToken ct = default - ) => await _interactionApi.CreateInteractionResponseAsync(interactionId, interactionToken, - new InteractionResponse(InteractionCallbackType.DeferredChannelMessageWithSource), ct: ct); - - public async Task SendFollowup(string interactionToken, params Embed[] embeds) - { - var res = await _interactionApi.CreateFollowupMessageAsync( - new(_discordSettings.ApplicationId), - interactionToken, - embeds: embeds.ToList() - ); - return res.IsSuccess - ? Result.FromSuccess() - : Result.FromError(res); - } - - public async Task SendFollowup(string interactionToken, string msg) - { - var res = await _interactionApi.CreateFollowupMessageAsync( - new(_discordSettings.ApplicationId), - interactionToken, - msg - ); - return res.IsSuccess - ? Result.FromSuccess() - : Result.FromError(res); - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/Interactions/InteractionExecutionEventsRunner.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/Interactions/InteractionExecutionEventsRunner.cs new file mode 100644 index 0000000..9a96ddd --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/Interactions/InteractionExecutionEventsRunner.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Remora.Discord.Commands.Contexts; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Services.Interactions; + +public interface IInteractionExecutionEventsRunner +{ + Task RunPostExecutionEvents( + InteractionContext interactionContext, + Result interactionResult, + CancellationToken ct + ); +} + +public interface IInteractionPostExecutionEvent +{ + Task AfterExecutionAsync( + InteractionContext interactionContext, + Result interactionResult, + CancellationToken ct = default + ); +} + +public class InteractionExecutionEventsRunner : IInteractionExecutionEventsRunner +{ + private readonly IEnumerable _events; + + public InteractionExecutionEventsRunner(IEnumerable events) + => _events = events; + + public async Task RunPostExecutionEvents( + InteractionContext interactionContext, + Result interactionResult, + CancellationToken ct + ) + { + var results = await Task.WhenAll( + _events.Select(x => x.AfterExecutionAsync(interactionContext, interactionResult, ct)) + ); + + foreach (var result in results) + { + if (!result.IsSuccess) + { + return result; + } + } + + return Result.FromSuccess(); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/CommandMessageResponder.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/CommandMessageResponder.cs new file mode 100644 index 0000000..1a1ad9c --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/CommandMessageResponder.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Contexts; +using Remora.Rest.Core; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; + +public class CommandMessageResponder : MessageResponderBase +{ + private readonly MessageContext _context; + + /// + /// Instantiates a new instance of the . + /// + /// The message context. + /// The channel API. + public CommandMessageResponder(MessageContext context, IDiscordRestChannelAPI channelApi) : base(channelApi) + => _context = context; + + /// + public override async Task Respond(string message) + => await Respond(_context.MessageID, _context.ChannelID, message); + + /// + public override async Task Respond(List embeds) + => await Respond(_context.MessageID, _context.ChannelID, embeds); + + /// + public override async Task RespondWithComponents( + IReadOnlyList components, + Optional content = new(), + params Embed[] embeds + ) => await RespondWithComponents(_context.MessageID, _context.ChannelID, components, content, embeds); +} diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/InteractionMessageResponder.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/InteractionMessageResponder.cs new file mode 100644 index 0000000..b946d1e --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/InteractionMessageResponder.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Contexts; +using Remora.Rest.Core; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; + +public class InteractionMessageResponder : MessageResponderBase +{ + private readonly InteractionContext _context; + + /// + /// Instantiates a new instance of the . + /// + /// The message context. + /// The channel API. + public InteractionMessageResponder(InteractionContext context, IDiscordRestChannelAPI channelApi) : base(channelApi) + => _context = context; + + /// + public override async Task Respond(string message) + => await Respond(_context.Message.Value.ID, _context.ChannelID, message); + + /// + public override async Task Respond(List embeds) + => await Respond(_context.Message.Value.ID, _context.ChannelID, embeds); + + /// + public override async Task RespondWithComponents( + IReadOnlyList components, + Optional content = new(), + params Embed[] embeds + ) => await RespondWithComponents(_context.Message.Value.ID, _context.ChannelID, components, content, embeds); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/MessageResponderBase.cs b/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/MessageResponderBase.cs new file mode 100644 index 0000000..5801dcc --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/Services/MessageResponders/MessageResponderBase.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; +using Remora.Results; + +namespace Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; + +public abstract class MessageResponderBase +{ + private readonly IDiscordRestChannelAPI _channelApi; + + protected MessageResponderBase(IDiscordRestChannelAPI channelApi) + { + _channelApi = channelApi; + } + + /// + /// Responds to the message the command was invoked by with a message with content. + /// + /// Content of the response. + /// Result of the asynchronous operation. + public abstract Task Respond(string message); + + /// + /// Responds to the message the command was invoked by with a message with s content. + /// + /// Content of the response. + /// Result of the asynchronous operation. + public async Task Respond(params Embed[] embeds) => + await Respond(embeds.ToList()); + + /// + /// Responds to the message the command was invoked by with a message with s content. + /// + /// Content of the response. + /// Result of the asynchronous operation. + public abstract Task Respond(List embeds); + + /// + /// Responds to the message the command was invoked by with a message with components. + /// + /// Components content of the response. + /// String content of the response. + /// Embeds content of the response. + /// + public abstract Task RespondWithComponents(IReadOnlyList components, Optional content = new(), params Embed[] embeds); + + protected async Task Respond(Snowflake parentMessageId, Snowflake parentMessageChannelId, string message) => + await _channelApi.CreateMessageAsync( + channelID: parentMessageChannelId, + content: message, + messageReference: new MessageReference(parentMessageId, FailIfNotExists: false) + ); + + protected async Task Respond(Snowflake parentMessageId, Snowflake parentMessageChannelId, List embeds) => + await _channelApi.CreateMessageAsync( + channelID: parentMessageChannelId, + embeds: embeds, + messageReference: new MessageReference(parentMessageId, FailIfNotExists: false) + ); + + protected async Task RespondWithComponents( + Snowflake parentMessageId, + Snowflake parentMessageChannelId, + IReadOnlyList components, + Optional content = new(), + params Embed[] embeds + ) => await _channelApi.CreateMessageAsync( + channelID: parentMessageChannelId, + content: content, + embeds: embeds, + components: new(components), + messageReference: new MessageReference(parentMessageId, FailIfNotExists: false) + ); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/UI/ActionRowUtils.cs b/src/Mmcc.Bot.RemoraAbstractions/UI/ActionRowUtils.cs new file mode 100644 index 0000000..e8ca228 --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/UI/ActionRowUtils.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.RemoraAbstractions.UI; + +public static class ActionRowUtils +{ + public static ActionRowComponent CreateActionRowWithComponents(params IMessageComponent[] childComponents) + => new(childComponents); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/UI/Extensions/InteractionModalCallbackDataExtensions.cs b/src/Mmcc.Bot.RemoraAbstractions/UI/Extensions/InteractionModalCallbackDataExtensions.cs new file mode 100644 index 0000000..430327f --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/UI/Extensions/InteractionModalCallbackDataExtensions.cs @@ -0,0 +1,10 @@ +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.RemoraAbstractions.UI.Extensions; + +public static class InteractionModalCallbackDataExtensions +{ + public static InteractionResponse GetInteractionResponse(this InteractionModalCallbackData modalData) + => new(InteractionCallbackType.Modal, new(modalData)); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/UI/Extensions/MessageComponentExtensions.cs b/src/Mmcc.Bot.RemoraAbstractions/UI/Extensions/MessageComponentExtensions.cs new file mode 100644 index 0000000..7f4b957 --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/UI/Extensions/MessageComponentExtensions.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.RemoraAbstractions.UI.Extensions; + +public static class MessageComponentExtensions +{ + public static List AsList(this IMessageComponent c) => new() {c}; +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/UI/FluentCallbackModalBuilder.cs b/src/Mmcc.Bot.RemoraAbstractions/UI/FluentCallbackModalBuilder.cs new file mode 100644 index 0000000..8a1b3ce --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/UI/FluentCallbackModalBuilder.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using Remora.Discord.API.Objects; +using Remora.Discord.Interactivity; + +namespace Mmcc.Bot.RemoraAbstractions.UI; + +public class FluentCallbackModalBuilder : + FluentCallbackModalBuilder.ITitleSelectionStage, + FluentCallbackModalBuilder.IComponentsSelectionStage, + FluentCallbackModalBuilder.IBuildStage +{ + private readonly string? _customId; + + private string? _title; + private List? _components; + + private FluentCallbackModalBuilder() {} + + private FluentCallbackModalBuilder(string id) + => _customId = id; + + public static ITitleSelectionStage WithId(string id) + => new FluentCallbackModalBuilder(CustomIDHelpers.CreateModalID(id)); + + public static ITitleSelectionStage WithId(string id, params string[] path) + => new FluentCallbackModalBuilder(CustomIDHelpers.CreateModalID(id, path)); + + public IComponentsSelectionStage HasTitle(string title) + { + _title = title; + return this; + } + + public IBuildStage WithCustomActionRows(params ActionRowComponent[] actionRowComponents) + { + _components = actionRowComponents.ToList(); + return this; + } + + public IBuildStage WithActionRowFromTextInputs(params TextInputComponent[] textInputComponents) + { + _components = textInputComponents.Select(x => new ActionRowComponent(new[] {x})).ToList(); + return this; + } + + public InteractionModalCallbackData Build() + => new(_customId!, _title!, _components!); + + public interface ITitleSelectionStage + { + public IComponentsSelectionStage HasTitle(string title); + } + + public interface IComponentsSelectionStage + { + public IBuildStage WithCustomActionRows(params ActionRowComponent[] actionRowComponents); + public IBuildStage WithActionRowFromTextInputs(params TextInputComponent[] textInputComponents); + } + + public interface IBuildStage + { + public InteractionModalCallbackData Build(); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/UI/FluentTextInputBuilder.cs b/src/Mmcc.Bot.RemoraAbstractions/UI/FluentTextInputBuilder.cs new file mode 100644 index 0000000..326588c --- /dev/null +++ b/src/Mmcc.Bot.RemoraAbstractions/UI/FluentTextInputBuilder.cs @@ -0,0 +1,101 @@ +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.RemoraAbstractions.UI; + +public class FluentTextInputBuilder : + FluentTextInputBuilder.ITextInputStyleSelectionStage, + FluentTextInputBuilder.ILabelSelectionStage, + FluentTextInputBuilder.IRequiredSelectionStage, + FluentTextInputBuilder.IBuildStage +{ + private readonly string? _customId; + + private TextInputStyle? _textInputStyle; + private string? _label; + private bool? _isRequired; + + private Optional _minLength; + private Optional _maxLength; + private Optional _startingValue; + private Optional _placeholderValue; + + private FluentTextInputBuilder() {} + + private FluentTextInputBuilder(string id) + => _customId = id; + + public static ITextInputStyleSelectionStage WithId(string id) + => new FluentTextInputBuilder(id); + + public ILabelSelectionStage HasStyle(TextInputStyle style) + { + _textInputStyle = style; + return this; + } + + public IRequiredSelectionStage HasLabel(string label) + { + _label = label; + return this; + } + + public IBuildStage IsRequired(bool isRequired) + { + _isRequired = isRequired; + return this; + } + + public IBuildStage WithMinimumLength(int minLength) + { + _minLength = minLength; + return this; + } + + public IBuildStage WithMaximumLength(int maxLength) + { + _maxLength = maxLength; + return this; + } + + public IBuildStage HasStartingValue(string startingValue) + { + _startingValue = startingValue; + return this; + } + + public IBuildStage HasPlaceholderValue(string placeholderValue) + { + _placeholderValue = placeholderValue; + return this; + } + + public TextInputComponent Build() + => new(_customId!, _textInputStyle!.Value, _label!, _minLength, _maxLength, _isRequired!.Value, _startingValue, + _placeholderValue); + + public interface ITextInputStyleSelectionStage + { + public ILabelSelectionStage HasStyle(TextInputStyle style); + } + + public interface ILabelSelectionStage + { + public IRequiredSelectionStage HasLabel(string label); + } + + public interface IRequiredSelectionStage + { + public IBuildStage IsRequired(bool isRequired); + } + + public interface IBuildStage + { + public IBuildStage WithMinimumLength(int minLength); + public IBuildStage WithMaximumLength(int maxLength); + public IBuildStage HasStartingValue(string startingValue); + public IBuildStage HasPlaceholderValue(string placeholderValue); + public TextInputComponent Build(); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.RemoraAbstractions/Ui/ActionRowUtils.cs b/src/Mmcc.Bot.RemoraAbstractions/Ui/ActionRowUtils.cs deleted file mode 100644 index 62ab8cd..0000000 --- a/src/Mmcc.Bot.RemoraAbstractions/Ui/ActionRowUtils.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Mmcc.Bot.Caching.Entities; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Objects; - -namespace Mmcc.Bot.RemoraAbstractions.Ui; - -// ReSharper disable once InconsistentNaming -public static class ActionRowUtils -{ - public static List FromButtons(params HandleableButton[] buttons) => - new() - { - new ActionRowComponent(buttons.Select(b => b.Component).ToList()) - }; -} \ No newline at end of file diff --git a/src/Mmcc.Bot.SourceGenerators/CommonContexts.cs b/src/Mmcc.Bot.SourceGenerators/CommonContexts.cs new file mode 100644 index 0000000..7f89261 --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/CommonContexts.cs @@ -0,0 +1,16 @@ +namespace Mmcc.Bot.SourceGenerators; + +public sealed class CommonContexts +{ + internal class ClassContext + { + public string Namespace { get; set; } = null!; + public string ClassName { get; set; } = null!; + } + + internal sealed class PropertyContext + { + public string Type { get; set; } = null!; + public string Name { get; set; } = null!; + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGenerator.cs b/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGenerator.cs new file mode 100644 index 0000000..d323dc0 --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGenerator.cs @@ -0,0 +1,485 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using static Mmcc.Bot.SourceGenerators.CommonContexts; +using static Mmcc.Bot.SourceGenerators.DiscordCommands.DiscordCommandGeneratorContexts; + +namespace Mmcc.Bot.SourceGenerators.DiscordCommands; + +/// +/// Generates a Discord command from a vertical slice architecture-style parent class. +/// +[Generator] +internal sealed class DiscordCommandGenerator : IIncrementalGenerator +{ + private static SymbolDisplayFormat TypeFormat + => SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.UseSpecialTypes | SymbolDisplayMiscellaneousOptions.ExpandNullable | + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(ctx => + ctx.AddSource("DiscordCommandAttribute.g.cs", SourceText.From(DiscordCommandGeneratorAttributes.DiscordCommandAttribute, Encoding.UTF8)) + ); + + var provider = context.SyntaxProvider + .CreateSyntaxProvider(IsVsaClassCandidateSyntactically, SemanticTransform) + .Where(static typesData => typesData.HasValue) + .Select(static (typesData, _) => GetVsaClassContext(typesData!.Value)) + .Where(static context => context is not null); + + context.RegisterSourceOutput(provider, GenerateSource!); + } + + private static VsaClassContext? GetVsaClassContext((INamedTypeSymbol VsaType, INamedTypeSymbol CmdGroupType, INamedTypeSymbol ViewType, AttributeData AttributeData) typesData) + { + var vsaNamespace = typesData.VsaType.ContainingNamespace.ToDisplayString(); + var vsaName = typesData.VsaType.Name; + + var requestInfo = GetRequestClassInfo(typesData.VsaType); + if (requestInfo is null) + return null; + + var discordCommandContext = GetDiscordCommandContext(typesData.CmdGroupType, typesData.ViewType, + requestInfo.Value.Type, typesData.AttributeData.TargetArguments); + var shouldHandleNullReturn = GetShouldHandleNullReturn(typesData.VsaType); + + return new VsaClassContext + { + Namespace = vsaNamespace, + ClassName = vsaName, + RequestClassContext = requestInfo.Value.Context, + DiscordCommandContext = discordCommandContext, + ShouldHandleNullReturn = shouldHandleNullReturn, + RemoraConditionsAttributeContexts = GetRemoraConditionsAttributeContexts(typesData.AttributeData) + }; + } + + private static IReadOnlyList GetRemoraConditionsAttributeContexts(AttributeData attributeData) + { + var conditionsAttributes = attributeData.RemoraConditionsAttributes; + var context = conditionsAttributes + .Select(attr => new ConditionAttributeContext + { + Namespace = attr.AttributeType.ContainingNamespace.ToDisplayString(), + ClassName = attr.AttributeType.Name, + ArgumentsValues = attr.Arguments? + .Select(arg => arg.Match(symbol => symbol.ToDisplayString(), expression => expression.ToFullString())) + .ToList() + }) + .ToList(); + + return context; + } + + private static (INamedTypeSymbol Type, RequestClassContext Context)? GetRequestClassInfo(INamedTypeSymbol vsaType) + { + var typeMembers = vsaType.GetTypeMembers(); + INamedTypeSymbol? requestType = null; + requestType ??= typeMembers.FirstOrDefault(x => x.Name.Equals("Query", StringComparison.Ordinal)); + requestType ??= typeMembers.FirstOrDefault(x => x.Name.Equals("Command", StringComparison.Ordinal)); + + if (requestType is null) + return null; + + var @namespace = requestType.ContainingNamespace.ToDisplayString(); + var className = requestType.Name; + var properties = requestType + .GetMembers() + .OfType() + .Where(p => p is + { + Kind: SymbolKind.Property, + DeclaredAccessibility: Accessibility.Public, + IsStatic: false, + SetMethod.IsInitOnly: true + }) + .Select(p => new PropertyContext + { + Name = p.Name, + Type = p.Type.ToDisplayString(TypeFormat) + }) + .ToList(); + + var context = new RequestClassContext + { + Namespace = @namespace, + ClassName = className, + Properties = properties + }; + + return (requestType, context); + } + + private static DiscordCommandContext GetDiscordCommandContext( + INamedTypeSymbol cmdGroupType, + INamedTypeSymbol viewType, + INamedTypeSymbol requestType, + AttributeArgumentListSyntax attributeArgumentsSyntax + ) + { + var @namespace = cmdGroupType.ContainingNamespace.ToDisplayString(); + var className = cmdGroupType.Name; + var args = attributeArgumentsSyntax.Arguments; + var commandName = args[0].Expression.ToFullString(); + var commandDescription = args[1].Expression.ToFullString(); + var isGreedy = args[2].Expression.IsKind(SyntaxKind.TrueLiteralExpression); + var aliases = args + .Skip(3) + .Select(x => x.Expression.ToFullString()) + .ToList(); + + var matchedViewContext = GetViewContext(viewType, requestType); + + return new DiscordCommandContext + { + Namespace = @namespace, + ClassName = className, + CommandName = commandName, + CommandDescription = commandDescription, + IsGreedy = isGreedy, + CommandAliases = aliases, + MatchedView = matchedViewContext + }; + } + + private static DiscordViewContext GetViewContext(INamedTypeSymbol viewType, INamedTypeSymbol requestType) + { + var @namespace = viewType.ContainingNamespace.ToDisplayString(); + var className = viewType.Name; + var onEmptyMethod = viewType + .GetMembers() + .OfType() + .FirstOrDefault(m => m is + { + Kind: SymbolKind.Method, + MethodKind: MethodKind.Ordinary, + IsStatic: true, + DeclaredAccessibility: Accessibility.Public or Accessibility.Internal, + Name: "OnEmpty", + Parameters: + { + Length: 1 + } parameters + } && SymbolEqualityComparer.Default.Equals(parameters[0].Type, requestType)); + + return new DiscordViewContext + { + Namespace = @namespace, + ClassName = className, + HasOnEmpty = onEmptyMethod is not null + }; + } + + private static bool GetShouldHandleNullReturn(INamedTypeSymbol vsaType) + { + var typeMembers = vsaType.GetTypeMembers(); + var handleMethod = typeMembers + .FirstOrDefault(x => x.Name.Equals("Handler", StringComparison.Ordinal))? + .GetMembers() + .OfType() + .FirstOrDefault(m => m is + { + Kind: SymbolKind.Method, + MethodKind: MethodKind.Ordinary, + IsStatic: false, + Name: "Handle" + }); + if (handleMethod is null) + return false; + + return IsReturnTypeNullable(handleMethod); + } + + private static bool IsReturnTypeNullable(IMethodSymbol method) + { + if (method.ReturnType.OriginalDefinition.ToDisplayString() + .Equals("System.Threading.Tasks.Task", StringComparison.Ordinal)) + { + var returnTypeInsideTask = ((INamedTypeSymbol)method.ReturnType).TypeArguments.FirstOrDefault(); + if (returnTypeInsideTask is null) + return false; // something weird would be going on; + + return IsReturnTypeNullable(returnTypeInsideTask); + } + + return IsReturnTypeNullable(method.ReturnType); + } + + private static bool IsReturnTypeNullable(ITypeSymbol type) + { + if (type.OriginalDefinition.ToDisplayString() + .Equals("Remora.Results.Result", StringComparison.Ordinal)) + { + return IsTypeInsideResultNullable(type); + } + + return IsTypeNullable(type); + } + + private static bool IsTypeInsideResultNullable(ITypeSymbol resultType) + { + var returnType = ((INamedTypeSymbol)resultType).TypeArguments.FirstOrDefault(); + return IsTypeNullable(returnType); + } + + private static bool IsTypeNullable(ITypeSymbol? returnType) => returnType?.NullableAnnotation is NullableAnnotation.Annotated; + + private static bool IsVsaClassCandidateSyntactically(SyntaxNode node, CancellationToken ct) + => node is ClassDeclarationSyntax + { + AttributeLists.Count: > 0, + BaseList: null or { Types.Count: 0 } + } candidate + && !candidate.Modifiers.Any(SyntaxKind.StaticKeyword); + + private static (INamedTypeSymbol VsaType, INamedTypeSymbol CmdGroupType, INamedTypeSymbol ViewType, AttributeData AttributeData)? SemanticTransform(GeneratorSyntaxContext ctx, CancellationToken ct) + { + var candidate = Unsafe.As(ctx.Node); + var symbol = ctx.SemanticModel.GetDeclaredSymbol(candidate, ct); + var discordCmdAttribute = + ctx.SemanticModel.Compilation.GetTypeByMetadataName("Mmcc.Bot.SourceGenerators.DiscordCommands.DiscordCommandAttribute`1"); + + if (symbol is not null + && TryGetAttributeData(candidate, discordCmdAttribute, ctx.SemanticModel, out var attributeData) + && attributeData.HasValue) + { + var viewType = ctx.SemanticModel.Compilation.GetTypeByMetadataName($"{symbol.ContainingNamespace}.{symbol.Name}View"); + + if (viewType is not null) + { + return (symbol, attributeData.Value.CmdGroupType, viewType, attributeData.Value); + } + } + + return null; + } + + private static bool TryGetAttributeData( + ClassDeclarationSyntax candidate, + INamedTypeSymbol? target, + SemanticModel semanticModel, + out AttributeData? attributeData + ) + { + INamedTypeSymbol? cmdGroupType = null; + AttributeArgumentListSyntax? targetArguments = null; + var conditionAttributes = new List<(INamedTypeSymbol, IReadOnlyList>?)>(); + foreach (var attributeList in candidate.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var attributeSymbolInfo = semanticModel.GetSymbolInfo(attribute); + var attributeSymbol = attributeSymbolInfo.Symbol; + + if (attributeSymbol is null) + continue; + + // Target attribute; + if (attribute is + { + Name: GenericNameSyntax + { + TypeArgumentList.Arguments: + { + Count: 1 + } typeArguments + }, + ArgumentList: + { + Arguments.Count: >= 2 + } attributeArgumentsSyntax + } + && SymbolEqualityComparer.Default.Equals(attributeSymbol.ContainingSymbol.OriginalDefinition, target) + ) + { + var commandGroupSymbolCandidate = semanticModel.GetSymbolInfo(typeArguments[0]).Symbol; + + if (commandGroupSymbolCandidate is INamedTypeSymbol commandGroupSymbol) + { + cmdGroupType = commandGroupSymbol; + targetArguments = attributeArgumentsSyntax; + } + } + // Remora conditions; + else if (attribute.Name.ToString().StartsWith("Require")) + { + var symbol = semanticModel.GetSymbolInfo(attribute).Symbol; + if (symbol is not IMethodSymbol methodSymbol) + continue; + + var conditionAttributeType = methodSymbol.ContainingType; + var conditionAttributeNamespace = conditionAttributeType.ContainingNamespace.ToDisplayString(); + if (!conditionAttributeNamespace.StartsWith("Remora.Discord.Commands.Conditions") && !conditionAttributeNamespace.StartsWith("Mmcc.Bot.RemoraAbstractions.Conditions")) + continue; + + if (attribute.ArgumentList is null || attribute.ArgumentList.Arguments.Count == 0) + { + conditionAttributes.Add((conditionAttributeType, null)); + } + else + { + var args = new List>(attribute.ArgumentList.Arguments.Count); + foreach (var argSyntax in attribute.ArgumentList.Arguments) + { + var argSymbol = semanticModel.GetSymbolInfo(argSyntax.Expression).Symbol; + if (argSymbol is not IFieldSymbol { Type: INamedTypeSymbol argType } argFieldSymbol + || argType.EnumUnderlyingType is null + ) + { + args.Add(argSyntax.Expression); + } + else + { + args.Add(OneOf.FromT0(argFieldSymbol)); + } + } + + conditionAttributes.Add((conditionAttributeType, args)); + } + } + } + } + + attributeData = cmdGroupType is null || targetArguments is null + ? null + : new AttributeData(cmdGroupType, targetArguments, conditionAttributes); + + return attributeData is not null; + } + + private static void GenerateSource(SourceProductionContext productionContext, VsaClassContext vsaContext) + { + var sanitisedCommandName = vsaContext.DiscordCommandContext.CommandName.Replace("\"", ""); + var methodName = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(sanitisedCommandName); + var props = vsaContext + .RequestClassContext + .Properties; + + var methodParamsString = GenerateDiscordCommandMethodParams(props, vsaContext.DiscordCommandContext.IsGreedy); + var requestCtorParams = string.Join(", ", props.Select(p => p.Name.ToCamelCase())); + + var generatedSource = $$""" + // auto-generated + + namespace {{vsaContext.DiscordCommandContext.Namespace}}; + + public partial class {{vsaContext.DiscordCommandContext.ClassName}} + { + {{GenerateRemoraConditionAttributesString(vsaContext.RemoraConditionsAttributeContexts)}} + [global::Remora.Commands.Attributes.Command({{vsaContext.DiscordCommandContext.CommandName}}{{GenerateAliasesString(vsaContext.DiscordCommandContext)}})] + [global::System.ComponentModel.Description({{vsaContext.DiscordCommandContext.CommandDescription}})] + public async global::System.Threading.Tasks.Task {{methodName}}({{methodParamsString}}) + { + var request = new {{vsaContext.Namespace}}.{{vsaContext.ClassName}}.{{vsaContext.RequestClassContext.ClassName}}({{requestCtorParams}}); + var result = await _mediator.Send(request); + + return result switch + { + { IsSuccess: true, Entity: { } e } + => await _vm.RespondWithView(new {{vsaContext.DiscordCommandContext.MatchedView.Namespace}}.{{vsaContext.DiscordCommandContext.MatchedView.ClassName}}(e)), + {{GenerateNullHandlerIfNeeded(vsaContext)}} + { IsSuccess: false } => result + }; + } + } + """; + + var fileName = $"{vsaContext.Namespace}.{vsaContext.ClassName}.dcmd.g.cs"; + productionContext.AddSource(fileName, generatedSource); + } + + private static string GenerateAliasesString(DiscordCommandContext discordCommandContext) + { + var aliases = discordCommandContext.CommandAliases; + var aliasesString = aliases.Any() + ? $", {string.Join(", ", aliases)}" + : string.Empty; + + return aliasesString; + } + + private static string GenerateNullHandlerIfNeeded(VsaClassContext vsaClassContext) + { + if (!vsaClassContext.ShouldHandleNullReturn) + return string.Empty; + + var viewContext = vsaClassContext.DiscordCommandContext.MatchedView; + + return !viewContext.HasOnEmpty + ? """ + + { IsSuccess: true } => + global::Remora.Results.Result.FromError(new global::Remora.Results.NotFoundError()), + + """ + : $$""" + + { IsSuccess: true } => + global::Remora.Results.Result.FromError(new global::Remora.Results.NotFoundError({{viewContext.Namespace}}.{{viewContext.ClassName}}.OnEmpty(request))), + + """; + } + + private static string GenerateRemoraConditionAttributesString(IReadOnlyList? remoraConditionsAttributeContexts) + { + if (remoraConditionsAttributeContexts is null || remoraConditionsAttributeContexts.Count == 0) + return string.Empty; + + const string indent = " "; + var sb = new StringBuilder(); + + foreach (var attribute in remoraConditionsAttributeContexts) + { + var attributeString = attribute.ArgumentsValues is null || attribute.ArgumentsValues.Count == 0 + ? $"[{attribute.Namespace}.{attribute.ClassName}]" + : $"[{attribute.Namespace}.{attribute.ClassName}({string.Join(", ", attribute.ArgumentsValues)})]"; + + sb.AppendLine($"{indent}{attributeString}"); + } + + return sb.ToString().TrimEnd(); + } + + private static string GenerateDiscordCommandMethodParams(IReadOnlyList props, bool isGreedy) + { + if (!isGreedy) + return string.Join(", ", props.Select(p => $"{p.Type} {p.Name.ToCamelCase()}")); + + var sb = new StringBuilder(); + for (int i = 0; i < props.Count; i++) + { + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + // justification: Trace - cleaner here imo; + if (i == props.Count - 1) + { + sb.Append($"[global::Remora.Commands.Attributes.Greedy] {props[i].Type} {props[i].Name.ToCamelCase()}"); + } + else + { + sb.Append($"{props[i].Type} {props[i].Name.ToCamelCase()}, "); + } + } + + return sb.ToString().TrimEnd(); + } +} + +internal readonly struct AttributeData +{ + internal readonly INamedTypeSymbol CmdGroupType; + internal readonly AttributeArgumentListSyntax TargetArguments; + internal readonly IReadOnlyList<(INamedTypeSymbol AttributeType, IReadOnlyList>? Arguments)> RemoraConditionsAttributes; + + internal AttributeData( + INamedTypeSymbol cmdGroupType, + AttributeArgumentListSyntax targetArguments, + IReadOnlyList<(INamedTypeSymbol, IReadOnlyList>?)> remoraConditionAttributes + ) + { + CmdGroupType = cmdGroupType; + TargetArguments = targetArguments; + RemoraConditionsAttributes = remoraConditionAttributes; + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGeneratorAttributes.cs b/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGeneratorAttributes.cs new file mode 100644 index 0000000..128864f --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGeneratorAttributes.cs @@ -0,0 +1,33 @@ +namespace Mmcc.Bot.SourceGenerators.DiscordCommands; + +internal static class DiscordCommandGeneratorAttributes +{ + internal static string DiscordCommandAttribute => + """ + namespace Mmcc.Bot.SourceGenerators.DiscordCommands; + + [global::System.CodeDom.Compiler.GeneratedCode("Mmcc.Bot.SourceGenerators", "1.0.0")] + [global::System.AttributeUsage(global::System.AttributeTargets.Class)] + public class DiscordCommandAttribute : global::System.Attribute + where TCommandGroup : global::Remora.Commands.Groups.CommandGroup + { + public string Name { get; set; } + public string Description { get; set; } + public bool IsGreedy { get; set; } + public string[] Aliases { get; set; } + + public DiscordCommandAttribute( + string name, + string description, + bool isGreedy = false, + params string[] aliases + ) + { + Name = name; + Description = description; + IsGreedy = isGreedy; + Aliases = aliases; + } + } + """; +} diff --git a/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGeneratorContexts.cs b/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGeneratorContexts.cs new file mode 100644 index 0000000..2e2873b --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/DiscordCommands/DiscordCommandGeneratorContexts.cs @@ -0,0 +1,38 @@ +using static Mmcc.Bot.SourceGenerators.CommonContexts; + +namespace Mmcc.Bot.SourceGenerators.DiscordCommands; + +internal sealed class DiscordCommandGeneratorContexts +{ + internal sealed class VsaClassContext : ClassContext + { + public RequestClassContext RequestClassContext { get; set; } = null!; + public DiscordCommandContext DiscordCommandContext { get; set; } = null!; + public bool ShouldHandleNullReturn { get; set; } + public IReadOnlyList RemoraConditionsAttributeContexts { get; set; } = null!; + } + + internal sealed class RequestClassContext : ClassContext + { + public IReadOnlyList Properties { get; set; } = null!; + } + + internal sealed class DiscordCommandContext : ClassContext + { + public bool IsGreedy { get; set; } + public string CommandName { get; set; } = null!; + public string CommandDescription { get; set; } = null!; + public IReadOnlyList CommandAliases { get; set; } = null!; + public DiscordViewContext MatchedView { get; set; } = null!; + } + + internal sealed class ConditionAttributeContext : ClassContext + { + public List? ArgumentsValues { get; set; } + } + + internal sealed class DiscordViewContext : ClassContext + { + public bool HasOnEmpty { get; set; } + } +} diff --git a/src/Mmcc.Bot.SourceGenerators/GlobalUsings.cs b/src/Mmcc.Bot.SourceGenerators/GlobalUsings.cs new file mode 100644 index 0000000..e712fbf --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using System.Runtime.CompilerServices; +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.CSharp.Syntax; +global using Microsoft.CodeAnalysis.Text; +global using OneOf; \ No newline at end of file diff --git a/src/Mmcc.Bot.SourceGenerators/Mmcc.Bot.SourceGenerators.csproj b/src/Mmcc.Bot.SourceGenerators/Mmcc.Bot.SourceGenerators.csproj new file mode 100644 index 0000000..b679a1b --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/Mmcc.Bot.SourceGenerators.csproj @@ -0,0 +1,35 @@ + + + + netstandard2.0 + true + false + enable + enable + latest + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + + + diff --git a/src/Mmcc.Bot.SourceGenerators/StringExtensions.cs b/src/Mmcc.Bot.SourceGenerators/StringExtensions.cs new file mode 100644 index 0000000..ba1c7ec --- /dev/null +++ b/src/Mmcc.Bot.SourceGenerators/StringExtensions.cs @@ -0,0 +1,9 @@ +namespace Mmcc.Bot.SourceGenerators; + +internal static class StringExtensions +{ + internal static string ToCamelCase(this string str) => + string.IsNullOrEmpty(str) || str.Length < 2 + ? str.ToLowerInvariant() + : char.ToLowerInvariant(str[0]) + str.Substring(1); +} \ No newline at end of file diff --git a/src/Mmcc.Bot.sln b/src/Mmcc.Bot.sln index 2a3d80e..63728b2 100644 --- a/src/Mmcc.Bot.sln +++ b/src/Mmcc.Bot.sln @@ -7,10 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mmcc.Bot", "Mmcc.Bot\Mmcc.B EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mmcc.Bot.Database", "Mmcc.Bot.Database\Mmcc.Bot.Database.csproj", "{7AA2B74C-B606-4D5F-A6A2-0CBD7213F204}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Generators", "Mmcc.Bot.Generators\Mmcc.Bot.Generators.csproj", "{7E9B771E-2800-45F2-B201-DE51B33647B1}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Caching", "Mmcc.Bot.Caching\Mmcc.Bot.Caching.csproj", "{4680CB41-16E1-436B-9C4D-F83991B06AC6}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.RemoraAbstractions", "Mmcc.Bot.RemoraAbstractions\Mmcc.Bot.RemoraAbstractions.csproj", "{A5862FD7-B26A-4219-B31F-25256BEF4D75}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Polychat", "Mmcc.Bot.Polychat\Mmcc.Bot.Polychat.csproj", "{AD6B79E6-93AA-4F69-81FE-3228A29B680B}" @@ -23,6 +19,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Common.Extensions" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Common", "Mmcc.Bot.Common\Mmcc.Bot.Common.csproj", "{10014CD6-C13A-4245-A218-498066327B53}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.InMemoryStore", "Mmcc.Bot.InMemoryStore\Mmcc.Bot.InMemoryStore.csproj", "{0BC009CA-0BA9-465A-B8A0-31E7F506F62F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mmcc.Bot.Generators", "Mmcc.Bot.Generators", "{A6ABA01C-861E-414D-B6D4-4AEE8A843490}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Generators.PolychatRequestResolverGenerator", "Mmcc.Bot.Generators\Mmcc.Bot.Generators.PolychatRequestResolverGenerator\Mmcc.Bot.Generators.PolychatRequestResolverGenerator.csproj", "{9FDC16C5-DBB4-4B53-9894-A3FDD5351922}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.Common.UI", "Mmcc.Bot.Common.UI\Mmcc.Bot.Common.UI.csproj", "{95A40405-B933-4C00-B13E-CA7B7362F160}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mmcc.Bot.SourceGenerators", "Mmcc.Bot.SourceGenerators\Mmcc.Bot.SourceGenerators.csproj", "{744B88E6-56D4-4E87-8BEE-37FC053FF5A0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -37,14 +43,6 @@ Global {7AA2B74C-B606-4D5F-A6A2-0CBD7213F204}.Debug|Any CPU.Build.0 = Debug|Any CPU {7AA2B74C-B606-4D5F-A6A2-0CBD7213F204}.Release|Any CPU.ActiveCfg = Release|Any CPU {7AA2B74C-B606-4D5F-A6A2-0CBD7213F204}.Release|Any CPU.Build.0 = Release|Any CPU - {7E9B771E-2800-45F2-B201-DE51B33647B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7E9B771E-2800-45F2-B201-DE51B33647B1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7E9B771E-2800-45F2-B201-DE51B33647B1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7E9B771E-2800-45F2-B201-DE51B33647B1}.Release|Any CPU.Build.0 = Release|Any CPU - {4680CB41-16E1-436B-9C4D-F83991B06AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4680CB41-16E1-436B-9C4D-F83991B06AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4680CB41-16E1-436B-9C4D-F83991B06AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4680CB41-16E1-436B-9C4D-F83991B06AC6}.Release|Any CPU.Build.0 = Release|Any CPU {A5862FD7-B26A-4219-B31F-25256BEF4D75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A5862FD7-B26A-4219-B31F-25256BEF4D75}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5862FD7-B26A-4219-B31F-25256BEF4D75}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -65,6 +63,22 @@ Global {10014CD6-C13A-4245-A218-498066327B53}.Debug|Any CPU.Build.0 = Debug|Any CPU {10014CD6-C13A-4245-A218-498066327B53}.Release|Any CPU.ActiveCfg = Release|Any CPU {10014CD6-C13A-4245-A218-498066327B53}.Release|Any CPU.Build.0 = Release|Any CPU + {0BC009CA-0BA9-465A-B8A0-31E7F506F62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0BC009CA-0BA9-465A-B8A0-31E7F506F62F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0BC009CA-0BA9-465A-B8A0-31E7F506F62F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0BC009CA-0BA9-465A-B8A0-31E7F506F62F}.Release|Any CPU.Build.0 = Release|Any CPU + {9FDC16C5-DBB4-4B53-9894-A3FDD5351922}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FDC16C5-DBB4-4B53-9894-A3FDD5351922}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FDC16C5-DBB4-4B53-9894-A3FDD5351922}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FDC16C5-DBB4-4B53-9894-A3FDD5351922}.Release|Any CPU.Build.0 = Release|Any CPU + {95A40405-B933-4C00-B13E-CA7B7362F160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95A40405-B933-4C00-B13E-CA7B7362F160}.Debug|Any CPU.Build.0 = Debug|Any CPU + {95A40405-B933-4C00-B13E-CA7B7362F160}.Release|Any CPU.ActiveCfg = Release|Any CPU + {95A40405-B933-4C00-B13E-CA7B7362F160}.Release|Any CPU.Build.0 = Release|Any CPU + {744B88E6-56D4-4E87-8BEE-37FC053FF5A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {744B88E6-56D4-4E87-8BEE-37FC053FF5A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {744B88E6-56D4-4E87-8BEE-37FC053FF5A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {744B88E6-56D4-4E87-8BEE-37FC053FF5A0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -75,5 +89,7 @@ Global GlobalSection(NestedProjects) = preSolution {A11CD52A-365B-4E2F-AF61-3352C6D52032} = {194115DD-FBE6-4E5C-9DED-28F15B9011B6} {10014CD6-C13A-4245-A218-498066327B53} = {194115DD-FBE6-4E5C-9DED-28F15B9011B6} + {9FDC16C5-DBB4-4B53-9894-A3FDD5351922} = {A6ABA01C-861E-414D-B6D4-4AEE8A843490} + {95A40405-B933-4C00-B13E-CA7B7362F160} = {194115DD-FBE6-4E5C-9DED-28F15B9011B6} EndGlobalSection EndGlobal diff --git a/src/Mmcc.Bot.sln.DotSettings b/src/Mmcc.Bot.sln.DotSettings index 467258b..1dace7c 100644 --- a/src/Mmcc.Bot.sln.DotSettings +++ b/src/Mmcc.Bot.sln.DotSettings @@ -1,5 +1,6 @@  True + True True True True diff --git a/src/Mmcc.Bot/Commands/CommandsSetup.cs b/src/Mmcc.Bot/Commands/CommandsSetup.cs index 3d883b5..030c47a 100644 --- a/src/Mmcc.Bot/Commands/CommandsSetup.cs +++ b/src/Mmcc.Bot/Commands/CommandsSetup.cs @@ -1,10 +1,7 @@ using Microsoft.Extensions.DependencyInjection; -using Mmcc.Bot.Commands.Core; -using Mmcc.Bot.Commands.Core.Help; -using Mmcc.Bot.Commands.Diagnostics; -using Mmcc.Bot.Commands.Guilds; using Mmcc.Bot.Commands.Minecraft; using Mmcc.Bot.Commands.Minecraft.Restarts; +using Mmcc.Bot.Commands.MmccInfo; using Mmcc.Bot.Commands.Moderation; using Mmcc.Bot.Commands.Moderation.Bans; using Mmcc.Bot.Commands.Moderation.MemberApplications; @@ -12,6 +9,10 @@ using Mmcc.Bot.Commands.Moderation.Warns; using Mmcc.Bot.Commands.Tags.Management; using Mmcc.Bot.Commands.Tags.Usage; +using Mmcc.Bot.Features.Diagnostics; +using Mmcc.Bot.Features.Guilds; +using Mmcc.Bot.Features.Help; +using Mmcc.Bot.Features.MmccInfo; using Remora.Commands.Extensions; using Remora.Discord.Commands.Extensions; @@ -31,31 +32,29 @@ public static IServiceCollection AddBotCommands(this IServiceCollection services { services.AddDiscordCommands(); - // core commands; - services.AddCommandGroup(); - services.AddCommandGroup(); - services.AddCommandGroup(); - - // tags; - services.AddCommandGroup(); - services.AddCommandGroup(); - - // diagnostics; - services.AddCommandGroup(); - - // in game; - services.AddCommandGroup(); - services.AddCommandGroup(); + services.AddCommandTree() + // add core commands; + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + // add tags; + .WithCommandGroup() + .WithCommandGroup() + // add diagnostics; + .WithCommandGroup() + // add in-game; + .WithCommandGroup() + .WithCommandGroup() + // add member apps; + .WithCommandGroup() + // add moderation; + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() + // and build it; + .Finish(); - // member apps; - services.AddCommandGroup(); - - // moderation; - services.AddCommandGroup(); - services.AddCommandGroup(); - services.AddCommandGroup(); - services.AddCommandGroup(); - return services; } } \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Core/Help/GetForAll.cs b/src/Mmcc.Bot/Commands/Core/Help/GetForAll.cs deleted file mode 100644 index bb9f149..0000000 --- a/src/Mmcc.Bot/Commands/Core/Help/GetForAll.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using MediatR; -using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.Common.Statics; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Commands.Trees; -using Remora.Discord.API.Objects; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Core.Help; - -/// -/// Gets help for all available commands, maintaining the grouped structure. -/// -public class GetForAll -{ - /// - /// Query to get all available commands, maintaining the grouped structure. - /// - public record Query : IRequest>>; - - public class Handler : RequestHandler>> - { - private readonly CommandTree _commandTree; - private readonly IColourPalette _colourPalette; - private readonly IHelpService _helpService; - - public Handler(CommandTree commandTree, IColourPalette colourPalette, IHelpService helpService) - { - _commandTree = commandTree; - _colourPalette = colourPalette; - _helpService = helpService; - } - - protected override Result> Handle(Query request) - { - var embeds = new List - { - new() - { - Title = ":information_source: Help", - Description = "Shows available commands by category", - Colour = _colourPalette.Blue, - Thumbnail = EmbedProperties.MmccLogoThumbnail - } - }; - - _helpService.TraverseAndGetHelpEmbeds(_commandTree.Root.Children.ToList(), embeds); - return embeds; - } - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Core/Help/GetHelpForCategory.cs b/src/Mmcc.Bot/Commands/Core/Help/GetHelpForCategory.cs deleted file mode 100644 index 830d210..0000000 --- a/src/Mmcc.Bot/Commands/Core/Help/GetHelpForCategory.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation; -using MediatR; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Commands.Trees; -using Remora.Commands.Trees.Nodes; -using Remora.Discord.API.Objects; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Core.Help; - -/// -/// Gets help for all available commands within a category. -/// -public class GetHelpForCategory -{ - /// - /// Query to get help embeds for all available commands within a category. - /// - /// The name/alias of the category. - public record Query(string CategoryName) : IRequest>; - - public class Validator : AbstractValidator - { - public Validator() => - RuleFor(q => q.CategoryName) - .NotEmpty(); - } - - public class Handler : RequestHandler> - { - private readonly CommandTree _commandTree; - private readonly IHelpService _helpService; - - public Handler(CommandTree commandTree, IHelpService helpService) - { - _commandTree = commandTree; - _helpService = helpService; - } - - protected override Result Handle(Query request) - { - var categoryName = request.CategoryName; - var categories = _commandTree.Root.Children.ToList() - .OfType() - .FirstOrDefault(gn => gn.Key.Equals(categoryName) || gn.Aliases.Contains(categoryName)); - var embeds = new List(); - - if (categories is null) - { - return Result.FromSuccess(null); - } - - _helpService.TraverseAndGetHelpEmbeds(categories.Children.ToList(), embeds); - - return Result.FromSuccess(embeds.FirstOrDefault()); - } - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Core/Help/HelpCommands.cs b/src/Mmcc.Bot/Commands/Core/Help/HelpCommands.cs deleted file mode 100644 index f8464ed..0000000 --- a/src/Mmcc.Bot/Commands/Core/Help/HelpCommands.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using MediatR; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.Commands.Contexts; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Core.Help; - -/// -/// Help commands. -/// -public class HelpCommands : CommandGroup -{ - private readonly MessageContext _context; - private readonly ICommandResponder _responder; - private readonly IDmSender _dmSender; - private readonly IMediator _mediator; - - /// - /// Instantiates a new instance of class. - /// - /// The message context. - /// The command responder. - /// The DM sender. - /// The mediator. - public HelpCommands( - MessageContext context, - ICommandResponder responder, - IDmSender dmSender, - IMediator mediator - ) - { - _context = context; - _responder = responder; - _mediator = mediator; - _dmSender = dmSender; - } - - [Command("help")] - [Description("Shows available commands")] - public async Task Help() - { - var getEmbedsResult = await _mediator.Send(new GetForAll.Query()); - - if (!getEmbedsResult.IsSuccess) - { - return getEmbedsResult; - } - - var embedChunks = getEmbedsResult.Entity.Chunk(10); - - foreach (var embeds in embedChunks) - { - var sendDmChunkRes = await _dmSender.Send(_context.User.ID, embeds); - - if (!sendDmChunkRes.IsSuccess) - { - return sendDmChunkRes; - } - } - - return await _responder.Respond("Help has been sent to your DMs :smile:."); - } - - [Command("help")] - [Description("Shows help for a given category")] - public async Task Help(string categoryName) => - await _mediator.Send(new GetHelpForCategory.Query(categoryName)) switch - { - { IsSuccess: true, Entity: { } embed } => - await _responder.Respond(embed), - - { IsSuccess: true } => - Result.FromError(new NotFoundError($"Could not find a category with name `{categoryName}`.")), - - { IsSuccess: false } res => res - }; -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Core/MmccInfoCommands.cs b/src/Mmcc.Bot/Commands/Core/MmccInfoCommands.cs deleted file mode 100644 index b9014a4..0000000 --- a/src/Mmcc.Bot/Commands/Core/MmccInfoCommands.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Threading.Tasks; -using Mmcc.Bot.Caching; -using Mmcc.Bot.Caching.Entities; -using Mmcc.Bot.Common.Statics; -using Mmcc.Bot.EventResponders.Buttons; -using Mmcc.Bot.RemoraAbstractions.Services; -using Mmcc.Bot.RemoraAbstractions.Ui; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; -using Remora.Discord.Commands.Contexts; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Core; - -public class MmccInfoCommands : CommandGroup -{ - private readonly ICommandResponder _responder; - private readonly IDiscordRestInteractionAPI _interactionApi; - private readonly IDiscordRestChannelAPI _channelApi; - private readonly IDiscordRestWebhookAPI _webhookApi; - private readonly IButtonHandlerRepository _handlerRepository; - private readonly IInteractionResponder _interactionResponder; - private readonly MessageContext _context; - - public MmccInfoCommands(ICommandResponder responder, IDiscordRestInteractionAPI interactionApi, - IDiscordRestChannelAPI channelApi, IButtonHandlerRepository handlerRepository, - IDiscordRestWebhookAPI webhookApi, IInteractionResponder interactionResponder, MessageContext context) - { - _responder = responder; - _interactionApi = interactionApi; - _channelApi = channelApi; - _handlerRepository = handlerRepository; - _webhookApi = webhookApi; - _interactionResponder = interactionResponder; - _context = context; - } - - [Command("mmcc")] - [Description("Shows useful MMCC links")] - public async Task Mmcc() - { - var components = new List - { - new ActionRowComponent(new List - { - new(ButtonComponentStyle.Link, "Website", new PartialEmoji(new Snowflake(863798570602856469)), URL: MmccUrls.Website), - new(ButtonComponentStyle.Link, "Donate", new PartialEmoji(Name: "❤️"), URL: MmccUrls.Donations), - new(ButtonComponentStyle.Link, "Wiki", new PartialEmoji(Name: "📖"), URL: MmccUrls.Wiki), - new(ButtonComponentStyle.Link, "Forum", new PartialEmoji(Name: "🗣️"), URL: MmccUrls.Forum), - new(ButtonComponentStyle.Link, "GitHub", new PartialEmoji(new Snowflake(453413238638641163)), URL: MmccUrls.GitHub) - }) - }; - - return await _channelApi.CreateMessageAsync( - channelID: _context.ChannelID, - content: "Useful links", - components: new(components) - ); - } - -#if DEBUG - // TODO: remove once app buttons are implemented; - [Command("test")] - public async Task Test() - { - var id = new Snowflake((ulong) DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - var component = new ButtonComponent(ButtonComponentStyle.Primary, "Test", - CustomID: id.ToString()); - var testButton = - HandleableButton.Create(id, component, - new TestHandler.Context(_context.ChannelID)); - - _handlerRepository.Register(testButton); - - return await _responder.RespondWithComponents(ActionRowUtils.FromButtons(testButton), "Test buttons"); - } -#endif -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Diagnostics/DiagnosticsCommands.cs b/src/Mmcc.Bot/Commands/Diagnostics/DiagnosticsCommands.cs deleted file mode 100644 index dfb7bbd..0000000 --- a/src/Mmcc.Bot/Commands/Diagnostics/DiagnosticsCommands.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Net.NetworkInformation; -using System.Text; -using System.Threading.Tasks; -using MediatR; -using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.Common.Statics; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Objects; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Diagnostics; - -/// -/// Diagnostics commands. -/// -[Group("diagnostics")] -[Description("Server and bot diagnostics")] -public class DiagnosticsCommands : CommandGroup -{ - private readonly IColourPalette _colourPalette; - private readonly IMediator _mediator; - private readonly ICommandResponder _responder; - - private readonly Dictionary _resourcesToCheck = new() - { - ["Discord"] = "discord.com", - ["Mojang API"] = "api.mojang.com", - ["MMCC"] = "s4.moddedminecraft.club" - }; - - /// - /// Instantiates a new instance of . - /// - /// The colour palette. - /// The mediator. - /// The command responder. - public DiagnosticsCommands( - IColourPalette colourPalette, - IMediator mediator, - ICommandResponder responder - ) - { - _colourPalette = colourPalette; - _mediator = mediator; - _responder = responder; - } - - /// - /// Show status of the bot and APIs it uses. - /// - /// Result of the operation. - [Command("bot")] - [Description("Show status of the bot and APIs it uses")] - public async Task BotDiagnostics() - { - var fields = new List - { - new("Bot status", ":green_circle: Operational", false) - }; - - foreach (var (name, address) in _resourcesToCheck) - { - var pingResult = await _mediator.Send(new PingNetworkResource.Query {Address = address}); - var fieldVal = !pingResult.IsSuccess || pingResult.Entity.Status != IPStatus.Success - ? ":x: Could not reach." - : pingResult.Entity.RoundtripTime switch - { - <= 50 => ":green_circle: ", - <= 120 => ":yellow_circle: ", - _ => ":red_circle: " - } + pingResult.Entity.RoundtripTime + " ms"; - - fields.Add(new($"{name} Status", fieldVal, false)); - } - - var embed = new Embed - { - Title = "Bot diagnostics", - Description = "Information about the status of the bot and the APIs it uses", - Fields = fields, - Timestamp = DateTimeOffset.UtcNow, - Colour = _colourPalette.Green - }; - return await _responder.Respond(embed); - } - - /// - /// Shows drives info - including free space. - /// - /// Result of the operation. - [Command("drives")] - [Description("Shows drives info (including free space)")] - [RequireGuild] - [RequireUserGuildPermission(DiscordPermission.BanMembers)] - public async Task DrivesDiagnostics() - { - var embedFields = new List(); - var drives = await _mediator.Send(new GetDrives.Query()); - - foreach (var d in drives) - { - var fieldValue = new StringBuilder(); - var spaceEmoji = d.PercentageUsed switch - { - <= 65 => ":green_circle:", - <= 85 => ":yellow_circle:", - _ => ":red_circle:" - }; - - fieldValue.AppendLine($"Volume label: {d.Label}"); - fieldValue.AppendLine($"File system: {d.DriveFormat}"); - fieldValue.AppendLine($"Available space: {spaceEmoji} {d.GigabytesFree:0.00} GB ({d.PercentageUsed:0.00}% used)"); - fieldValue.AppendLine($"Total size: {d.GigabytesTotalSize:0.00} GB"); - - embedFields.Add(new EmbedField(d.Name, fieldValue.ToString(), false)); - } - - var embed = new Embed - { - Title = "Drives diagnostics", - Colour = _colourPalette.Blue, - Thumbnail = EmbedProperties.MmccLogoThumbnail, - Footer = new EmbedFooter("Dedicated server"), - Timestamp = DateTimeOffset.UtcNow, - Fields = embedFields - }; - return await _responder.Respond(embed); - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Diagnostics/PingNetworkResource.cs b/src/Mmcc.Bot/Commands/Diagnostics/PingNetworkResource.cs deleted file mode 100644 index 8a12d68..0000000 --- a/src/Mmcc.Bot/Commands/Diagnostics/PingNetworkResource.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Net.NetworkInformation; -using System.Threading; -using System.Threading.Tasks; -using FluentValidation; -using MediatR; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Diagnostics; - -/// -/// Pings a network resource. -/// -public class PingNetworkResource -{ - /// - /// Query to ping a network resource. - /// - public class Query : IRequest> - { - /// - /// Address of the network resource to ping. - /// - public string Address { get; set; } = null!; - } - - public class Validator : AbstractValidator - { - public Validator() - { - RuleFor(q => q.Address) - .NotEmpty(); - } - } - - public class QueryResult - { - /// - /// Address of the pinged network resource. - /// - public string Address { get; set; } = null!; - - /// - /// Status. - /// - public IPStatus Status { get; set; } - - /// - /// Roundtrip time in milliseconds. - /// - public long? RoundtripTime { get; set; } - } - - /// - public class Handler : IRequestHandler> - { - private const int Timeout = 120; - - /// - public async Task> Handle(Query request, CancellationToken cancellationToken) - { - try - { - var ping = new Ping(); - var options = new PingOptions - { - DontFragment = true - }; - var buffer = new byte[32]; - var reply = await ping.SendPingAsync(request.Address, Timeout, buffer, options); - - return Result.FromSuccess(new() - { - Address = reply.Address.ToString(), - Status = reply.Status, - RoundtripTime = reply.RoundtripTime, - }); - } - catch (Exception e) - { - return e; - } - } - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Guilds/GuildCommands.cs b/src/Mmcc.Bot/Commands/Guilds/GuildCommands.cs deleted file mode 100644 index 5087f39..0000000 --- a/src/Mmcc.Bot/Commands/Guilds/GuildCommands.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using MediatR; -using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Objects; -using Remora.Discord.Commands.Contexts; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.Commands.Guilds; - -/// -/// Core commands. -/// -[RequireGuild] -public class GuildCommands : CommandGroup -{ - private readonly MessageContext _context; - private readonly IColourPalette _colourPalette; - private readonly IMediator _mediator; - private readonly ICommandResponder _responder; - - /// - /// Instantiates a new instance of class. - /// - /// The message context. - /// The colour palette. - /// The mediator. - /// The command responder. - public GuildCommands( - MessageContext context, - IColourPalette colourPalette, - IMediator mediator, - ICommandResponder responder - ) - { - _context = context; - _colourPalette = colourPalette; - _mediator = mediator; - _responder = responder; - } - - [Command("guild")] - [Description("Provides information about the current guild.")] - public async Task GuildInfo() => - await _mediator.Send(new GetGuildInfo.Query(_context.GuildID.Value)) switch - { - { IsSuccess: true, Entity: { } e } => - await _responder.Respond(new Embed - { - Title = "Guild info", - Description = "Information about the current guild.", - Fields = new List - { - new("Name", e.GuildName, false), - new("Owner", $"<@{e.GuildOwnerId}>"), - new("Max members", e.GuildMaxMembers.ToString() ?? "Unavailable", false), - new("Available roles", string.Join(", ", e.GuildRoles.Select(r => $"<@&{r.ID}>"))) - }, - Timestamp = DateTimeOffset.UtcNow, - Colour = _colourPalette.Blue, - Thumbnail = e.GuildIconUrl is null - ? new Optional() - : new EmbedThumbnail(e.GuildIconUrl.ToString()) - }), - - { IsSuccess: true } => - Result.FromError(new NotFoundError($"Guild with ID: {_context.GuildID.Value} not found")), - - { IsSuccess: false } res => res, - }; - - [Command("invite")] - [Description("Gives an invite link to the current guild.")] - public async Task Invite() => - await _mediator.Send(new GetInviteLink.Query(_context.GuildID.Value)) switch - { - {IsSuccess: true, Entity: { } e} => - await _responder.Respond($"https://discord.gg/{e}"), - - {IsSuccess: true} => Result.FromError(new NotFoundError("Could not find invite link for this guild.")), - - {IsSuccess: false} res => res - }; -} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Minecraft/MinecraftServersCommands.cs b/src/Mmcc.Bot/Commands/Minecraft/MinecraftServersCommands.cs index 057010a..c0581d1 100644 --- a/src/Mmcc.Bot/Commands/Minecraft/MinecraftServersCommands.cs +++ b/src/Mmcc.Bot/Commands/Minecraft/MinecraftServersCommands.cs @@ -9,8 +9,8 @@ using Mmcc.Bot.Common.Statics; using Mmcc.Bot.Polychat.MessageSenders; using Mmcc.Bot.Polychat.Services; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -20,9 +20,6 @@ namespace Mmcc.Bot.Commands.Minecraft; -/// -/// Commands for managing MC servers. -/// [Group("mc")] [Description("Minecraft (Polychat)")] [RequireGuild] @@ -32,22 +29,14 @@ public class MinecraftServersCommands : CommandGroup private readonly IMediator _mediator; private readonly IColourPalette _colourPalette; private readonly IPolychatService _polychatService; - private readonly ICommandResponder _responder; - - /// - /// Instantiates a new instance of class. - /// - /// The message context. - /// The mediator. - /// The colour palette. - /// The polychat service. - /// The command responder. + private readonly CommandMessageResponder _responder; + public MinecraftServersCommands( MessageContext context, IMediator mediator, IColourPalette colourPalette, IPolychatService polychatService, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; @@ -56,44 +45,24 @@ ICommandResponder responder _polychatService = polychatService; _responder = responder; } - - /// - /// Shows current TPS of a MC server. - /// - /// ID of the server. - /// Result of the operation. + [Command("tps")] [Description("Shows current TPS of a MC server")] public async Task Tps(string serverId) => await _mediator.Send(new SendTpsCommand.Command(serverId, _context.ChannelID)); - - /// - /// Executes a command on a MC server. - /// - /// ID of the server. - /// Command arguments. - /// Result of the operation. + [Command("exec", "e", "execute")] [Description("Executes a command on a MC server")] [RequireUserGuildPermission(DiscordPermission.BanMembers)] public async Task Exec(string serverId, [Greedy] IEnumerable args) => await _mediator.Send(new SendExecCommand.Command(serverId, _context.ChannelID, args)); - - /// - /// Restarts a MC server. - /// - /// ID of the server to restart. - /// Result of the operation. + [Command("restart", "r")] [Description("Restarts a server")] [RequireUserGuildPermission(DiscordPermission.BanMembers)] public async Task Restart(string serverId) => await _mediator.Send(new SendRestartCommand.Command(serverId, _context.ChannelID)); - - /// - /// Shows info about online servers. - /// - /// Result of the operation. + [Command("online", "o")] [Description("Shows info about online servers")] public async Task Online() diff --git a/src/Mmcc.Bot/Commands/Minecraft/Restarts/MinecraftAutoRestartsCommands.cs b/src/Mmcc.Bot/Commands/Minecraft/Restarts/MinecraftAutoRestartsCommands.cs index 8b37a2b..49bb2bf 100644 --- a/src/Mmcc.Bot/Commands/Minecraft/Restarts/MinecraftAutoRestartsCommands.cs +++ b/src/Mmcc.Bot/Commands/Minecraft/Restarts/MinecraftAutoRestartsCommands.cs @@ -6,8 +6,9 @@ using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Statics; using Mmcc.Bot.Polychat.Jobs.Recurring.Restarts; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Mmcc.Bot.RemoraAbstractions.Timestamps; using Remora.Commands.Attributes; using Remora.Commands.Groups; @@ -23,11 +24,11 @@ public class MinecraftAutoRestartsCommands : CommandGroup { private readonly IMediator _mediator; private readonly IColourPalette _colourPalette; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; public MinecraftAutoRestartsCommands(IMediator mediator, IColourPalette colourPalette, - ICommandResponder responder + CommandMessageResponder responder ) { _mediator = mediator; @@ -97,9 +98,9 @@ public async Task Scheduled() { var res = await _mediator.Send(new GetAllScheduled.Query()); - return res.Count switch + return res switch { - > 0 => await _responder.Respond(new Embed + [_, ..] => await _responder.Respond(new Embed { Title = "Scheduled recurring restarts", Thumbnail = EmbedProperties.MmccLogoThumbnail, diff --git a/src/Mmcc.Bot/Commands/Minecraft/Views/OnlineServers.view.cs b/src/Mmcc.Bot/Commands/Minecraft/Views/OnlineServers.view.cs new file mode 100644 index 0000000..37c5773 --- /dev/null +++ b/src/Mmcc.Bot/Commands/Minecraft/Views/OnlineServers.view.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Mmcc.Bot.Polychat.Models; +using Porbeagle; +using Porbeagle.Attributes; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Commands.Minecraft.Views; + +[DiscordView] +public sealed partial record OnlineServersView : IMessageView +{ + public OnlineServersView(IEnumerable results) + => Embed = new(); + + public Optional Text { get; init; } = new(); + + public Embed Embed { get; } +} + diff --git a/src/Mmcc.Bot/Commands/Moderation/Bans/BanCommands.cs b/src/Mmcc.Bot/Commands/Moderation/Bans/BanCommands.cs index 0059a38..b2cb8aa 100644 --- a/src/Mmcc.Bot/Commands/Moderation/Bans/BanCommands.cs +++ b/src/Mmcc.Bot/Commands/Moderation/Bans/BanCommands.cs @@ -4,8 +4,9 @@ using MediatR; using Mmcc.Bot.Common.Models; using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -28,7 +29,7 @@ public class BanCommands : CommandGroup private readonly MessageContext _context; private readonly IMediator _mediator; private readonly Embed _embedBase; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; /// /// Instantiates a new instance of class. @@ -41,7 +42,7 @@ public BanCommands( MessageContext context, IMediator mediator, IColourPalette colourPalette, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; @@ -68,7 +69,7 @@ ICommandResponder responder public async Task BanDiscord(IUser user, ExpiryDate expiryDate, [Greedy] string reason) => await _mediator.Send(new BanModerationAction.Command { - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ChannelId = _context.ChannelID, UserDiscordId = user.ID, Reason = reason, @@ -100,7 +101,7 @@ await _responder.Respond(_embedBase with public async Task BanIg(string ign, ExpiryDate expiryDate, [Greedy] string reason) => await _mediator.Send(new BanModerationAction.Command { - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ChannelId = _context.ChannelID, UserIgn = ign, Reason = reason, @@ -133,7 +134,7 @@ public async Task BanAll(IUser discordUser, string ign, ExpiryDate expi [Greedy] string reason) => await _mediator.Send(new BanModerationAction.Command { - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ChannelId = _context.ChannelID, UserIgn = ign, Reason = reason, diff --git a/src/Mmcc.Bot/Commands/Moderation/Bans/Unban.cs b/src/Mmcc.Bot/Commands/Moderation/Bans/Unban.cs index 4e924e8..1d13cc3 100644 --- a/src/Mmcc.Bot/Commands/Moderation/Bans/Unban.cs +++ b/src/Mmcc.Bot/Commands/Moderation/Bans/Unban.cs @@ -4,7 +4,9 @@ using FluentValidation; using MediatR; using Microsoft.Extensions.Logging; +using Mmcc.Bot.Common.Extensions.Remora.Discord.API.Abstractions.Rest; using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.Common.Models.Settings; using Mmcc.Bot.Common.Statics; using Mmcc.Bot.Database; using Mmcc.Bot.Database.Entities; @@ -23,34 +25,17 @@ namespace Mmcc.Bot.Commands.Moderation.Bans; /// public class Unban { - /// - /// Command to unban a user. - /// public class Command : IRequest> { - /// - /// Moderation action. - /// public ModerationAction ModerationAction { get; set; } = null!; - - /// - /// ID of the channel to which polychat2 will send the confirmation message. - /// - public Snowflake ChannelId { get; set; } } - - /// - /// Validates the . - /// + public class Validator : AbstractValidator { public Validator() { RuleFor(c => c.ModerationAction) .NotNull(); - - RuleFor(c => c.ChannelId) - .NotNull(); } } @@ -64,17 +49,8 @@ public class Handler : IRequestHandler> private readonly IDiscordRestChannelAPI _channelApi; private readonly IColourPalette _colourPalette; private readonly ILogger _logger; - - /// - /// Instantiates a new instance of class. - /// - /// The DB context. - /// The polychat service. - /// The guild API. - /// The user API. - /// The channel API. - /// The colour palette. - /// The logger. + private readonly DiscordSettings _discordSettings; + public Handler( BotContext context, IPolychatService ps, @@ -82,7 +58,8 @@ public Handler( IDiscordRestUserAPI userApi, IDiscordRestChannelAPI channelApi, IColourPalette colourPalette, - ILogger logger + ILogger logger, + DiscordSettings discordSettings ) { _context = context; @@ -92,6 +69,7 @@ ILogger logger _channelApi = channelApi; _colourPalette = colourPalette; _logger = logger; + _discordSettings = discordSettings; } /// @@ -99,18 +77,25 @@ public async Task> Handle(Command request, Cancellation { var ma = request.ModerationAction; if (ma.ModerationActionType != ModerationActionType.Ban) - return new UnsupportedArgumentError( - $"Wrong moderation action type. Expected: {ModerationActionType.Ban}, got: {ma.ModerationActionType}"); - //if (!ma.IsActive) return new ValidationError("Moderation action is already inactive."); + return new UnsupportedArgumentError($"Wrong moderation action type. Expected: {ModerationActionType.Ban}, got: {ma.ModerationActionType}"); + if (!ma.IsActive) + return new UnsupportedArgumentError("Moderation action is already inactive."); + if (ma.UserIgn is not null) { + var getLogsChannel = await _guildApi.FindGuildChannelByName(new(ma.GuildId), _discordSettings.ChannelNames.ModerationLogs); + if (!getLogsChannel.IsSuccess) + { + _logger.LogError("An error has occurred while obtaining logs channel."); + } + var proto = new GenericCommand { - DefaultCommand = "ban", - DiscordCommandName = "ban", - DiscordChannelId = request.ChannelId.ToString(), - Args = {request.ModerationAction.UserIgn} + DiscordCommandName = "exec", + DefaultCommand = "$args", + Args = { "pardon", ma.UserIgn }, + DiscordChannelId = getLogsChannel.Entity?.ID.ToString() }; await _ps.BroadcastMessage(proto); } @@ -140,7 +125,7 @@ public async Task> Handle(Command request, Cancellation var createDmResult = await _userApi.CreateDMAsync(userDiscordIdSnowflake, cancellationToken); const string warningMsg = "Failed to send a DM notification to the user. It may be because they have blocked the bot or don't share any servers. This warning can in most cases be ignored."; - if (!createDmResult.IsSuccess || createDmResult.Entity is null) + if (!createDmResult.IsSuccess) { _logger.LogWarning(warningMsg); } diff --git a/src/Mmcc.Bot/Commands/Moderation/GeneralModerationCommands.cs b/src/Mmcc.Bot/Commands/Moderation/GeneralModerationCommands.cs index 6ca3c53..29dc585 100644 --- a/src/Mmcc.Bot/Commands/Moderation/GeneralModerationCommands.cs +++ b/src/Mmcc.Bot/Commands/Moderation/GeneralModerationCommands.cs @@ -8,8 +8,9 @@ using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Statics; using Mmcc.Bot.Database.Entities; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -23,15 +24,15 @@ namespace Mmcc.Bot.Commands.Moderation; /// /// General moderation commands that do not fit into any specific categories. /// +[RequireGuild] [Group("moderation", "mod")] [Description("Moderation (general)")] -[RequireGuild] -public class GeneralModerationCommands : CommandGroup +public partial class GeneralModerationCommands : CommandGroup { private readonly MessageContext _context; private readonly IMediator _mediator; private readonly IColourPalette _colourPalette; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; /// /// Instantiates a new instance of class. @@ -44,7 +45,7 @@ public GeneralModerationCommands( MessageContext context, IMediator mediator, IColourPalette colourPalette, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; @@ -105,10 +106,9 @@ public async Task Deactivate(int id) return getAppResult; } - Result deactivateResult = getAppResult.Entity.ModerationActionType switch + Result deactivateResult = getAppResult.Entity?.ModerationActionType switch { - ModerationActionType.Ban => await _mediator.Send(new Unban.Command - { ModerationAction = getAppResult.Entity, ChannelId = _context.ChannelID }), + ModerationActionType.Ban => await _mediator.Send(new Unban.Command { ModerationAction = getAppResult.Entity }), _ => Result.FromError(new UnsupportedFeatureError("Unsupported moderation type.")) }; diff --git a/src/Mmcc.Bot/Commands/Moderation/MemberApplications/MemberApplicationsCommands.cs b/src/Mmcc.Bot/Commands/Moderation/MemberApplications/MemberApplicationsCommands.cs index df27e5d..5ebf5be 100644 --- a/src/Mmcc.Bot/Commands/Moderation/MemberApplications/MemberApplicationsCommands.cs +++ b/src/Mmcc.Bot/Commands/Moderation/MemberApplications/MemberApplicationsCommands.cs @@ -1,23 +1,31 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using MediatR; +using Mmcc.Bot.Common.Errors; using Mmcc.Bot.Common.Extensions.Database.Entities; using Mmcc.Bot.Common.Extensions.Remora.Discord.API.Abstractions.Rest; using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Models.Settings; using Mmcc.Bot.Common.Statics; +using Mmcc.Bot.CommonEmbedProviders; using Mmcc.Bot.Database.Entities; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.InMemoryStore.Stores; +using Mmcc.Bot.Providers.CommonEmbedFieldsProviders; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; +using Mmcc.Bot.RemoraAbstractions.UI; +using Mmcc.Bot.RemoraAbstractions.UI.Extensions; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; using Remora.Discord.Commands.Contexts; +using Remora.Discord.Interactivity; using Remora.Rest.Core; using Remora.Results; @@ -37,18 +45,12 @@ public class MemberApplicationsCommands : CommandGroup private readonly IColourPalette _colourPalette; private readonly DiscordSettings _discordSettings; private readonly IDiscordRestGuildAPI _guildApi; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; + private readonly IMessageMemberAppContextStore _memberAppContextStore; + + private readonly ICommonEmbedProvider _memberAppEmbedProvider; + private readonly ICommonEmbedFieldsProvider> _memberAppsEmbedFieldsProvider; - /// - /// Instantiates a new instance of . - /// - /// The message context. - /// The channel API. - /// The mediator. - /// The colour palette. - /// The Discord settings. - /// The guild API. - /// The command responder. public MemberApplicationsCommands( MessageContext context, IDiscordRestChannelAPI channelApi, @@ -56,7 +58,10 @@ public MemberApplicationsCommands( IColourPalette colourPalette, DiscordSettings discordSettings, IDiscordRestGuildAPI guildApi, - ICommandResponder responder + CommandMessageResponder responder, + IMessageMemberAppContextStore memberAppContextStore, + ICommonEmbedProvider memberAppEmbedProvider, + ICommonEmbedFieldsProvider> memberAppsEmbedFieldsProvider ) { _context = context; @@ -66,6 +71,9 @@ ICommandResponder responder _discordSettings = discordSettings; _guildApi = guildApi; _responder = responder; + _memberAppContextStore = memberAppContextStore; + _memberAppEmbedProvider = memberAppEmbedProvider; + _memberAppsEmbedFieldsProvider = memberAppsEmbedFieldsProvider; } [Command("info")] @@ -111,21 +119,33 @@ await _responder.Respond(new Embed /// Result of the operation. [Command("view", "v")] [Description("Views a member application by ID.")] - public async Task View(int id) => - await _mediator.Send(new GetById.Query - { - ApplicationId = id, - GuildId = _context.Message.GuildID.Value - }) switch - { - { IsSuccess: true, Entity: { } e } => - await _responder.Respond(e.GetEmbed(_colourPalette)), + public async Task View(int id) + { + var getResult = await _mediator.Send(new GetById.Query + { + ApplicationId = id, + GuildId = _context.GuildID.Value + }); - { IsSuccess: true } => - Result.FromError(new NotFoundError($"Application with ID `{id}` could not be found.")), + if (getResult is {IsSuccess: true, Entity: { } app}) + { + _memberAppContextStore.Add(_context.MessageID.Value, app.MemberApplicationId); + + var approveButton = new ButtonComponent + ( + ButtonComponentStyle.Success, + Label: "Approve", + CustomID: CustomIDHelpers.CreateButtonID("approve-btn") + ); + var actionRows = ActionRowUtils.CreateActionRowWithComponents(approveButton).AsList(); - { IsSuccess: false } res => res - }; + return await _responder.RespondWithComponents(actionRows, new(), _memberAppEmbedProvider.GetEmbed(app)); + } + + return getResult is {IsSuccess: true} + ? Result.FromError(new NotFoundError($"Application with ID `{id}` could not be found.")) + : getResult; + } /// /// Views the next pending application in the queue. @@ -134,10 +154,10 @@ await _responder.Respond(e.GetEmbed(_colourPalette)), [Command("next", "n")] [Description("Views the next pending application in the queue")] public async Task ViewNextPending() => - await _mediator.Send(new GetNextPending.Query { GuildId = _context.Message.GuildID.Value }) switch + await _mediator.Send(new GetNextPending.Query { GuildId = _context.GuildID.Value }) switch { { IsSuccess: true, Entity: { } e } => - await _responder.Respond(e.GetEmbed(_colourPalette)), + await _responder.Respond(_memberAppEmbedProvider.GetEmbed(e)), { IsSuccess: true } => await _responder.Respond(new Embed @@ -168,7 +188,7 @@ public async Task ViewPending() return await _mediator.Send(new GetByStatus.Query { - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ApplicationStatus = ApplicationStatus.Pending, Limit = 25, SortByDescending = false @@ -178,7 +198,7 @@ public async Task ViewPending() await _responder.Respond( !e.Any() ? embedBase with { Description = "There are no pending applications at the moment." } - : embedBase with { Fields = e.GetEmbedFields().ToList() } + : embedBase with { Fields = _memberAppsEmbedFieldsProvider.GetEmbedFields(e).ToList() } ), { IsSuccess: false } res => res @@ -202,7 +222,7 @@ public async Task ViewApproved() return await _mediator.Send(new GetByStatus.Query { - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ApplicationStatus = ApplicationStatus.Approved, Limit = 10, SortByDescending = true @@ -212,7 +232,7 @@ public async Task ViewApproved() await _responder.Respond( !e.Any() ? embedBase with { Description = "You have not approved any applications yet." } - : embedBase with { Fields = e.GetEmbedFields().ToList() } + : embedBase with { Fields = _memberAppsEmbedFieldsProvider.GetEmbedFields(e).ToList() } ), { IsSuccess: false } res => res @@ -236,7 +256,7 @@ public async Task ViewRejected() return await _mediator.Send(new GetByStatus.Query { - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ApplicationStatus = ApplicationStatus.Rejected, Limit = 10, SortByDescending = true @@ -246,7 +266,7 @@ public async Task ViewRejected() await _responder.Respond( !e.Any() ? embedBase with { Description = "You have not rejected any applications yet." } - : embedBase with { Fields = e.GetEmbedFields().ToList() } + : embedBase with { Fields = _memberAppsEmbedFieldsProvider.GetEmbedFields(e).ToList() } ), { IsSuccess: false } res => res @@ -265,7 +285,7 @@ await _responder.Respond( [RequireUserGuildPermission(DiscordPermission.BanMembers)] public async Task Approve(int id, string serverPrefix, List ignsList) { - var getMembersChannelResult = await _guildApi.FindGuildChannelByName(_context.Message.GuildID.Value, + var getMembersChannelResult = await _guildApi.FindGuildChannelByName(_context.GuildID.Value, _discordSettings.ChannelNames.MemberApps); if (!getMembersChannelResult.IsSuccess) { @@ -275,7 +295,7 @@ public async Task Approve(int id, string serverPrefix, List ign var commandResult = await _mediator.Send(new ApproveAutomatically.Command { Id = id, - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, ChannelId = _context.ChannelID, ServerPrefix = serverPrefix, Igns = ignsList @@ -327,7 +347,7 @@ public async Task Approve(int id, string serverPrefix, List ign [RequireUserGuildPermission(DiscordPermission.BanMembers)] public async Task Reject(int id, [Greedy] string reason) { - var getMembersChannelResult = await _guildApi.FindGuildChannelByName(_context.Message.GuildID.Value, + var getMembersChannelResult = await _guildApi.FindGuildChannelByName(_context.GuildID.Value, _discordSettings.ChannelNames.MemberApps); if (!getMembersChannelResult.IsSuccess) { @@ -335,7 +355,7 @@ public async Task Reject(int id, [Greedy] string reason) } var rejectCommandResult = await _mediator.Send(new Reject.Command - {Id = id, GuildId = _context.Message.GuildID.Value}); + {Id = id, GuildId = _context.GuildID.Value}); if (!rejectCommandResult.IsSuccess) { return rejectCommandResult; diff --git a/src/Mmcc.Bot/Commands/Moderation/PlayerInfo/PlayerInfoCommands.cs b/src/Mmcc.Bot/Commands/Moderation/PlayerInfo/PlayerInfoCommands.cs index d37990b..a31e976 100644 --- a/src/Mmcc.Bot/Commands/Moderation/PlayerInfo/PlayerInfoCommands.cs +++ b/src/Mmcc.Bot/Commands/Moderation/PlayerInfo/PlayerInfoCommands.cs @@ -10,8 +10,9 @@ using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Statics; using Mmcc.Bot.Mojang; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API; @@ -37,7 +38,7 @@ public class PlayerInfoCommands : CommandGroup private readonly IColourPalette _colourPalette; private readonly IMojangApiService _mojangApi; private readonly IDiscordRestGuildAPI _guildApi; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; /// /// Instantiates a new instance of . @@ -54,7 +55,7 @@ public PlayerInfoCommands( IColourPalette colourPalette, IMojangApiService mojangApi, IDiscordRestGuildAPI guildApi, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; @@ -86,7 +87,7 @@ public async Task InfoDiscord(IUser user) fields.Add(user.GetEmbedField()); - var getGuildMemberResult = await _guildApi.GetGuildMemberAsync(_context.Message.GuildID.Value, user.ID); + var getGuildMemberResult = await _guildApi.GetGuildMemberAsync(_context.GuildID.Value, user.ID); if (getGuildMemberResult.IsSuccess) { var guildMember = getGuildMemberResult.Entity; @@ -110,7 +111,7 @@ public async Task InfoDiscord(IUser user) } var queryResult = - await _mediator.Send(new GetByDiscordId.Query(_context.Message.GuildID.Value, user.ID.Value)); + await _mediator.Send(new GetByDiscordId.Query(_context.GuildID.Value, user.ID.Value)); if (queryResult.IsSuccess) { @@ -150,7 +151,7 @@ public async Task InfoIg(string ign) Thumbnail = EmbedProperties.MmccLogoThumbnail }; var fields = new List(); - var queryResult = await _mediator.Send(new GetByIgn.Query(_context.Message.GuildID.Value, ign)); + var queryResult = await _mediator.Send(new GetByIgn.Query(_context.GuildID.Value, ign)); var getUuid = await _mojangApi.GetPlayerUuidInfo(ign); if (getUuid.IsSuccess && getUuid.Entity is not null) diff --git a/src/Mmcc.Bot/Commands/Moderation/Warns/WarnCommands.cs b/src/Mmcc.Bot/Commands/Moderation/Warns/WarnCommands.cs index 10d6180..930b744 100644 --- a/src/Mmcc.Bot/Commands/Moderation/Warns/WarnCommands.cs +++ b/src/Mmcc.Bot/Commands/Moderation/Warns/WarnCommands.cs @@ -3,8 +3,9 @@ using System.Threading.Tasks; using MediatR; using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -26,7 +27,7 @@ public class WarnCommands : CommandGroup private readonly MessageContext _context; private readonly IMediator _mediator; private readonly Embed _embedBase; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; /// /// Instantiates a new instance of class. @@ -39,7 +40,7 @@ public WarnCommands( MessageContext context, IMediator mediator, IColourPalette colourPalette, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; @@ -59,7 +60,7 @@ public async Task WarnDiscord(IUser user, [Greedy] string reason) => await _mediator.Send(new Warn.Command { UserDiscordId = user.ID, - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, Reason = reason, UserIgn = null }) switch @@ -80,7 +81,7 @@ public async Task WarnIg(string ign, [Greedy] string reason) => await _mediator.Send(new Warn.Command { UserIgn = ign, - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, Reason = reason, UserDiscordId = null }) switch @@ -105,7 +106,7 @@ public async Task WarnAll(IUser discordUser, string ign, [Greedy] strin { UserDiscordId = discordUser.ID, UserIgn = ign, - GuildId = _context.Message.GuildID.Value, + GuildId = _context.GuildID.Value, Reason = reason } ) switch diff --git a/src/Mmcc.Bot/Commands/Tags/Management/TagsManagementCommands.cs b/src/Mmcc.Bot/Commands/Tags/Management/TagsManagementCommands.cs index 3716173..6fb3ddc 100644 --- a/src/Mmcc.Bot/Commands/Tags/Management/TagsManagementCommands.cs +++ b/src/Mmcc.Bot/Commands/Tags/Management/TagsManagementCommands.cs @@ -6,8 +6,9 @@ using MediatR; using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Statics; -using Mmcc.Bot.RemoraAbstractions.Conditions.Attributes; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Conditions; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -28,7 +29,7 @@ public class TagsManagementCommands : CommandGroup private readonly MessageContext _context; private readonly IMediator _mediator; private readonly IColourPalette _colourPalette; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; /// /// Instantiates a new instance of class. @@ -41,7 +42,7 @@ public TagsManagementCommands( MessageContext context, IMediator mediator, IColourPalette colourPalette, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; diff --git a/src/Mmcc.Bot/Commands/Tags/Usage/TagsUsageCommands.cs b/src/Mmcc.Bot/Commands/Tags/Usage/TagsUsageCommands.cs index e8ed586..4c19dbe 100644 --- a/src/Mmcc.Bot/Commands/Tags/Usage/TagsUsageCommands.cs +++ b/src/Mmcc.Bot/Commands/Tags/Usage/TagsUsageCommands.cs @@ -1,7 +1,9 @@ using System.ComponentModel; using System.Threading.Tasks; using MediatR; -using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.Database.Entities; +using Mmcc.Bot.Notifications.Moderation; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.Commands.Contexts; @@ -16,7 +18,7 @@ public class TagsUsageCommands : CommandGroup { private readonly MessageContext _context; private readonly IMediator _mediator; - private readonly ICommandResponder _responder; + private readonly CommandMessageResponder _responder; /// /// Instantiates a new instance of . @@ -27,7 +29,7 @@ public class TagsUsageCommands : CommandGroup public TagsUsageCommands( MessageContext context, IMediator mediator, - ICommandResponder responder + CommandMessageResponder responder ) { _context = context; @@ -51,4 +53,15 @@ await _responder.Respond(e.Content), {IsSuccess: false} res => res }; } + + [Command("tester")] + [Description("Sends a given tag.")] + public async Task SendTagg(string tagName) + { + await _mediator.Publish(new ModerationActionExpiredNotification(new ModerationAction(ModerationActionType.Ban, + 0, true, "asas", 0, + null, "fasdfdsafsd"))); + + return Result.FromSuccess(); + } } \ No newline at end of file diff --git a/src/Mmcc.Bot/CoreSetup/ConfigurationSetup.cs b/src/Mmcc.Bot/CoreSetup/ConfigurationSetup.cs index f162c96..5a50659 100644 --- a/src/Mmcc.Bot/CoreSetup/ConfigurationSetup.cs +++ b/src/Mmcc.Bot/CoreSetup/ConfigurationSetup.cs @@ -3,6 +3,7 @@ using Mmcc.Bot.Common.Extensions.Microsoft.Extensions.DependencyInjection; using Mmcc.Bot.Common.Models.Settings; using Mmcc.Bot.Database.Settings; +using Mmcc.Bot.Features.Diagnostics; using Mmcc.Bot.Polychat.Models.Settings; using Remora.Discord.API.Abstractions.Gateway.Commands; using Remora.Discord.Gateway; @@ -25,6 +26,8 @@ public static IServiceCollection ConfigureBot( HostBuilderContext hostContext ) { + services.AddScoped(); + // add command line args config; services.AddSingleton(); @@ -45,7 +48,8 @@ HostBuilderContext hostContext | GatewayIntents.GuildMembers | GatewayIntents.GuildBans | GatewayIntents.GuildMessages - | GatewayIntents.GuildMessageReactions; + | GatewayIntents.GuildMessageReactions + | GatewayIntents.MessageContents; }); return services; diff --git a/src/Mmcc.Bot/EventResponders/Buttons/ButtonInteractionCreateResponder.cs b/src/Mmcc.Bot/EventResponders/Buttons/ButtonInteractionCreateResponder.cs deleted file mode 100644 index b43994c..0000000 --- a/src/Mmcc.Bot/EventResponders/Buttons/ButtonInteractionCreateResponder.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Microsoft.Extensions.Logging; -using Mmcc.Bot.Caching; -using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.Common.Statics; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Commands.Results; -using Remora.Discord.API.Abstractions.Gateway.Events; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Objects; -using Remora.Discord.Gateway.Responders; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.EventResponders.Buttons; - -public class ButtonInteractionCreateResponder : IResponder -{ - private readonly IButtonHandlerRepository _handlerRepository; - private readonly IDiscordPermissionsService _permissionsService; - private readonly IColourPalette _colourPalette; - private readonly ILogger _logger; - private readonly IInteractionResponder _interactionResponder; - private readonly IMediator _mediator; - - public ButtonInteractionCreateResponder( - IButtonHandlerRepository handlerRepository, - IDiscordPermissionsService permissionsService, - IColourPalette colourPalette, - ILogger logger, - IInteractionResponder interactionResponder, - IMediator mediator - ) - { - _handlerRepository = handlerRepository; - _permissionsService = permissionsService; - _colourPalette = colourPalette; - _logger = logger; - _interactionResponder = interactionResponder; - _mediator = mediator; - } - - public async Task RespondAsync(IInteractionCreate ev, CancellationToken ct = new()) - { - if (ev.Type is not InteractionType.MessageComponent - || !ev.Message.HasValue - || !ev.Member.HasValue - || !ev.Member.Value.User.HasValue - || !ev.ChannelID.HasValue - || !ev.Data.HasValue - || !ev.Data.Value.CustomID.HasValue - ) - { - return Result.FromSuccess(); - } - - var notifyAboutDeferredRes = await _interactionResponder.NotifyDeferredMessageIsComing(ev.ID, ev.Token, ct); - if (!notifyAboutDeferredRes.IsSuccess) - { - return notifyAboutDeferredRes; - } - - var customId = ev.Data.Value.CustomID.Value; - var idParseSuccessful = Snowflake.TryParse(customId, out var id); - if (!idParseSuccessful) - { - var errorEmbed = new Embed - { - Title = ":x: Invalid custom ID format", - Description = - $"The custom ID: {customId} was not recognised as a valid Mmcc.Bot button ID.", - Thumbnail = EmbedProperties.MmccLogoThumbnail, - Colour = _colourPalette.Red, - Timestamp = DateTimeOffset.UtcNow - }; - var errSendRes = await _interactionResponder.SendFollowup(ev.Token, errorEmbed); - return errSendRes.IsSuccess - ? Result.FromError(new ParsingError(customId, "Could not parse button ID.")) - : errSendRes; - } - - var ulongId = id!.Value.Value; - var handler = _handlerRepository.GetOrDefault(ulongId); - if (handler is null) - { - _logger.LogWarning("Could not find a button handler for button with ID: {customId}.", ulongId); - - var errorEmbed = new Embed - { - Title = ":x: Interaction expired", - Description = - "This button has expired. Request the command with the button again to generate a new button.", - Thumbnail = EmbedProperties.MmccLogoThumbnail, - Colour = _colourPalette.Red, - Timestamp = DateTimeOffset.UtcNow - }; - return await _interactionResponder.SendFollowup(ev.Token, errorEmbed); - } - - // check if user has permission; - // ReSharper disable once InvertIf - if (handler.RequiredPermission.HasValue) - { - var checkResult = await _permissionsService.CheckHasRequiredPermission(handler.RequiredPermission.Value, - ev.ChannelID.Value, ev.Member.Value.User.Value, ct); - - // ReSharper disable once InvertIf - if (!checkResult.IsSuccess) - { - var errorEmbed = new Embed - { - Title = ":x: Unauthorised", - Description = - "You do not have permission to use this button.", - Fields = new List - { - new("Required permission", $"`{handler.RequiredPermission.Value.ToString()}`") - }, - Thumbnail = EmbedProperties.MmccLogoThumbnail, - Colour = _colourPalette.Red, - Timestamp = DateTimeOffset.UtcNow - }; - return await _interactionResponder.SendFollowup(ev.Token, errorEmbed); - } - } - - var context = handler.Context; - var command = Activator.CreateInstance(handler.HandlerCommandType, ev.Token, context); - var buttonActionResult = (Result) (await _mediator.Send(command!, ct))!; - - if (!buttonActionResult.IsSuccess) - { - var errorEmbed = new Embed - { - Thumbnail = EmbedProperties.MmccLogoThumbnail, - Colour = _colourPalette.Red, - Timestamp = DateTimeOffset.UtcNow, - Title = $":x: {buttonActionResult.Error.GetType()}.", - Description = buttonActionResult.Error.Message - }; - - return await _interactionResponder.SendFollowup(ev.Token, errorEmbed); - } - - return Result.FromSuccess(); - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot/EventResponders/Buttons/TestHandler.cs b/src/Mmcc.Bot/EventResponders/Buttons/TestHandler.cs deleted file mode 100644 index c17b0b7..0000000 --- a/src/Mmcc.Bot/EventResponders/Buttons/TestHandler.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Mmcc.Bot.RemoraAbstractions.Services; -using Remora.Rest.Core; -using Remora.Results; - -namespace Mmcc.Bot.EventResponders.Buttons; - -public class TestHandler -{ - public record Command(string InteractionToken, Context Context) : IRequest; - - public record Context(Snowflake Id); - - public class Handler : IRequestHandler - { - private readonly IInteractionResponder _responder; - - public Handler(IInteractionResponder responder) - { - _responder = responder; - } - - public async Task Handle(Command req, CancellationToken ct) - { - var msg = JsonSerializer.Serialize(req.Context); - - var respondRes = await _responder.SendFollowup(req.InteractionToken, msg); - return respondRes.IsSuccess - ? Result.FromSuccess() - : Result.FromError(respondRes.Error); - } - } -} \ No newline at end of file diff --git a/src/Mmcc.Bot/EventResponders/EventRespondersSetup.cs b/src/Mmcc.Bot/EventResponders/EventRespondersSetup.cs index 57fe061..8ddeb64 100644 --- a/src/Mmcc.Bot/EventResponders/EventRespondersSetup.cs +++ b/src/Mmcc.Bot/EventResponders/EventRespondersSetup.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.DependencyInjection; -using Mmcc.Bot.EventResponders.Buttons; using Mmcc.Bot.EventResponders.Feedback; using Mmcc.Bot.EventResponders.Guilds; +using Mmcc.Bot.EventResponders.Interactions; using Mmcc.Bot.EventResponders.Moderation.MemberApplications; using Mmcc.Bot.EventResponders.Users; using Remora.Discord.Gateway.Extensions; +using Remora.Discord.Interactivity; +using Remora.Extensions.Options.Immutable; namespace Mmcc.Bot.EventResponders; @@ -27,7 +29,9 @@ public static IServiceCollection AddBotGatewayEventResponders(this IServiceColle services.AddResponder(); services.AddResponder(); services.AddResponder(); - services.AddResponder(); + + services.AddResponder(); + services.Configure(() => new InteractivityResponderOptions()); return services; } diff --git a/src/Mmcc.Bot/EventResponders/Guilds/GuildCreatedResponder.cs b/src/Mmcc.Bot/EventResponders/Guilds/GuildCreatedResponder.cs index c295c6a..fc0a1e8 100644 --- a/src/Mmcc.Bot/EventResponders/Guilds/GuildCreatedResponder.cs +++ b/src/Mmcc.Bot/EventResponders/Guilds/GuildCreatedResponder.cs @@ -52,43 +52,27 @@ public async Task RespondAsync(IGuildCreate ev, CancellationToken ct = d .Select(p => p.GetValue(_discordSettings.ChannelNames) as string) .Where(s => s is not null) .ToList()!; - - if (!channels.HasValue) + + + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (var requiredChannel in requiredChannels) { - foreach (var requiredChannel in requiredChannels) + // ReSharper disable once InvertIf + if (channels.FirstOrDefault(c => c.Name.HasValue && c.Name.Value!.Equals(requiredChannel)) is null) { - var createChannelResult = await _guildApi.CreateGuildChannelAsync(ev.ID, requiredChannel, ChannelType.GuildText, ct :ct); + var createChannelResult = + await _guildApi.CreateGuildChannelAsync(ev.ID, requiredChannel, ChannelType.GuildText, ct: ct); if (!createChannelResult.IsSuccess) { - return new SetupError("Failed to create required channels."); + return new SetupError("Failed to create required channels."); } - + _logger.LogInformation( $"Created required channel \"{requiredChannel}\" in guild with ID: \"{ev.ID}\" and Name: \"{ev.Name}\""); } } - else - { - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var requiredChannel in requiredChannels) - { - // ReSharper disable once InvertIf - if (channels.Value.FirstOrDefault(c => c.Name.Value.Equals(requiredChannel)) is null) - { - var createChannelResult = await _guildApi.CreateGuildChannelAsync(ev.ID, requiredChannel, ChannelType.GuildText, ct :ct); - if (!createChannelResult.IsSuccess) - { - return new SetupError("Failed to create required channels."); - } - - _logger.LogInformation( - $"Created required channel \"{requiredChannel}\" in guild with ID: \"{ev.ID}\" and Name: \"{ev.Name}\""); - } - } - } - _logger.LogInformation($"Successfully set up guild with ID: \"{ev.ID}\" and Name: \"{ev.Name}\""); return Result.FromSuccess(); } diff --git a/src/Mmcc.Bot/EventResponders/Interactions/InteractivityResponder.cs b/src/Mmcc.Bot/EventResponders/Interactions/InteractivityResponder.cs new file mode 100644 index 0000000..dca025a --- /dev/null +++ b/src/Mmcc.Bot/EventResponders/Interactions/InteractivityResponder.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Humanizer; +using Microsoft.Extensions.Options; +using Mmcc.Bot.RemoraAbstractions.Services.Interactions; +using Remora.Commands.Services; +using Remora.Commands.Tokenization; +using Remora.Commands.Trees; +using Remora.Discord.API.Abstractions.Gateway.Events; +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.Extensions; +using Remora.Discord.Commands.Services; +using Remora.Discord.Gateway.Responders; +using Remora.Discord.Interactivity; +using Remora.Results; + +namespace Mmcc.Bot.EventResponders.Interactions; + +public sealed class InteractivityResponder : IResponder +{ + private readonly ContextInjectionService _contextInjectionService; + private readonly IDiscordRestInteractionAPI _interactionApi; + private readonly IServiceProvider _services; + private readonly InteractivityResponderOptions _options; + private readonly CommandService _commandService; + + private readonly TokenizerOptions _tokenizerOptions; + private readonly TreeSearchOptions _treeSearchOptions; + + private readonly IInteractionExecutionEventsRunner _eventsRunner; + + /// + /// Initializes a new instance of the class. + /// + /// The command service. + /// The responder options. + /// The interaction API. + /// The available services. + /// The context injection service. + /// The tokenizer options. + /// The tree search options. + /// The interaction execution events runner. + public InteractivityResponder + ( + CommandService commandService, + IOptions options, + IDiscordRestInteractionAPI interactionApi, + IServiceProvider services, + ContextInjectionService contextInjectionService, + IOptions tokenizerOptions, + IOptions treeSearchOptions, + IInteractionExecutionEventsRunner eventsRunner + ) + { + _services = services; + _contextInjectionService = contextInjectionService; + _eventsRunner = eventsRunner; + _interactionApi = interactionApi; + _commandService = commandService; + _options = options.Value; + + _tokenizerOptions = tokenizerOptions.Value; + _treeSearchOptions = treeSearchOptions.Value; + } + + /// + public async Task RespondAsync(IInteractionCreate gatewayEvent, CancellationToken ct = default) + { + if (gatewayEvent.Type is not (InteractionType.MessageComponent or InteractionType.ModalSubmit)) + { + return Result.FromSuccess(); + } + + if (!gatewayEvent.Data.IsDefined(out var data)) + { + return new InvalidOperationError("Component or modal interaction without data received. Bug?"); + } + + var createContext = gatewayEvent.CreateContext(); + if (!createContext.IsSuccess) + { + return (Result)createContext; + } + + var context = createContext.Entity; + _contextInjectionService.Context = context; + + return data.TryPickT1(out var componentData, out var remainder) + ? await HandleComponentInteractionAsync(context, componentData, ct) + : remainder.TryPickT1(out var modalSubmitData, out _) + ? await HandleModalInteractionAsync(context, modalSubmitData, ct) + : Result.FromSuccess(); + } + + private async Task HandleComponentInteractionAsync + ( + InteractionContext context, + IMessageComponentData data, + CancellationToken ct = default + ) + { + if (!data.CustomID.StartsWith(Constants.InteractionTree)) + { + // Not a component we handle + return Result.FromSuccess(); + } + + if (data.ComponentType is ComponentType.SelectMenu) + { + if (!data.Values.HasValue) + { + return new InvalidOperationError("The interaction did not contain any selected values."); + } + } + + var commandPath = data.CustomID[Constants.InteractionTree.Length..][2..] + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + + var buildParameters = data.ComponentType switch + { + ComponentType.Button => new Dictionary>(), + ComponentType.SelectMenu => Result>>.FromSuccess + ( + new Dictionary> + { + { "values", data.Values.Value } + } + ), + _ => new InvalidOperationError("An unsupported component type was encountered.") + }; + + if (!buildParameters.IsSuccess) + { + return (Result)buildParameters; + } + + var parameters = buildParameters.Entity; + + return await TryExecuteInteractionCommandAsync(context, commandPath, parameters, ct); + } + + private async Task HandleModalInteractionAsync + ( + InteractionContext context, + IModalSubmitData data, + CancellationToken ct = default + ) + { + if (!data.CustomID.StartsWith(Constants.InteractionTree)) + { + // Not a component we handle + return Result.FromSuccess(); + } + + var commandPath = data.CustomID[Constants.InteractionTree.Length..][2..] + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + + var parameters = ExtractParameters(data.Components); + + return await TryExecuteInteractionCommandAsync + ( + context, + commandPath, + parameters, + ct + ); + } + + private static IReadOnlyDictionary> ExtractParameters + ( + IEnumerable components + ) + { + var parameters = new Dictionary>(); + foreach (var component in components) + { + if (component is IPartialActionRowComponent actionRow) + { + if (!actionRow.Components.IsDefined(out var rowComponents)) + { + continue; + } + + var nestedComponents = ExtractParameters(rowComponents); + foreach (var nestedComponent in nestedComponents) + { + parameters.Add(nestedComponent.Key, nestedComponent.Value); + } + + continue; + } + + switch (component) + { + case IPartialTextInputComponent textInput: + { + if (!textInput.CustomID.IsDefined(out var id)) + { + continue; + } + + if (!textInput.Value.IsDefined(out var value)) + { + continue; + } + + parameters.Add(id.Replace('-', '_').Camelize(), new[] { value }); + break; + } + case IPartialSelectMenuComponent selectMenu: + { + if (!selectMenu.CustomID.IsDefined(out var id)) + { + continue; + } + + if (!selectMenu.Options.IsDefined(out var options)) + { + continue; + } + + var values = options.Where(op => op.Value.HasValue).Select(op => op.Value.Value).ToList(); + + parameters.Add(id.Replace('-', '_').Camelize(), values); + break; + } + } + } + + return parameters; + } + + private async Task TryExecuteInteractionCommandAsync + ( + InteractionContext context, + IReadOnlyList commandPath, + IReadOnlyDictionary> parameters, + CancellationToken ct + ) + { + var prepareCommand = await _commandService.TryPrepareCommandAsync + ( + commandPath, + parameters, + _services, + searchOptions: _treeSearchOptions, + tokenizerOptions: _tokenizerOptions, + treeName: Constants.InteractionTree, + ct: ct + ); + + if (!prepareCommand.IsSuccess) + { + return (Result)prepareCommand; + } + + var preparedCommand = prepareCommand.Entity; + + var suppressResponseAttribute = preparedCommand.Command.Node + .FindCustomAttributeOnLocalTree(); + + var shouldSendResponse = !(suppressResponseAttribute?.Suppress ?? _options.SuppressAutomaticResponses); + + // ReSharper disable once InvertIf + if (shouldSendResponse) + { + var response = new InteractionResponse(InteractionCallbackType.DeferredUpdateMessage); + var createResponse = await _interactionApi.CreateInteractionResponseAsync + ( + context.ID, + context.Token, + response, + ct: ct + ); + + if (!createResponse.IsSuccess) + { + return createResponse; + } + } + + // Run the actual command + var executionResult = (Result)await _commandService.TryExecuteAsync( + preparedCommand, + _services, + ct + ); + + return await _eventsRunner.RunPostExecutionEvents(context, executionResult, ct); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationCreatedResponder.cs b/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationCreatedResponder.cs index 81e6c53..7ebc506 100644 --- a/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationCreatedResponder.cs +++ b/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationCreatedResponder.cs @@ -68,7 +68,7 @@ public async Task RespondAsync(IMessageCreate ev, CancellationToken ct = } // return if the message isn't in #member-apps; - if (!channelName.Value.Equals(_discordSettings.ChannelNames.MemberApps)) + if (!channelName.Value!.Equals(_discordSettings.ChannelNames.MemberApps)) { return Result.FromSuccess(); } diff --git a/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationUpdatedResponder.cs b/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationUpdatedResponder.cs index 2654291..1c4e6d3 100644 --- a/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationUpdatedResponder.cs +++ b/src/Mmcc.Bot/EventResponders/Moderation/MemberApplications/MemberApplicationUpdatedResponder.cs @@ -69,7 +69,7 @@ public async Task RespondAsync(IMessageUpdate ev, CancellationToken ct = } // return if the message isn't in #member-apps; - if (!channelName.Value.Equals(_discordSettings.ChannelNames.MemberApps)) + if (!channelName.Value!.Equals(_discordSettings.ChannelNames.MemberApps)) { return Result.FromSuccess(); } diff --git a/src/Mmcc.Bot/Features/Diagnostics/DiagnosticsCommands.cs b/src/Mmcc.Bot/Features/Diagnostics/DiagnosticsCommands.cs new file mode 100644 index 0000000..1c5c899 --- /dev/null +++ b/src/Mmcc.Bot/Features/Diagnostics/DiagnosticsCommands.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using MediatR; +using Porbeagle; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; + +namespace Mmcc.Bot.Features.Diagnostics; + +[Group("diagnostics")] +[Description("Server and bot diagnostics")] +public sealed partial class DiagnosticsCommands : CommandGroup +{ + private readonly IMediator _mediator; + private readonly IContextAwareViewManager _vm; + + public DiagnosticsCommands(IMediator mediator, IContextAwareViewManager vm) + { + _mediator = mediator; + _vm = vm; + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Features/Diagnostics/DiagnosticsSettings.cs b/src/Mmcc.Bot/Features/Diagnostics/DiagnosticsSettings.cs new file mode 100644 index 0000000..efca0a6 --- /dev/null +++ b/src/Mmcc.Bot/Features/Diagnostics/DiagnosticsSettings.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace Mmcc.Bot.Features.Diagnostics; + +public interface IDiagnosticsSettings +{ + int Timeout { get; } + IReadOnlyDictionary NetworkResourcesToCheck { get; } +} + +public class DiagnosticsSettings : IDiagnosticsSettings +{ + public int Timeout { get; } = 120; + + public IReadOnlyDictionary NetworkResourcesToCheck { get; } = new Dictionary + { + ["Discord"] = "discord.com", + ["Mojang API"] = "api.mojang.com", + ["MMCC"] = "s4.moddedminecraft.club" + }.AsReadOnly(); +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Features/Diagnostics/GetBotDiagnostics.cs b/src/Mmcc.Bot/Features/Diagnostics/GetBotDiagnostics.cs new file mode 100644 index 0000000..447f2f4 --- /dev/null +++ b/src/Mmcc.Bot/Features/Diagnostics/GetBotDiagnostics.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Net.NetworkInformation; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Mmcc.Bot.SourceGenerators.DiscordCommands; +using Remora.Results; + +namespace Mmcc.Bot.Features.Diagnostics; + +[DiscordCommand("bot", "Show status of the bot and APIs it uses", isGreedy: false)] +public sealed class GetBotDiagnostics +{ + public record struct Query : IRequest>>; + + public sealed record QueryResult(string Name, string Address, IPStatus Status, long? RoundtripTime); + + public sealed class Handler : IRequestHandler>> + { + private readonly IDiagnosticsSettings _settings; + + public Handler(IDiagnosticsSettings settings) + => _settings = settings; + + public async Task>> Handle(Query request, CancellationToken cancellationToken) + { + try + { + var results = new List(_settings.NetworkResourcesToCheck.Count); + foreach (var (name, address) in _settings.NetworkResourcesToCheck) + { + var pingResult = await PingResource(name, address); + + results.Add(pingResult); + } + + return Result>.FromSuccess(results); + } + catch (Exception e) + { + return e; + } + } + + private async Task PingResource(string name, string address) + { + var ping = new Ping(); + var options = new PingOptions + { + DontFragment = true + }; + var buffer = new byte[32]; + var reply = await ping.SendPingAsync(address, _settings.Timeout, buffer, options); + + return new(Name: name, Address: reply.Address.ToString(), Status: reply.Status, RoundtripTime: reply.RoundtripTime); + } + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Features/Diagnostics/GetBotDiagnostics.dview.cs b/src/Mmcc.Bot/Features/Diagnostics/GetBotDiagnostics.dview.cs new file mode 100644 index 0000000..a050e72 --- /dev/null +++ b/src/Mmcc.Bot/Features/Diagnostics/GetBotDiagnostics.dview.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.NetworkInformation; +using Mmcc.Bot.Common.Models.Colours; +using Porbeagle; +using Porbeagle.Attributes; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Features.Diagnostics; + +[DiscordView] +public sealed partial record GetBotDiagnosticsView : IMessageView +{ + public GetBotDiagnosticsView(IEnumerable results) + => Embed = new BotDiagnosticsEmbed(results); + + public Optional Text { get; init; } = new(); + + public Embed Embed { get; } +} + +public sealed record BotDiagnosticsEmbed : Embed +{ + public BotDiagnosticsEmbed(IEnumerable results) : base( + Title: "Bot diagnostics", + Description: "Information about the status of the bot and the APIs it uses", + Timestamp: DateTimeOffset.UtcNow, + Colour: ColourPalette.Green, + Fields: GetFields(results) + ) + { + } + + private static Optional> GetFields(IEnumerable results) + { + var fields = new List + { + new EmbedField(Name: "Bot status", Value: ":green_circle: Operational", IsInline: false) + }; + + fields.AddRange(results.Select(x => x.ToEmbedField())); + + return fields; + } +} + +file static class PingResultMapperExtensions +{ + internal static IEmbedField ToEmbedField(this GetBotDiagnostics.QueryResult pingResult) + { + var fieldTextValue = pingResult.Status switch + { + IPStatus.Success => pingResult.RoundtripTime switch + { + <= 50 => ":green_circle: ", + <= 120 => ":yellow_circle: ", + _ => ":red_circle: " + } + pingResult.RoundtripTime + " ms", + + _ => ":x: Could not reach." + }; + + return new EmbedField(Name: $"{pingResult.Name} Status", Value: fieldTextValue, IsInline: false); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Commands/Diagnostics/GetDrives.cs b/src/Mmcc.Bot/Features/Diagnostics/GetDrivesDiagnostics.cs similarity index 74% rename from src/Mmcc.Bot/Commands/Diagnostics/GetDrives.cs rename to src/Mmcc.Bot/Features/Diagnostics/GetDrivesDiagnostics.cs index 9867ec3..bcdba78 100644 --- a/src/Mmcc.Bot/Commands/Diagnostics/GetDrives.cs +++ b/src/Mmcc.Bot/Features/Diagnostics/GetDrivesDiagnostics.cs @@ -3,24 +3,24 @@ using System.Linq; using MediatR; -namespace Mmcc.Bot.Commands.Diagnostics; +namespace Mmcc.Bot.Features.Diagnostics; /// -/// Query to get drives. +/// Gets the drives diagnostics. /// -public class GetDrives +public sealed class GetDrivesDiagnostics { /// /// Query to get drives. /// - public class Query : IRequest> + public sealed class Query : IRequest> { } - + /// - /// Result of the drive query. + /// Drive diagnostics. /// - public class QueryResult + public sealed class QueryResult { public string Name { get; set; } = null!; public DriveType DriveType { get; set; } @@ -30,11 +30,9 @@ public class QueryResult public double PercentageUsed { get; set; } public float GigabytesTotalSize { get; set; } } - - /// - public class Handler : RequestHandler> + + public sealed class Handler : RequestHandler> { - /// protected override IList Handle(Query request) { var allDrives = DriveInfo.GetDrives(); @@ -47,7 +45,7 @@ protected override IList Handle(Query request) Label = string.IsNullOrWhiteSpace(d.VolumeLabel) ? "None" : d.VolumeLabel, DriveFormat = d.DriveFormat, GigabytesFree = d.AvailableFreeSpace / 1024f / 1024f / 1024f, - PercentageUsed = (double) (d.TotalSize - d.AvailableFreeSpace) / d.TotalSize * 100, + PercentageUsed = (double)(d.TotalSize - d.AvailableFreeSpace) / d.TotalSize * 100, GigabytesTotalSize = d.TotalSize / 1024f / 1024f / 1024f }) .ToList(); diff --git a/src/Mmcc.Bot/Features/Diagnostics/GetDrivesDiagnostics.dview.cs b/src/Mmcc.Bot/Features/Diagnostics/GetDrivesDiagnostics.dview.cs new file mode 100644 index 0000000..d0cb410 --- /dev/null +++ b/src/Mmcc.Bot/Features/Diagnostics/GetDrivesDiagnostics.dview.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.Common.Statics; +using Porbeagle; +using Porbeagle.Attributes; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Features.Diagnostics; + +[DiscordView] +public sealed partial record GetDrivesDiagnosticsView : IMessageView +{ + public GetDrivesDiagnosticsView(IEnumerable results) + => Embed = new DrivesDiagnosticsEmbed(results); + + public Optional Text { get; init; } = new(); + public Embed Embed { get; } +} + +public sealed record DrivesDiagnosticsEmbed : Embed +{ + public DrivesDiagnosticsEmbed(IEnumerable results) : base( + Title: "Drives diagnostics", + Colour: ColourPalette.Blue, + Thumbnail: EmbedProperties.MmccLogoThumbnail, + Footer: new EmbedFooter("Dedicated server"), + Timestamp: DateTimeOffset.UtcNow, + Fields: results.Select(x => x.ToEmbedField()).ToList() + ) + { + } +} + +file static class DriveDiagnosticsMapperExtensions +{ + internal static IEmbedField ToEmbedField(this GetDrivesDiagnostics.QueryResult d) + { + var freeSpaceRemainingEmoji = d.PercentageUsed switch + { + <= 65 => ":green_circle:", + <= 85 => ":yellow_circle:", + _ => ":red_circle:" + }; + + var fieldTextValue = $""" + Volume label: {d.Label} + File system: {d.DriveFormat} + Available space: {freeSpaceRemainingEmoji} {d.GigabytesFree:0.00} GB ({d.PercentageUsed:0.00}% used) + Total size: {d.GigabytesTotalSize:0.00} GB + """; + + return new EmbedField(Name: d.Name, Value: fieldTextValue, IsInline: false); + } +} diff --git a/src/Mmcc.Bot/Commands/Guilds/GetGuildInfo.cs b/src/Mmcc.Bot/Features/Guilds/GetGuildInfo.cs similarity index 77% rename from src/Mmcc.Bot/Commands/Guilds/GetGuildInfo.cs rename to src/Mmcc.Bot/Features/Guilds/GetGuildInfo.cs index 09df9b0..02487bd 100644 --- a/src/Mmcc.Bot/Commands/Guilds/GetGuildInfo.cs +++ b/src/Mmcc.Bot/Features/Guilds/GetGuildInfo.cs @@ -11,7 +11,7 @@ using Remora.Rest.Core; using Remora.Results; -namespace Mmcc.Bot.Commands.Guilds; +namespace Mmcc.Bot.Features.Guilds; /// /// Gets guild info. @@ -21,12 +21,12 @@ public class GetGuildInfo /// /// Query to get guild info. /// - public record Query(Snowflake GuildId) : IRequest>; + public sealed record Query(Snowflake GuildId) : IRequest>; /// /// Validates the . /// - public class Validator : AbstractValidator + public sealed class Validator : AbstractValidator { public Validator() { @@ -38,29 +38,23 @@ public Validator() /// /// Result of the query to get guild info. /// - public record QueryResult( + public sealed record QueryResult( string GuildName, Snowflake GuildOwnerId, int? GuildMaxMembers, IList GuildRoles, Uri? GuildIconUrl ); - - /// - public class Handler : IRequestHandler> + + public sealed class Handler : IRequestHandler> { private readonly IDiscordRestGuildAPI _guildApi; - - /// - /// Instantiates a new instance of class. - /// - /// The guild API. + public Handler(IDiscordRestGuildAPI guildApi) { _guildApi = guildApi; } - - /// + public async Task> Handle(Query request, CancellationToken cancellationToken) { var getGuildInfoResult = await _guildApi.GetGuildAsync(request.GuildId, ct: cancellationToken); @@ -70,11 +64,7 @@ public async Task> Handle(Query request, CancellationToken c } var guildInfo = getGuildInfoResult.Entity; - if (guildInfo is null) - { - return new NotFoundError("Guild not found."); - } - + Uri? iconUrl; if (guildInfo.Icon is not null) { diff --git a/src/Mmcc.Bot/Features/Guilds/GetGuildInfo.dview.cs b/src/Mmcc.Bot/Features/Guilds/GetGuildInfo.dview.cs new file mode 100644 index 0000000..6acaa86 --- /dev/null +++ b/src/Mmcc.Bot/Features/Guilds/GetGuildInfo.dview.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Mmcc.Bot.Common.Models.Colours; +using Porbeagle; +using Porbeagle.Attributes; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Features.Guilds; + +[DiscordView] +public sealed partial record GetGuildInfoView : IMessageView +{ + public GetGuildInfoView(GetGuildInfo.QueryResult guildInfo) + => Embed = new GuildInfoEmbed(guildInfo); + + public Optional Text { get; init; } = new(); + + public Embed Embed { get; } +} + +public sealed record GuildInfoEmbed : Embed +{ + public GuildInfoEmbed(GetGuildInfo.QueryResult guildInfo) : base( + Title: "Guild info", + Description: "Information about the current guild.", + Timestamp: DateTimeOffset.UtcNow, + Colour: ColourPalette.Blue, + Thumbnail: guildInfo.GuildIconUrl is null + ? new Optional() + : new EmbedThumbnail(guildInfo.GuildIconUrl.ToString()), + Fields:new List + { + new(Name: "Name", Value: guildInfo.GuildName, IsInline: false), + new(Name: "Owner", Value: $"<@{guildInfo.GuildOwnerId}>", IsInline: false), + new(Name: "Max members", Value: guildInfo.GuildMaxMembers.ToString() ?? "Unavailable", IsInline: false), + new(Name: "Available roles", Value: guildInfo.GuildRoles.AsDiscordFormattedText(), IsInline: false) + } + ) + { + } +} + +file static class RolesListMapperExtensions +{ + internal static string AsDiscordFormattedText(this IEnumerable roles) + { + var linkableRoles = roles.Select(r => $"<@&{r.ID}>"); + + return string.Join(", ", linkableRoles); + } +} diff --git a/src/Mmcc.Bot/Commands/Guilds/GetInviteLink.cs b/src/Mmcc.Bot/Features/Guilds/GetInviteLink.cs similarity index 73% rename from src/Mmcc.Bot/Commands/Guilds/GetInviteLink.cs rename to src/Mmcc.Bot/Features/Guilds/GetInviteLink.cs index 35c0757..367e864 100644 --- a/src/Mmcc.Bot/Commands/Guilds/GetInviteLink.cs +++ b/src/Mmcc.Bot/Features/Guilds/GetInviteLink.cs @@ -7,22 +7,22 @@ using Remora.Rest.Core; using Remora.Results; -namespace Mmcc.Bot.Commands.Guilds; +namespace Mmcc.Bot.Features.Guilds; /// /// Gets an invite link to a guild. /// -public class GetInviteLink +public sealed class GetInviteLink { /// /// Query to get an invite link to a guild. /// - public record Query(Snowflake GuildId) : IRequest>; + public sealed record Query(Snowflake GuildId) : IRequest>; /// /// Validates the . /// - public class Validator : AbstractValidator + public sealed class Validator : AbstractValidator { public Validator() { @@ -30,34 +30,26 @@ public Validator() .NotNull(); } } - - /// - public class Handler : IRequestHandler> + + public sealed class Handler : IRequestHandler> { private readonly IDiscordRestGuildAPI _guildApi; - - /// - /// Instantiates a new instance of class. - /// - /// The guild API. + public Handler(IDiscordRestGuildAPI guildApi) { _guildApi = guildApi; } - - /// + public async Task> Handle(Query request, CancellationToken cancellationToken) { var getGuildInvitesResult = await _guildApi.GetGuildInvitesAsync(request.GuildId, cancellationToken); - if (!getGuildInvitesResult.IsSuccess) { return Result.FromError(getGuildInvitesResult); } var invites = getGuildInvitesResult.Entity; - - if (invites is null || !invites.Any()) + if (!invites.Any()) { return new NotFoundError( "Could not find an active invite link. Administrators should create an invite link in Discord guild settings that does not expire."); diff --git a/src/Mmcc.Bot/Features/Guilds/GuildCommands.cs b/src/Mmcc.Bot/Features/Guilds/GuildCommands.cs new file mode 100644 index 0000000..629b7e4 --- /dev/null +++ b/src/Mmcc.Bot/Features/Guilds/GuildCommands.cs @@ -0,0 +1,65 @@ +using System.ComponentModel; +using System.Threading.Tasks; +using MediatR; +using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.RemoraAbstractions.Conditions.CommandSpecific; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; +using Porbeagle; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.Commands.Contexts; +using Remora.Results; + +namespace Mmcc.Bot.Features.Guilds; + +[RequireGuild] +public sealed class GuildCommands : CommandGroup +{ + private readonly MessageContext _context; + private readonly IColourPalette _colourPalette; + private readonly IMediator _mediator; + private readonly CommandMessageResponder _responder; + private readonly IContextAwareViewManager _viewManager; + + public GuildCommands( + MessageContext context, + IColourPalette colourPalette, + IMediator mediator, + CommandMessageResponder responder, + IContextAwareViewManager viewManager + ) + { + _context = context; + _colourPalette = colourPalette; + _mediator = mediator; + _responder = responder; + _viewManager = viewManager; + } + + [Command("guild")] + [Description("Provides information about the current guild.")] + public async Task GuildInfo() => + await _mediator.Send(new GetGuildInfo.Query(_context.GuildID.Value)) switch + { + { IsSuccess: true, Entity: { } guildInfo } => + await _viewManager.RespondWithView(new GetGuildInfoView(guildInfo)), + + { IsSuccess: true } => + Result.FromError(new NotFoundError($"Guild with ID: {_context.GuildID.Value} not found")), + + { IsSuccess: false } res => res + }; + + [Command("invite")] + [Description("Gives an invite link to the current guild.")] + public async Task Invite() => + await _mediator.Send(new GetInviteLink.Query(_context.GuildID.Value)) switch + { + {IsSuccess: true, Entity: { } e} => + await _responder.Respond($"https://discord.gg/{e}"), + + {IsSuccess: true} => Result.FromError(new NotFoundError("Could not find invite link for this guild.")), + + {IsSuccess: false} res => res + }; +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Features/Help/HelpCommands.cs b/src/Mmcc.Bot/Features/Help/HelpCommands.cs new file mode 100644 index 0000000..82612a5 --- /dev/null +++ b/src/Mmcc.Bot/Features/Help/HelpCommands.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Services.MessageResponders; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Results; + +namespace Mmcc.Bot.Features.Help; + +/// +/// Help commands. +/// +public class HelpCommands : CommandGroup +{ + private readonly CommandMessageResponder _responder; + private readonly IHelpService _helpService; + + public HelpCommands(CommandMessageResponder responder, IHelpService helpService) + { + _responder = responder; + _helpService = helpService; + } + + [Command("help")] + [Description("Shows available commands")] + public async Task Help() + { + var helpEmbed = _helpService.GetHelpForAll(); + + return await _responder.Respond(helpEmbed); + } + + [Command("help")] + [Description("Shows help for a given category")] + public async Task Help([Greedy] IEnumerable path) => + _helpService.GetHelpForCategory(path.ToList()) switch + { + { IsSuccess: true, Entity: { } embed } => + await _responder.Respond(embed), + + { IsSuccess: false } res => res + }; +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Features/MmccInfo/MmccInfo.view.cs b/src/Mmcc.Bot/Features/MmccInfo/MmccInfo.view.cs new file mode 100644 index 0000000..b6d064d --- /dev/null +++ b/src/Mmcc.Bot/Features/MmccInfo/MmccInfo.view.cs @@ -0,0 +1,27 @@ +using Mmcc.Bot.Common.UI.Buttons; +using Porbeagle; +using Porbeagle.Attributes; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Commands.MmccInfo; + +[DiscordView] +public sealed partial record MmccInfoView : IMessageView +{ + public Optional Text { get; init; } = "Useful links"; + + [ActionRow(0)] + private MmccWebsiteButton MmccWebsiteButton { get; } = new(); + + [ActionRow(0)] + private DonateButton DonateButton { get; } = new(); + + [ActionRow(0)] + private WikiButton WikiButton { get; } = new(); + + [ActionRow(0)] + private ForumButton ForumButton { get; } = new(); + + [ActionRow(0)] + private MmccGithubOrgButton MmccGitHubOrgButton { get; } = new(); +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Features/MmccInfo/MmccInfoCommands.cs b/src/Mmcc.Bot/Features/MmccInfo/MmccInfoCommands.cs new file mode 100644 index 0000000..6c603bb --- /dev/null +++ b/src/Mmcc.Bot/Features/MmccInfo/MmccInfoCommands.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.Threading.Tasks; +using Mmcc.Bot.Commands.MmccInfo; +using Porbeagle; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Results; + +namespace Mmcc.Bot.Features.MmccInfo; + +public class MmccInfoCommands : CommandGroup +{ + private readonly IContextAwareViewManager _viewManager; + + public MmccInfoCommands(IContextAwareViewManager viewManager) => + _viewManager = viewManager; + + [Command("mmcc")] + [Description("Shows useful MMCC links")] + public async Task GetMmccInfo() + => await _viewManager.RespondWithView(new MmccInfoView()); +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Hosting/Moderation/ModerationBackgroundService.cs b/src/Mmcc.Bot/Hosting/Moderation/ModerationBackgroundService.cs index 91f7794..273498e 100644 --- a/src/Mmcc.Bot/Hosting/Moderation/ModerationBackgroundService.cs +++ b/src/Mmcc.Bot/Hosting/Moderation/ModerationBackgroundService.cs @@ -1,22 +1,15 @@ using System; -using System.Collections.Generic; -using System.Text; using System.Threading; using System.Threading.Tasks; using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Mmcc.Bot.Commands.Moderation.Bans; -using Mmcc.Bot.Common.Extensions.Database.Entities; -using Mmcc.Bot.Common.Extensions.Remora.Discord.API.Abstractions.Rest; using Mmcc.Bot.Common.Hosting; using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Models.Settings; using Mmcc.Bot.Database.Entities; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; using Remora.Discord.Commands.Results; -using Remora.Rest.Core; using Remora.Results; namespace Mmcc.Bot.Hosting.Moderation; @@ -53,15 +46,13 @@ protected override async Task OnExecute(CancellationToken ct) using var scope = _sp.CreateScope(); var provider = scope.ServiceProvider; - var guildApi = provider.GetRequiredService(); var mediator = provider.GetRequiredService(); - var channelApi = provider.GetRequiredService(); - var getAllPendingResult = await mediator.Send(new GetExpiredActions.Query(), ct); + var getAllPendingResult = await mediator.Send(new GetExpiredActions.Query(), ct); if (!getAllPendingResult.IsSuccess) { _logger.LogError( - "An error has occurred while running an iteration of the {Service} timed background service:\n{Error}", + "An error has occurred while getting expired modification actions as part of hosted service: {HostedServiceName}:\n{Error}", nameof(ModerationBackgroundService), getAllPendingResult.Error ); @@ -72,22 +63,9 @@ protected override async Task OnExecute(CancellationToken ct) foreach (var ma in actionsToDeactivate) { - var getLogsChannel = await guildApi.FindGuildChannelByName(new Snowflake(ma.GuildId), - _discordSettings.ChannelNames.ModerationLogs); - if (!getLogsChannel.IsSuccess) - { - _logger.LogError( - "An error has occurred while running an iteration of the {Service} timed background service:\n{Error}", - nameof(ModerationBackgroundService), - getLogsChannel.Error - ); - break; - } - - Result unbanResult = ma.ModerationActionType switch + var unbanResult = ma.ModerationActionType switch { - ModerationActionType.Ban => await mediator.Send(new Unban.Command - { ModerationAction = ma, ChannelId = getLogsChannel.Entity.ID }, ct), + ModerationActionType.Ban => await mediator.Send(new Unban.Command { ModerationAction = ma }, ct), _ => Result.FromError(new UnsupportedFeatureError("Unsupported moderation type.")) }; @@ -101,45 +79,6 @@ protected override async Task OnExecute(CancellationToken ct) break; } - var typeString = ma.ModerationActionType.ToStringWithEmoji(); - var userSb = new StringBuilder(); - - if (ma.UserDiscordId is not null) - { - userSb.AppendLine($"Discord user: <@{ma.UserDiscordId}>"); - } - - if (ma.UserIgn is not null) - { - userSb.AppendLine($"IGN: `{ma.UserIgn}`"); - } - - var notificationEmbed = new Embed - { - Title = $"Moderation action with ID: {ma.ModerationActionId} has expired.", - Description = "Moderation action has expired and has therefore been deactivated.", - Colour = _colourPalette.Green, - Fields = new List - { - new("Action type", typeString, false), - new("User info", userSb.ToString(), false) - }, - Timestamp = DateTimeOffset.UtcNow - }; - var sendNotificationResult = await channelApi.CreateMessageAsync(getLogsChannel.Entity.ID, - embeds: new[] { notificationEmbed }, ct: ct); - if (!sendNotificationResult.IsSuccess) - { - _logger.LogWarning( - "Successfully deactivated expired moderation action with ID: {Id} but failed to send a notification to the logs channel." + - "It may be because the bot doesn't have permissions in that channel or has since been removed from the guild. This warning can in most cases be ignored." + - "The error was:\n{Error}", - ma.ModerationActionId, - sendNotificationResult.Error - ); - break; - } - _logger.LogInformation( "Successfully deactivated expired moderation action with ID: {Id}", ma.ModerationActionId); } diff --git a/src/Mmcc.Bot/Interactions/InteractionsSetup.cs b/src/Mmcc.Bot/Interactions/InteractionsSetup.cs new file mode 100644 index 0000000..3ab3636 --- /dev/null +++ b/src/Mmcc.Bot/Interactions/InteractionsSetup.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Mmcc.Bot.Interactions.Moderation.MemberApplications; +using Remora.Discord.Gateway.Extensions; +using Remora.Discord.Interactivity; +using Remora.Discord.Interactivity.Extensions; +using Remora.Extensions.Options.Immutable; + +using InteractivityResponder = Mmcc.Bot.EventResponders.Interactions.InteractivityResponder; + +namespace Mmcc.Bot.Interactions; + +/// +/// Extension methods that register interactive functionality with the service collection. +/// +public static class InteractionsSetup +{ + /// + /// Registers interactive functionality with the service collection. + /// + /// The . + /// The . + public static IServiceCollection AddInteractions(this IServiceCollection services) + { + //services.AddInteractivity(); <= default Remora interactivity. We don't use because we have a custom pipeline + + services.AddMemoryCache(); + + services.AddInteractionGroup(); + + return services; + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Interactions/Moderation/MemberApplications/MemberApplicationsInteractions.cs b/src/Mmcc.Bot/Interactions/Moderation/MemberApplications/MemberApplicationsInteractions.cs new file mode 100644 index 0000000..4737651 --- /dev/null +++ b/src/Mmcc.Bot/Interactions/Moderation/MemberApplications/MemberApplicationsInteractions.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MediatR; +using Mmcc.Bot.Commands.Moderation.MemberApplications; +using Mmcc.Bot.Common.Errors; +using Mmcc.Bot.Common.Extensions.Remora.Discord.API.Abstractions.Rest; +using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.Common.Models.Settings; +using Mmcc.Bot.Common.Statics; +using Mmcc.Bot.InMemoryStore.Stores; +using Mmcc.Bot.RemoraAbstractions.Conditions.InteractionSpecific; +using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.UI; +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.Interactivity; +using Remora.Rest.Core; +using Remora.Results; + +namespace Mmcc.Bot.Interactions.Moderation.MemberApplications; + +public class MemberApplicationsInteractions : InteractionGroup +{ + private readonly InteractionContext _context; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IInteractionHelperService _interactionHelperService; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IMessageMemberAppContextStore _memberAppContextStore; + private readonly DiscordSettings _discordSettings; + private readonly IMediator _mediator; + private readonly IColourPalette _colourPalette; + + public MemberApplicationsInteractions( + InteractionContext context, + IDiscordRestChannelAPI channelApi, + IInteractionHelperService interactionHelperService, + IDiscordRestGuildAPI guildApi, + IMessageMemberAppContextStore memberAppContextStore, + DiscordSettings discordSettings, + IMediator mediator, + IColourPalette colourPalette + ) + { + _channelApi = channelApi; + _interactionHelperService = interactionHelperService; + _guildApi = guildApi; + _memberAppContextStore = memberAppContextStore; + _discordSettings = discordSettings; + _mediator = mediator; + _colourPalette = colourPalette; + _context = context; + } + + [Button("approve-btn")] + [SuppressInteractionResponse(true)] + [InteractionRequireGuild] + [InteractionRequireUserGuildPermission(DiscordPermission.BanMembers)] + public async Task OnApproveButtonPressed() + { + var serverPrefixInput = FluentTextInputBuilder + .WithId("serverPrefix") + .HasStyle(TextInputStyle.Short) + .HasLabel("Server Prefix") + .IsRequired(true) + .Build(); + + var ignsListInput = FluentTextInputBuilder + .WithId("igns") + .HasStyle(TextInputStyle.Paragraph) + .HasLabel("IGNs List (use space to separate usernames).") + .IsRequired(true) + .Build(); + + var modal = FluentCallbackModalBuilder + .WithId("approve") + .HasTitle("Approve member application") + .WithActionRowFromTextInputs(serverPrefixInput, ignsListInput) + .Build(); + + return await _interactionHelperService.RespondWithModal(modal); + } + + [Modal("approve")] + [SuppressInteractionResponse(true)] + [InteractionRequireGuild] + [InteractionRequireUserGuildPermission(DiscordPermission.BanMembers)] + public async Task OnApproveModal(string serverPrefix, string igns) + { + var notificationResult = await _interactionHelperService.NotifyDeferredMessageIsComing(); + if (!notificationResult.IsSuccess) + { + return notificationResult; + } + + var ignsList = igns.Split(' ').ToList(); + + var approveResult = await ApproveMemberApplication(serverPrefix, ignsList); + if (!approveResult.IsSuccess) + { + return Result.FromError(approveResult); + } + + var sendSuccessEmbed = await _interactionHelperService.SendFollowup(approveResult.Entity); + return !sendSuccessEmbed.IsSuccess + ? Result.FromError(sendSuccessEmbed.Error) + : Result.FromSuccess(); + } + + private async Task> ApproveMemberApplication(string serverPrefix, List ignsList) + { + if (_context.Message is not {Value.MessageReference.Value.MessageID.HasValue: true}) + { + return Result.FromError(new PropertyMissingOrNullError( + "The message containing the button unexpectedly did not reference the original command message")); + } + + var messageReference = _context.Message.Value.MessageReference.Value.MessageID.Value; + + var memberAppId = _memberAppContextStore.GetOrDefault(messageReference.Value); + if (memberAppId is null) + { + return Result.FromError(new InteractionExpiredError( + $"Interaction has expired in the {typeof(IMessageMemberAppContextStore)} store. Please run the original command again and press the button in the new response.")); + } + + var getMembersChannelResult = await _guildApi.FindGuildChannelByName(_context.GuildID.Value, + _discordSettings.ChannelNames.MemberApps); + if (!getMembersChannelResult.IsSuccess) + { + return Result.FromError(getMembersChannelResult); + } + + var commandResult = await _mediator.Send(new ApproveAutomatically.Command + { + Id = memberAppId.Value, + GuildId = _context.GuildID.Value, + ChannelId = _context.ChannelID, + ServerPrefix = serverPrefix, + Igns = ignsList + }); + if (!commandResult.IsSuccess) + { + return Result.FromError(commandResult); + } + + var userNotificationEmbed = new Embed + { + Title = ":white_check_mark: Application approved.", + Description = "Your application has been approved.", + Thumbnail = EmbedProperties.MmccLogoThumbnail, + Colour = _colourPalette.Green, + Fields = new List + { + new("Approved by", $"<@{_context.User.ID}>", false) + } + }; + var sendUserNotificationEmbedResult = await _channelApi.CreateMessageAsync( + channelID: getMembersChannelResult.Entity.ID, + embeds: new[] { userNotificationEmbed }, + messageReference: new MessageReference(new Snowflake(commandResult.Entity.MessageId))); + + if (!sendUserNotificationEmbedResult.IsSuccess) + { + return Result.FromError(sendUserNotificationEmbedResult); + } + + return new Embed + { + Title = ":white_check_mark: Approved the application successfully", + Description = $"Application with ID `{memberAppId.Value}` has been :white_check_mark: *approved*.", + Thumbnail = EmbedProperties.MmccLogoThumbnail, + Colour = _colourPalette.Green + }; + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Middleware/ErrorNotificationMiddleware.cs b/src/Mmcc.Bot/Middleware/ErrorNotificationMiddleware.cs index 0a90b1e..75c523f 100644 --- a/src/Mmcc.Bot/Middleware/ErrorNotificationMiddleware.cs +++ b/src/Mmcc.Bot/Middleware/ErrorNotificationMiddleware.cs @@ -1,16 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Mmcc.Bot.Common.Errors; -using Mmcc.Bot.Common.Extensions.FluentValidation.Results; -using Mmcc.Bot.Common.Models.Colours; -using Mmcc.Bot.Common.Models.Settings; -using Mmcc.Bot.Common.Statics; -using Remora.Commands.Results; +using Mmcc.Bot.RemoraAbstractions.Services; using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Services; using Remora.Results; @@ -21,20 +13,17 @@ public class ErrorNotificationMiddleware : IPostExecutionEvent { private readonly ILogger _logger; private readonly IDiscordRestChannelAPI _channelApi; - private readonly IColourPalette _colourPalette; - private readonly DiscordSettings _discordSettings; - + private readonly IErrorProcessingService _errorProcessingService; + public ErrorNotificationMiddleware( ILogger logger, - IDiscordRestChannelAPI channelApi, - IColourPalette colourPalette, - DiscordSettings discordSettings + IDiscordRestChannelAPI channelApi, + IErrorProcessingService errorProcessingService ) { _logger = logger; _channelApi = channelApi; - _colourPalette = colourPalette; - _discordSettings = discordSettings; + _errorProcessingService = errorProcessingService; } public async Task AfterExecutionAsync( @@ -49,41 +38,7 @@ CancellationToken ct } var err = executionResult.Error; - var errorEmbed = new Embed - { - Thumbnail = EmbedProperties.MmccLogoThumbnail, - Colour = _colourPalette.Red, - Timestamp = DateTimeOffset.UtcNow - }; - errorEmbed = err switch - { - CommandNotFoundError cnfe => errorEmbed with - { - Title = ":exclamation: Command not found", - Description = $"Could not find a matching command for `{_discordSettings.Prefix}{cnfe.OriginalInput}`." - }, - ValidationError(var message, var readOnlyList, _) => errorEmbed with - { - Title = ":exclamation: Validation error.", - Description = message.Replace('\'', '`'), - Fields = new List {readOnlyList.ToEmbedField()} - }, - NotFoundError => errorEmbed with - { - Title = ":x: Resource not found.", - Description = err.Message - }, - null => errorEmbed with - { - Title = ":exclamation: Error.", - Description = "Unknown error." - }, - _ => errorEmbed with - { - Title = $":x: {err.GetType()}.", - Description = err.Message - } - }; + var errorEmbed = _errorProcessingService.GetErrorEmbed(err); var sendEmbedResult = await _channelApi.CreateMessageAsync(context.ChannelID, embeds: new[] { errorEmbed }, ct: ct); diff --git a/src/Mmcc.Bot/Middleware/InteractionErrorNotificationMiddleware.cs b/src/Mmcc.Bot/Middleware/InteractionErrorNotificationMiddleware.cs new file mode 100644 index 0000000..6f1f956 --- /dev/null +++ b/src/Mmcc.Bot/Middleware/InteractionErrorNotificationMiddleware.cs @@ -0,0 +1,48 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Mmcc.Bot.RemoraAbstractions.Services; +using Mmcc.Bot.RemoraAbstractions.Services.Interactions; +using Remora.Discord.Commands.Contexts; +using Remora.Results; + +namespace Mmcc.Bot.Middleware; + +public class InteractionErrorNotificationMiddleware : IInteractionPostExecutionEvent +{ + private readonly ILogger _logger; + private readonly IErrorProcessingService _errorProcessingService; + private readonly IInteractionHelperService _interactionHelper; + + public InteractionErrorNotificationMiddleware( + ILogger logger, + IErrorProcessingService errorProcessingService, + IInteractionHelperService interactionHelper + ) + { + _logger = logger; + _errorProcessingService = errorProcessingService; + _interactionHelper = interactionHelper; + } + + public async Task AfterExecutionAsync( + InteractionContext interactionContext, + Result interactionResult, + CancellationToken ct + ) + { + if (interactionResult.IsSuccess) + { + return Result.FromSuccess(); + } + + var err = interactionResult.Error; + var errorEmbed = _errorProcessingService.GetErrorEmbed(err); + + // var sendEmbedResult = await _channelApi.CreateMessageAsync(interactionContext.ChannelID, embeds: new[] { errorEmbed }, ct: ct); + var sendErrorEmbed = await _interactionHelper.SendFollowup(errorEmbed); + return !sendErrorEmbed.IsSuccess + ? Result.FromError(sendErrorEmbed.Error) + : Result.FromSuccess(); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Middleware/MiddlewareSetup.cs b/src/Mmcc.Bot/Middleware/MiddlewareSetup.cs index 21ec04a..316250a 100644 --- a/src/Mmcc.Bot/Middleware/MiddlewareSetup.cs +++ b/src/Mmcc.Bot/Middleware/MiddlewareSetup.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Mmcc.Bot.RemoraAbstractions.Services.Interactions; using Remora.Discord.Commands.Services; namespace Mmcc.Bot.Middleware; @@ -16,6 +17,7 @@ public static class MiddlewareSetup public static IServiceCollection AddBotMiddlewares(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Mmcc.Bot/Mmcc.Bot.csproj b/src/Mmcc.Bot/Mmcc.Bot.csproj index c8585d6..a144e4e 100644 --- a/src/Mmcc.Bot/Mmcc.Bot.csproj +++ b/src/Mmcc.Bot/Mmcc.Bot.csproj @@ -1,39 +1,49 @@  - net6.0 + net8.0 dotnet-Mmcc.Bot-4BA5AD38-B3B4-456A-853F-080768D88F42 enable - 10 + preview - - + + - - - - - - - - - - + + + + + + + + + + + - + + + + + + + + + + diff --git a/src/Mmcc.Bot/Notifications/DiscordNotificationHandler.cs b/src/Mmcc.Bot/Notifications/DiscordNotificationHandler.cs new file mode 100644 index 0000000..31bb99e --- /dev/null +++ b/src/Mmcc.Bot/Notifications/DiscordNotificationHandler.cs @@ -0,0 +1,65 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Logging; +using Mmcc.Bot.Common; +using Mmcc.Bot.Common.Extensions.Remora.Discord.API.Abstractions.Rest; +using Mmcc.Bot.Common.Models; +using Mmcc.Bot.Common.Models.Settings; +using Mmcc.Bot.Common.UI.Embeds; +using Remora.Discord.API.Abstractions.Rest; + +namespace Mmcc.Bot.Notifications; + +[ExcludeFromMediatrAssemblyScan] +public class DiscordNotificationHandler : INotificationHandler + where TNotification : IMmccNotification, IDiscordNotifiable +{ + private readonly ILogger> _logger; + private readonly DiscordSettings _discordSettings; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildAPI _guildApi; + + public DiscordNotificationHandler( + ILogger> logger, + DiscordSettings discordSettings, + IDiscordRestChannelAPI channelApi, + IDiscordRestGuildAPI guildApi + ) + { + _logger = logger; + _discordSettings = discordSettings; + _channelApi = channelApi; + _guildApi = guildApi; + } + + public async Task Handle(TNotification notification, CancellationToken cancellationToken) + { + // TODO: cache this; + var logsChannelName = _discordSettings.ChannelNames.ModerationLogs; + var getLogsChannel = await _guildApi.FindGuildChannelByName(notification.TargetGuildId, logsChannelName); + if (!getLogsChannel.IsSuccess) + { + _logger.LogError( + "An error has occurred while running an iteration of the {Service} timed background service:\n{Error}", + nameof(DiscordNotificationHandler), + getLogsChannel.Error + ); + } + + var notificationEmbed = new NotificationEmbed(notification); + var sendNotificationResult = await _channelApi.CreateMessageAsync(getLogsChannel.Entity.ID, + embeds: new[] { notificationEmbed }, ct: cancellationToken); + if (!sendNotificationResult.IsSuccess) + { + _logger.LogWarning( + "Successfully deactivated expired moderation action but failed to send a notification to the logs channel {LogsChannelName} in guild {GuildId}." + + "It may be because the bot doesn't have permissions in that channel or has since been removed from the guild. This warning can in most cases be ignored." + + "The error was:\n{Error}", + logsChannelName, + notification.TargetGuildId, + sendNotificationResult.Error + ); + } + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Notifications/Moderation/ModerationActionExpiredNotification.cs b/src/Mmcc.Bot/Notifications/Moderation/ModerationActionExpiredNotification.cs new file mode 100644 index 0000000..5d60e12 --- /dev/null +++ b/src/Mmcc.Bot/Notifications/Moderation/ModerationActionExpiredNotification.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Mmcc.Bot.Common.Extensions.Database.Entities; +using Mmcc.Bot.Common.Models; +using Mmcc.Bot.Database.Entities; +using Remora.Rest.Core; + +namespace Mmcc.Bot.Notifications.Moderation; + +public record ModerationActionExpiredNotification( + string Title, + string? Description, + DateTimeOffset? Timestamp, + IReadOnlyList>? CustomProperties, + Snowflake TargetGuildId +) : IMmccNotification, IDiscordNotifiable +{ + public ModerationActionExpiredNotification(ModerationAction ma) + : this( + Title: $"Moderation action with ID: {ma.ModerationActionId} has expired.", + Description: "Moderation action has expired and has therefore been deactivated.", + Timestamp: DateTimeOffset.UtcNow, + TargetGuildId: new(ma.GuildId), + CustomProperties: new List> + { + new("Action type", ma.ModerationActionType.ToStringWithEmoji()), + new("User info", ma.GetUserDataDisplayString()) + } + ) + { + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Program.cs b/src/Mmcc.Bot/Program.cs index 8161175..8f3f56d 100644 --- a/src/Mmcc.Bot/Program.cs +++ b/src/Mmcc.Bot/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -6,8 +7,8 @@ using Microsoft.Extensions.Logging; using Mmcc.Bot; using Mmcc.Bot.Behaviours; -using Mmcc.Bot.Caching; using Mmcc.Bot.Commands; +using Mmcc.Bot.Common; using Mmcc.Bot.Common.Extensions.Hosting; using Mmcc.Bot.Common.Models.Colours; using Mmcc.Bot.Common.Models.Settings; @@ -18,11 +19,15 @@ using Mmcc.Bot.EventResponders.Moderation.MemberApplications; using Mmcc.Bot.Hosting; using Mmcc.Bot.Hosting.Moderation; +using Mmcc.Bot.InMemoryStore.Stores; +using Mmcc.Bot.Interactions; using Mmcc.Bot.Middleware; -using Mmcc.Bot.Mojang; +using Mmcc.Bot.Notifications; using Mmcc.Bot.Polychat; using Mmcc.Bot.Polychat.Networking; +using Mmcc.Bot.Providers; using Mmcc.Bot.RemoraAbstractions; +using Porbeagle; using Remora.Discord.Caching.Extensions; using Remora.Discord.Hosting.Extensions; using Serilog; @@ -35,16 +40,19 @@ builder.AddDebug(); } }) + .AddDiscordService(provider => provider.GetRequiredService().Token) .ConfigureServices((hostContext, services) => { // config; services.ConfigureBot(hostContext); // db; + services.AddInMemoryStores(); services.AddBotDatabaseContext(); + // TODO: remove this; services.AddSingleton(); - + // FluentValidation; services.AddValidatorsFromAssemblyContaining(); services.AddValidatorsFromAssemblyContaining(); @@ -53,29 +61,31 @@ services.AddAppInsights(hostContext); // MediatR; - services.AddMediatR(typeof(CreateFromDiscordMessage), typeof(PolychatRequest<>)); + services.AddMediatR(new [] { typeof(CreateFromDiscordMessage), typeof(PolychatRequest<>) }, cfg => + { + cfg.WithEvaluator(t => t.GetCustomAttribute() is null); + }); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + services.AddTransient(typeof(INotificationHandler<>), typeof(DiscordNotificationHandler<>)); // Mmcc.Bot.X projects; - services.AddMmccCaching(); - services.AddMojangApi(); services.AddPolychat(hostContext.Configuration.GetSection("Ssmp")); + services.AddProviders(); + // Remora.Discord bot setup; services.AddRemoraAbstractions(); services.AddBotMiddlewares(); services.AddBotCommands(); + services.AddInteractions(); services.AddBotGatewayEventResponders(); services.AddDiscordCaching(); services.AddBotBackgroundServices(); + services.AddScoped(); services.AddHangfire(); }) - .AddDiscordService(provider => - { - var discordConfig = provider.GetRequiredService(); - return discordConfig.Token; - }) .UseSerilog(LoggerSetup.ConfigureLogger) .UseDefaultServiceProvider(options => options.ValidateScopes = true) .Build(); diff --git a/src/Mmcc.Bot/Providers/CommonEmbedFieldsProviders/ICommonEmbedFieldsProvider.cs b/src/Mmcc.Bot/Providers/CommonEmbedFieldsProviders/ICommonEmbedFieldsProvider.cs new file mode 100644 index 0000000..621847a --- /dev/null +++ b/src/Mmcc.Bot/Providers/CommonEmbedFieldsProviders/ICommonEmbedFieldsProvider.cs @@ -0,0 +1,13 @@ +using System.Collections; +using System.Collections.Generic; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Providers.CommonEmbedFieldsProviders; + +public interface ICommonEmbedFieldsProvider where T : IEnumerable +{ + /// + /// Gets an that represent the . + /// + IEnumerable GetEmbedFields(T objs); +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Providers/CommonEmbedFieldsProviders/MemberApplicationsEmbedFieldProvider.cs b/src/Mmcc.Bot/Providers/CommonEmbedFieldsProviders/MemberApplicationsEmbedFieldProvider.cs new file mode 100644 index 0000000..fd5fbb4 --- /dev/null +++ b/src/Mmcc.Bot/Providers/CommonEmbedFieldsProviders/MemberApplicationsEmbedFieldProvider.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; +using Mmcc.Bot.Database.Entities; +using Mmcc.Bot.RemoraAbstractions.Timestamps; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Providers.CommonEmbedFieldsProviders; + +public class MemberApplicationsEmbedFieldProvider : ICommonEmbedFieldsProvider> +{ + public IEnumerable GetEmbedFields(IEnumerable memberApplications) + { + return memberApplications.Select(app => new EmbedField + ( + $"[{app.MemberApplicationId}] {app.AuthorDiscordName}", + $"*Submitted at:* {new DiscordTimestamp(app.AppTime).AsStyled(DiscordTimestampStyle.ShortDateTime)}.", + false + )); + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Providers/CommonEmbedProviders/ICommonEmbedProvider.cs b/src/Mmcc.Bot/Providers/CommonEmbedProviders/ICommonEmbedProvider.cs new file mode 100644 index 0000000..1693e02 --- /dev/null +++ b/src/Mmcc.Bot/Providers/CommonEmbedProviders/ICommonEmbedProvider.cs @@ -0,0 +1,11 @@ +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.CommonEmbedProviders; + +public interface ICommonEmbedProvider +{ + /// + /// Gets an embed representation of . + /// + Embed GetEmbed(T obj); +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Providers/CommonEmbedProviders/MemberApplicationEmbedProvider.cs b/src/Mmcc.Bot/Providers/CommonEmbedProviders/MemberApplicationEmbedProvider.cs new file mode 100644 index 0000000..a46ec74 --- /dev/null +++ b/src/Mmcc.Bot/Providers/CommonEmbedProviders/MemberApplicationEmbedProvider.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Mmcc.Bot.Common.Models.Colours; +using Mmcc.Bot.CommonEmbedProviders; +using Mmcc.Bot.Database.Entities; +using Mmcc.Bot.RemoraAbstractions.Timestamps; +using Remora.Discord.API.Objects; + +namespace Mmcc.Bot.Providers.CommonEmbedProviders; + +public class MemberApplicationEmbedProvider : ICommonEmbedProvider +{ + private readonly IColourPalette _colourPalette; + + public MemberApplicationEmbedProvider(IColourPalette colourPalette) + => _colourPalette = colourPalette; + + public Embed GetEmbed(MemberApplication memberApplication) + { + var statusStr = memberApplication.AppStatus.ToString(); + var embedConditionalAttributes = memberApplication.AppStatus switch + { + ApplicationStatus.Pending => new + { + Colour = _colourPalette.Blue, + StatusFieldValue = $":clock1: {statusStr}" + }, + ApplicationStatus.Approved => new + { + Colour = _colourPalette.Green, + StatusFieldValue = $":white_check_mark: {statusStr}" + }, + ApplicationStatus.Rejected => new + { + Colour = _colourPalette.Red, + StatusFieldValue = $":no_entry: {statusStr}" + }, + _ => throw new ArgumentOutOfRangeException(nameof(memberApplication)) + }; + + return new Embed + { + Title = $"Member Application #{memberApplication.MemberApplicationId}", + Description = + $"Submitted at {new DiscordTimestamp(memberApplication.AppTime).AsStyled(DiscordTimestampStyle.ShortDateTime)}.", + Fields = new List + { + new + ( + "Author", + $"{memberApplication.AuthorDiscordName} (ID: `{memberApplication.AuthorDiscordId}`)", + false + ), + new("Status", embedConditionalAttributes.StatusFieldValue, false), + new + ( + "Provided details", + $"{memberApplication.MessageContent}\n" + + $"**[Original message (click here)](https://discord.com/channels/{memberApplication.GuildId}/{memberApplication.ChannelId}/{memberApplication.MessageId})**", + false + ) + }, + Colour = embedConditionalAttributes.Colour, + Thumbnail = new EmbedThumbnail(memberApplication.ImageUrl) + }; + } +} \ No newline at end of file diff --git a/src/Mmcc.Bot/Providers/ProvidersSetup.cs b/src/Mmcc.Bot/Providers/ProvidersSetup.cs new file mode 100644 index 0000000..fcd11bb --- /dev/null +++ b/src/Mmcc.Bot/Providers/ProvidersSetup.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Mmcc.Bot.CommonEmbedProviders; +using Mmcc.Bot.Database.Entities; +using Mmcc.Bot.Providers.CommonEmbedFieldsProviders; +using Mmcc.Bot.Providers.CommonEmbedProviders; + +namespace Mmcc.Bot.Providers; + +/// +/// Extension methods that register Mmcc.Bot.Providers with the service collection. +/// +public static class ProvidersSetup +{ + /// + /// Registers Mmcc.Bot.Providers classes with the service collection. + /// + /// The . + /// The . + public static IServiceCollection AddProviders(this IServiceCollection services) + { + services.AddSingleton, MemberApplicationEmbedProvider>(); + + services + .AddSingleton + < + ICommonEmbedFieldsProvider>, + MemberApplicationsEmbedFieldProvider + >(); + + return services; + } +} \ No newline at end of file diff --git a/src/porbeagle b/src/porbeagle new file mode 160000 index 0000000..7b6516e --- /dev/null +++ b/src/porbeagle @@ -0,0 +1 @@ +Subproject commit 7b6516e39783ce892b80685ef93750a68c711ee3