Skip to content

Commit 3876a40

Browse files
committed
warn decay
1 parent 09db6df commit 3876a40

File tree

6 files changed

+106
-44
lines changed

6 files changed

+106
-44
lines changed

src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import net.discordjug.javabot.data.config.BotConfig;
1111
import net.discordjug.javabot.systems.help.HelpExperienceService;
1212
import net.discordjug.javabot.systems.help.model.HelpAccount;
13+
import net.discordjug.javabot.systems.moderation.ModerationService;
1314
import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository;
1415
import net.discordjug.javabot.systems.qotw.QOTWPointsService;
1516
import net.discordjug.javabot.systems.qotw.model.QOTWAccount;
@@ -29,7 +30,6 @@
2930

3031
import java.sql.Connection;
3132
import java.sql.SQLException;
32-
import java.time.LocalDateTime;
3333
import java.util.concurrent.TimeUnit;
3434

3535
import javax.sql.DataSource;
@@ -108,8 +108,8 @@ public ResponseEntity<UserProfileData> getUserProfile(
108108
HelpAccount helpAccount = helpExperienceService.getOrCreateAccount(user.getIdLong());
109109
data.setHelpAccount(HelpAccountData.of(botConfig, helpAccount, guild));
110110
// User Warns
111-
LocalDateTime cutoff = LocalDateTime.now().minusDays(botConfig.get(guild).getModerationConfig().getWarnTimeoutDays());
112-
data.setWarns(warnRepository.getActiveWarnsByUserId(user.getIdLong(), cutoff));
111+
ModerationService moderationService = new ModerationService(null, botConfig.get(guild), warnRepository, null);
112+
data.setWarns(moderationService.getTotalSeverityWeight(user.getIdLong()).contributingWarns());
113113
// Insert into cache
114114
getCache().put(new Pair<>(guild.getIdLong(), user.getIdLong()), data);
115115
}

