Skip to content

Commit 09d914d

Browse files
authored
Auto-prune of full helper roles (#495)
* Adding auto prune of full helper roles * Added hints to change logging level (also for bot only) * Fixed bug with routines starting too early * Message mods if pruning didnt help * also fixed a bug with BotCore missing its onReady event * Undid accidental commit * Fix after rebase * CR improvements
1 parent 617eb81 commit 09d914d

File tree

6 files changed

+240
-40
lines changed

6 files changed

+240
-40
lines changed

application/src/main/java/org/togetherjava/tjbot/Application.java

+7-1
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,14 @@ public static void runBot(Config config) {
8282
JDA jda = JDABuilder.createDefault(config.getToken())
8383
.enableIntents(GatewayIntent.GUILD_MEMBERS)
8484
.build();
85-
jda.addEventListener(new BotCore(jda, database, config));
85+
86+
BotCore core = new BotCore(jda, database, config);
87+
jda.addEventListener(core);
8688
jda.awaitReady();
89+
90+
// We fire the event manually, since the core might be added too late to receive the
91+
// actual event fired from JDA
92+
core.onReady(jda);
8793
logger.info("Bot is ready");
8894
} catch (LoginException e) {
8995
logger.error("Failed to login", e);

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

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ public enum Features {
7474
features.add(new ScamHistoryPurgeRoutine(scamHistoryStore));
7575
features.add(new BotMessageCleanup(config));
7676
features.add(new HelpThreadActivityUpdater(helpSystemHelper));
77+
features
78+
.add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, database));
7779

7880
// Message receivers
7981
features.add(new TopHelpersMessageListener(database, config));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package org.togetherjava.tjbot.commands.help;
2+
3+
import net.dv8tion.jda.api.JDA;
4+
import net.dv8tion.jda.api.entities.Guild;
5+
import net.dv8tion.jda.api.entities.Member;
6+
import net.dv8tion.jda.api.entities.Role;
7+
import net.dv8tion.jda.api.entities.TextChannel;
8+
import org.jetbrains.annotations.NotNull;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
import org.togetherjava.tjbot.commands.Routine;
12+
import org.togetherjava.tjbot.config.Config;
13+
import org.togetherjava.tjbot.db.Database;
14+
import org.togetherjava.tjbot.moderation.ModAuditLogWriter;
15+
16+
import java.time.Duration;
17+
import java.time.Instant;
18+
import java.time.Period;
19+
import java.util.*;
20+
import java.util.concurrent.TimeUnit;
21+
22+
import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES;
23+
24+
/**
25+
* Due to a technical limitation in Discord, roles with more than 100 users can not be ghost-pinged
26+
* into helper threads.
27+
* <p>
28+
* This routine mitigates the problem by automatically pruning inactive users from helper roles
29+
* approaching this limit.
30+
*/
31+
public final class AutoPruneHelperRoutine implements Routine {
32+
private static final Logger logger = LoggerFactory.getLogger(AutoPruneHelperRoutine.class);
33+
34+
private static final int ROLE_FULL_LIMIT = 100;
35+
private static final int ROLE_FULL_THRESHOLD = 95;
36+
private static final int PRUNE_MEMBER_AMOUNT = 10;
37+
private static final Period INACTIVE_AFTER = Period.ofDays(90);
38+
private static final int RECENTLY_JOINED_DAYS = 7;
39+
40+
private final HelpSystemHelper helper;
41+
private final ModAuditLogWriter modAuditLogWriter;
42+
private final Database database;
43+
private final List<String> allCategories;
44+
45+
/**
46+
* Creates a new instance.
47+
*
48+
* @param config to determine all helper categories
49+
* @param helper the helper to use
50+
* @param modAuditLogWriter to inform mods when manual pruning becomes necessary
51+
* @param database to determine whether an user is inactive
52+
*/
53+
public AutoPruneHelperRoutine(@NotNull Config config, @NotNull HelpSystemHelper helper,
54+
@NotNull ModAuditLogWriter modAuditLogWriter, @NotNull Database database) {
55+
allCategories = config.getHelpSystem().getCategories();
56+
this.helper = helper;
57+
this.modAuditLogWriter = modAuditLogWriter;
58+
this.database = database;
59+
}
60+
61+
@Override
62+
public @NotNull Schedule createSchedule() {
63+
return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.HOURS);
64+
}
65+
66+
@Override
67+
public void runRoutine(@NotNull JDA jda) {
68+
jda.getGuildCache().forEach(this::pruneForGuild);
69+
}
70+
71+
private void pruneForGuild(@NotNull Guild guild) {
72+
TextChannel overviewChannel = guild.getTextChannels()
73+
.stream()
74+
.filter(channel -> helper.isOverviewChannelName(channel.getName()))
75+
.findAny()
76+
.orElseThrow();
77+
Instant now = Instant.now();
78+
79+
allCategories.stream()
80+
.map(category -> helper.handleFindRoleForCategory(category, guild))
81+
.filter(Optional::isPresent)
82+
.map(Optional::orElseThrow)
83+
.forEach(role -> pruneRoleIfFull(role, overviewChannel, now));
84+
}
85+
86+
private void pruneRoleIfFull(@NotNull Role role, @NotNull TextChannel overviewChannel,
87+
@NotNull Instant when) {
88+
role.getGuild().findMembersWithRoles(role).onSuccess(members -> {
89+
if (isRoleFull(members)) {
90+
logger.debug("Helper role {} is full, starting to prune.", role.getName());
91+
pruneRole(role, members, overviewChannel, when);
92+
}
93+
});
94+
}
95+
96+
private boolean isRoleFull(@NotNull Collection<?> members) {
97+
return members.size() >= ROLE_FULL_THRESHOLD;
98+
}
99+
100+
private void pruneRole(@NotNull Role role, @NotNull List<? extends Member> members,
101+
@NotNull TextChannel overviewChannel, @NotNull Instant when) {
102+
List<Member> membersShuffled = new ArrayList<>(members);
103+
Collections.shuffle(membersShuffled);
104+
105+
List<Member> membersToPrune = membersShuffled.stream()
106+
.filter(member -> isMemberInactive(member, when))
107+
.limit(PRUNE_MEMBER_AMOUNT)
108+
.toList();
109+
if (membersToPrune.size() < PRUNE_MEMBER_AMOUNT) {
110+
warnModsAbout(
111+
"Attempting to prune helpers from role **%s** (%d members), but only found %d inactive users. That is less than expected, the category might eventually grow beyond the limit."
112+
.formatted(role.getName(), members.size(), membersToPrune.size()),
113+
role.getGuild());
114+
}
115+
if (members.size() - membersToPrune.size() >= ROLE_FULL_LIMIT) {
116+
warnModsAbout(
117+
"The helper role **%s** went beyond its member limit (%d), despite automatic pruning. It will not function correctly anymore. Please manually prune some users."
118+
.formatted(role.getName(), ROLE_FULL_LIMIT),
119+
role.getGuild());
120+
}
121+
122+
logger.info("Pruning {} users {} from role {}", membersToPrune.size(), membersToPrune,
123+
role.getName());
124+
membersToPrune.forEach(member -> pruneMemberFromRole(member, role, overviewChannel));
125+
}
126+
127+
private boolean isMemberInactive(@NotNull Member member, @NotNull Instant when) {
128+
if (member.hasTimeJoined()) {
129+
Instant memberJoined = member.getTimeJoined().toInstant();
130+
if (Duration.between(memberJoined, when).toDays() <= RECENTLY_JOINED_DAYS) {
131+
// New users are protected from purging to not immediately kick them out of the role
132+
// again
133+
return false;
134+
}
135+
}
136+
137+
Instant latestActiveMoment = when.minus(INACTIVE_AFTER);
138+
139+
// Has no recent help message
140+
return database.read(context -> context.fetchCount(HELP_CHANNEL_MESSAGES,
141+
HELP_CHANNEL_MESSAGES.GUILD_ID.eq(member.getGuild().getIdLong())
142+
.and(HELP_CHANNEL_MESSAGES.AUTHOR_ID.eq(member.getIdLong()))
143+
.and(HELP_CHANNEL_MESSAGES.SENT_AT.greaterThan(latestActiveMoment)))) == 0;
144+
}
145+
146+
private void pruneMemberFromRole(@NotNull Member member, @NotNull Role role,
147+
@NotNull TextChannel overviewChannel) {
148+
Guild guild = member.getGuild();
149+
150+
String dmMessage =
151+
"""
152+
You seem to have been inactive for some time in server **%s**, hence we removed you from the **%s** role.
153+
If that was a mistake, just head back to %s and select the role again.
154+
Sorry for any inconvenience caused by this 🙇"""
155+
.formatted(guild.getName(), role.getName(), overviewChannel.getAsMention());
156+
157+
guild.removeRoleFromMember(member, role)
158+
.flatMap(any -> member.getUser().openPrivateChannel())
159+
.flatMap(channel -> channel.sendMessage(dmMessage))
160+
.queue();
161+
}
162+
163+
private void warnModsAbout(@NotNull String message, @NotNull Guild guild) {
164+
logger.warn(message);
165+
166+
modAuditLogWriter.write("Auto-prune helpers", message, null, Instant.now(), guild);
167+
}
168+
}

