Skip to content

Commit 2bc4e8a

Browse files
Merge branch 'develop' into feature/replicating-slash-command
2 parents 6381594 + 74c679e commit 2bc4e8a

13 files changed

Lines changed: 416 additions & 31 deletions

File tree

application/build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ dependencies {
4747

4848
implementation 'net.dv8tion:JDA:4.4.0_351'
4949

50-
implementation 'org.apache.logging.log4j:log4j-api:2.14.1'
51-
implementation 'org.apache.logging.log4j:log4j-core:2.14.1'
52-
implementation 'org.apache.logging.log4j:log4j-slf4j18-impl:2.14.1'
50+
implementation 'org.apache.logging.log4j:log4j-api:2.15.0'
51+
implementation 'org.apache.logging.log4j:log4j-core:2.15.0'
52+
implementation 'org.apache.logging.log4j:log4j-slf4j18-impl:2.15.0'
5353

5454
implementation 'org.jooq:jooq:3.15.3'
5555

application/src/main/java/org/togetherjava/tjbot/commands/Commands.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
77
import org.togetherjava.tjbot.commands.free.FreeCommand;
88
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
9-
import org.togetherjava.tjbot.commands.moderation.BanCommand;
10-
import org.togetherjava.tjbot.commands.moderation.KickCommand;
11-
import org.togetherjava.tjbot.commands.moderation.UnbanCommand;
9+
import org.togetherjava.tjbot.commands.moderation.*;
1210
import org.togetherjava.tjbot.commands.tags.TagCommand;
1311
import org.togetherjava.tjbot.commands.tags.TagManageCommand;
1412
import org.togetherjava.tjbot.commands.tags.TagSystem;
@@ -41,6 +39,7 @@ public enum Commands {
4139
public static @NotNull Collection<SlashCommand> createSlashCommands(
4240
@NotNull Database database) {
4341
TagSystem tagSystem = new TagSystem(database);
42+
ModerationActionsStore actionsStore = new ModerationActionsStore(database);
4443
// NOTE The command system can add special system relevant commands also by itself,
4544
// hence this list may not necessarily represent the full list of all commands actually
4645
// available.
@@ -53,10 +52,11 @@ public enum Commands {
5352
commands.add(new TagManageCommand(tagSystem));
5453
commands.add(new TagsCommand(tagSystem));
5554
commands.add(new VcActivityCommand());
56-
commands.add(new KickCommand());
57-
commands.add(new BanCommand());
58-
commands.add(new UnbanCommand());
55+
commands.add(new KickCommand(actionsStore));
56+
commands.add(new BanCommand(actionsStore));
57+
commands.add(new UnbanCommand(actionsStore));
5958
commands.add(new FreeCommand());
59+
commands.add(new AuditCommand(actionsStore));
6060

6161
return commands;
6262
}

application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@
99
*/
1010
enum UserStrings {
1111
NEW_QUESTION("""
12-
Thank you for asking a question in an available channel.
13-
When a helper who can answer this question reads it they will help you.
14-
Please be patient.
12+
Thank you for asking in an available channel. A helper will be with you shortly!
1513
16-
__Please do not post your question in other channels__
14+
__Do not post your question in other channels.__
1715
"""),
1816
MARK_AS_FREE("""
1917
This channel is now available for a question to be asked.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.togetherjava.tjbot.commands.moderation;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
import org.jetbrains.annotations.Nullable;
5+
import org.togetherjava.tjbot.db.generated.tables.records.ModerationActionsRecord;
6+
7+
import java.time.Instant;
8+
9+
/**
10+
* Record for actions as maintained by {@link ModerationActionsStore}. Each action has a unique
11+
* caseId.
12+
*
13+
* @param caseId the unique case id associated with this action
14+
* @param issuedAt the instant at which this action was issued
15+
* @param guildId the id of the guild in which context this action happened
16+
* @param authorId the id of the user who issued the action
17+
* @param targetId the id of the user who was the target of the action
18+
* @param actionType the type of the action
19+
* @param actionExpiresAt the instant at which this action expires, for temporary actions; otherwise
20+
* {@code null}
21+
* @param reason the reason why this action was executed
22+
*/
23+
public record ActionRecord(int caseId, @NotNull Instant issuedAt, long guildId, long authorId,
24+
long targetId, @NotNull ModerationUtils.Action actionType,
25+
@Nullable Instant actionExpiresAt, @NotNull String reason) {
26+
27+
/**
28+
* Creates the action record that corresponds to the given action entry from the database table.
29+
*
30+
* @param action the action to convert
31+
* @return the corresponding action record
32+
*/
33+
@SuppressWarnings("StaticMethodOnlyUsedInOneClass")
34+
static @NotNull ActionRecord of(@NotNull ModerationActionsRecord action) {
35+
return new ActionRecord(action.getCaseId(), action.getIssuedAt(), action.getGuildId(),
36+
action.getAuthorId(), action.getTargetId(),
37+
ModerationUtils.Action.valueOf(action.getActionType()), action.getActionExpiresAt(),
38+
action.getReason());
39+
}
40+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package org.togetherjava.tjbot.commands.moderation;
2+
3+
import net.dv8tion.jda.api.EmbedBuilder;
4+
import net.dv8tion.jda.api.JDA;
5+
import net.dv8tion.jda.api.entities.*;
6+
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
7+
import net.dv8tion.jda.api.interactions.Interaction;
8+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
9+
import net.dv8tion.jda.api.interactions.commands.OptionType;
10+
import net.dv8tion.jda.api.requests.RestAction;
11+
import net.dv8tion.jda.api.utils.TimeUtil;
12+
import org.jetbrains.annotations.NotNull;
13+
import org.jetbrains.annotations.Nullable;
14+
import org.togetherjava.tjbot.commands.SlashCommandAdapter;
15+
import org.togetherjava.tjbot.commands.SlashCommandVisibility;
16+
import org.togetherjava.tjbot.config.Config;
17+
18+
import java.time.ZoneOffset;
19+
import java.util.ArrayList;
20+
import java.util.Collection;
21+
import java.util.List;
22+
import java.util.Objects;
23+
import java.util.function.Predicate;
24+
import java.util.regex.Pattern;
25+
26+
/**
27+
* This command lists all moderation actions that have been taken against a given user, for example
28+
* warnings, mutes and bans.
29+
* <p>
30+
* The command fails if the user triggering it is lacking permissions to either audit other users or
31+
* to audit the specific given user (for example a moderator attempting to audit an admin).
32+
*/
33+
public final class AuditCommand extends SlashCommandAdapter {
34+
private static final String TARGET_OPTION = "user";
35+
private static final String COMMAND_NAME = "audit";
36+
private static final String ACTION_VERB = "audit";
37+
private final Predicate<String> hasRequiredRole;
38+
private final ModerationActionsStore actionsStore;
39+
40+
/**
41+
* Constructs an instance.
42+
*
43+
* @param actionsStore used to store actions issued by this command
44+
*/
45+
public AuditCommand(@NotNull ModerationActionsStore actionsStore) {
46+
super(COMMAND_NAME, "Lists all moderation actions that have been taken against a user",
47+
SlashCommandVisibility.GUILD);
48+
49+
getData().addOption(OptionType.USER, TARGET_OPTION, "The user who to retrieve actions for",
50+
true);
51+
52+
hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
53+
.asMatchPredicate();
54+
this.actionsStore = Objects.requireNonNull(actionsStore);
55+
}
56+
57+
private static MessageEmbed createSummaryMessage(@NotNull User user,
58+
@NotNull Collection<ActionRecord> actions) {
59+
int actionAmount = actions.size();
60+
String description = actionAmount == 0 ? "There are **no actions** against the user."
61+
: "There are **%d actions** against the user.".formatted(actionAmount);
62+
63+
return new EmbedBuilder().setTitle("Audit log of **%s**".formatted(user.getAsTag()))
64+
.setAuthor(user.getName(), null, user.getAvatarUrl())
65+
.setDescription(description)
66+
.setColor(ModerationUtils.AMBIENT_COLOR)
67+
.build();
68+
}
69+
70+
private static RestAction<MessageEmbed> actionToMessage(@NotNull ActionRecord action,
71+
@NotNull JDA jda) {
72+
String footer = action.actionExpiresAt() == null ? null
73+
: "Temporary action, expires at %s".formatted(TimeUtil
74+
.getDateTimeString(action.actionExpiresAt().atOffset(ZoneOffset.UTC)));
75+
76+
return jda.retrieveUserById(action.authorId())
77+
.map(author -> new EmbedBuilder().setTitle(action.actionType().name())
78+
.setAuthor(author == null ? "(unknown user)" : author.getAsTag(), null,
79+
author == null ? null : author.getAvatarUrl())
80+
.setDescription(action.reason())
81+
.setTimestamp(action.issuedAt())
82+
.setFooter(footer)
83+
.setColor(ModerationUtils.AMBIENT_COLOR)
84+
.build());
85+
}
86+
87+
private static <E> List<E> prependElement(E element, Collection<? extends E> elements) {
88+
List<E> allElements = new ArrayList<>(elements.size() + 1);
89+
allElements.add(element);
90+
allElements.addAll(elements);
91+
return allElements;
92+
}
93+
94+
@Override
95+
public void onSlashCommand(@NotNull SlashCommandEvent event) {
96+
OptionMapping targetOption =
97+
Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null");
98+
User target = targetOption.getAsUser();
99+
Member author = Objects.requireNonNull(event.getMember(), "The author is null");
100+
101+
Guild guild = Objects.requireNonNull(event.getGuild());
102+
Member bot = guild.getSelfMember();
103+
104+
if (!handleChecks(bot, author, targetOption.getAsMember(), guild, event)) {
105+
return;
106+
}
107+
108+
auditUser(target, guild, event);
109+
}
110+
111+
@SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
112+
private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
113+
@Nullable Member target, @NotNull Guild guild, @NotNull Interaction event) {
114+
// Member doesn't exist if attempting to audit a user who is not part of the guild.
115+
if (target != null && !ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author,
116+
target, event)) {
117+
return false;
118+
}
119+
return ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event);
120+
}
121+
122+
private void auditUser(@NotNull User user, @NotNull ISnowflake guild,
123+
@NotNull Interaction event) {
124+
List<ActionRecord> actions =
125+
actionsStore.getActionsByTargetAscending(guild.getIdLong(), user.getIdLong());
126+
127+
MessageEmbed summary = createSummaryMessage(user, actions);
128+
if (actions.isEmpty()) {
129+
event.replyEmbeds(summary).queue();
130+
return;
131+
}
132+
133+
// Computing messages for actual actions is done deferred and might require asking the
134+
// Discord API
135+
event.deferReply().queue();
136+
JDA jda = event.getJDA();
137+
138+
RestAction<List<MessageEmbed>> messagesTask = RestAction
139+
.allOf(actions.stream().map(action -> actionToMessage(action, jda)).toList());
140+
messagesTask.map(messages -> prependElement(summary, messages))
141+
.flatMap(messages -> event.getHook().sendMessageEmbeds(messages))
142+
.queue();
143+
}
144+
}

application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@ public final class BanCommand extends SlashCommandAdapter {
4343
private static final String COMMAND_NAME = "ban";
4444
private static final String ACTION_VERB = "ban";
4545
private final Predicate<String> hasRequiredRole;
46+
private final ModerationActionsStore actionsStore;
4647

4748
/**
4849
* Constructs an instance.
50+
*
51+
* @param actionsStore used to store actions issued by this command
4952
*/
50-
public BanCommand() {
53+
public BanCommand(@NotNull ModerationActionsStore actionsStore) {
5154
super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD);
5255

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

5962
hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
6063
.asMatchPredicate();
64+
this.actionsStore = Objects.requireNonNull(actionsStore);
6165
}
6266

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

7478
@SuppressWarnings("MethodWithTooManyParameters")
75-
private static RestAction<InteractionHook> banUserFlow(@NotNull User target,
76-
@NotNull Member author, @NotNull String reason, int deleteHistoryDays,
77-
@NotNull Guild guild, @NotNull SlashCommandEvent event) {
79+
private RestAction<InteractionHook> banUserFlow(@NotNull User target, @NotNull Member author,
80+
@NotNull String reason, int deleteHistoryDays, @NotNull Guild guild,
81+
@NotNull SlashCommandEvent event) {
7882
return sendDm(target, reason, guild, event)
7983
.flatMap(hasSentDm -> banUser(target, author, reason, deleteHistoryDays, guild)
8084
.map(banResult -> hasSentDm))
@@ -97,13 +101,16 @@ private static RestAction<Boolean> sendDm(@NotNull ISnowflake target, @NotNull S
97101
.map(Result::isSuccess);
98102
}
99103

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

111+
actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
112+
ModerationUtils.Action.BAN, null, reason);
113+
107114
return guild.ban(target, deleteHistoryDays, reason);
108115
}
109116

application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,22 @@ public final class KickCommand extends SlashCommandAdapter {
3636
private static final String COMMAND_NAME = "kick";
3737
private static final String ACTION_VERB = "kick";
3838
private final Predicate<String> hasRequiredRole;
39+
private final ModerationActionsStore actionsStore;
3940

4041
/**
4142
* Constructs an instance.
43+
*
44+
* @param actionsStore used to store actions issued by this command
4245
*/
43-
public KickCommand() {
46+
public KickCommand(@NotNull ModerationActionsStore actionsStore) {
4447
super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD);
4548

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

4952
hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
5053
.asMatchPredicate();
54+
this.actionsStore = Objects.requireNonNull(actionsStore);
5155
}
5256

5357
private static void handleAbsentTarget(@NotNull Interaction event) {
@@ -56,7 +60,7 @@ private static void handleAbsentTarget(@NotNull Interaction event) {
5660
.queue();
5761
}
5862

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

84-
private static AuditableRestAction<Void> kickUser(@NotNull Member target,
85-
@NotNull Member author, @NotNull String reason, @NotNull Guild guild) {
88+
private AuditableRestAction<Void> kickUser(@NotNull Member target, @NotNull Member author,
89+
@NotNull String reason, @NotNull Guild guild) {
8690
logger.info("'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.",
8791
author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(),
8892
target.getId(), guild.getName(), reason);
8993

94+
actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
95+
ModerationUtils.Action.KICK, null, reason);
96+
9097
return guild.kick(target, reason).reason(reason);
9198
}
9299

0 commit comments

Comments
 (0)