Skip to content

Audit rework #472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 3, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@

import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.MessageBuilder;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.requests.RestAction;
import net.dv8tion.jda.api.interactions.components.ActionRow;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.dv8tion.jda.api.utils.TimeUtil;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import org.togetherjava.tjbot.commands.SlashCommandAdapter;
import org.togetherjava.tjbot.commands.SlashCommandVisibility;
import org.togetherjava.tjbot.config.Config;

import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
Expand All @@ -32,12 +39,15 @@ 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 = 25;
private static final String PREVIOUS_BUTTON_LABEL = "⬅";
private static final String NEXT_BUTTON_LABEL = "➡";
private final Predicate<String> hasRequiredRole;
private final ModerationActionsStore actionsStore;

/**
* Constructs an instance.
*
*
* @param actionsStore used to store actions issued by this command
* @param config the config to use for this
*/
Expand All @@ -53,27 +63,29 @@ public AuditCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Confi
this.actionsStore = Objects.requireNonNull(actionsStore);
}

private static @NotNull MessageEmbed createSummaryMessage(@NotNull User user,
private static @NotNull EmbedBuilder createSummaryEmbed(@NotNull User user,
@NotNull Collection<ActionRecord> actions) {
return new EmbedBuilder().setTitle("Audit log of **%s**".formatted(user.getAsTag()))
.setAuthor(user.getName(), null, user.getAvatarUrl())
.setDescription(createSummaryMessageDescription(actions))
.setColor(ModerationUtils.AMBIENT_COLOR)
.build();
.setColor(ModerationUtils.AMBIENT_COLOR);
}

private static @NotNull String createSummaryMessageDescription(
@NotNull Collection<ActionRecord> actions) {
int actionAmount = actions.size();

String shortSummary = "There are **%s actions** against the user."
.formatted(actionAmount == 0 ? "no" : actionAmount);

if (actionAmount == 0) {
return "There are **no actions** against the user.";
return shortSummary;
}

String shortSummary = "There are **%d actions** against the user.".formatted(actionAmount);

// Summary of all actions with their count, like "- Warn: 5", descending
Map<ModerationAction, Long> actionTypeToCount = actions.stream()
.collect(Collectors.groupingBy(ActionRecord::actionType, Collectors.counting()));

String typeCountSummary = actionTypeToCount.entrySet()
.stream()
.filter(typeAndCount -> typeAndCount.getValue() > 0)
Expand All @@ -85,30 +97,27 @@ public AuditCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Confi
return shortSummary + "\n" + typeCountSummary;
}

private static @NotNull RestAction<MessageEmbed> actionToMessage(@NotNull ActionRecord action,
private static @NotNull MessageEmbed.Field actionToField(@NotNull ActionRecord action,
@NotNull JDA jda) {
String footer = action.actionExpiresAt() == null ? null
: "Temporary action, expires at %s".formatted(TimeUtil
.getDateTimeString(action.actionExpiresAt().atOffset(ZoneOffset.UTC)));

return jda.retrieveUserById(action.authorId())
.onErrorMap(error -> null)
.map(author -> new EmbedBuilder().setTitle(action.actionType().name())
.setAuthor(author == null ? "(unknown user)" : author.getAsTag(), null,
author == null ? null : author.getAvatarUrl())
.setDescription(action.reason())
.setTimestamp(action.issuedAt())
.setFooter(footer)
.setColor(ModerationUtils.AMBIENT_COLOR)
.build());
}

private static <E> @NotNull List<E> prependElement(@NotNull E element,
@NotNull Collection<? extends E> elements) {
List<E> allElements = new ArrayList<>(elements.size() + 1);
allElements.add(element);
allElements.addAll(elements);
return allElements;
Function<Instant, String> formatTime = instant -> {
if (instant == null) {
return "";
}
return TimeUtil.getDateTimeString(instant.atOffset(ZoneOffset.UTC));
};

User author = jda.getUserById(action.authorId());

Instant expiresAt = action.actionExpiresAt();
String expiresAtFormatted = expiresAt == null ? ""
: "\nTemporary action, expires at: " + formatTime.apply(expiresAt);

return new MessageEmbed.Field(
action.actionType().name() + " by "
+ (author == null ? "(unknown user)" : author.getAsTag()),
action.reason() + "\nIssued at: " + formatTime.apply(action.issuedAt())
+ expiresAtFormatted,
false);
}

@Override
Expand All @@ -121,16 +130,19 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) {
Guild guild = Objects.requireNonNull(event.getGuild());
Member bot = guild.getSelfMember();

if (!handleChecks(bot, author, targetOption.getAsMember(), guild, event)) {
if (!handleChecks(bot, author, targetOption.getAsMember(), event)) {
return;
}

auditUser(target, guild, event);
event
.reply(auditUser(guild.getIdLong(), target.getIdLong(), event.getMember().getIdLong(),
1, event.getJDA()))
.queue();
}

@SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
@Nullable Member target, @NotNull Guild guild, @NotNull IReplyCallback event) {
@Nullable Member target, @NotNull IReplyCallback event) {
// Member doesn't exist if attempting to audit a user who is not part of the guild.
if (target != null && !ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author,
target, event)) {
Expand All @@ -139,26 +151,95 @@ private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
return ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event);
}