application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java

+44-28
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import net.dv8tion.jda.api.entities.Channel;
66
import net.dv8tion.jda.api.entities.Guild;
77
import net.dv8tion.jda.api.entities.TextChannel;
8-
import net.dv8tion.jda.api.events.ReadyEvent;
98
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
109
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
1110
import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent;
@@ -31,6 +30,7 @@
3130
import java.util.concurrent.ExecutorService;
3231
import java.util.concurrent.Executors;
3332
import java.util.concurrent.ScheduledExecutorService;
33+
import java.util.concurrent.atomic.AtomicBoolean;
3434
import java.util.function.Function;
3535
import java.util.regex.Pattern;
3636
import java.util.stream.Collectors;
@@ -56,9 +56,11 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid
5656
Executors.newScheduledThreadPool(5);
5757
private final Config config;
5858
private final Map<String, UserInteractor> nameToInteractor;
59+
private final List<Routine> routines;
5960
private final ComponentIdParser componentIdParser;
6061
private final ComponentIdStore componentIdStore;
6162
private final Map<Pattern, MessageReceiver> channelNameToMessageReceiver = new HashMap<>();
63+
private final AtomicBoolean receivedOnReady = new AtomicBoolean(false);
6264

6365
/**
6466
* Creates a new command system which uses the given database to allow commands to persist data.
@@ -87,31 +89,11 @@ public BotCore(@NotNull JDA jda, @NotNull Database database, @NotNull Config con
8789
.map(EventReceiver.class::cast)
8890
.forEach(jda::addEventListener);
8991

90-
// Routines
91-
features.stream()
92+
// Routines (are scheduled once the core is ready)
93+
routines = features.stream()
9294
.filter(Routine.class::isInstance)
9395
.map(Routine.class::cast)
94-
.forEach(routine -> {
95-
Runnable command = () -> {
96-
String routineName = routine.getClass().getSimpleName();
97-
try {
98-
logger.debug("Running routine %s...".formatted(routineName));
99-
routine.runRoutine(jda);
100-
logger.debug("Finished routine %s.".formatted(routineName));
101-
} catch (Exception e) {
102-
logger.error("Unknown error in routine {}.", routineName, e);
103-
}
104-
};
105-
106-
Routine.Schedule schedule = routine.createSchedule();
107-
switch (schedule.mode()) {
108-
case FIXED_RATE -> ROUTINE_SERVICE.scheduleAtFixedRate(command,
109-
schedule.initialDuration(), schedule.duration(), schedule.unit());
110-
case FIXED_DELAY -> ROUTINE_SERVICE.scheduleWithFixedDelay(command,
111-
schedule.initialDuration(), schedule.duration(), schedule.unit());
112-
default -> throw new AssertionError("Unsupported schedule mode");
113-
}
114-
});
96+
.toList();
11597

11698
// User Interactors (e.g. slash commands)
11799
nameToInteractor = features.stream()
@@ -159,16 +141,50 @@ public BotCore(@NotNull JDA jda, @NotNull Database database, @NotNull Config con
159141
.map(SlashCommand.class::cast);
160142
}
161143

162-
@Override
163-
public void onReady(@NotNull ReadyEvent event) {
144+
/**
145+
* Trigger once JDA is ready. Subsequent calls are ignored.
146+
*
147+
* @param jda the JDA instance to work with
148+
*/
149+
public void onReady(@NotNull JDA jda) {
150+
if (!receivedOnReady.compareAndSet(false, true)) {
151+
// Ensures that we only enter the event once
152+
return;
153+
}
154+
164155
// Register reload on all guilds
165156
logger.debug("JDA is ready, registering reload command");
166-
event.getJDA()
167-
.getGuildCache()
157+
jda.getGuildCache()
168158
.forEach(guild -> COMMAND_SERVICE.execute(() -> registerReloadCommand(guild)));
169159
// NOTE We do not have to wait for reload to complete for the command system to be ready
170160
// itself
171161
logger.debug("Bot core is now ready");
162+
163+
scheduleRoutines(jda);
164+
}
165+
166+
private void scheduleRoutines(@NotNull JDA jda) {
167+
routines.forEach(routine -> {
168+
Runnable command = () -> {
169+
String routineName = routine.getClass().getSimpleName();
170+
try {
171+
logger.debug("Running routine %s...".formatted(routineName));
172+
routine.runRoutine(jda);
173+
logger.debug("Finished routine %s.".formatted(routineName));
174+
} catch (Exception e) {
175+
logger.error("Unknown error in routine {}.", routineName, e);
176+
}
177+
};
178+
179+
Routine.Schedule schedule = routine.createSchedule();
180+
switch (schedule.mode()) {
181+
case FIXED_RATE -> ROUTINE_SERVICE.scheduleAtFixedRate(command,
182+
schedule.initialDuration(), schedule.duration(), schedule.unit());
183+
case FIXED_DELAY -> ROUTINE_SERVICE.scheduleWithFixedDelay(command,
184+
schedule.initialDuration(), schedule.duration(), schedule.unit());
185+
default -> throw new AssertionError("Unsupported schedule mode");
186+
}
187+
});
172188
}
173189

