diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index bc4e580441..d8978d1bcf 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -219,7 +219,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new GitHubCommand(githubReference)); features.add(new ModMailCommand(jda, config)); features.add(new HelpThreadCommand(config, helpSystemHelper, metrics)); - features.add(new ReportCommand(config)); + features.add(new ReportCommand(config, actionsStore)); features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper, diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java index 5378b1a606..fd44a18095 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java @@ -1,6 +1,7 @@ package org.togetherjava.tjbot.features.moderation; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.IPermissionHolder; @@ -12,6 +13,7 @@ import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; import net.dv8tion.jda.api.utils.Result; +import net.dv8tion.jda.api.utils.TimeUtil; import net.dv8tion.jda.internal.requests.CompletedRestAction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,11 +27,17 @@ import java.awt.Color; import java.time.Instant; +import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Predicate; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * Utility class offering helpers revolving around user moderation, such as banning or kicking. @@ -45,6 +53,10 @@ private ModerationUtils() { * {@link AuditableRestAction#reason(String)}. */ private static final int REASON_MAX_LENGTH = 512; + /** + * The maximum amount of moderation actions displayed on a single audit log page + */ + private static final int MAX_AUDIT_PAGE_LENGTH = 10; /** * Human-readable text representing the duration of a permanent action, will be shown to the * user as option for selection. @@ -273,7 +285,7 @@ static boolean handleHasAuthorPermissions(String actionVerb, Permission permissi * Creates a message to be displayed as response to a moderation action. *

* Essentially, it informs others about the action, such as "John banned Bob for playing with - * the fire.". + * the fire". * * @param author the author executing the action * @param action the action that is executed @@ -442,4 +454,86 @@ static RestAction sendModActionDm(RestAction embedBuilder */ record TemporaryData(Instant expiresAt, String duration) { } + + /** + * Splits a list of moderation records into discrete pages capped at 10 items each. + * + * @param moderationActions the list of chronological actions against a target + * @return a list of sub-lists where each sub-list contains a maximum of 10 items + */ + public static List> groupActionsByPages( + List moderationActions) { + List> groupedModerationActions = new ArrayList<>(); + + for (int i = 0; i < moderationActions.size(); i++) { + if (i % MAX_AUDIT_PAGE_LENGTH == 0) { + groupedModerationActions.add(new ArrayList<>(MAX_AUDIT_PAGE_LENGTH)); + } + groupedModerationActions.getLast().add(moderationActions.get(i)); + } + + return groupedModerationActions; + } + + /** + * Generates a structural text overview outlining the count total of each action type. + * + * @param moderationActions a collection of history records + * @return a formatted markdown description summary + */ + public static String createSummaryMessageDescription( + Collection moderationActions) { + int moderationActionAmount = moderationActions.size(); + + String shortSummary = "There are **%s actions** against the user." + .formatted(moderationActionAmount == 0 ? "no" : moderationActionAmount); + + if (moderationActionAmount == 0) { + return shortSummary; + } + + Map moderationActionTypeToCount = moderationActions.stream() + .collect(Collectors.groupingBy(ActionRecord::actionType, Collectors.counting())); + + String typeCountSummary = moderationActionTypeToCount.entrySet() + .stream() + .filter(typeAndCount -> typeAndCount.getValue() > 0) + .sorted(Map.Entry.comparingByValue().reversed()) + .map(typeAndCount -> "- **%s**: %d".formatted(typeAndCount.getKey(), + typeAndCount.getValue())) + .collect(Collectors.joining("\n")); + + return shortSummary + "\n" + typeCountSummary; + } + + /** + * Converts an action record item asynchronously into a formatted embed data field. + * + * @param moderationActionRecord the moderation action history record to convert + * @param jda the active JDA instance used to resolve the moderator's handle + * @return a rest action that resolves to the embed field representing the moderation action + */ + public static RestAction moderationActionToEmbedField( + ActionRecord moderationActionRecord, JDA jda) { + return jda.retrieveUserById(moderationActionRecord.authorId()) + .map(author -> author == null ? "(unknown user)" : author.getName()) + .map(authorText -> { + String expiresAtFormatted = moderationActionRecord.actionExpiresAt() == null ? "" + : "\nTemporary action, expires at: " + TimeUtil.getDateTimeString( + moderationActionRecord.actionExpiresAt().atOffset(ZoneOffset.UTC)); + + String embedFieldName = "%s by %s" + .formatted(moderationActionRecord.actionType().name(), authorText); + String embedFieldDescription = """ + %s + Issued at: %s%s + """.formatted(moderationActionRecord.reason(), + TimeUtil.getDateTimeString( + moderationActionRecord.issuedAt().atOffset(ZoneOffset.UTC)), + expiresAtFormatted); + + return new MessageEmbed.Field(embedFieldName, embedFieldDescription, false); + }); + } + } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ReportCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ReportCommand.java index ced7aaec4a..0be9428566 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ReportCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ReportCommand.java @@ -3,19 +3,18 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.interactions.components.text.TextInput; import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; import net.dv8tion.jda.api.interactions.modals.Modal; +import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; import net.dv8tion.jda.api.utils.Result; import org.slf4j.Logger; @@ -25,14 +24,13 @@ import org.togetherjava.tjbot.features.BotCommandAdapter; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.MessageContextCommand; +import org.togetherjava.tjbot.features.componentids.Lifespan; import org.togetherjava.tjbot.features.utils.MessageUtils; import java.awt.Color; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -53,15 +51,20 @@ public final class ReportCommand extends BotCommandAdapter implements MessageCon private final Predicate modMailChannelNamePredicate; private final Predicate configModGroupPattern; private final String configModMailChannelPattern; + private final ModerationActionsStore moderationActionsStore; /** * Creates a new instance. * * @param config to get the channel to forward reports to + * @param moderationActionsStore to get the history of moderation actions against the reported + * user */ - public ReportCommand(Config config) { + public ReportCommand(Config config, ModerationActionsStore moderationActionsStore) { super(Commands.message(COMMAND_NAME), CommandVisibility.GUILD); + this.moderationActionsStore = Objects.requireNonNull(moderationActionsStore); + modMailChannelNamePredicate = Pattern.compile(config.getModMailChannelPattern()).asMatchPredicate(); @@ -182,9 +185,13 @@ private MessageCreateAction createModMessage(String reportReason, .setColor(AMBIENT_COLOR) .build(); + String historyButtonId = + generateComponentId(Lifespan.REGULAR, reportedMessage.authorId, "0"); + MessageCreateAction message = modMailAuditLog.sendMessageEmbeds(reportedMessageEmbed, reportReasonEmbed) - .addActionRow(Button.link(reportedMessage.jumpUrl, "Go to message")); + .addActionRow(Button.link(reportedMessage.jumpUrl, "Go to message"), + Button.primary(historyButtonId, "Audit")); Optional moderatorRole = guild.getRoles() .stream() @@ -222,7 +229,7 @@ private static String createUserReply(Result result) { } private record ReportedMessage(String content, String id, String jumpUrl, String channelID, - Instant timestamp, String authorName, String authorAvatarUrl) { + Instant timestamp, String authorName, String authorAvatarUrl, String authorId) { static ReportedMessage ofArgs(List args) { String content = args.getFirst(); String id = args.get(1); @@ -231,8 +238,92 @@ static ReportedMessage ofArgs(List args) { Instant timestamp = Instant.parse(args.get(4)); String authorName = args.get(5); String authorAvatarUrl = args.get(6); + String authorId = args.get(7); return new ReportedMessage(content, id, jumpUrl, channelID, timestamp, authorName, - authorAvatarUrl); + authorAvatarUrl, authorId); + } + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + event.deferReply(true).queue(); + + Guild guild = + Objects.requireNonNull(event.getGuild(), "Guild cannot be null for this command."); + long guildId = guild.getIdLong(); + + long reportedUserId = Long.parseLong(args.get(0)); + int targetPage = Integer.parseInt(args.get(1)); + + List actions = new ArrayList<>( + moderationActionsStore.getActionsByTargetAscending(guildId, reportedUserId)); + Collections.reverse(actions); + List> pages = ModerationUtils.groupActionsByPages(actions); + + event.getJDA() + .retrieveUserById(reportedUserId) + .flatMap(user -> prepareAuditEmbedTasks(event, user, actions, pages, targetPage)) + .onErrorFlatMap(_ -> event.getHook() + .sendMessage("Could not load audit data for this user.") + .map(msg -> null)) + .queue(); + } + + private RestAction> prepareAuditEmbedTasks( + ButtonInteractionEvent event, User user, List actions, + List> pages, int targetPage) { + + EmbedBuilder auditEmbed = + new EmbedBuilder().setTitle("Audit log of **%s**".formatted(user.getName())) + .setAuthor(user.getName(), null, user.getEffectiveAvatarUrl()) + .setColor(Color.BLACK) + .setDescription(ModerationUtils.createSummaryMessageDescription(actions)); + + if (pages.isEmpty()) { + return event.getHook() + .editOriginalEmbeds(auditEmbed.build()) + .setComponents(List.of()) + .map(_ -> List.of()); } + + int currentPageIndex = Math.clamp(targetPage, 0, pages.size() - 1); + + List> fetchFieldActions = pages.get(currentPageIndex) + .stream() + .map(actionRecord -> ModerationUtils.moderationActionToEmbedField(actionRecord, + event.getJDA())) + .toList(); + + return RestAction.allOf(fetchFieldActions).map(embedFields -> { + finalizeAndSendEmbed(event, auditEmbed, embedFields, user.getIdLong(), currentPageIndex, + pages.size()); + return embedFields; + }); + } + + private void finalizeAndSendEmbed(ButtonInteractionEvent event, EmbedBuilder auditEmbed, + List embedFields, long reportedUserId, int currentPageIndex, + int totalPages) { + auditEmbed.clearFields(); + embedFields.forEach(auditEmbed::addField); + + auditEmbed.setFooter( + "Page %d/%d (Most recent first)".formatted(currentPageIndex + 1, totalPages)); + + String prevButtonId = generateComponentId(Lifespan.REGULAR, String.valueOf(reportedUserId), + String.valueOf(currentPageIndex - 1)); + String nextButtonId = generateComponentId(Lifespan.REGULAR, String.valueOf(reportedUserId), + String.valueOf(currentPageIndex + 1)); + + Button prevButton = + Button.primary(prevButtonId, "◀ Previous").withDisabled(currentPageIndex == 0); + Button nextButton = Button.primary(nextButtonId, "Next ▶") + .withDisabled(currentPageIndex == totalPages - 1); + + event.getHook() + .editOriginalEmbeds(auditEmbed.build()) + .setActionRow(prevButton, nextButton) + .queue(); } + } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/AuditCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/AuditCommand.java index b466bcf18f..560fc690be 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/AuditCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/AuditCommand.java @@ -13,7 +13,6 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.RestAction; -import net.dv8tion.jda.api.utils.TimeUtil; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.dv8tion.jda.api.utils.messages.MessageEditBuilder; import net.dv8tion.jda.api.utils.messages.MessageRequest; @@ -22,21 +21,16 @@ import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; import org.togetherjava.tjbot.features.moderation.ActionRecord; -import org.togetherjava.tjbot.features.moderation.ModerationAction; import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; import org.togetherjava.tjbot.features.moderation.ModerationUtils; import javax.annotation.Nullable; -import java.time.Instant; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.function.Supplier; -import java.util.stream.Collectors; /** * This command lists all moderation actions that have been taken against a given user, for example @@ -49,7 +43,6 @@ public final class AuditCommand extends SlashCommandAdapter { private static final String TARGET_OPTION = "user"; private static final String COMMAND_NAME = "audit"; private static final String ACTION_VERB = "audit"; - private static final int MAX_PAGE_LENGTH = 10; private static final String PREVIOUS_BUTTON_LABEL = "⬅"; private static final String NEXT_BUTTON_LABEL = "➡"; private final ModerationActionsStore actionsStore; @@ -101,8 +94,8 @@ private boolean handleChecks(Member bot, Member author, @Nullable Member target, /** * @param pageNumber page number to display when actions are divided into pages and each page - * can contain {@link AuditCommand#MAX_PAGE_LENGTH} actions, {@code -1} encodes the last - * page + * can contain {@link ModerationUtils#groupActionsByPages(List)} actions, {@code -1} + * encodes the last page */ private > RestAction auditUser( Supplier messageBuilderSupplier, long guildId, long targetId, long callerId, @@ -126,17 +119,8 @@ private > RestAction auditUser( pageNumberInLimits, totalPages, guildId, targetId, callerId)); } - private List> groupActionsByPages(List actions) { - List> groupedActions = new ArrayList<>(); - for (int i = 0; i < actions.size(); i++) { - if (i % AuditCommand.MAX_PAGE_LENGTH == 0) { - groupedActions.add(new ArrayList<>(AuditCommand.MAX_PAGE_LENGTH)); - } - - groupedActions.getLast().add(actions.get(i)); - } - - return groupedActions; + private static List> groupActionsByPages(List actions) { + return ModerationUtils.groupActionsByPages(actions); } private static EmbedBuilder createSummaryEmbed(User user, Collection actions) { @@ -144,35 +128,10 @@ private static EmbedBuilder createSummaryEmbed(User user, Collection actions) { - int actionAmount = actions.size(); - - String shortSummary = "There are **%s actions** against the user." - .formatted(actionAmount == 0 ? "no" : actionAmount); - - if (actionAmount == 0) { - return shortSummary; - } - - // Summary of all actions with their count, like "- Warn: 5", descending - Map actionTypeToCount = actions.stream() - .collect(Collectors.groupingBy(ActionRecord::actionType, Collectors.counting())); - - String typeCountSummary = actionTypeToCount.entrySet() - .stream() - .filter(typeAndCount -> typeAndCount.getValue() > 0) - .sorted(Map.Entry.comparingByValue().reversed()) - .map(typeAndCount -> "- **%s**: %d".formatted(typeAndCount.getKey(), - typeAndCount.getValue())) - .collect(Collectors.joining("\n")); - - return shortSummary + "\n" + typeCountSummary; - } - private RestAction attachEmbedFields(EmbedBuilder auditEmbed, List> groupedActions, int pageNumber, int totalPages, JDA jda) { @@ -193,25 +152,7 @@ private RestAction attachEmbedFields(EmbedBuilder auditEmbed, } private static RestAction actionToField(ActionRecord action, JDA jda) { - return jda.retrieveUserById(action.authorId()) - .map(author -> author == null ? "(unknown user)" : author.getName()) - .map(authorText -> { - String expiresAtFormatted = action.actionExpiresAt() == null ? "" - : "\nTemporary action, expires at: " + formatTime(action.actionExpiresAt()); - - String fieldName = "%s by %s".formatted(action.actionType().name(), authorText); - String fieldDescription = """ - %s - Issued at: %s%s - """.formatted(action.reason(), formatTime(action.issuedAt()), - expiresAtFormatted); - - return new MessageEmbed.Field(fieldName, fieldDescription, false); - }); - } - - private static String formatTime(Instant when) { - return TimeUtil.getDateTimeString(when.atOffset(ZoneOffset.UTC)); + return ModerationUtils.moderationActionToEmbedField(action, jda); } private > R attachPageTurnButtons(