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>