Skip to content

Add moderation actions table #298

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 6 commits into from
Dec 10, 2021
Merged
Show file tree
Hide file tree
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 @@ -8,6 +8,7 @@
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
import org.togetherjava.tjbot.commands.moderation.BanCommand;
import org.togetherjava.tjbot.commands.moderation.KickCommand;
import org.togetherjava.tjbot.commands.moderation.ModerationActionsStore;
import org.togetherjava.tjbot.commands.moderation.UnbanCommand;
import org.togetherjava.tjbot.commands.tags.TagCommand;
import org.togetherjava.tjbot.commands.tags.TagManageCommand;
Expand Down Expand Up @@ -41,6 +42,7 @@ public enum Commands {
public static @NotNull Collection<SlashCommand> createSlashCommands(
@NotNull Database database) {
TagSystem tagSystem = new TagSystem(database);
ModerationActionsStore actionsStore = new ModerationActionsStore(database);
// NOTE The command system can add special system relevant commands also by itself,
// hence this list may not necessarily represent the full list of all commands actually
// available.
Expand All @@ -53,9 +55,9 @@ public enum Commands {
commands.add(new TagManageCommand(tagSystem));
commands.add(new TagsCommand(tagSystem));
commands.add(new VcActivityCommand());
commands.add(new KickCommand());
commands.add(new BanCommand());
commands.add(new UnbanCommand());
commands.add(new KickCommand(actionsStore));
commands.add(new BanCommand(actionsStore));
commands.add(new UnbanCommand(actionsStore));
commands.add(new FreeCommand());

return commands;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.togetherjava.tjbot.commands.moderation;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.togetherjava.tjbot.db.generated.tables.records.ModerationActionsRecord;

import java.time.Instant;

/**
* Record for actions as maintained by {@link ModerationActionsStore}. Each action has a unique
* caseId.
*
* @param caseId the unique case id associated with this action
* @param issuedAt the instant at which this action was issued
* @param guildId the id of the guild in which context this action happened
* @param authorId the id of the user who issued the action
* @param targetId the id of the user who was the target of the action
* @param actionType the type of the action
* @param actionExpiresAt the instant at which this action expires, for temporary actions; otherwise
* {@code null}
* @param reason the reason why this action was executed
*/
public record ActionRecord(int caseId, @NotNull Instant issuedAt, long guildId, long authorId,
long targetId, @NotNull ModerationUtils.Action actionType,
@Nullable Instant actionExpiresAt, @NotNull String reason) {

/**
* Creates the action record that corresponds to the given action entry from the database table.
*
* @param action the action to convert
* @return the corresponding action record
*/
@SuppressWarnings("StaticMethodOnlyUsedInOneClass")
static @NotNull ActionRecord of(@NotNull ModerationActionsRecord action) {
return new ActionRecord(action.getCaseId(), action.getIssuedAt(), action.getGuildId(),
action.getAuthorId(), action.getTargetId(),
ModerationUtils.Action.valueOf(action.getActionType()), action.getActionExpiresAt(),
action.getReason());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ public final class BanCommand extends SlashCommandAdapter {
private static final String COMMAND_NAME = "ban";
private static final String ACTION_VERB = "ban";
private final Predicate<String> hasRequiredRole;
private final ModerationActionsStore actionsStore;

/**
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
*/
public BanCommand() {
public BanCommand(@NotNull ModerationActionsStore actionsStore) {
super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD);

getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to ban", true)
Expand All @@ -58,6 +61,7 @@ public BanCommand() {

hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
.asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}

private static RestAction<InteractionHook> handleAlreadyBanned(@NotNull Guild.Ban ban,
Expand All @@ -72,9 +76,9 @@ private static RestAction<InteractionHook> handleAlreadyBanned(@NotNull Guild.Ba
}

@SuppressWarnings("MethodWithTooManyParameters")
private static RestAction<InteractionHook> banUserFlow(@NotNull User target,
@NotNull Member author, @NotNull String reason, int deleteHistoryDays,
@NotNull Guild guild, @NotNull SlashCommandEvent event) {
private RestAction<InteractionHook> banUserFlow(@NotNull User target, @NotNull Member author,
@NotNull String reason, int deleteHistoryDays, @NotNull Guild guild,
@NotNull SlashCommandEvent event) {
return sendDm(target, reason, guild, event)
.flatMap(hasSentDm -> banUser(target, author, reason, deleteHistoryDays, guild)
.map(banResult -> hasSentDm))
Expand All @@ -97,13 +101,16 @@ private static RestAction<Boolean> sendDm(@NotNull ISnowflake target, @NotNull S
.map(Result::isSuccess);
}

private static AuditableRestAction<Void> banUser(@NotNull User target, @NotNull Member author,
private AuditableRestAction<Void> banUser(@NotNull User target, @NotNull Member author,
@NotNull String reason, int deleteHistoryDays, @NotNull Guild guild) {
logger.info(
"'{}' ({}) banned the user '{}' ({}) from guild '{}' and deleted their message history of the last {} days, for reason '{}'.",
author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(),
guild.getName(), deleteHistoryDays, reason);

actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
ModerationUtils.Action.BAN, null, reason);

return guild.ban(target, deleteHistoryDays, reason);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,22 @@ public final class KickCommand extends SlashCommandAdapter {
private static final String COMMAND_NAME = "kick";
private static final String ACTION_VERB = "kick";
private final Predicate<String> hasRequiredRole;
private final ModerationActionsStore actionsStore;

/**
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
*/
public KickCommand() {
public KickCommand(@NotNull ModerationActionsStore actionsStore) {
super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD);

getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to kick", true)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be kicked", true);

hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
.asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}

private static void handleAbsentTarget(@NotNull Interaction event) {
Expand All @@ -56,7 +60,7 @@ private static void handleAbsentTarget(@NotNull Interaction event) {
.queue();
}

private static void kickUserFlow(@NotNull Member target, @NotNull Member author,
private void kickUserFlow(@NotNull Member target, @NotNull Member author,
@NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) {
sendDm(target, reason, guild, event)
.flatMap(hasSentDm -> kickUser(target, author, reason, guild)
Expand All @@ -81,12 +85,15 @@ private static RestAction<Boolean> sendDm(@NotNull ISnowflake target, @NotNull S
.map(Result::isSuccess);
}

private static AuditableRestAction<Void> kickUser(@NotNull Member target,
@NotNull Member author, @NotNull String reason, @NotNull Guild guild) {
private AuditableRestAction<Void> kickUser(@NotNull Member target, @NotNull Member author,
@NotNull String reason, @NotNull Guild guild) {
logger.info("'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.",
author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(),
target.getId(), guild.getName(), reason);

actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
ModerationUtils.Action.KICK, null, reason);

return guild.kick(target, reason).reason(reason);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package org.togetherjava.tjbot.commands.moderation;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jooq.Condition;
import org.togetherjava.tjbot.db.Database;
import org.togetherjava.tjbot.db.generated.tables.ModerationActions;
import org.togetherjava.tjbot.db.generated.tables.records.ModerationActionsRecord;

import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

/**
* Store for moderation actions, e.g. as banning users. Can be used to retrieve information about
* past events, such as when a user has been banned the last time.
*
* Actions have to be added to the store using
* {@link #addAction(long, long, long, ModerationUtils.Action, Instant, String)} at the time they
* are executed and can then be retrieved by methods such as
* {@link #getActionsByTypeAscending(long, ModerationUtils.Action)} or
* {@link #findActionByCaseId(int)}.
*
* Be aware that timestamps associated with actions, such as {@link ActionRecord#issuedAt()} are
* slightly off the timestamps used by Discord.
*
* The store persists the actions and is thread safe.
*/
@SuppressWarnings("ClassCanBeRecord")
public final class ModerationActionsStore {
private final Database database;

/**
* Creates a new instance which writes and retrieves actions from a given database.
*
* @param database the database to write and retrieve actions from
*/
public ModerationActionsStore(@NotNull Database database) {
this.database = Objects.requireNonNull(database);
}

/**
* Gets all actions of a given type that have been written to the store, chronologically
* ascending with the earliest action first.
*
* @param guildId the id of the guild, only actions that happened in the context of that guild
* will be retrieved
* @param actionType the type of action to filter for
* @return a list of all actions with the given type, chronologically ascending
*/
public @NotNull List<ActionRecord> getActionsByTypeAscending(long guildId,
@NotNull ModerationUtils.Action actionType) {
Objects.requireNonNull(actionType);

return getActionsFromGuildAscending(guildId,
ModerationActions.MODERATION_ACTIONS.ACTION_TYPE.eq(actionType.name()));
}

/**
* Gets all actions executed against a given target that have been written to the store,
* chronologically ascending with the earliest action first.
*
* @param guildId the id of the guild, only actions that happened in the context of that guild
* will be retrieved
* @param targetId the id of the target user to filter for
* @return a list of all actions executed against the target, chronologically ascending
*/
public @NotNull List<ActionRecord> getActionsByTargetAscending(long guildId, long targetId) {
return getActionsFromGuildAscending(guildId,
ModerationActions.MODERATION_ACTIONS.TARGET_ID.eq(targetId));
}

/**
* Gets all actions executed by a given author that have been written to the store,
* chronologically ascending with the earliest action first.
*
* @param guildId the id of the guild, only actions that happened in the context of that guild
* will be retrieved
* @param authorId the id of the author user to filter for
* @return a list of all actions executed by the author, chronologically ascending
*/
public @NotNull List<ActionRecord> getActionsByAuthorAscending(long guildId, long authorId) {
return getActionsFromGuildAscending(guildId,
ModerationActions.MODERATION_ACTIONS.AUTHOR_ID.eq(authorId));
}

/**
* Gets the action with the given case id from the store, if present.
*
* @param caseId the actions' case id to search for
* @return the action with the given case id, if present
*/
public @NotNull Optional<ActionRecord> findActionByCaseId(int caseId) {
return Optional
.of(database.read(context -> context.selectFrom(ModerationActions.MODERATION_ACTIONS)
.where(ModerationActions.MODERATION_ACTIONS.CASE_ID.eq(caseId))
.fetchOne()))
.map(ActionRecord::of);
}

/**
* Adds the given action to the store. A unique case id will be associated to the action and
* returned.
*
* It is assumed that the action is issued at the point in time this method is called. It is not
* possible to assign a different timestamp, especially not an earlier point in time.
* Consequently, this causes the timestamps to be slightly off from the timestamps recorded by
* Discord itself.
*
* @param guildId the id of the guild in which context this action happened
* @param authorId the id of the user who issued the action
* @param targetId the id of the user who was the target of the action
* @param actionType the type of the action
* @param actionExpiresAt the instant at which this action expires, for temporary actions;
* otherwise {@code null}
* @param reason the reason why this action was executed
* @return the unique case id associated with the action
*/
@SuppressWarnings("MethodWithTooManyParameters")
public int addAction(long guildId, long authorId, long targetId,
@NotNull ModerationUtils.Action actionType, @Nullable Instant actionExpiresAt,
@NotNull String reason) {
Objects.requireNonNull(actionType);
Objects.requireNonNull(reason);

return database.writeAndProvide(context -> {
ModerationActionsRecord actionRecord =
context.newRecord(ModerationActions.MODERATION_ACTIONS)
.setIssuedAt(Instant.now())
.setGuildId(guildId)
.setAuthorId(authorId)
.setTargetId(targetId)
.setActionType(actionType.name())
.setActionExpiresAt(actionExpiresAt)
.setReason(reason);
actionRecord.insert();
return actionRecord.getCaseId();
});
}

private @NotNull List<ActionRecord> getActionsFromGuildAscending(long guildId,
@NotNull Condition condition) {
Objects.requireNonNull(condition);

return database.read(context -> context.selectFrom(ModerationActions.MODERATION_ACTIONS)
.where(ModerationActions.MODERATION_ACTIONS.GUILD_ID.eq(guildId).and(condition))
.orderBy(ModerationActions.MODERATION_ACTIONS.ISSUED_AT.asc())
.stream()
.map(ActionRecord::of)
.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ public final class UnbanCommand extends SlashCommandAdapter {
private static final String COMMAND_NAME = "unban";
private static final String ACTION_VERB = "unban";
private final Predicate<String> hasRequiredRole;
private final ModerationActionsStore actionsStore;

/**
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
*/
public UnbanCommand() {
public UnbanCommand(@NotNull ModerationActionsStore actionsStore) {
super(COMMAND_NAME, "Unbans the given user from the server", SlashCommandVisibility.GUILD);

getData()
Expand All @@ -43,9 +46,10 @@ public UnbanCommand() {

hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
.asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}

private static void unban(@NotNull User target, @NotNull Member author, @NotNull String reason,
private void unban(@NotNull User target, @NotNull Member author, @NotNull String reason,
@NotNull Guild guild, @NotNull Interaction event) {
guild.unban(target).reason(reason).queue(result -> {
MessageEmbed message = ModerationUtils.createActionResponse(author.getUser(),
Expand All @@ -55,6 +59,9 @@ private static void unban(@NotNull User target, @NotNull Member author, @NotNull
logger.info("'{}' ({}) unbanned the user '{}' ({}) from guild '{}' for reason '{}'.",
author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(),
guild.getName(), reason);

actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
ModerationUtils.Action.UNBAN, null, reason);
}, unbanFailure -> handleFailure(unbanFailure, target, event));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE moderation_actions
(
case_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
issued_at TIMESTAMP NOT NULL,
guild_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
action_type TEXT NOT NULL,
action_expires_at TIMESTAMP,
reason TEXT NOT NULL
)