private void auditUser(@NotNull User user, @NotNull ISnowflake guild,
@NotNull IReplyCallback event) {
List<ActionRecord> actions =
actionsStore.getActionsByTargetAscending(guild.getIdLong(), user.getIdLong());
private @NotNull List<List<ActionRecord>> groupActionsByPages(
@NotNull List<ActionRecord> actions) {
List<List<ActionRecord>> 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.get(groupedActions.size() - 1).add(actions.get(i));
}

return groupedActions;
}

/**
* @param pageNumber page number to display when actions are divided into pages and each page
* can contain {@link AuditCommand#MAX_PAGE_LENGTH} actions
*/
private @NotNull Message auditUser(long guildId, long targetId, long callerId, int pageNumber,
@NotNull JDA jda) {
List<ActionRecord> actions = actionsStore.getActionsByTargetAscending(guildId, targetId);
List<List<ActionRecord>> groupedActions = groupActionsByPages(actions);
int totalPages = groupedActions.size();

// Handles the case of too low page number and too high page number
pageNumber = Math.max(1, pageNumber);
pageNumber = Math.min(totalPages, pageNumber);

EmbedBuilder audit = createSummaryEmbed(jda.retrieveUserById(targetId).complete(), actions);

if (groupedActions.isEmpty()) {
return new MessageBuilder(audit.build()).build();
}

groupedActions.get(pageNumber - 1)
.forEach(action -> audit.addField(actionToField(action, jda)));

return new MessageBuilder(audit.setFooter("Page: " + pageNumber + "/" + totalPages).build())
.setActionRows(makeActionRow(guildId, targetId, callerId, pageNumber, totalPages))
.build();
}

private @NotNull ActionRow makeActionRow(long guildId, long targetId, long callerId,
int pageNumber, int totalPages) {
int previousButtonTurnPageBy = -1;
Button previousButton = createPageTurnButton(PREVIOUS_BUTTON_LABEL, guildId, targetId,
callerId, pageNumber, previousButtonTurnPageBy);
if (pageNumber == 1) {
previousButton = previousButton.asDisabled();
}

int nextButtonTurnPageBy = 1;
Button nextButton = createPageTurnButton(NEXT_BUTTON_LABEL, guildId, targetId, callerId,
pageNumber, nextButtonTurnPageBy);
if (pageNumber == totalPages) {
nextButton = nextButton.asDisabled();
}

return ActionRow.of(previousButton, nextButton);
}

private @NotNull Button createPageTurnButton(@NotNull String label, long guildId, long targetId,
long callerId, long pageNumber, int turnPageBy) {
return Button.primary(generateComponentId(String.valueOf(guildId), String.valueOf(targetId),
String.valueOf(callerId), String.valueOf(pageNumber), String.valueOf(turnPageBy)),
label);
}

@Override
public void onButtonClick(@NotNull ButtonInteractionEvent event, @NotNull List<String> args) {
long callerId = Long.parseLong(args.get(2));
long interactorId = event.getMember().getIdLong();

if (callerId != interactorId) {
event.reply("Only the user who triggered the command can use these buttons.")
.setEphemeral(true)
.queue();

MessageEmbed summary = createSummaryMessage(user, actions);
if (actions.isEmpty()) {
event.replyEmbeds(summary).queue();
return;
}

// Computing messages for actual actions is done deferred and might require asking the
// Discord API
event.deferReply().queue();
JDA jda = event.getJDA();
int currentPage = Integer.parseInt(args.get(3));
int turnPageBy = Integer.parseInt(args.get(4));

long guildId = Long.parseLong(args.get(0));
long targetId = Long.parseLong(args.get(1));
int pageToDisplay = currentPage + turnPageBy;

RestAction<List<MessageEmbed>> messagesTask = RestAction
.allOf(actions.stream().map(action -> actionToMessage(action, jda)).toList());
messagesTask.map(messages -> prependElement(summary, messages))
.flatMap(messages -> event.getHook().sendMessageEmbeds(messages))
event.editMessage(auditUser(guildId, targetId, interactorId, pageToDisplay, event.getJDA()))
.queue();
}
}