diff --git a/CompatBot/Commands/AutoCompleteProviders/WarningAutoCompleteProvider.cs b/CompatBot/Commands/AutoCompleteProviders/WarningAutoCompleteProvider.cs new file mode 100644 index 00000000..6853921a --- /dev/null +++ b/CompatBot/Commands/AutoCompleteProviders/WarningAutoCompleteProvider.cs @@ -0,0 +1,51 @@ +using System.Linq.Expressions; +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; + +namespace CompatBot.Commands.AutoCompleteProviders; + +public class WarningAutoCompleteProvider: IAutoCompleteProvider +{ + public async ValueTask> AutoCompleteAsync(AutoCompleteContext context) + { + var authorized = await context.User.IsWhitelistedAsync(context.Client, context.Guild).ConfigureAwait(false); + if (!authorized) + return [new($"{Config.Reactions.Denied} You are not authorized to use this command.", -1)]; + + //var user = context.Arguments.FirstOrDefault(kvp => kvp.Key.Name.Equals("user") && kvp.Value is DiscordUser).Value as DiscordUser; + Expression> filter = context.Command.Name is nameof(Warnings.Revert) + ? w => w.Retracted + : w => !w.Retracted; + await using var db = new BotDb(); + IEnumerable result; + if (context.UserInput is not { Length: > 0 } prefix) + result = db.Warning + .OrderByDescending(w => w.Id) + .Where(filter) + .Take(25) + .AsNoTracking() + .AsEnumerable(); + else + { + prefix = prefix.ToLowerInvariant(); + var prefixMatches = db.Warning + .Where(filter) + .Where(w => w.Id.ToString().StartsWith(prefix) || w.Reason.StartsWith(prefix)) + .Take(25); + var substringMatches= db.Warning + .Where(filter) + .Where(w => w.Id.ToString().Contains(prefix) || w.Reason.Contains(prefix)) + .Take(50); + result = prefixMatches + .Concat(substringMatches) + .Distinct() + .OrderByDescending(i => i.Id) + .Take(25) + .AsNoTracking() + .AsEnumerable(); + } + return result.Select( + w => new DiscordAutoCompleteChoice($"{w.Id}: {w.Timestamp?.AsUtc():O}: {w.Reason}", w.Id) + ).ToList(); + } +} \ No newline at end of file diff --git a/CompatBot/Commands/BotMod.cs b/CompatBot/Commands/BotMod.cs index d2b3ad60..21bb0b61 100644 --- a/CompatBot/Commands/BotMod.cs +++ b/CompatBot/Commands/BotMod.cs @@ -49,7 +49,7 @@ internal static class Mod await ctx.RespondAsync($"{Config.Reactions.Failure} {moderator.Mention} is not a moderator (yet)", ephemeral: true).ConfigureAwait(false); } - [Command("💔 Remove bot admin permissions")] + [Command("💔 Remove bot admin permissions"), SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] public static async ValueTask Unsudo(UserCommandContext ctx, DiscordUser sudoer) { if (ctx.Client.CurrentApplication.Owners?.Any(u => u.Id == sudoer.Id) ?? false) diff --git a/CompatBot/Commands/MessageMenuCommands.cs b/CompatBot/Commands/MessageMenuCommands.cs index 7664ca5c..254f7c34 100644 --- a/CompatBot/Commands/MessageMenuCommands.cs +++ b/CompatBot/Commands/MessageMenuCommands.cs @@ -12,7 +12,7 @@ namespace CompatBot.Commands; internal static class MessageMenuCommands { /* - [Command("🗨️ message")] + [Command("🗨️ message command with very long name")] [Description("12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901")] public async ValueTask AnalyzerTest(){} */ diff --git a/CompatBot/Commands/Warnings.UserMenu.cs b/CompatBot/Commands/Warnings.UserMenu.cs new file mode 100644 index 00000000..0ff2d492 --- /dev/null +++ b/CompatBot/Commands/Warnings.UserMenu.cs @@ -0,0 +1,151 @@ +using CompatApiClient.Utils; +using DSharpPlus.Commands.Processors.MessageCommands; +using DSharpPlus.Commands.Processors.UserCommands; +using DSharpPlus.Interactivity; +using Microsoft.Extensions.DependencyInjection; + +namespace CompatBot.Commands; + +internal static class WarningsContextMenus +{ + [Command("❗ Warn user"), RequiresBotModRole, SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] + [Description("Give user a warning")] + public static async ValueTask Warn(UserCommandContext ctx, DiscordUser user) + { + var interactivity = ctx.Extension.ServiceProvider.GetService(); + if (interactivity is null) + { + await ctx.RespondAsync($"{Config.Reactions.Failure} Couldn't get interactivity extension").ConfigureAwait(false); + return; + } + + var interaction = ctx.Interaction; + var modal = new DiscordInteractionResponseBuilder() + .AsEphemeral() + .WithCustomId($"modal:warn:{Guid.NewGuid():n}") + .WithTitle("Issue new warning") + .AddComponents( + new DiscordTextInputComponent( + "Warning reason", + "warning", + "Rule #2", + min_length: 2 + ) + ); + await ctx.RespondWithModalAsync(modal).ConfigureAwait(false); + + try + { + InteractivityResult modalResult; + string reason; + do + { + modalResult = await interactivity.WaitForModalAsync(modal.CustomId, ctx.User).ConfigureAwait(false); + if (modalResult.TimedOut) + return; + } while (!modalResult.Result.Values.TryGetValue("warning", out reason!)); + + interaction = modalResult.Result.Interaction; + await interaction.CreateResponseAsync( + DiscordInteractionResponseType.DeferredChannelMessageWithSource, + new DiscordInteractionResponseBuilder().AsEphemeral() + ).ConfigureAwait(false); + var (saved, suppress, recent, total) = await Warnings.AddAsync(user.Id, ctx.User, reason).ConfigureAwait(false); + if (!saved) + { + await ctx.RespondAsync($"{Config.Reactions.Failure} Couldn't save the warning, please try again", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!suppress) + { + var userMsgContent = $"{Config.Reactions.Success} User warning saved, {user.Mention} has {recent} recent warning{StringUtils.GetSuffix(recent)} ({total} total)"; + var userMsg = new DiscordMessageBuilder() + .WithContent(userMsgContent) + .AddMention(UserMention.All); + await ctx.Channel.SendMessageAsync(userMsg).ConfigureAwait(false); + } + await Warnings.ListUserWarningsAsync(ctx.Client, ctx.Interaction, user.Id, user.Username.Sanitize()).ConfigureAwait(false); + + } + catch (Exception e) + { + Config.Log.Error(e); + var msg = new DiscordInteractionResponseBuilder() + .AsEphemeral() + .WithContent($"{Config.Reactions.Failure} Failed to change nickname, check bot's permissions"); + await interaction.EditOriginalResponseAsync(new(msg)).ConfigureAwait(false); + } + } + + [Command("❗ Warn user"), RequiresBotModRole, SlashCommandTypes(DiscordApplicationCommandType.MessageContextMenu)] + [Description("Give user a warning")] + public static async ValueTask Warn(MessageCommandContext ctx, DiscordMessage message) + { + var interactivity = ctx.Extension.ServiceProvider.GetService(); + if (interactivity is null) + { + await ctx.RespondAsync($"{Config.Reactions.Failure} Couldn't get interactivity extension").ConfigureAwait(false); + return; + } + + var interaction = ctx.Interaction; + var modal = new DiscordInteractionResponseBuilder() + .AsEphemeral() + .WithCustomId($"modal:warn:{Guid.NewGuid():n}") + .WithTitle("Issue new warning") + .AddComponents( + new DiscordTextInputComponent( + "Warning reason", + "warning", + "Rule #2", + min_length: 2 + ) + ); + await ctx.RespondWithModalAsync(modal).ConfigureAwait(false); + + try + { + InteractivityResult modalResult; + string reason; + do + { + modalResult = await interactivity.WaitForModalAsync(modal.CustomId, ctx.User).ConfigureAwait(false); + if (modalResult.TimedOut) + return; + } while (!modalResult.Result.Values.TryGetValue("warning", out reason!)); + + interaction = modalResult.Result.Interaction; + await interaction.CreateResponseAsync( + DiscordInteractionResponseType.DeferredChannelMessageWithSource, + new DiscordInteractionResponseBuilder().AsEphemeral() + ).ConfigureAwait(false); + var user = message.Author!; + var (saved, suppress, recent, total) = await Warnings.AddAsync(user.Id, ctx.User, reason, message.Content.Sanitize()).ConfigureAwait(false); + if (!saved) + { + await ctx.RespondAsync($"{Config.Reactions.Failure} Couldn't save the warning, please try again", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!suppress) + { + var userMsgContent = $"{Config.Reactions.Success} User warning saved, {user.Mention} has {recent} recent warning{StringUtils.GetSuffix(recent)} ({total} total)"; + var userMsg = new DiscordMessageBuilder() + .WithContent(userMsgContent) + .AddMention(UserMention.All); + await ctx.Channel.SendMessageAsync(userMsg).ConfigureAwait(false); + } + await Warnings.ListUserWarningsAsync(ctx.Client, ctx.Interaction, user.Id, user.Username.Sanitize()).ConfigureAwait(false); + + } + catch (Exception e) + { + Config.Log.Error(e); + var msg = new DiscordInteractionResponseBuilder() + .AsEphemeral() + .WithContent($"{Config.Reactions.Failure} Failed to change nickname, check bot's permissions"); + await interaction.EditOriginalResponseAsync(new(msg)).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Warnings.cs b/CompatBot/Commands/Warnings.cs index e507fde1..eb2f6fab 100644 --- a/CompatBot/Commands/Warnings.cs +++ b/CompatBot/Commands/Warnings.cs @@ -1,145 +1,131 @@ using CompatApiClient.Utils; +using CompatBot.Commands.AutoCompleteProviders; using CompatBot.Database; using Microsoft.EntityFrameworkCore; namespace CompatBot.Commands; -//[Command("warn")] +[Command("warning"), RequiresBotModRole] [Description("Command used to manage warnings")] -internal sealed partial class Warnings +internal static partial class Warnings { - /* - [DefaultGroupCommand] //attributes on overloads do not work, so no easy permission checks - [Description("Command used to issue a new warning")] - public async Task Warn(CommandContext ctx, [Description("User to warn. Can also use @id")] DiscordUser user, [RemainingText, Description("Warning explanation")] string reason) + [Command("give")] + [Description("Issue a new warning to a user")] + public static async ValueTask Warn( + SlashCommandContext ctx, + [Description("User to warn")] + DiscordUser user, + [Description("Warning explanation")] + string reason + ) { - //need to do manual check of the attribute in all GroupCommand overloads :( - if (!await new RequiresBotModRoleAttribute().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) + await ctx.DeferResponseAsync(ephemeral: true).ConfigureAwait(false); + var (saved, suppress, recent, total) = await AddAsync(user.Id, ctx.User, reason).ConfigureAwait(false); + if (!saved) + { + await ctx.RespondAsync($"{Config.Reactions.Failure} Couldn't save the warning, please try again", ephemeral: true).ConfigureAwait(false); return; + } - if (await AddAsync(ctx, user.Id, user.Username.Sanitize(), ctx.Message.Author, reason).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't save the warning, please try again").ConfigureAwait(false); + if (!suppress) + { + var userMsgContent = $"{Config.Reactions.Success} User warning saved, {user.Mention} has {recent} recent warning{StringUtils.GetSuffix(recent)} ({total} total)"; + var userMsg = new DiscordMessageBuilder() + .WithContent(userMsgContent) + .AddMention(UserMention.All); + await ctx.Channel.SendMessageAsync(userMsg).ConfigureAwait(false); + } + await ListUserWarningsAsync(ctx.Client, ctx.Interaction, user.Id, user.Username.Sanitize()).ConfigureAwait(false); } - [DefaultGroupCommand] - public async Task Warn(CommandContext ctx, [Description("ID of a user to warn")] ulong userId, [RemainingText, Description("Warning explanation")] string reason) + [Command("update")] + [Description("Change warning details")] + public static async ValueTask Edit( + SlashCommandContext ctx, + [Description("Warning ID to edit"), SlashAutoCompleteProvider] + int id, + [Description("Updated warning explanation")] + string reason, + [Description("User to filter autocomplete results")] + DiscordUser? user = null + ) { - if (!await new RequiresBotModRoleAttribute().ExecuteCheckAsync(ctx, false).ConfigureAwait(false)) - return; - - if (await AddAsync(ctx, userId, $"<@{userId}>", ctx.Message.Author, reason).ConfigureAwait(false)) - await ctx.ReactWithAsync(Config.Reactions.Success).ConfigureAwait(false); - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Couldn't save the warning, please try again").ConfigureAwait(false); - } - - [Command("edit"), RequiresBotModRole] - [Description("Edit specified warning")] - public async Task Edit(CommandContext ctx, [Description("Warning ID to edit")] int id) - { - var interact = ctx.Client.GetInteractivity(); await using var db = new BotDb(); var warnings = await db.Warning.Where(w => id.Equals(w.Id)).ToListAsync().ConfigureAwait(false); - if (warnings.Count == 0) + if (warnings.Count is 0) { - await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} Warn not found", true); + await ctx.RespondAsync($"{Config.Reactions.Failure} Warning not found", ephemeral: true).ConfigureAwait(false); return; } var warningToEdit = warnings.First(); if (warningToEdit.IssuerId != ctx.User.Id) { - await ctx.ReactWithAsync(Config.Reactions.Denied, $"{ctx.Message.Author.Mention} This warn wasn't issued by you :(", true); + await ctx.RespondAsync($"{Config.Reactions.Denied} This warning wasn't issued by you", ephemeral: true).ConfigureAwait(false); return; } - var msg = await ctx.Channel.SendMessageAsync("Updated warn reason?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync( - m => m.Author == ctx.User - && m.Channel == ctx.Channel - && !string.IsNullOrEmpty(m.Content) - ).ConfigureAwait(false); - - await msg.DeleteAsync().ConfigureAwait(false); - - if (string.IsNullOrEmpty(response.Result?.Content)) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't edit warning without a new reason").ConfigureAwait(false); - return; - } - - warningToEdit.Reason = response.Result.Content; + warningToEdit.Reason = reason; await db.SaveChangesAsync().ConfigureAwait(false); - await ctx.Channel.SendMessageAsync($"Warning successfully edited!").ConfigureAwait(false); + await ctx.RespondAsync("Warning successfully updated", ephemeral: true).ConfigureAwait(false); } - [Command("remove"), TextAlias("delete", "del"), RequiresBotModRole] + [Command("remove")] [Description("Removes specified warnings")] - public async Task Remove(CommandContext ctx, [Description("Warning IDs to remove separated with space")] params int[] ids) + public static async ValueTask Remove( + SlashCommandContext ctx, + [Description("Warning ID to remove"), SlashAutoCompleteProvider] + int id, + [Description("Reason for warning removal")] + string reason, + [Description("User to filter autocomplete results")] + DiscordUser? user = null + ) { - var interact = ctx.Client.GetInteractivity(); - var msg = await ctx.Channel.SendMessageAsync("What is the reason for removal?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync( - m => m.Author == ctx.User - && m.Channel == ctx.Channel - && !string.IsNullOrEmpty(m.Content) - ).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result?.Content)) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't remove warnings without a reason").ConfigureAwait(false); - return; - } - - await msg.DeleteAsync().ConfigureAwait(false); await using var db = new BotDb(); - var warningsToRemove = await db.Warning.Where(w => ids.Contains(w.Id)).ToListAsync().ConfigureAwait(false); + var warningsToRemove = await db.Warning.Where(w => w.Id == id).ToListAsync().ConfigureAwait(false); foreach (var w in warningsToRemove) { w.Retracted = true; w.RetractedBy = ctx.User.Id; - w.RetractionReason = response.Result.Content; + w.RetractionReason = reason; w.RetractionTimestamp = DateTime.UtcNow.Ticks; } var removedCount = await db.SaveChangesAsync().ConfigureAwait(false); - if (removedCount == ids.Length) - await ctx.Channel.SendMessageAsync($"Warning{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); + if (removedCount is 0) + await ctx.RespondAsync($"{Config.Reactions.Failure} Failed to remove warning").ConfigureAwait(false); else - await ctx.Channel.SendMessageAsync($"Removed {removedCount} items, but was asked to remove {ids.Length}").ConfigureAwait(false); + { + await ctx.Channel.SendMessageAsync("Warning successfully removed").ConfigureAwait(false); + user ??= await ctx.Client.GetUserAsync(warningsToRemove[0].DiscordId).ConfigureAwait(false); + await ListUserWarningsAsync(ctx.Client, ctx.Interaction, user.Id, user.Username.Sanitize(), false).ConfigureAwait(false); + } } - [Command("clear"), RequiresBotModRole] + [Command("clear")] [Description("Removes **all** warnings for a user")] - public Task Clear(CommandContext ctx, [Description("User to clear warnings for")] DiscordUser user) - => Clear(ctx, user.Id); - - [Command("clear"), RequiresBotModRole] - public async Task Clear(CommandContext ctx, [Description("User ID to clear warnings for")] ulong userId) + public static async ValueTask Clear( + SlashCommandContext ctx, + [Description("User to clear warnings for")] + DiscordUser user, + [Description("Reason for clear warning removal")] + string reason + ) { - var interact = ctx.Client.GetInteractivity(); - var msg = await ctx.Channel.SendMessageAsync("What is the reason for removing all the warnings?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync(m => m.Author == ctx.User && m.Channel == ctx.Channel && !string.IsNullOrEmpty(m.Content)).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result?.Content)) - { - await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Can't remove warnings without a reason").ConfigureAwait(false); - return; - } - - await msg.DeleteAsync().ConfigureAwait(false); try { await using var db = new BotDb(); - var warningsToRemove = await db.Warning.Where(w => w.DiscordId == userId && !w.Retracted).ToListAsync().ConfigureAwait(false); + var warningsToRemove = await db.Warning.Where(w => w.DiscordId == user.Id && !w.Retracted).ToListAsync().ConfigureAwait(false); foreach (var w in warningsToRemove) { w.Retracted = true; w.RetractedBy = ctx.User.Id; - w.RetractionReason = response.Result.Content; + w.RetractionReason = reason; w.RetractionTimestamp = DateTime.UtcNow.Ticks; } var removed = await db.SaveChangesAsync().ConfigureAwait(false); await ctx.Channel.SendMessageAsync($"{removed} warning{StringUtils.GetSuffix(removed)} successfully removed!").ConfigureAwait(false); + await ListUserWarningsAsync(ctx.Client, ctx.Interaction, user.Id, user.Username.Sanitize()).ConfigureAwait(false); } catch (Exception e) { @@ -147,9 +133,15 @@ internal sealed partial class Warnings } } - [Command("revert"), RequiresBotModRole] - [Description("Changes the state of the warning status")] - public async Task Revert(CommandContext ctx, [Description("Warning ID to change")] int id) + [Command("revert")] + [Description("Bring back warning that's been removed before")] + public static async ValueTask Revert( + SlashCommandContext ctx, + [Description("Warning ID to change"), SlashAutoCompleteProvider] + int id, + [Description("User to filter autocomplete results")] + DiscordUser? user = null + ) { await using var db = new BotDb(); var warn = await db.Warning.FirstOrDefaultAsync(w => w.Id == id).ConfigureAwait(false); @@ -160,82 +152,60 @@ internal sealed partial class Warnings warn.RetractionReason = null; warn.RetractionTimestamp = null; await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); - await ctx.ReactWithAsync(Config.Reactions.Success, "Reissued the warning", true).ConfigureAwait(false); + await ctx.RespondAsync($"{Config.Reactions.Success} Reissued the warning", ephemeral: true).ConfigureAwait(false); } else - await Remove(ctx, id).ConfigureAwait(false); + await ctx.RespondAsync($"{Config.Reactions.Failure} Warning is not retracted", ephemeral: true).ConfigureAwait(false); } - internal static async Task AddAsync(CommandContext ctx, ulong userId, string userName, DiscordUser issuer, string? reason, string? fullReason = null) + internal static async ValueTask<(bool saved, bool suppress, int recentCount, int totalCount)> + AddAsync(ulong userId, DiscordUser issuer, string reason, string? fullReason = null) { - reason = await Sudo.Fix.FixChannelMentionAsync(ctx, reason).ConfigureAwait(false); - return await AddAsync(ctx.Client, ctx.Message, userId, userName, issuer, reason, fullReason); - } - */ - - internal static async Task AddAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, DiscordUser issuer, string? reason, string? fullReason = null) - { - /* - if (string.IsNullOrEmpty(reason)) - { - var interact = client.GetInteractivity(); - var msg = await message.Channel.SendMessageAsync("What is the reason for this warning?").ConfigureAwait(false); - var response = await interact.WaitForMessageAsync( - m => m.Author == message.Author - && m.Channel == message.Channel - && !string.IsNullOrEmpty(m.Content) - ).ConfigureAwait(false); - if (string.IsNullOrEmpty(response.Result.Content)) - { - await msg.UpdateOrCreateMessageAsync(message.Channel, "A reason needs to be provided").ConfigureAwait(false); - return false; - } - - await msg.DeleteAsync().ConfigureAwait(false); - reason = response.Result.Content; - } - */ try { await using var db = new BotDb(); - await db.Warning.AddAsync(new Warning { DiscordId = userId, IssuerId = issuer.Id, Reason = reason, FullReason = fullReason ?? "", Timestamp = DateTime.UtcNow.Ticks }).ConfigureAwait(false); + await db.Warning.AddAsync( + new() + { + DiscordId = userId, + IssuerId = issuer.Id, + Reason = reason, + FullReason = fullReason ?? "", + Timestamp = DateTime.UtcNow.Ticks + } + ).ConfigureAwait(false); await db.SaveChangesAsync().ConfigureAwait(false); var threshold = DateTime.UtcNow.AddMinutes(-15).Ticks; - var recentCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted && w.Timestamp > threshold); - if (recentCount > 3) - { - Config.Log.Debug("Suicide behavior detected, not spamming with warning responses"); - return true; - } - var totalCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted); - await message.Channel.SendMessageAsync($"User warning saved! User currently has {totalCount} warning{StringUtils.GetSuffix(totalCount)}!").ConfigureAwait(false); - if (totalCount > 1) - await ListUserWarningsAsync(client, message, userId, userName).ConfigureAwait(false); - return true; + var recentCount = db.Warning.Count(w => w.DiscordId == userId && !w.Retracted && w.Timestamp > threshold); + if (recentCount < 4) + return (true, false, recentCount, totalCount); + + Config.Log.Debug("Suicide behavior detected, not spamming with warning responses"); + return (true, true, recentCount, totalCount); } catch (Exception e) { Config.Log.Error(e, "Couldn't save the warning"); - return false; + return default; } } //note: be sure to pass a sanitized userName - private static async Task ListUserWarningsAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, bool skipIfOne = true) + //note2: itneraction must be deferred + internal static async ValueTask ListUserWarningsAsync(DiscordClient client, DiscordInteraction interaction, ulong userId, string userName, bool skipIfOne = true) { try { - var isWhitelisted = (await client.GetMemberAsync(message.Author).ConfigureAwait(false))?.IsWhitelisted() is true; - if (message.Author.Id != userId && !isWhitelisted) + var isWhitelisted = await interaction.User.IsWhitelistedAsync(client, interaction.Guild).ConfigureAwait(false); + if (interaction.User.Id != userId && !isWhitelisted) { - Config.Log.Error($"Somehow {message.Author.Username} ({message.Author.Id}) triggered warning list for {userId}"); + Config.Log.Error($"Somehow {interaction.User.Username} ({interaction.User.Id}) triggered warning list for {userId}"); return; } - var channel = message.Channel; - var isPrivate = channel.IsPrivate; + const bool ephemeral = true; int count, removed; bool isKot, isDoggo; await using var db = new BotDb(); @@ -243,7 +213,8 @@ internal sealed partial class Warnings removed = await db.Warning.CountAsync(w => w.DiscordId == userId && w.Retracted).ConfigureAwait(false); isKot = db.Kot.Any(k => k.UserId == userId); isDoggo = db.Doggo.Any(d => d.UserId == userId); - if (count == 0) + var response = new DiscordInteractionResponseBuilder().AsEphemeral(ephemeral); + if (count is 0) { if (isKot && isDoggo) { @@ -252,7 +223,7 @@ internal sealed partial class Warnings else isDoggo = false; } - var msg = (removed, isPrivate, isKot, isDoggo) switch + var msg = (removed, ephemeral, isKot, isDoggo) switch { (0, _, true, false) => $"{userName} has no warnings, is an upstanding kot, and a paw bean of this community", (0, _, false, true) => $"{userName} has no warnings, is a good boy, and a wiggling tail of this community", @@ -262,37 +233,41 @@ internal sealed partial class Warnings (_, _, false, true) => $"{userName} has no warnings, but are they a good boy?", _ => $"{userName} has no warnings", }; - await message.Channel.SendMessageAsync(msg).ConfigureAwait(false); - if (!isPrivate || removed == 0) + ; + await interaction.EditOriginalResponseAsync(new(response.WithContent(msg))).ConfigureAwait(false); + if (!ephemeral || removed is 0) return; } - if (count == 1 && skipIfOne) + if (count is 1 && skipIfOne) + { + await interaction.EditOriginalResponseAsync(new(response.WithContent("No additional warnings on record"))).ConfigureAwait(false); return; + } const int maxWarningsInPublicChannel = 3; var showCount = Math.Min(maxWarningsInPublicChannel, count); var table = new AsciiTable( new AsciiColumn("ID", alignToRight: true), - new AsciiColumn("±", disabled: !isPrivate || !isWhitelisted), + new AsciiColumn("±", disabled: !ephemeral || !isWhitelisted), new AsciiColumn("By", maxWidth: 15), new AsciiColumn("On date (UTC)"), new AsciiColumn("Reason"), - new AsciiColumn("Context", disabled: !isPrivate, maxWidth: 4096) + new AsciiColumn("Context", disabled: !ephemeral, maxWidth: 4096) ); IQueryable query = db.Warning.Where(w => w.DiscordId == userId).OrderByDescending(w => w.Id); - if (!isPrivate || !isWhitelisted) + if (!ephemeral || !isWhitelisted) query = query.Where(w => !w.Retracted); - if (!isPrivate && !isWhitelisted) + if (!ephemeral && !isWhitelisted) query = query.Take(maxWarningsInPublicChannel); foreach (var warning in await query.ToListAsync().ConfigureAwait(false)) { if (warning.Retracted) { - if (isWhitelisted && isPrivate) + if (isWhitelisted && ephemeral) { var retractedByName = warning.RetractedBy.HasValue - ? await client.GetUserNameAsync(channel, warning.RetractedBy.Value, isPrivate, "unknown mod").ConfigureAwait(false) + ? await client.GetUserNameAsync(interaction.Channel, warning.RetractedBy.Value, ephemeral, "unknown mod").ConfigureAwait(false) : ""; var retractionTimestamp = warning.RetractionTimestamp.HasValue ? new DateTime(warning.RetractionTimestamp.Value, DateTimeKind.Utc).ToString("u") @@ -301,7 +276,7 @@ internal sealed partial class Warnings var issuerName = warning.IssuerId == 0 ? "" - : await client.GetUserNameAsync(channel, warning.IssuerId, isPrivate, "unknown mod").ConfigureAwait(false); + : await client.GetUserNameAsync(interaction.Channel, warning.IssuerId, ephemeral, "unknown mod").ConfigureAwait(false); var timestamp = warning.Timestamp.HasValue ? new DateTime(warning.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; @@ -312,7 +287,7 @@ internal sealed partial class Warnings { var issuerName = warning.IssuerId == 0 ? "" - : await client.GetUserNameAsync(channel, warning.IssuerId, isPrivate, "unknown mod").ConfigureAwait(false); + : await client.GetUserNameAsync(interaction.Channel, warning.IssuerId, ephemeral, "unknown mod").ConfigureAwait(false); var timestamp = warning.Timestamp.HasValue ? new DateTime(warning.Timestamp.Value, DateTimeKind.Utc).ToString("u") : ""; @@ -321,14 +296,22 @@ internal sealed partial class Warnings } var result = new StringBuilder("Warning list for ").Append(Formatter.Sanitize(userName)); - if (!isPrivate && !isWhitelisted && count > maxWarningsInPublicChannel) + if (!ephemeral && !isWhitelisted && count > maxWarningsInPublicChannel) result.Append($" (last {showCount} of {count}, full list in DMs)"); result.AppendLine(":").Append(table); - await channel.SendAutosplitMessageAsync(result).ConfigureAwait(false); + var pages = AutosplitResponseHelper.AutosplitMessage(result.ToString()); + await interaction.EditOriginalResponseAsync(new(response.WithContent(pages[0]))).ConfigureAwait(false); + foreach (var page in pages.Skip(1).Take(4)) + { + var followupMsg = new DiscordFollowupMessageBuilder() + .AsEphemeral(ephemeral) + .WithContent(page); + await interaction.CreateFollowupMessageAsync(followupMsg).ConfigureAwait(false); + } } catch (Exception e) { Config.Log.Warn(e); } } -} +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/ContentFilter.cs b/CompatBot/Database/Providers/ContentFilter.cs index 51aa0c98..b2aa7e79 100644 --- a/CompatBot/Database/Providers/ContentFilter.cs +++ b/CompatBot/Database/Providers/ContentFilter.cs @@ -224,7 +224,14 @@ internal static class ContentFilter { try { - await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, warningReason ?? "Mention of piracy", message.Content.Sanitize()).ConfigureAwait(false); + var (saved, suppress, recent, total) = await Warnings.AddAsync( + message.Author!.Id, + client.CurrentUser, + warningReason ?? "Mention of piracy", + message.Content.Sanitize() + ).ConfigureAwait(false); + if (saved && !suppress && message.Channel is not null) + await message.Channel.SendMessageAsync($"User warning saved, {message.Author.Mention} has {recent} recent warning{StringUtils.GetSuffix(recent)} ({total} total)").ConfigureAwait(false); completedActions.Add(FilterAction.IssueWarning); } catch (Exception e) diff --git a/CompatBot/EventHandlers/DiscordInviteFilter.cs b/CompatBot/EventHandlers/DiscordInviteFilter.cs index 36d795ac..7c839a9b 100644 --- a/CompatBot/EventHandlers/DiscordInviteFilter.cs +++ b/CompatBot/EventHandlers/DiscordInviteFilter.cs @@ -120,7 +120,16 @@ internal static partial class DiscordInviteFilter await message.Channel.SendMessageAsync(userMsg).ConfigureAwait(false); if (circumventionAttempt) - await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Attempted to circumvent discord invite filter", codeResolveMsg); + { + var (saved, suppress, recent, total) = await Warnings.AddAsync( + message.Author.Id, + client.CurrentUser, + "Attempted to circumvent discord invite filter", + codeResolveMsg + ).ConfigureAwait(false); + if (saved && !suppress) + await message.Channel.SendMessageAsync($"User warning saved, {message.Author.Mention} has {recent} recent warning{StringUtils.GetSuffix(recent)} ({total} total)").ConfigureAwait(false); + } return false; } } diff --git a/CompatBot/EventHandlers/LogParsingHandler.cs b/CompatBot/EventHandlers/LogParsingHandler.cs index 6ab08b45..2b98980c 100644 --- a/CompatBot/EventHandlers/LogParsingHandler.cs +++ b/CompatBot/EventHandlers/LogParsingHandler.cs @@ -208,8 +208,17 @@ public static class LogParsingHandler { Config.Log.Error(e, "Failed to send piracy report"); } - if (!(message.Channel.IsPrivate || (message.Channel.Name?.Contains("spam") ?? true))) - await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Pirated Release", $"{result.SelectedFilter.String} - {result.SelectedFilterContext?.Sanitize()}"); + if (!(message.Channel!.IsPrivate || message.Channel.Name.Contains("spam"))) + { + var (saved, suppress, recent, total) = await Warnings.AddAsync( + message.Author.Id, + client.CurrentUser, + "Pirated Release", + $"{result.SelectedFilter.String} - {result.SelectedFilterContext?.Sanitize()}" + ); + if (saved && !suppress) + await message.Channel.SendMessageAsync($"User warning saved, {message.Author.Mention} has {recent} recent warning{StringUtils.GetSuffix(recent)} ({total} total)").ConfigureAwait(false); + } } } else diff --git a/CompatBot/Utils/Extensions/CommandContextExtensions.cs b/CompatBot/Utils/Extensions/CommandContextExtensions.cs index c4eb9e1a..fc91523b 100644 --- a/CompatBot/Utils/Extensions/CommandContextExtensions.cs +++ b/CompatBot/Utils/Extensions/CommandContextExtensions.cs @@ -53,9 +53,6 @@ public static partial class CommandContextExtensions public static Task GetChannelForSpamAsync(this CommandContext ctx) => ctx.Channel.IsSpamChannel() ? Task.FromResult(ctx.Channel) : ctx.CreateDmAsync(); - public static Task GetUserNameAsync(this CommandContext ctx, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user") - => ctx.Client.GetUserNameAsync(ctx.Channel, userId, forDmPurposes, defaultName); - public static async Task GetMessageAsync(this CommandContext ctx, string messageLink) { if (MessageLinkPattern().Match(messageLink) is Match m diff --git a/CompatBot/Utils/Extensions/DiscordClientExtensions.cs b/CompatBot/Utils/Extensions/DiscordClientExtensions.cs index a0a1291c..9a97f0e5 100644 --- a/CompatBot/Utils/Extensions/DiscordClientExtensions.cs +++ b/CompatBot/Utils/Extensions/DiscordClientExtensions.cs @@ -43,7 +43,10 @@ public static class DiscordClientExtensions public static Task GetMemberAsync(this DiscordClient client, DiscordGuild? guild, ulong userId) => guild is null ? GetMemberAsync(client, userId) : GetMemberAsync(client, guild.Id, userId); - public static async Task GetUserNameAsync(this DiscordClient client, DiscordChannel channel, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user") + public static ValueTask GetUserNameAsync(this CommandContext ctx, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user") + => GetUserNameAsync(ctx.Client, ctx.Channel, userId, forDmPurposes, defaultName); + + public static async ValueTask GetUserNameAsync(this DiscordClient client, DiscordChannel channel, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user") { var isPrivate = forDmPurposes ?? channel.IsPrivate; if (userId == 0) diff --git a/CompatBot/Utils/Extensions/RolesExtensions.cs b/CompatBot/Utils/Extensions/RolesExtensions.cs index f576b240..5b45f156 100644 --- a/CompatBot/Utils/Extensions/RolesExtensions.cs +++ b/CompatBot/Utils/Extensions/RolesExtensions.cs @@ -6,46 +6,46 @@ internal static class RolesExtensions { public static async ValueTask IsModeratorAsync(this DiscordUser? user, DiscordClient client, DiscordGuild? guild = null) { - if (user == null) + if (user is null) return false; if (ModProvider.IsSudoer(user.Id)) return true; - var member = await (guild == null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); + var member = await (guild is null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); return member?.Roles.IsModerator() is true; } public static async ValueTask IsWhitelistedAsync(this DiscordUser? user, DiscordClient client, DiscordGuild? guild = null) { - if (user == null) + if (user is null) return false; if (ModProvider.IsMod(user.Id)) return true; - var member = await (guild == null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); + var member = await (guild is null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); return member?.Roles.IsWhitelisted() is true; } public static async ValueTask IsSmartlistedAsync(this DiscordUser? user, DiscordClient client, DiscordGuild? guild = null) { - if (user == null) + if (user is null) return false; if (ModProvider.IsMod(user.Id)) return true; - var member = await (guild == null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); + var member = await (guild is null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); return member?.Roles.IsSmartlisted() is true; } public static async ValueTask IsSupporterAsync(this DiscordUser? user, DiscordClient client, DiscordGuild? guild = null) { - if (user == null) + if (user is null) return false; - var member = await (guild == null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); + var member = await (guild is null ? client.GetMemberAsync(user) : client.GetMemberAsync(guild, user)).ConfigureAwait(false); return member?.Roles.IsSupporter() is true; } diff --git a/SourceGenerators/AttributeUsageAnalyzer.cs b/SourceGenerators/AttributeUsageAnalyzer.cs index ffcecb43..1af8d0f0 100644 --- a/SourceGenerators/AttributeUsageAnalyzer.cs +++ b/SourceGenerators/AttributeUsageAnalyzer.cs @@ -32,7 +32,7 @@ public class AttributeUsageAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "Description must be less than or equal to 100 characters." ); - private static readonly DiagnosticDescriptor CommandWithEmojiVariationSelector = new( + private static readonly DiagnosticDescriptor CommandWithEmojiVariationSelectorRule = new( "DSP0003", "Emoji with variation selector", "Command name has an emoji character with VS{0} ({1}), which may not work as a command name", @@ -41,11 +41,21 @@ public class AttributeUsageAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "Commands should avoid using variation selectors for emoji characters in command names." ); + private static readonly DiagnosticDescriptor CommandNameLengthRule = new( + "DSP0004", + "Command name length is too long", + "Command name is {0} characters long, which is {1} characters longer than allowed", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Command name must be between 1 and 32 characters long." + ); public override ImmutableArray SupportedDiagnostics { get; } = [ AccessCheckAttributeOnGroupCommandRule, DescriptionLengthRule, - CommandWithEmojiVariationSelector, + CommandWithEmojiVariationSelectorRule, + CommandNameLengthRule, ]; public override void Initialize(AnalysisContext context) @@ -197,19 +207,31 @@ public class AttributeUsageAnalyzer : DiagnosticAnalyzer if (actualName is not {Length: >0}) return; + if (actualName.Length > 32) + { + context.ReportDiagnostic( + Diagnostic.Create(CommandNameLengthRule, + // The highlighted area in the analyzed source code. Keep it as specific as possible. + attributeSyntax.GetLocation(), + // The value is passed to the 'MessageFormat' argument of your rule. + actualName.Length, actualName.Length - 32 + ) + ); + } + var vs = actualName.ToCharArray().FirstOrDefault(VariationSelectors.Contains); if (vs is default(char)) return; - - var diagnostic = Diagnostic.Create(CommandWithEmojiVariationSelector, - // The highlighted area in the analyzed source code. Keep it as specific as possible. - attributeSyntax.GetLocation(), - // The value is passed to the 'MessageFormat' argument of your rule. - vs - 0xFE00 + 1, $"0x{(int)vs:X4}" - ); // Reporting a diagnostic is the primary outcome of analyzers. - context.ReportDiagnostic(diagnostic); + context.ReportDiagnostic( + Diagnostic.Create(CommandWithEmojiVariationSelectorRule, + // The highlighted area in the analyzed source code. Keep it as specific as possible. + attributeSyntax.GetLocation(), + // The value is passed to the 'MessageFormat' argument of your rule. + vs - 0xFE00 + 1, $"0x{(int)vs:X4}" + ) + ); } private static bool IsDescendantOfAttribute(AttributeData attributeData, string baseAttributeClassNameWithNamespace)