Skip to content

Re-added Auto-Pruner for full helper roles #1203

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 25, 2024
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
7 changes: 7 additions & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@
"rateLimitWindowSeconds": 10,
"rateLimitRequestsInWindow": 3
},
"helperPruneConfig": {
"roleFullLimit": 250,
"roleFullThreshold": 245,
"pruneMemberAmount": 7,
"inactivateAfterDays": 90,
"recentlyJoinedDays": 4
},
"featureBlacklist": {
"normal": [
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public final class Config {
private final String openaiApiKey;
private final String sourceCodeBaseUrl;
private final JShellConfig jshell;
private final HelperPruneConfig helperPruneConfig;
private final FeatureBlacklistConfig featureBlacklistConfig;
private final RSSFeedsConfig rssFeedsConfig;
private final String selectRolesChannelPattern;
Expand Down Expand Up @@ -93,6 +94,8 @@ private Config(@JsonProperty(value = "token", required = true) String token,
@JsonProperty(value = "jshell", required = true) JShellConfig jshell,
@JsonProperty(value = "memberCountCategoryPattern",
required = true) String memberCountCategoryPattern,
@JsonProperty(value = "helperPruneConfig",
required = true) HelperPruneConfig helperPruneConfig,
@JsonProperty(value = "featureBlacklist",
required = true) FeatureBlacklistConfig featureBlacklistConfig,
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
Expand Down Expand Up @@ -128,6 +131,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
this.openaiApiKey = Objects.requireNonNull(openaiApiKey);
this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl);
this.jshell = Objects.requireNonNull(jshell);
this.helperPruneConfig = Objects.requireNonNull(helperPruneConfig);
this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig);
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
Expand Down Expand Up @@ -396,6 +400,15 @@ public JShellConfig getJshell() {
return jshell;
}

/**
* Gets the config for automatic pruning of helper roles.
*
* @return the configuration
*/
public HelperPruneConfig getHelperPruneConfig() {
return helperPruneConfig;
}

/**
* The configuration of blacklisted features.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.togetherjava.tjbot.config;


/**
* Config for automatic pruning of helper roles, see
* {@link org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine}.
*
* @param roleFullLimit if a helper role contains that many users, it is considered full and pruning
* must occur
* @param roleFullThreshold if a helper role contains that many users, pruning will start to occur
* to prevent reaching the limit
* @param pruneMemberAmount amount of users to remove from helper roles during a prune
* @param inactivateAfterDays after how many days of inactivity a user is eligible for pruning
* @param recentlyJoinedDays if a user is with the server for just this amount of days, they are
* protected from pruning
*/
public record HelperPruneConfig(int roleFullLimit, int roleFullThreshold, int pruneMemberAmount,
int inactivateAfterDays, int recentlyJoinedDays) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener;
import org.togetherjava.tjbot.features.github.GitHubCommand;
import org.togetherjava.tjbot.features.github.GitHubReference;
import org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine;
import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener;
import org.togetherjava.tjbot.features.help.HelpSystemHelper;
import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater;
Expand Down Expand Up @@ -132,6 +133,8 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
features.add(new ScamHistoryPurgeRoutine(scamHistoryStore));
features.add(new HelpThreadMetadataPurger(database));
features.add(new HelpThreadActivityUpdater(helpSystemHelper));
features
.add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, database));
features.add(new HelpThreadAutoArchiver(helpSystemHelper));
features.add(new LeftoverBookmarksCleanupRoutine(bookmarksSystem));
features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package org.togetherjava.tjbot.features.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.channel.concrete.TextChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.config.HelperPruneConfig;
import org.togetherjava.tjbot.db.Database;
import org.togetherjava.tjbot.features.Routine;
import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter;

import javax.annotation.Nullable;

import java.time.Duration;
import java.time.Instant;
import java.time.Period;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.regex.Pattern;

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

/**
* Due to a technical limitation in Discord, roles with more than 250 users cannot 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 final int roleFullLimit;
private final int roleFullThreshold;
private final int pruneMemberAmount;
private final Period inactiveAfter;
private final int recentlyJoinedDays;

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

/**
* 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 a user is inactive
*/
public AutoPruneHelperRoutine(Config config, HelpSystemHelper helper,
ModAuditLogWriter modAuditLogWriter, Database database) {
allCategories = config.getHelpSystem().getCategories();
this.helper = helper;
this.modAuditLogWriter = modAuditLogWriter;
this.database = database;

HelperPruneConfig helperPruneConfig = config.getHelperPruneConfig();
roleFullLimit = helperPruneConfig.roleFullLimit();
roleFullThreshold = helperPruneConfig.roleFullThreshold();
pruneMemberAmount = helperPruneConfig.pruneMemberAmount();
inactiveAfter = Period.ofDays(helperPruneConfig.inactivateAfterDays());
recentlyJoinedDays = helperPruneConfig.recentlyJoinedDays();
selectYourRolesChannelNamePredicate =
Pattern.compile(config.getSelectRolesChannelPattern()).asMatchPredicate();
}

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

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

private void pruneForGuild(Guild guild) {
Instant now = Instant.now();
TextChannel selectRoleChannel = getSelectRolesChannelOptional(guild.getJDA()).orElse(null);

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

private void pruneRoleIfFull(Role role, @Nullable TextChannel selectRoleChannel, 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, selectRoleChannel, when);
}
});
}

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

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

List<Member> membersToPrune = membersShuffled.stream()
.filter(member -> isMemberInactive(member, when))
.limit(pruneMemberAmount)
.toList();
if (membersToPrune.size() < pruneMemberAmount) {
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() >= roleFullLimit) {
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(), roleFullLimit),
role.getGuild());
}

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

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

Instant latestActiveMoment = when.minus(inactiveAfter);

// 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(Member member, Role role,
@Nullable TextChannel selectRoleChannel) {
Guild guild = member.getGuild();

String channelMentionOrFallbackMessage =
selectRoleChannel == null ? "role selection channel"
: selectRoleChannel.getAsMention();

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(), channelMentionOrFallbackMessage);

guild.removeRoleFromMember(member, role)
.flatMap(any -> member.getUser().openPrivateChannel())
.flatMap(channel -> channel.sendMessage(dmMessage))
.queue(null, failure -> logger.debug(
"Failed sending a DM to user ({}) while pruning them from a helper role.",
member.getId()));
}

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

modAuditLogWriter.write("Auto-prune helpers", message, null, Instant.now(), guild);
}

private Optional<TextChannel> getSelectRolesChannelOptional(JDA jda) {
return jda.getTextChannels()
.stream()
.filter(textChannel -> selectYourRolesChannelNamePredicate.test(textChannel.getName()))
.findFirst();
}
}
Loading