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 ad852119ac..c048c9e5a0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -221,7 +221,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 55f63ca155..026b4a4ea0 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 javax.annotation.Nullable; 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. @@ -268,7 +280,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 @@ -437,4 +449,87 @@ 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 = moderationActions.size() - 1; i >= 0; i--) { + if (groupedModerationActions.isEmpty() + || groupedModerationActions.getLast().size() == MAX_AUDIT_PAGE_LENGTH) { + 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 5fe15a4c6d..5142833ecf 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 @@ -7,15 +7,18 @@ 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.User; 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,11 +28,14 @@ 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.AmbientColors; import org.togetherjava.tjbot.features.utils.MessageUtils; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -52,15 +58,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(); @@ -181,9 +192,16 @@ private MessageCreateAction createModMessage(String reportReason, .setColor(AmbientColors.MODMAIL) .build(); + long reportedUserId = Long.parseLong(reportedMessage.authorId); + int startingPage = getStartingPageForUser(guild.getIdLong(), reportedUserId); + + String historyButtonId = generateComponentId(Lifespan.REGULAR, reportedMessage.authorId, + String.valueOf(startingPage)); + 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() @@ -221,7 +239,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); @@ -230,8 +248,105 @@ 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) { + if (event.getMessage().isEphemeral()) { + event.deferEdit().queue(); + } else { + 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() + .editOriginal("Could not load audit data for this user.") + .map(_ -> 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(AmbientColors.MODMAIL) + .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".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(); + } + + private int getStartingPageForUser(long guildId, long userId) { + List actions = new ArrayList<>( + moderationActionsStore.getActionsByTargetAscending(guildId, userId)); + + Collections.reverse(actions); + List> pages = ModerationUtils.groupActionsByPages(actions); + + return Math.max(0, pages.size() - 1); + } + } 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 530fa6e739..c1bfb4b64c 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,22 +21,19 @@ 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 org.togetherjava.tjbot.features.utils.AmbientColors; 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; + +import static org.togetherjava.tjbot.features.moderation.ModerationUtils.createSummaryMessageDescription; /** * This command lists all moderation actions that have been taken against a given user, for example @@ -50,7 +46,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; @@ -102,14 +97,14 @@ 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, int pageNumber, JDA jda) { List actions = actionsStore.getActionsByTargetAscending(guildId, targetId); - List> groupedActions = groupActionsByPages(actions); + List> groupedActions = ModerationUtils.groupActionsByPages(actions); int totalPages = groupedActions.size(); int pageNumberInLimits; @@ -127,19 +122,6 @@ 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 EmbedBuilder createSummaryEmbed(User user, Collection actions) { String avatarOrDefaultUrl = user.getEffectiveAvatarUrl(); @@ -149,31 +131,6 @@ 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) { @@ -183,7 +140,8 @@ private RestAction attachEmbedFields(EmbedBuilder auditEmbed, List> embedFieldTasks = new ArrayList<>(); groupedActions.get(pageNumber - 1) - .forEach(action -> embedFieldTasks.add(actionToField(action, jda))); + .forEach(action -> embedFieldTasks + .add(ModerationUtils.moderationActionToEmbedField(action, jda))); return RestAction.allOf(embedFieldTasks).map(embedFields -> { embedFields.forEach(auditEmbed::addField); @@ -193,28 +151,6 @@ 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)); - } - private > R attachPageTurnButtons( Supplier messageBuilderSupplier, EmbedBuilder auditEmbed, int pageNumber, int totalPages, long guildId, long targetId, long callerId) {