src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ public class ModerationConfig extends GuildConfigItem {
5252
* being removed from the server. Warnings older than this are still kept,
5353
* but ignored.
5454
*/
55-
private int warnTimeoutDays = 30;
55+
private int maxWarnValidityDays = 30;
56+
57+
/**
58+
* The number of days it takes to remove a certain amount of severity weights from the warns of a user.
59+
*/
60+
private int warnDecayDays = maxWarnValidityDays;
61+
62+
/**
63+
* The total severity weight removed from a user after {@link ModerationConfig#warnDecayDays} passed.
64+
*/
65+
private int warnDecayAmount = 0;
5666

5767
/**
5868
* The maximum total severity that a user can accrue from warnings before

src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.time.Instant;
2525
import java.time.LocalDateTime;
2626
import java.time.ZoneOffset;
27+
import java.util.Collections;
2728
import java.util.List;
2829
import java.util.Optional;
2930
import java.util.concurrent.ExecutorService;
@@ -88,7 +89,7 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB
8889
asyncPool.execute(() -> {
8990
try {
9091
warnRepository.insert(new Warn(user.getIdLong(), warnedBy.getIdLong(), severity, reason));
91-
int totalSeverity = warnRepository.getTotalSeverityWeight(user.getIdLong(), LocalDateTime.now().minusDays(moderationConfig.getWarnTimeoutDays()));
92+
long totalSeverity = getTotalSeverityWeight(user.getIdLong()).totalSeverity();
9293
MessageEmbed warnEmbed = buildWarnEmbed(user, warnedBy, severity, totalSeverity, reason);
9394
notificationService.withUser(user, warnedBy.getGuild()).sendDirectMessage(c -> c.sendMessageEmbeds(warnEmbed));
9495
notificationService.withGuild(moderationConfig.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(warnEmbed));
@@ -106,7 +107,46 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB
106107
}
107108
});
108109
}
109-
110+
111+
/**
112+
* Gets the total warn severity weight of a given user.
113+
* @implSpec
114+
* Only warns from the last {@link ModerationConfig#getMaxWarnValidityDays()} days are checked.
115+
* Every {@link ModerationConfig#getWarnDecayDays()} days, {@link ModerationConfig#getWarnDecayAmount()} of severity are subtracted from the total severity of the user.
116+
* This method does not allow negative severities at any point in time.
117+
* The oldest considered warn decides on the amount of severity to subtract.
118+
* Hence, all warns that would (together) not increase the severity due to being too old are ignored.
119+
* @implNote
120+
* The total severity is calculated per warn by considering the severity of all warns after the given warn and subtracting the discount subtrahend corresponding to the given (oldest) warn from the severity.
121+
* Then, the maximum discounted severity over all warns is chosen.
122+
* @param userId the ID of the user to check
123+
* @return the accumulated warn severity weight of the user along with all warns contributing to it.
124+
* @see SeverityInformation
125+
*/
126+
public SeverityInformation getTotalSeverityWeight(long userId) {
127+
LocalDateTime now = LocalDateTime.now();
128+
List<Warn> activeWarns = warnRepository.getActiveWarnsByUserId(userId, now.minusDays(moderationConfig.getMaxWarnValidityDays()));
129+
int accumulatedUndiscountedSeverity = 0;
130+
long maxSeverity = 0;
131+
long usedSeverityDiscount = 0;
132+
List<Warn> contributingWarns = Collections.emptyList();
133+
134+
for (int i = 0; i < activeWarns.size(); i++) {
135+
Warn warn = activeWarns.get(i);
136+
accumulatedUndiscountedSeverity += warn.getSeverityWeight();
137+
long daysSinceWarn = Duration.between(warn.getCreatedAt(), now).toDays();
138+
long discountAmount = moderationConfig.getWarnDecayAmount() * (daysSinceWarn / moderationConfig.getWarnDecayDays());
139+
long currentSeverity = accumulatedUndiscountedSeverity - discountAmount;
140+
if (currentSeverity > maxSeverity) {
141+
maxSeverity = currentSeverity;
142+
contributingWarns = activeWarns.subList(0, i + 1);
143+
usedSeverityDiscount = discountAmount;
144+
}
145+
}
146+
147+
return new SeverityInformation(maxSeverity, usedSeverityDiscount, Collections.unmodifiableList(contributingWarns));
148+
}
149+
110150
/**
111151
* Clears warns from the given user by discarding all warns.
112152
*
@@ -157,7 +197,7 @@ public boolean discardWarnById(long id, User clearedBy) {
157197
public List<Warn> getWarns(long userId) {
158198
try {
159199
WarnRepository repo = warnRepository;
160-
LocalDateTime cutoff = LocalDateTime.now().minusDays(moderationConfig.getWarnTimeoutDays());
200+
LocalDateTime cutoff = LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays());
161201
return repo.getActiveWarnsByUserId(userId, cutoff);
162202
} catch (DataAccessException e) {
163203
ExceptionLogger.capture(e, getClass().getSimpleName());
@@ -356,7 +396,7 @@ private void sendGuildNotification(Guild guild, MessageEmbed embed) {
356396
.build();
357397
}
358398

359-
private @NotNull MessageEmbed buildWarnEmbed(User user, Member warnedBy, @NotNull WarnSeverity severity, int totalSeverity, String reason) {
399+
private @NotNull MessageEmbed buildWarnEmbed(User user, Member warnedBy, @NotNull WarnSeverity severity, long totalSeverity, String reason) {
360400
return buildModerationEmbed(user, warnedBy, reason)
361401
.setTitle(String.format("Warn Added (%d/%d)", totalSeverity, moderationConfig.getMaxWarnSeverity()))
362402
.setColor(Responses.Type.WARN.getColor())
@@ -407,4 +447,13 @@ private void sendGuildNotification(Guild guild, MessageEmbed embed) {
407447
.setColor(Responses.Type.SUCCESS.getColor())
408448
.build();
409449
}
450+
451+
/**
452+
* Records information about the total severity of a user as well as the warns contributing to it.
453+
* @param totalSeverity the total severity of the user
454+
* @param severityDiscount the amount the severity was reduced due to warn decay
455+
* @param contributingWarns the (active) warns contributing to that severity
456+
* @see ModerationService#getTotalSeverityWeight(long)
457+
*/
458+
public record SeverityInformation(long totalSeverity, long severityDiscount, List<Warn> contributingWarns) {}
410459
}

src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package net.discordjug.javabot.systems.moderation.warn;
22

33
import java.time.Instant;
4-
import java.time.LocalDateTime;
54
import java.time.ZoneOffset;
65
import java.util.List;
76
import java.util.concurrent.ExecutorService;
@@ -13,8 +12,10 @@
1312

