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() .AddCommandTree() .WithCommandGroup() + .WithCommandGroup() .WithCommandGroup() .WithCommandGroup(); 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; + +/// +/// Handles the command to clear messages in a channel: /clear. +/// +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; + } + + /// + /// A slash command that clears messages in the channel it was executed. + /// + /// The amount of messages to clear. + /// + /// 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. + /// + [Command("clear", "очистить")] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)] + [Description("удаляет сообщения")] + public async Task 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(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; +/// +/// Handles the command to kick members of a guild: /kick. +/// 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 { /// 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 { 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 { /// - /// Adds a footer with the 's avatar and tag (username#0000). + /// Adds a footer with the 's avatar and tag (@username or username#0000). /// /// The builder to add the footer to. /// The user whose tag and avatar to add. @@ -120,10 +120,20 @@ public static class Extensions { /// /// The string to sanitize. /// The sanitized string that can be safely used in . - public static string SanitizeForBlockCode(this string s) { + private static string SanitizeForBlockCode(this string s) { return s.Replace("```", "​`​`​`​"); } + /// + /// Sanitizes a string (see ) and formats the string with block code. + /// + /// The string to sanitize and format. + /// The sanitized string formatted with . + 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 @@ ever - - Deleted {0} messages in {1} - + + Cleared {0} messages + Kicked {0}: {1} @@ -516,4 +516,7 @@ This user is already muted! + + From {0}: + 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 @@ всегда - - Удалено {0} сообщений в {1} - + + Очищено {0} сообщений + Выгнан {0}: {1} @@ -516,4 +516,7 @@ Вы были забанены + + От {0}: + 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 @@ всегда - - удалено {0} сообщений в {1} - + + вырезано {0} забавных сообщений + выгнан {0}: {1} @@ -516,4 +516,7 @@ этот шизоид УЖЕ замучился + + от {0} +