From 17c43be878ff9a966494a068fc2431780d77f6df Mon Sep 17 00:00:00 2001
From: Octol1ttle <l1ttleofficial@outlook.com>
Date: Sat, 1 Jul 2023 16:45:50 +0500
Subject: [PATCH] Add /clear command

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
---
 Boyfriend.cs                  |   1 +
 Commands/BanCommandGroup.cs   |   5 +-
 Commands/ClearCommandGroup.cs | 120 ++++++++++++++++++++++++++++++++++
 Commands/KickCommandGroup.cs  |   8 ++-
 Data/GuildConfiguration.cs    |   1 +
 EventResponders.cs            |   2 +-
 Extensions.cs                 |  14 +++-
 Messages.Designer.cs          |  10 ++-
 Messages.resx                 |   9 ++-
 Messages.ru.resx              |   9 ++-
 Messages.tt-ru.resx           |   9 ++-
 11 files changed, 169 insertions(+), 19 deletions(-)
 create mode 100644 Commands/ClearCommandGroup.cs

diff --git a/Boyfriend.cs b/Boyfriend.cs
index 1a3e2a6..2c2c07a 100644
--- a/Boyfriend.cs
+++ b/Boyfriend.cs
@@ -76,6 +76,7 @@ public class Boyfriend {
                         .AddHostedService<GuildUpdateService>()
                         .AddCommandTree()
                         .WithCommandGroup<BanCommandGroup>()
+                        .WithCommandGroup<ClearCommandGroup>()
                         .WithCommandGroup<KickCommandGroup>()
                         .WithCommandGroup<MuteCommandGroup>();
                     var responderTypes = typeof(Boyfriend).Assembly
diff --git a/Commands/BanCommandGroup.cs b/Commands/BanCommandGroup.cs
index 62d6597..e9d20aa 100644
--- a/Commands/BanCommandGroup.cs
+++ b/Commands/BanCommandGroup.cs
@@ -113,6 +113,7 @@ public class BanCommandGroup : CommandGroup {
                     string.Format(
                         Messages.DescriptionActionExpiresAt,
                         Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value))));