1413
import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand;
1514
import net.discordjug.javabot.data.config.BotConfig;
15+
import net.discordjug.javabot.systems.moderation.ModerationService;
1616
import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository;
1717
import net.discordjug.javabot.systems.moderation.warn.model.Warn;
18+
import net.discordjug.javabot.systems.notification.NotificationService;
1819
import net.discordjug.javabot.util.ExceptionLogger;
1920
import net.discordjug.javabot.util.Responses;
2021
import net.discordjug.javabot.util.UserUtils;
@@ -34,17 +35,20 @@ public class WarnsListCommand extends SlashCommand {
3435
private final BotConfig botConfig;
3536
private final WarnRepository warnRepository;
3637
private final ExecutorService asyncPool;
38+
private final NotificationService notificationService;
3739

3840
/**
3941
* The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}.
4042
* @param botConfig The main configuration of the bot
4143
* @param asyncPool The main thread pool for asynchronous operations
4244
* @param warnRepository DAO for interacting with the set of {@link Warn} objects.
45+
* @param notificationService service object for notifying users
4346
*/
44-
public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) {
47+
public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository, NotificationService notificationService) {
4548
this.botConfig = botConfig;
4649
this.warnRepository = warnRepository;
4750
this.asyncPool = asyncPool;
51+
this.notificationService = notificationService;
4852
setCommandData(Commands.slash("warns", "Shows a list of all recent warning.")
4953
.addOption(OptionType.USER, "user", "If given, shows the recent warns of the given user instead.", false)
5054
.setGuildOnly(true)
@@ -54,22 +58,35 @@ public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepo
5458
/**
5559
* Builds an {@link MessageEmbed} which contains all recents warnings of a user.
5660
*
57-
* @param warns A {@link List} with all {@link Warn}s.
61+
* @param severityInformation object containing information about active warns and their severities
5862
* @param user The corresponding {@link User}.
5963
* @return The fully-built {@link MessageEmbed}.
6064
*/
61-
protected static @NotNull MessageEmbed buildWarnsEmbed(@Nonnull List<Warn> warns, @Nonnull User user) {
65+
protected static @NotNull MessageEmbed buildWarnsEmbed(@Nonnull ModerationService.SeverityInformation severityInformation, @Nonnull User user) {
66+
List<Warn> warns = severityInformation.contributingWarns();
6267
EmbedBuilder builder = new EmbedBuilder()
6368
.setAuthor(UserUtils.getUserTag(user), null, user.getEffectiveAvatarUrl())
6469
.setTitle("Recent Warns")
6570
.setDescription(String.format("%s has `%s` active warns with a total of `%s` severity.\n",
66-
user.getAsMention(), warns.size(), warns.stream().mapToInt(Warn::getSeverityWeight).sum()))
71+
user.getAsMention(), warns.size(), severityInformation.totalSeverity()))
6772
.setColor(Responses.Type.WARN.getColor())
6873
.setTimestamp(Instant.now());
69-
warns.forEach(w -> builder.getDescriptionBuilder().append(
70-
String.format("\n`%s` <t:%s>\nWarned by: <@%s>\nSeverity: `%s (%s)`\nReason: %s\n",
71-
w.getId(), w.getCreatedAt().toInstant(ZoneOffset.UTC).getEpochSecond(),
72-
w.getWarnedBy(), w.getSeverity(), w.getSeverityWeight(), w.getReason())));
74+
75+
if (severityInformation.severityDiscount() > 0) {
76+
builder.appendDescription("(Due to warn decay, the total severity is `"+severityInformation.severityDiscount()+"` lower than the sum of all warn severities.)\n");
77+
}
78+
79+
warns.forEach(w -> {
80+
String text = String.format("\n`%s` <t:%s>\nWarned by: <@%s>\nSeverity: `%s (%s)`\nReason: %s\n",
81+
w.getId(), w.getCreatedAt().toInstant(ZoneOffset.UTC).getEpochSecond(),
82+
w.getWarnedBy(), w.getSeverity(), w.getSeverityWeight(), w.getReason());
83+
StringBuilder descriptionBuilder = builder.getDescriptionBuilder();
84+
if (descriptionBuilder.length() + text.length() < MessageEmbed.DESCRIPTION_MAX_LENGTH) {
85+
descriptionBuilder.append(text);
86+
} else {
87+
builder.setFooter("Some warns have been omitted due to length limitations. Contact staff in case you want to see an exhaustive list of warns.");
88+
}
89+
});
7390
return builder.build();
7491
}
7592

@@ -81,10 +98,10 @@ public void execute(@NotNull SlashCommandInteractionEvent event) {
8198
return;
8299
}
83100
event.deferReply(false).queue();
84-
LocalDateTime cutoff = LocalDateTime.now().minusDays(botConfig.get(event.getGuild()).getModerationConfig().getWarnTimeoutDays());
101+
ModerationService moderationService = new ModerationService(notificationService, botConfig, event, warnRepository, asyncPool);
85102
asyncPool.execute(() -> {
86103
try {
87-
event.getHook().sendMessageEmbeds(buildWarnsEmbed(warnRepository.getActiveWarnsByUserId(user.getIdLong(), cutoff), user)).queue();
104+
event.getHook().sendMessageEmbeds(buildWarnsEmbed(moderationService.getTotalSeverityWeight(user.getIdLong()), user)).queue();
88105
} catch (DataAccessException e) {
89106
ExceptionLogger.capture(e, WarnsListCommand.class.getSimpleName());
90107
}

src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand;
44
import net.discordjug.javabot.data.config.BotConfig;
5+
import net.discordjug.javabot.systems.moderation.ModerationService;
56
import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository;
7+
import net.discordjug.javabot.systems.notification.NotificationService;
68
import net.discordjug.javabot.util.ExceptionLogger;
79
import net.discordjug.javabot.util.Responses;
810
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent;
911
import net.dv8tion.jda.api.interactions.commands.build.Commands;
1012

11-
import java.time.LocalDateTime;
1213
import java.util.concurrent.ExecutorService;
1314

1415
import org.springframework.dao.DataAccessException;
@@ -21,17 +22,20 @@ public class WarnsListContext extends ContextCommand.User {
2122
private final BotConfig botConfig;
2223
private final ExecutorService asyncPool;
2324
private final WarnRepository warnRepository;
25+
private final NotificationService notificationService;
2426

2527
/**
2628
* The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.CommandData}.
2729
* @param botConfig The main configuration of the bot
2830
* @param asyncPool The main thread pool for asynchronous operations
2931
* @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects.
32+
* @param notificationService service object for notifying users
3033
*/
31-
public WarnsListContext(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) {
34+
public WarnsListContext(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository, NotificationService notificationService) {
3235
this.botConfig = botConfig;
3336
this.asyncPool = asyncPool;
3437
this.warnRepository = warnRepository;
38+
this.notificationService = notificationService;
3539
setCommandData(Commands.user("Show Warns")
3640
.setGuildOnly(true)
3741
);
@@ -44,10 +48,10 @@ public void execute(UserContextInteractionEvent event) {
4448
return;
4549
}
4650
event.deferReply(false).queue();
47-
LocalDateTime cutoff = LocalDateTime.now().minusDays(botConfig.get(event.getGuild()).getModerationConfig().getWarnTimeoutDays());
51+
ModerationService moderationService = new ModerationService(notificationService, botConfig.get(event.getGuild()), warnRepository, asyncPool);
4852
asyncPool.execute(() -> {
4953
try {
50-
event.getHook().sendMessageEmbeds(WarnsListCommand.buildWarnsEmbed(warnRepository.getActiveWarnsByUserId(event.getTarget().getIdLong(), cutoff), event.getTarget())).queue();
54+
event.getHook().sendMessageEmbeds(WarnsListCommand.buildWarnsEmbed(moderationService.getTotalSeverityWeight(event.getTarget().getIdLong()), event.getTarget())).queue();
5155
} catch (DataAccessException e) {
5256
ExceptionLogger.capture(e, WarnsListContext.class.getSimpleName());
5357
}

src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,25 +61,7 @@ public Optional<Warn> findById(long id) throws DataAccessException {
6161
return Optional.empty();
6262
}
6363
}
64-
65-
/**
66-
* Gets the total severity weight of all warns for the given user, which
67-
* were created after the given cutoff, and haven't been discarded.
68-
*
69-
* @param userId The id of the user.
70-
* @param cutoff The time after which to look for warns.
71-
* @return The total weight of all warn severities.
72-
* @throws SQLException If an error occurs.
73-
*/
74-
public int getTotalSeverityWeight(long userId, LocalDateTime cutoff) throws DataAccessException {
75-
try {
76-
return jdbcTemplate.queryForObject("SELECT SUM(severity_weight) FROM warn WHERE user_id = ? AND discarded = FALSE AND created_at > ?", (rs, rows)->rs.getInt(1),
77-
userId, Timestamp.valueOf(cutoff));
78-
}catch (EmptyResultDataAccessException e) {
79-
return 0;
80-
}
81-
}
82-
64+
8365
/**
8466
* Discards all warnings that have been issued to a given user.
8567
*
@@ -135,12 +117,12 @@ private Warn read(ResultSet rs) throws SQLException {
135117
* @return A List with all Warns.
136118
*/
137119
public List<Warn> getActiveWarnsByUserId(long userId, LocalDateTime cutoff) {
138-
return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ? AND discarded = FALSE AND created_at > ?",(rs, row)->this.read(rs),
120+
return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ? AND discarded = FALSE AND created_at > ? ORDER BY created_at DESC",(rs, row)->this.read(rs),
139121
userId, Timestamp.valueOf(cutoff));
140122
}
141123

142124
public List<Warn> getAllWarnsByUserId(long userId) {
143-
return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ?",(rs, row)->this.read(rs),
125+
return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ?",(rs, row) -> this.read(rs),
144126
userId);
145127
}
146128
}

0 commit comments

Comments
 (0)