Skip to content
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 @@ -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 @@ -76,6 +76,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