Skip to content

Commit

Permalink
Auto-prune of full helper roles (#495)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Zabuzard authored Aug 20, 2022
1 parent 617eb81 commit 09d914d
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,14 @@ public static void runBot(Config config) {
JDA jda = JDABuilder.createDefault(config.getToken())
.enableIntents(GatewayIntent.GUILD_MEMBERS)
.build();
jda.addEventListener(new BotCore(jda, database, config));

BotCore core = new BotCore(jda, database, config);
jda.addEventListener(core);
jda.awaitReady();

// We fire the event manually, since the core might be added too late to receive the
// actual event fired from JDA
core.onReady(jda);
logger.info("Bot is ready");
} catch (LoginException e) {
logger.error("Failed to login", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ public enum Features {
features.add(new ScamHistoryPurgeRoutine(scamHistoryStore));
features.add(new BotMessageCleanup(config));
features.add(new HelpThreadActivityUpdater(helpSystemHelper));
features
.add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, database));

// Message receivers
features.add(new TopHelpersMessageListener(database, config));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package org.togetherjava.tjbot.commands.help;

import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.TextChannel;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.commands.Routine;
import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.db.Database;
import org.togetherjava.tjbot.moderation.ModAuditLogWriter;

import java.time.Duration;
import java.time.Instant;
import java.time.Period;
import java.util.*;
import java.util.concurrent.TimeUnit;

import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES;

/**
* Due to a technical limitation in Discord, roles with more than 100 users can not be ghost-pinged
* into helper threads.
* <p>
* This routine mitigates the problem by automatically pruning inactive users from helper roles
* approaching this limit.
*/
public final class AutoPruneHelperRoutine implements Routine {
private static final Logger logger = LoggerFactory.getLogger(AutoPruneHelperRoutine.class);

private static final int ROLE_FULL_LIMIT = 100;
private static final int ROLE_FULL_THRESHOLD = 95;
private static final int PRUNE_MEMBER_AMOUNT = 10;
private static final Period INACTIVE_AFTER = Period.ofDays(90);
private static final int RECENTLY_JOINED_DAYS = 7;

private final HelpSystemHelper helper;
private final ModAuditLogWriter modAuditLogWriter;
private final Database database;
private final List<String> allCategories;

/**
* Creates a new instance.
*
* @param config to determine all helper categories
* @param helper the helper to use
* @param modAuditLogWriter to inform mods when manual pruning becomes necessary
* @param database to determine whether an user is inactive
*/
public AutoPruneHelperRoutine(@NotNull Config config, @NotNull HelpSystemHelper helper,
@NotNull ModAuditLogWriter modAuditLogWriter, @NotNull Database database) {
allCategories = config.getHelpSystem().getCategories();
this.helper = helper;
this.modAuditLogWriter = modAuditLogWriter;
this.database = database;
}

@Override
public @NotNull Schedule createSchedule() {
return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.HOURS);
}

@Override
public void runRoutine(@NotNull JDA jda) {
jda.getGuildCache().forEach(this::pruneForGuild);
}

private void pruneForGuild(@NotNull Guild guild) {
TextChannel overviewChannel = guild.getTextChannels()
.stream()
.filter(channel -> helper.isOverviewChannelName(channel.getName()))
.findAny()
.orElseThrow();
Instant now = Instant.now();

allCategories.stream()
.map(category -> helper.handleFindRoleForCategory(category, guild))
.filter(Optional::isPresent)
.map(Optional::orElseThrow)
.forEach(role -> pruneRoleIfFull(role, overviewChannel, now));
}

private void pruneRoleIfFull(@NotNull Role role, @NotNull TextChannel overviewChannel,
@NotNull Instant when) {
role.getGuild().findMembersWithRoles(role).onSuccess(members -> {
if (isRoleFull(members)) {
logger.debug("Helper role {} is full, starting to prune.", role.getName());
pruneRole(role, members, overviewChannel, when);
}
});
}

private boolean isRoleFull(@NotNull Collection<?> members) {
return members.size() >= ROLE_FULL_THRESHOLD;
}

private void pruneRole(@NotNull Role role, @NotNull List<? extends Member> members,
@NotNull TextChannel overviewChannel, @NotNull Instant when) {
List<Member> membersShuffled = new ArrayList<>(members);
Collections.shuffle(membersShuffled);

List<Member> membersToPrune = membersShuffled.stream()
.filter(member -> isMemberInactive(member, when))
.limit(PRUNE_MEMBER_AMOUNT)
.toList();
if (membersToPrune.size() < PRUNE_MEMBER_AMOUNT) {
warnModsAbout(
"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."
.formatted(role.getName(), members.size(), membersToPrune.size()),
role.getGuild());
}
if (members.size() - membersToPrune.size() >= ROLE_FULL_LIMIT) {
warnModsAbout(
"The helper role **%s** went beyond its member limit (%d), despite automatic pruning. It will not function correctly anymore. Please manually prune some users."
.formatted(role.getName(), ROLE_FULL_LIMIT),
role.getGuild());
}

logger.info("Pruning {} users {} from role {}", membersToPrune.size(), membersToPrune,
role.getName());
membersToPrune.forEach(member -> pruneMemberFromRole(member, role, overviewChannel));
}

private boolean isMemberInactive(@NotNull Member member, @NotNull Instant when) {
if (member.hasTimeJoined()) {
Instant memberJoined = member.getTimeJoined().toInstant();
if (Duration.between(memberJoined, when).toDays() <= RECENTLY_JOINED_DAYS) {
// New users are protected from purging to not immediately kick them out of the role
// again
return false;
}
}

Instant latestActiveMoment = when.minus(INACTIVE_AFTER);

// Has no recent help message
return database.read(context -> context.fetchCount(HELP_CHANNEL_MESSAGES,
HELP_CHANNEL_MESSAGES.GUILD_ID.eq(member.getGuild().getIdLong())
.and(HELP_CHANNEL_MESSAGES.AUTHOR_ID.eq(member.getIdLong()))
.and(HELP_CHANNEL_MESSAGES.SENT_AT.greaterThan(latestActiveMoment)))) == 0;
}

private void pruneMemberFromRole(@NotNull Member member, @NotNull Role role,
@NotNull TextChannel overviewChannel) {
Guild guild = member.getGuild();

String dmMessage =
"""
You seem to have been inactive for some time in server **%s**, hence we removed you from the **%s** role.
If that was a mistake, just head back to %s and select the role again.
Sorry for any inconvenience caused by this 🙇"""
.formatted(guild.getName(), role.getName(), overviewChannel.getAsMention());

guild.removeRoleFromMember(member, role)
.flatMap(any -> member.getUser().openPrivateChannel())
.flatMap(channel -> channel.sendMessage(dmMessage))
.queue();
}

private void warnModsAbout(@NotNull String message, @NotNull Guild guild) {
logger.warn(message);

modAuditLogWriter.write("Auto-prune helpers", message, null, Instant.now(), guild);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import net.dv8tion.jda.api.entities.Channel;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.TextChannel;
import net.dv8tion.jda.api.events.ReadyEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent;
Expand All @@ -31,6 +30,7 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
Expand All @@ -56,9 +56,11 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid
Executors.newScheduledThreadPool(5);
private final Config config;
private final Map<String, UserInteractor> nameToInteractor;
private final List<Routine> routines;
private final ComponentIdParser componentIdParser;
private final ComponentIdStore componentIdStore;
private final Map<Pattern, MessageReceiver> channelNameToMessageReceiver = new HashMap<>();
private final AtomicBoolean receivedOnReady = new AtomicBoolean(false);

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

// Routines
features.stream()
// Routines (are scheduled once the core is ready)
routines = features.stream()
.filter(Routine.class::isInstance)
.map(Routine.class::cast)
.forEach(routine -> {
Runnable command = () -> {
String routineName = routine.getClass().getSimpleName();
try {
logger.debug("Running routine %s...".formatted(routineName));
routine.runRoutine(jda);
logger.debug("Finished routine %s.".formatted(routineName));
} catch (Exception e) {
logger.error("Unknown error in routine {}.", routineName, e);
}
};

Routine.Schedule schedule = routine.createSchedule();
switch (schedule.mode()) {
case FIXED_RATE -> ROUTINE_SERVICE.scheduleAtFixedRate(command,
schedule.initialDuration(), schedule.duration(), schedule.unit());
case FIXED_DELAY -> ROUTINE_SERVICE.scheduleWithFixedDelay(command,
schedule.initialDuration(), schedule.duration(), schedule.unit());
default -> throw new AssertionError("Unsupported schedule mode");
}
});
.toList();

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

@Override
public void onReady(@NotNull ReadyEvent event) {
/**
* Trigger once JDA is ready. Subsequent calls are ignored.
*
* @param jda the JDA instance to work with
*/
public void onReady(@NotNull JDA jda) {
if (!receivedOnReady.compareAndSet(false, true)) {
// Ensures that we only enter the event once
return;
}

// Register reload on all guilds
logger.debug("JDA is ready, registering reload command");
event.getJDA()
.getGuildCache()
jda.getGuildCache()
.forEach(guild -> COMMAND_SERVICE.execute(() -> registerReloadCommand(guild)));
// NOTE We do not have to wait for reload to complete for the command system to be ready
// itself
logger.debug("Bot core is now ready");

scheduleRoutines(jda);
}

private void scheduleRoutines(@NotNull JDA jda) {
routines.forEach(routine -> {
Runnable command = () -> {
String routineName = routine.getClass().getSimpleName();
try {
logger.debug("Running routine %s...".formatted(routineName));
routine.runRoutine(jda);
logger.debug("Finished routine %s.".formatted(routineName));
} catch (Exception e) {
logger.error("Unknown error in routine {}.", routineName, e);
}
};

Routine.Schedule schedule = routine.createSchedule();
switch (schedule.mode()) {
case FIXED_RATE -> ROUTINE_SERVICE.scheduleAtFixedRate(command,
schedule.initialDuration(), schedule.duration(), schedule.unit());
case FIXED_DELAY -> ROUTINE_SERVICE.scheduleWithFixedDelay(command,
schedule.initialDuration(), schedule.duration(), schedule.unit());
default -> throw new AssertionError("Unsupported schedule mode");
}
});
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import net.dv8tion.jda.api.requests.restaction.MessageAction;
import net.dv8tion.jda.api.utils.AttachmentOption;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.config.Config;
Expand Down Expand Up @@ -51,26 +52,29 @@ public ModAuditLogWriter(@NotNull Config config) {
*
* @param title the title of the log embed
* @param description the description of the log embed
* @param author the author of the log message
* @param author the author of the log message, if any
* @param timestamp the timestamp of the log message
* @param guild the guild to write this log to
* @param attachments attachments that will be added to the message. none or many.
*/
public void write(@NotNull String title, @NotNull String description, @NotNull User author,
public void write(@NotNull String title, @NotNull String description, @Nullable User author,
@NotNull TemporalAccessor timestamp, @NotNull Guild guild,
@NotNull Attachment... attachments) {
Optional<TextChannel> auditLogChannel = getAndHandleModAuditLogChannel(guild);
if (auditLogChannel.isEmpty()) {
return;
}

MessageAction message = auditLogChannel.orElseThrow()
.sendMessageEmbeds(new EmbedBuilder().setTitle(title)
.setDescription(description)
.setAuthor(author.getAsTag(), null, author.getAvatarUrl())
.setTimestamp(timestamp)
.setColor(EMBED_COLOR)
.build());
EmbedBuilder embedBuilder = new EmbedBuilder().setTitle(title)
.setDescription(description)
.setTimestamp(timestamp)
.setColor(EMBED_COLOR);
if (author != null) {
embedBuilder.setAuthor(author.getAsTag(), null, author.getAvatarUrl());
}

MessageAction message =
auditLogChannel.orElseThrow().sendMessageEmbeds(embedBuilder.build());

for (Attachment attachment : attachments) {
message = message.addFile(attachment.getContentRaw(), attachment.name());
Expand Down Expand Up @@ -102,14 +106,14 @@ public Optional<TextChannel> getAndHandleModAuditLogChannel(@NotNull Guild guild
/**
* Represents attachment to messages, as for example used by
* {@link MessageAction#addFile(File, String, AttachmentOption...)}.
*
*
* @param name the name of the attachment, example: {@code "foo.md"}
* @param content the content of the attachment
*/
public record Attachment(@NotNull String name, @NotNull String content) {
/**
* Gets the content raw, interpreted as UTF-8.
*
*
* @return the raw content of the attachment
*/
public byte @NotNull [] getContentRaw() {
Expand Down
4 changes: 4 additions & 0 deletions application/src/main/resources/log4j2.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
</Async>
</Appenders>
<Loggers>
<!-- Change this level to see more of our logs -->
<Logger name="org.togetherjava.tjbot" level="info"/>

<!-- Change this level to see more logs of everything (including JDA) -->
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="File"/>
Expand Down

0 comments on commit 09d914d

Please sign in to comment.