174190
@Override

application/src/main/java/org/togetherjava/tjbot/moderation/ModAuditLogWriter.java

+15-11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import net.dv8tion.jda.api.requests.restaction.MessageAction;
88
import net.dv8tion.jda.api.utils.AttachmentOption;
99
import org.jetbrains.annotations.NotNull;
10+
import org.jetbrains.annotations.Nullable;
1011
import org.slf4j.Logger;
1112
import org.slf4j.LoggerFactory;
1213
import org.togetherjava.tjbot.config.Config;
@@ -51,26 +52,29 @@ public ModAuditLogWriter(@NotNull Config config) {
5152
*
5253
* @param title the title of the log embed
5354
* @param description the description of the log embed
54-
* @param author the author of the log message
55+
* @param author the author of the log message, if any
5556
* @param timestamp the timestamp of the log message
5657
* @param guild the guild to write this log to
5758
* @param attachments attachments that will be added to the message. none or many.
5859
*/
59-
public void write(@NotNull String title, @NotNull String description, @NotNull User author,
60+
public void write(@NotNull String title, @NotNull String description, @Nullable User author,
6061
@NotNull TemporalAccessor timestamp, @NotNull Guild guild,
6162
@NotNull Attachment... attachments) {
6263
Optional<TextChannel> auditLogChannel = getAndHandleModAuditLogChannel(guild);
6364
if (auditLogChannel.isEmpty()) {
6465
return;
6566
}
6667

67-
MessageAction message = auditLogChannel.orElseThrow()
68-
.sendMessageEmbeds(new EmbedBuilder().setTitle(title)
69-
.setDescription(description)
70-
.setAuthor(author.getAsTag(), null, author.getAvatarUrl())
71-
.setTimestamp(timestamp)
72-
.setColor(EMBED_COLOR)
73-
.build());
68+
EmbedBuilder embedBuilder = new EmbedBuilder().setTitle(title)
69+
.setDescription(description)
70+
.setTimestamp(timestamp)
71+
.setColor(EMBED_COLOR);
72+
if (author != null) {
73+
embedBuilder.setAuthor(author.getAsTag(), null, author.getAvatarUrl());
74+
}
75+
76+
MessageAction message =
77+
auditLogChannel.orElseThrow().sendMessageEmbeds(embedBuilder.build());
7478

7579
for (Attachment attachment : attachments) {
7680
message = message.addFile(attachment.getContentRaw(), attachment.name());
@@ -102,14 +106,14 @@ public Optional<TextChannel> getAndHandleModAuditLogChannel(@NotNull Guild guild
102106
/**
103107
* Represents attachment to messages, as for example used by
104108
* {@link MessageAction#addFile(File, String, AttachmentOption...)}.
105-
*
109+
*
106110
* @param name the name of the attachment, example: {@code "foo.md"}
107111
* @param content the content of the attachment
108112
*/
109113
public record Attachment(@NotNull String name, @NotNull String content) {
110114
/**
111115
* Gets the content raw, interpreted as UTF-8.
112-
*
116+
*
113117
* @return the raw content of the attachment
114118
*/
115119
public byte @NotNull [] getContentRaw() {

application/src/main/resources/log4j2.xml

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
</Async>
2323
</Appenders>
2424
<Loggers>
25+
<!-- Change this level to see more of our logs -->
26+
<Logger name="org.togetherjava.tjbot" level="info"/>
27+
28+
<!-- Change this level to see more logs of everything (including JDA) -->
2529
<Root level="info">
2630
<AppenderRef ref="Console"/>
2731
<AppenderRef ref="File"/>

0 commit comments

Comments
 (0)