Skip to content

Commit b6cd50b

Browse files
authored
Re-added Auto-Pruner for full helper roles (#1203)
* Revert "Removed now obsolete HelperPrune routine (config change) (#1076)" This reverts commit a11088e. * bumped role limit from 100 to 250
1 parent d9873d6 commit b6cd50b

File tree

5 files changed

+233
-0
lines changed

5 files changed

+233
-0
lines changed

application/config.json.template

+7
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@
9898
"rateLimitWindowSeconds": 10,
9999
"rateLimitRequestsInWindow": 3
100100
},
101+
"helperPruneConfig": {
102+
"roleFullLimit": 250,
103+
"roleFullThreshold": 245,
104+
"pruneMemberAmount": 7,
105+
"inactivateAfterDays": 90,
106+
"recentlyJoinedDays": 4
107+
},
101108
"featureBlacklist": {
102109
"normal": [
103110
],

application/src/main/java/org/togetherjava/tjbot/config/Config.java

+13
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public final class Config {
4343
private final String openaiApiKey;
4444
private final String sourceCodeBaseUrl;
4545
private final JShellConfig jshell;
46+
private final HelperPruneConfig helperPruneConfig;
4647
private final FeatureBlacklistConfig featureBlacklistConfig;
4748
private final RSSFeedsConfig rssFeedsConfig;
4849
private final String selectRolesChannelPattern;
@@ -93,6 +94,8 @@ private Config(@JsonProperty(value = "token", required = true) String token,
9394
@JsonProperty(value = "jshell", required = true) JShellConfig jshell,
9495
@JsonProperty(value = "memberCountCategoryPattern",
9596
required = true) String memberCountCategoryPattern,
97+
@JsonProperty(value = "helperPruneConfig",
98+
required = true) HelperPruneConfig helperPruneConfig,
9699
@JsonProperty(value = "featureBlacklist",
97100
required = true) FeatureBlacklistConfig featureBlacklistConfig,
98101
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
@@ -128,6 +131,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
128131
this.openaiApiKey = Objects.requireNonNull(openaiApiKey);
129132
this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl);
130133
this.jshell = Objects.requireNonNull(jshell);
134+
this.helperPruneConfig = Objects.requireNonNull(helperPruneConfig);
131135
this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig);
132136
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
133137
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
@@ -396,6 +400,15 @@ public JShellConfig getJshell() {
396400
return jshell;
397401
}
398402

403+
/**
404+
* Gets the config for automatic pruning of helper roles.
405+
*
406+
* @return the configuration
407+
*/
408+
public HelperPruneConfig getHelperPruneConfig() {
409+
return helperPruneConfig;
410+
}
411+
399412
/**
400413
* The configuration of blacklisted features.
401414
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package org.togetherjava.tjbot.config;
2+
3+
4+
/**
5+
* Config for automatic pruning of helper roles, see
6+
* {@link org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine}.
7+
*
8+
* @param roleFullLimit if a helper role contains that many users, it is considered full and pruning
9+
* must occur
10+
* @param roleFullThreshold if a helper role contains that many users, pruning will start to occur
11+
* to prevent reaching the limit
12+
* @param pruneMemberAmount amount of users to remove from helper roles during a prune
13+
* @param inactivateAfterDays after how many days of inactivity a user is eligible for pruning
14+
* @param recentlyJoinedDays if a user is with the server for just this amount of days, they are
15+
* protected from pruning
16+
*/
17+
public record HelperPruneConfig(int roleFullLimit, int roleFullThreshold, int pruneMemberAmount,
18+
int inactivateAfterDays, int recentlyJoinedDays) {
19+
}

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

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener;
2424
import org.togetherjava.tjbot.features.github.GitHubCommand;
2525
import org.togetherjava.tjbot.features.github.GitHubReference;
26+
import org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine;
2627
import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener;
2728
import org.togetherjava.tjbot.features.help.HelpSystemHelper;
2829
import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater;
@@ -132,6 +133,8 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
132133
features.add(new ScamHistoryPurgeRoutine(scamHistoryStore));
133134
features.add(new HelpThreadMetadataPurger(database));
134135
features.add(new HelpThreadActivityUpdater(helpSystemHelper));
136+
features
137+
.add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, database));
135138
features.add(new HelpThreadAutoArchiver(helpSystemHelper));
136139
features.add(new LeftoverBookmarksCleanupRoutine(bookmarksSystem));
137140
features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package org.togetherjava.tjbot.features.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.channel.concrete.TextChannel;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
11+
import org.togetherjava.tjbot.config.Config;
12+
import org.togetherjava.tjbot.config.HelperPruneConfig;
13+
import org.togetherjava.tjbot.db.Database;
14+
import org.togetherjava.tjbot.features.Routine;
15+
import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter;
16+
17+
import javax.annotation.Nullable;
18+
19+
import java.time.Duration;
20+
import java.time.Instant;
21+
import java.time.Period;
22+
import java.util.*;
23+
import java.util.concurrent.TimeUnit;
24+
import java.util.function.Predicate;
25+
import java.util.regex.Pattern;
26+
27+
import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES;
28+
29+
/**
30+
* Due to a technical limitation in Discord, roles with more than 250 users cannot be ghost-pinged
31+
* into helper threads.
32+
* <p>
33+
* This routine mitigates the problem by automatically pruning inactive users from helper roles
34+
* approaching this limit.
35+
*/
36+
public final class AutoPruneHelperRoutine implements Routine {
37+
private static final Logger logger = LoggerFactory.getLogger(AutoPruneHelperRoutine.class);
38+
39+
private final int roleFullLimit;
40+
private final int roleFullThreshold;
41+
private final int pruneMemberAmount;
42+
private final Period inactiveAfter;
43+
private final int recentlyJoinedDays;
44+
45+
private final HelpSystemHelper helper;
46+
private final ModAuditLogWriter modAuditLogWriter;
47+
private final Database database;
48+
private final List<String> allCategories;
49+
private final Predicate<String> selectYourRolesChannelNamePredicate;
50+
51+
/**
52+
* Creates a new instance.
53+
*
54+
* @param config to determine all helper categories
55+
* @param helper the helper to use
56+
* @param modAuditLogWriter to inform mods when manual pruning becomes necessary
57+
* @param database to determine whether a user is inactive
58+
*/
59+
public AutoPruneHelperRoutine(Config config, HelpSystemHelper helper,
60+
ModAuditLogWriter modAuditLogWriter, Database database) {
61+
allCategories = config.getHelpSystem().getCategories();
62+
this.helper = helper;
63+
this.modAuditLogWriter = modAuditLogWriter;
64+
this.database = database;
65+
66+
HelperPruneConfig helperPruneConfig = config.getHelperPruneConfig();
67+
roleFullLimit = helperPruneConfig.roleFullLimit();
68+
roleFullThreshold = helperPruneConfig.roleFullThreshold();
69+
pruneMemberAmount = helperPruneConfig.pruneMemberAmount();
70+
inactiveAfter = Period.ofDays(helperPruneConfig.inactivateAfterDays());
71+
recentlyJoinedDays = helperPruneConfig.recentlyJoinedDays();
72+
selectYourRolesChannelNamePredicate =
73+
Pattern.compile(config.getSelectRolesChannelPattern()).asMatchPredicate();
74+
}
75+
76+
@Override
77+
public Schedule createSchedule() {
78+
return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.HOURS);
79+
}
80+
81+
@Override
82+
public void runRoutine(JDA jda) {
83+
jda.getGuildCache().forEach(this::pruneForGuild);
84+
}
85+
86+
private void pruneForGuild(Guild guild) {
87+
Instant now = Instant.now();
88+
TextChannel selectRoleChannel = getSelectRolesChannelOptional(guild.getJDA()).orElse(null);
89+
90+
allCategories.stream()
91+
.map(category -> helper.handleFindRoleForCategory(category, guild))
92+
.filter(Optional::isPresent)
93+
.map(Optional::orElseThrow)
94+
.forEach(role -> pruneRoleIfFull(role, selectRoleChannel, now));
95+
}
96+
97+
private void pruneRoleIfFull(Role role, @Nullable TextChannel selectRoleChannel, Instant when) {
98+
role.getGuild().findMembersWithRoles(role).onSuccess(members -> {
99+
if (isRoleFull(members)) {
100+
logger.debug("Helper role {} is full, starting to prune.", role.getName());
101+
pruneRole(role, members, selectRoleChannel, when);
102+
}
103+
});
104+
}
105+
106+
private boolean isRoleFull(Collection<?> members) {
107+
return members.size() >= roleFullThreshold;
108+
}
109+
110+
private void pruneRole(Role role, List<? extends Member> members,
111+
@Nullable TextChannel selectRoleChannel, Instant when) {
112+
List<Member> membersShuffled = new ArrayList<>(members);
113+
Collections.shuffle(membersShuffled);
114+
115+
List<Member> membersToPrune = membersShuffled.stream()
116+
.filter(member -> isMemberInactive(member, when))
117+
.limit(pruneMemberAmount)
118+
.toList();
119+
if (membersToPrune.size() < pruneMemberAmount) {
120+
warnModsAbout(
121+
"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."
122+
.formatted(role.getName(), members.size(), membersToPrune.size()),
123+
role.getGuild());
124+
}
125+
if (members.size() - membersToPrune.size() >= roleFullLimit) {
126+
warnModsAbout(
127+
"The helper role **%s** went beyond its member limit (%d), despite automatic pruning. It will not function correctly anymore. Please manually prune some users."
128+
.formatted(role.getName(), roleFullLimit),
129+
role.getGuild());
130+
}
131+
132+
logger.info("Pruning {} users {} from role {}", membersToPrune.size(), membersToPrune,
133+
role.getName());
134+
membersToPrune.forEach(member -> pruneMemberFromRole(member, role, selectRoleChannel));
135+
}
136+
137+
private boolean isMemberInactive(Member member, Instant when) {
138+
if (member.hasTimeJoined()) {
139+
Instant memberJoined = member.getTimeJoined().toInstant();
140+
if (Duration.between(memberJoined, when).toDays() <= recentlyJoinedDays) {
141+
// New users are protected from purging to not immediately kick them out of the role
142+
// again
143+
return false;
144+
}
145+
}
146+
147+
Instant latestActiveMoment = when.minus(inactiveAfter);
148+
149+
// Has no recent help message
150+
return database.read(context -> context.fetchCount(HELP_CHANNEL_MESSAGES,
151+
HELP_CHANNEL_MESSAGES.GUILD_ID.eq(member.getGuild().getIdLong())
152+
.and(HELP_CHANNEL_MESSAGES.AUTHOR_ID.eq(member.getIdLong()))
153+
.and(HELP_CHANNEL_MESSAGES.SENT_AT.greaterThan(latestActiveMoment)))) == 0;
154+
}
155+
156+
private void pruneMemberFromRole(Member member, Role role,
157+
@Nullable TextChannel selectRoleChannel) {
158+
Guild guild = member.getGuild();
159+
160+
String channelMentionOrFallbackMessage =
161+
selectRoleChannel == null ? "role selection channel"
162+
: selectRoleChannel.getAsMention();
163+
164+
String dmMessage =
165+
"""
166+
You seem to have been inactive for some time in server **%s**, hence we removed you from the **%s** role.
167+
If that was a mistake, just head back to %s and select the role again.
168+
Sorry for any inconvenience caused by this 🙇"""
169+
.formatted(guild.getName(), role.getName(), channelMentionOrFallbackMessage);
170+
171+
guild.removeRoleFromMember(member, role)
172+
.flatMap(any -> member.getUser().openPrivateChannel())
173+
.flatMap(channel -> channel.sendMessage(dmMessage))
174+
.queue(null, failure -> logger.debug(
175+
"Failed sending a DM to user ({}) while pruning them from a helper role.",
176+
member.getId()));
177+
}
178+
179+
private void warnModsAbout(String message, Guild guild) {
180+
logger.warn(message);
181+
182+
modAuditLogWriter.write("Auto-prune helpers", message, null, Instant.now(), guild);
183+
}
184+
185+
private Optional<TextChannel> getSelectRolesChannelOptional(JDA jda) {
186+
return jda.getTextChannels()
187+
.stream()
188+
.filter(textChannel -> selectYourRolesChannelNamePredicate.test(textChannel.getName()))
189+
.findFirst();
190+
}
191+
}

0 commit comments

Comments
 (0)