+            var description = builder.ToString();
 
             var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken);
             if (dmChannelResult.IsDefined(out var dmChannel)) {
@@ -122,7 +123,7 @@ public class BanCommandGroup : CommandGroup {
 
                 var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
                     .WithTitle(Messages.YouWereBanned)
-                    .WithDescription(builder.ToString())
+                    .WithDescription(description)
                     .WithActionFooter(user)
                     .WithCurrentTimestamp()
                     .WithColour(ColorsList.Red)
@@ -150,7 +151,7 @@ public class BanCommandGroup : CommandGroup {
                 || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
                 var logEmbed = new EmbedBuilder().WithSmallTitle(
                         string.Format(Messages.UserBanned, target.GetTag()), target)
-                    .WithDescription(builder.ToString())
+                    .WithDescription(description)
                     .WithActionFooter(user)
                     .WithCurrentTimestamp()
                     .WithColour(ColorsList.Red)
diff --git a/Commands/ClearCommandGroup.cs b/Commands/ClearCommandGroup.cs
new file mode 100644
index 0000000..c5a26c1
--- /dev/null
+++ b/Commands/ClearCommandGroup.cs
@@ -0,0 +1,120 @@
+using System.ComponentModel;
+using System.Text;
+using Boyfriend.Services.Data;
+using Remora.Commands.Attributes;
+using Remora.Commands.Groups;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Commands.Attributes;
+using Remora.Discord.Commands.Conditions;
+using Remora.Discord.Commands.Contexts;
+using Remora.Discord.Commands.Feedback.Services;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Extensions.Formatting;
+using Remora.Rest.Core;
+using Remora.Results;
+
+// ReSharper disable ClassNeverInstantiated.Global
+// ReSharper disable UnusedMember.Global
+
+namespace Boyfriend.Commands;
+
+/// <summary>
+///     Handles the command to clear messages in a channel: /clear.
+/// </summary>
+public class ClearCommandGroup : CommandGroup {
+    private readonly IDiscordRestChannelAPI _channelApi;
+    private readonly ICommandContext        _context;
+    private readonly GuildDataService       _dataService;
+    private readonly FeedbackService        _feedbackService;
+    private readonly IDiscordRestUserAPI    _userApi;
+
+    public ClearCommandGroup(
+        IDiscordRestChannelAPI channelApi,      ICommandContext     context, GuildDataService dataService,
+        FeedbackService        feedbackService, IDiscordRestUserAPI userApi) {
+        _channelApi = channelApi;
+        _context = context;
+        _dataService = dataService;
+        _feedbackService = feedbackService;
+        _userApi = userApi;
+    }
+
+    /// <summary>
+    ///     A slash command that clears messages in the channel it was executed.
+    /// </summary>
+    /// <param name="amount">The amount of messages to clear.</param>
+    /// <returns>
+    ///     A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages
+    ///     were cleared and vice-versa.
+    /// </returns>
+    [Command("clear", "очистить")]
+    [RequireContext(ChannelContext.Guild)]
+    [RequireDiscordPermission(DiscordPermission.ManageMessages)]
+    [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
+    [Description("удаляет сообщения")]
+    public async Task<Result> ClearMessagesAsync(
+        [Description("сколько удалять")] [MinValue(2)] [MaxValue(100)]
+        int amount) {
+        if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
+            return Result.FromError(
+                new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
+
+        var messagesResult = await _channelApi.GetChannelMessagesAsync(
+            channelId.Value, limit: amount + 1, ct: CancellationToken);
+        if (!messagesResult.IsDefined(out var messages))
+            return Result.FromError(messagesResult);
+
+        var cfg = await _dataService.GetConfiguration(guildId.Value);
+        Messages.Culture = cfg.GetCulture();
+
+        var idList = new List<Snowflake>(messages.Count);
+        var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine();
+        for (var i = messages.Count - 1; i >= 1; i--) { // '>= 1' to skip last message ('Boyfriend is thinking...')
+            var message = messages[i];
+            idList.Add(message.ID);
+            builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author)));
+            builder.Append(message.Content.InBlockCode());
+        }
+
+        var description = builder.ToString();
+
+        var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
+        if (!userResult.IsDefined(out var user))
+            return Result.FromError(userResult);
+
+        var deleteResult = await _channelApi.BulkDeleteMessagesAsync(
+            channelId.Value, idList, user.GetTag().EncodeHeader(), CancellationToken);
+        if (!deleteResult.IsSuccess)
+            return Result.FromError(deleteResult.Error);
+
+        // The current user's avatar is used when sending messages
+        var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
+        if (!currentUserResult.IsDefined(out var currentUser))
+            return Result.FromError(currentUserResult);
+
+        var title = string.Format(Messages.MessagesCleared, amount.ToString());
+        if (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value) {
+            var logEmbed = new EmbedBuilder().WithSmallTitle(title, currentUser)
+                .WithDescription(description)
+                .WithActionFooter(user)
+                .WithCurrentTimestamp()
+                .WithColour(ColorsList.Red)
+                .Build();
+
+            if (!logEmbed.IsDefined(out var logBuilt))
+                return Result.FromError(logEmbed);
+
+            // Not awaiting to reduce response time
+            if (cfg.PrivateFeedbackChannel != channelId.Value)
+                _ = _channelApi.CreateMessageAsync(
+                    cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { logBuilt },
+                    ct: CancellationToken);
+        }
+
+        var embed = new EmbedBuilder().WithSmallTitle(title, currentUser)
+            .WithColour(ColorsList.Green).Build();
+        if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+        return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
+    }
+}
diff --git a/Commands/KickCommandGroup.cs b/Commands/KickCommandGroup.cs
index 1cc4611..ccc326f 100644
--- a/Commands/KickCommandGroup.cs
+++ b/Commands/KickCommandGroup.cs
@@ -17,6 +17,9 @@ using Remora.Results;
 
 namespace Boyfriend.Commands;
 
+/// <summary>
+///     Handles the command to kick members of a guild: /kick.
+/// </summary>
 public class KickCommandGroup : CommandGroup {
     private readonly IDiscordRestChannelAPI _channelApi;
     private readonly ICommandContext        _context;
@@ -68,9 +71,8 @@ public class KickCommandGroup : CommandGroup {
         if (!currentUserResult.IsDefined(out var currentUser))
             return Result.FromError(currentUserResult);
 
-        var data = await _dataService.GetData(guildId.Value, CancellationToken);
-        var cfg = data.Configuration;
-        Messages.Culture = data.Culture;
+        var cfg = await _dataService.GetConfiguration(guildId.Value);
+        Messages.Culture = cfg.GetCulture();
 
         var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
         if (!memberResult.IsSuccess) {
diff --git a/Data/GuildConfiguration.cs b/Data/GuildConfiguration.cs
index d5d490c..440e2b7 100644
--- a/Data/GuildConfiguration.cs
+++ b/Data/GuildConfiguration.cs
@@ -83,6 +83,7 @@ public class GuildConfiguration {
     /// </summary>
     public TimeSpan EventEarlyNotificationOffset { get; set; } = TimeSpan.Zero;
 
+    // Do not convert this to a property, else serialization will be attempted
     public CultureInfo GetCulture() {
         return CultureInfoCache[Language];
     }
diff --git a/EventResponders.cs b/EventResponders.cs
index 071faba..8f6eed3 100644
--- a/EventResponders.cs
+++ b/EventResponders.cs
@@ -120,7 +120,7 @@ public class MessageDeletedResponder : IResponder<IMessageDelete> {
                     Messages.CachedMessageDeleted,
                     message.Author.GetTag()), message.Author)
             .WithDescription(
-                $"{Mention.Channel(gatewayEvent.ChannelID)}\n{Markdown.BlockCode(message.Content.SanitizeForBlockCode())}")
+                $"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}")
             .WithActionFooter(user)
             .WithTimestamp(message.Timestamp)
             .WithColour(ColorsList.Red)
diff --git a/Extensions.cs b/Extensions.cs
index 00e25e8..bb9ff20 100644
--- a/Extensions.cs
+++ b/Extensions.cs
@@ -15,7 +15,7 @@ namespace Boyfriend;
 
 public static class Extensions {
     /// <summary>
-    ///     Adds a footer with the <paramref name="user" />'s avatar and tag (username#0000).
+    ///     Adds a footer with the <paramref name="user" />'s avatar and tag (@username or username#0000).
     /// </summary>
     /// <param name="builder">The builder to add the footer to.</param>
     /// <param name="user">The user whose tag and avatar to add.</param>
@@ -120,10 +120,20 @@ public static class Extensions {
     /// </summary>
     /// <param name="s">The string to sanitize.</param>
     /// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
-    public static string SanitizeForBlockCode(this string s) {
+    private static string SanitizeForBlockCode(this string s) {
         return s.Replace("```", "​`​`​`​");
     }
 
+    /// <summary>
+    ///     Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string with block code.
+    /// </summary>
+    /// <param name="s">The string to sanitize and format.</param>
+    /// <returns>The sanitized string formatted with <see cref="Markdown.BlockCode(string)" />.</returns>
+    public static string InBlockCode(this string s) {
+        s = s.SanitizeForBlockCode();
+        return $"```{s.SanitizeForBlockCode()}{(s.EndsWith("`") || string.IsNullOrWhiteSpace(s) ? " " : "")}```";
+    }
+
     public static string Localized(this string key) {
         return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key;
     }
diff --git a/Messages.Designer.cs b/Messages.Designer.cs
index 668b6a6..cc4b48b 100644
--- a/Messages.Designer.cs
+++ b/Messages.Designer.cs
@@ -345,9 +345,9 @@ namespace Boyfriend {
             }
         }
         
-        internal static string FeedbackMessagesCleared {
+        internal static string MessagesCleared {
             get {
-                return ResourceManager.GetString("FeedbackMessagesCleared", resourceCulture);
+                return ResourceManager.GetString("MessagesCleared", resourceCulture);
             }
         }
         
@@ -866,5 +866,11 @@ namespace Boyfriend {
                 return ResourceManager.GetString("UserAlreadyMuted", resourceCulture);
             }
         }
+        
+        internal static string MessageFrom {
+            get {
+                return ResourceManager.GetString("MessageFrom", resourceCulture);
+            }
+        }
     }
 }
diff --git a/Messages.resx b/Messages.resx
index cb979ad..d2a652a 100644
--- a/Messages.resx
+++ b/Messages.resx
@@ -255,9 +255,9 @@
     <data name="Ever" xml:space="preserve">
     <value>ever</value>
   </data>
-    <data name="FeedbackMessagesCleared" xml:space="preserve">
-    <value>Deleted {0} messages in {1}</value>
-  </data>
+    <data name="MessagesCleared" xml:space="preserve">
+        <value>Cleared {0} messages</value>
+    </data>
     <data name="FeedbackMemberKicked" xml:space="preserve">
     <value>Kicked {0}: {1}</value>
   </data>
@@ -516,4 +516,7 @@
     <data name="UserAlreadyMuted" xml:space="preserve">
         <value>This user is already muted!</value>
     </data>
+    <data name="MessageFrom" xml:space="preserve">
+        <value>From {0}:</value>
+    </data>
 </root>
diff --git a/Messages.ru.resx b/Messages.ru.resx
index 5ff4f90..bbc5b64 100644
--- a/Messages.ru.resx
+++ b/Messages.ru.resx
@@ -252,9 +252,9 @@
     <data name="Ever" xml:space="preserve">
     <value>всегда</value>
   </data>
-    <data name="FeedbackMessagesCleared" xml:space="preserve">
-    <value>Удалено {0} сообщений в {1}</value>
-  </data>
+    <data name="MessagesCleared" xml:space="preserve">
+        <value>Очищено {0} сообщений</value>
+    </data>
     <data name="FeedbackMemberKicked" xml:space="preserve">
     <value>Выгнан {0}: {1}</value>
   </data>
@@ -516,4 +516,7 @@
     <data name="YouWereBanned" xml:space="preserve">
         <value>Вы были забанены</value>
     </data>
+    <data name="MessageFrom" xml:space="preserve">
+        <value>От {0}:</value>
+    </data>
 </root>
diff --git a/Messages.tt-ru.resx b/Messages.tt-ru.resx
index 54473ef..f598ebf 100644
--- a/Messages.tt-ru.resx
+++ b/Messages.tt-ru.resx
@@ -255,9 +255,9 @@
     <data name="Ever" xml:space="preserve">
     <value>всегда</value>
   </data>
-    <data name="FeedbackMessagesCleared" xml:space="preserve">
-    <value>удалено {0} сообщений в {1}</value>
-  </data>
+    <data name="MessagesCleared" xml:space="preserve">
+        <value>вырезано {0} забавных сообщений</value>
+    </data>
     <data name="FeedbackMemberKicked" xml:space="preserve">
     <value>выгнан {0}: {1}</value>
   </data>
@@ -516,4 +516,7 @@
     <data name="UserAlreadyMuted" xml:space="preserve">
         <value>этот шизоид УЖЕ замучился</value>
     </data>
+    <data name="MessageFrom" xml:space="preserve">
+        <value>от {0}</value>
+    </data>
 </root>